qdata-quote 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.
- qdata_quote/__init__.py +3 -0
- qdata_quote/bench.py +112 -0
- qdata_quote/service.py +139 -0
- qdata_quote/sources/__init__.py +0 -0
- qdata_quote/sources/base.py +99 -0
- qdata_quote/sources/sina.py +103 -0
- qdata_quote/sources/tencent.py +122 -0
- qdata_quote/types.py +100 -0
- qdata_quote-0.1.0.dist-info/METADATA +16 -0
- qdata_quote-0.1.0.dist-info/RECORD +11 -0
- qdata_quote-0.1.0.dist-info/WHEEL +4 -0
qdata_quote/__init__.py
ADDED
qdata_quote/bench.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""性能基准测试:对比 easyquotation 与 qdata_quote 的采集速度"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load_stock_codes() -> list[str]:
|
|
11
|
+
"""从 easyquotation 加载全市场股票代码"""
|
|
12
|
+
try:
|
|
13
|
+
from easyquotation import helpers
|
|
14
|
+
|
|
15
|
+
return helpers.get_stock_codes()
|
|
16
|
+
except ImportError:
|
|
17
|
+
print("需要安装 easyquotation: pip install easyquotation")
|
|
18
|
+
sys.exit(1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _bench_easyquotation_sina(codes: list[str]) -> float:
|
|
22
|
+
"""easyquotation 新浪源耗时"""
|
|
23
|
+
import easyquotation
|
|
24
|
+
|
|
25
|
+
q = easyquotation.use("sina")
|
|
26
|
+
start = time.perf_counter()
|
|
27
|
+
q.real(codes)
|
|
28
|
+
return time.perf_counter() - start
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _bench_easyquotation_tencent(codes: list[str]) -> float:
|
|
32
|
+
"""easyquotation 腾讯源耗时"""
|
|
33
|
+
import easyquotation
|
|
34
|
+
|
|
35
|
+
q = easyquotation.use("qq")
|
|
36
|
+
start = time.perf_counter()
|
|
37
|
+
q.real(codes)
|
|
38
|
+
return time.perf_counter() - start
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _bench_qdata_async(codes: list[str], source: str) -> float:
|
|
42
|
+
"""qdata_quote 异步耗时"""
|
|
43
|
+
from qdata_quote import QuoteService
|
|
44
|
+
|
|
45
|
+
service = QuoteService()
|
|
46
|
+
async with service:
|
|
47
|
+
start = time.perf_counter()
|
|
48
|
+
await service.get_real(codes, source=source)
|
|
49
|
+
return time.perf_counter() - start
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _bench_qdata_sync(codes: list[str], source: str) -> float:
|
|
53
|
+
"""qdata_quote 同步耗时"""
|
|
54
|
+
from qdata_quote import QuoteService
|
|
55
|
+
|
|
56
|
+
service = QuoteService()
|
|
57
|
+
start = time.perf_counter()
|
|
58
|
+
service.get_real_sync(codes, source=source)
|
|
59
|
+
return time.perf_counter() - start
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _print_result(name: str, elapsed: float, baseline: float | None = None):
|
|
63
|
+
suffix = ""
|
|
64
|
+
if baseline is not None and elapsed > 0:
|
|
65
|
+
suffix = f" ({baseline / elapsed:.1f}x)"
|
|
66
|
+
print(f" {name:<35s} {elapsed:.3f}s{suffix}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run(codes: list[str] | None = None):
|
|
70
|
+
"""运行基准测试
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
codes: 股票代码列表,为 None 时使用全市场代码
|
|
74
|
+
"""
|
|
75
|
+
codes = codes or _load_stock_codes()
|
|
76
|
+
print(f"股票数量: {len(codes)}")
|
|
77
|
+
print()
|
|
78
|
+
|
|
79
|
+
# 新浪源对比
|
|
80
|
+
print("=== 新浪源 ===")
|
|
81
|
+
try:
|
|
82
|
+
baseline = _bench_easyquotation_sina(codes)
|
|
83
|
+
_print_result("easyquotation", baseline)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
print(f" easyquotation 失败: {e}")
|
|
86
|
+
baseline = None
|
|
87
|
+
|
|
88
|
+
sync_time = _bench_qdata_sync(codes, "sina")
|
|
89
|
+
_print_result("qdata_quote (sync)", sync_time, baseline)
|
|
90
|
+
|
|
91
|
+
async_time = asyncio.run(_bench_qdata_async(codes, "sina"))
|
|
92
|
+
_print_result("qdata_quote (async)", async_time, baseline)
|
|
93
|
+
|
|
94
|
+
# 腾讯源对比
|
|
95
|
+
print()
|
|
96
|
+
print("=== 腾讯源 ===")
|
|
97
|
+
try:
|
|
98
|
+
baseline_t = _bench_easyquotation_tencent(codes)
|
|
99
|
+
_print_result("easyquotation", baseline_t)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
print(f" easyquotation 失败: {e}")
|
|
102
|
+
baseline_t = None
|
|
103
|
+
|
|
104
|
+
sync_time_t = _bench_qdata_sync(codes, "tencent")
|
|
105
|
+
_print_result("qdata_quote (sync)", sync_time_t, baseline_t)
|
|
106
|
+
|
|
107
|
+
async_time_t = asyncio.run(_bench_qdata_async(codes, "tencent"))
|
|
108
|
+
_print_result("qdata_quote (async)", async_time_t, baseline_t)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
run()
|
qdata_quote/service.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""行情服务主类"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from qdata_quote.sources.sina import SinaSource
|
|
11
|
+
from qdata_quote.sources.tencent import TencentSource
|
|
12
|
+
from qdata_quote.types import add_prefix
|
|
13
|
+
|
|
14
|
+
SourceName = Literal["sina", "tencent"]
|
|
15
|
+
|
|
16
|
+
# 超时配置:连接 5 秒,读取 10 秒
|
|
17
|
+
_TIMEOUT = httpx.Timeout(timeout=10.0, connect=5.0)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class QuoteService:
|
|
21
|
+
"""实时行情服务
|
|
22
|
+
|
|
23
|
+
支持新浪和腾讯两个数据源,提供异步和同步两套接口。
|
|
24
|
+
使用前通过 set_stock_codes 设置全市场股票代码列表。
|
|
25
|
+
|
|
26
|
+
用法:
|
|
27
|
+
service = QuoteService()
|
|
28
|
+
service.set_stock_codes(["000001", "600000", ...])
|
|
29
|
+
|
|
30
|
+
# 异步(高性能)
|
|
31
|
+
df = await service.get_real(["000001"])
|
|
32
|
+
df_all = await service.get_all()
|
|
33
|
+
|
|
34
|
+
# 同步(便捷)
|
|
35
|
+
df = service.get_real_sync(["000001"])
|
|
36
|
+
df_all = service.get_all_sync()
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self):
|
|
40
|
+
self._stock_codes: list[str] = []
|
|
41
|
+
self._sources = {
|
|
42
|
+
"sina": SinaSource(),
|
|
43
|
+
"tencent": TencentSource(),
|
|
44
|
+
}
|
|
45
|
+
self._async_client: httpx.AsyncClient | None = None
|
|
46
|
+
self._sync_client: httpx.Client | None = None
|
|
47
|
+
self._default_source: SourceName = "sina"
|
|
48
|
+
|
|
49
|
+
def set_stock_codes(self, codes: list[str]) -> None:
|
|
50
|
+
"""设置全市场股票代码列表"""
|
|
51
|
+
self._stock_codes = codes
|
|
52
|
+
|
|
53
|
+
# --- 异步接口 ---
|
|
54
|
+
|
|
55
|
+
async def _get_async_client(self) -> httpx.AsyncClient:
|
|
56
|
+
"""获取或创建异步客户端"""
|
|
57
|
+
if self._async_client is None or self._async_client.is_closed:
|
|
58
|
+
self._async_client = httpx.AsyncClient(timeout=_TIMEOUT)
|
|
59
|
+
return self._async_client
|
|
60
|
+
|
|
61
|
+
async def get_real(
|
|
62
|
+
self,
|
|
63
|
+
codes: list[str] | str,
|
|
64
|
+
source: SourceName | None = None,
|
|
65
|
+
) -> pd.DataFrame:
|
|
66
|
+
"""异步获取指定股票的实时行情
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
codes: 股票代码或代码列表,如 "000001" 或 ["000001", "600000"]
|
|
70
|
+
source: 数据源,"sina" 或 "tencent",默认 "sina"
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
统一格式的 DataFrame,index 为带前缀的股票代码
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(codes, str):
|
|
76
|
+
codes = [codes]
|
|
77
|
+
source = source or self._default_source
|
|
78
|
+
client = await self._get_async_client()
|
|
79
|
+
prefixed = [add_prefix(c) for c in codes]
|
|
80
|
+
return await self._sources[source].fetch(client, prefixed)
|
|
81
|
+
|
|
82
|
+
async def get_all(self, source: SourceName | None = None) -> pd.DataFrame:
|
|
83
|
+
"""异步获取全市场行情快照"""
|
|
84
|
+
return await self.get_real(self._stock_codes, source)
|
|
85
|
+
|
|
86
|
+
async def close(self) -> None:
|
|
87
|
+
"""关闭异步客户端"""
|
|
88
|
+
if self._async_client and not self._async_client.is_closed:
|
|
89
|
+
await self._async_client.aclose()
|
|
90
|
+
|
|
91
|
+
async def __aenter__(self):
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
async def __aexit__(self, *args):
|
|
95
|
+
await self.close()
|
|
96
|
+
|
|
97
|
+
# --- 同步接口 ---
|
|
98
|
+
|
|
99
|
+
def _get_sync_client(self) -> httpx.Client:
|
|
100
|
+
"""获取或创建同步客户端"""
|
|
101
|
+
if self._sync_client is None or self._sync_client.is_closed:
|
|
102
|
+
self._sync_client = httpx.Client(timeout=_TIMEOUT)
|
|
103
|
+
return self._sync_client
|
|
104
|
+
|
|
105
|
+
def get_real_sync(
|
|
106
|
+
self,
|
|
107
|
+
codes: list[str] | str,
|
|
108
|
+
source: SourceName | None = None,
|
|
109
|
+
) -> pd.DataFrame:
|
|
110
|
+
"""同步获取指定股票的实时行情
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
codes: 股票代码或代码列表
|
|
114
|
+
source: 数据源,"sina" 或 "tencent",默认 "sina"
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
统一格式的 DataFrame
|
|
118
|
+
"""
|
|
119
|
+
if isinstance(codes, str):
|
|
120
|
+
codes = [codes]
|
|
121
|
+
source = source or self._default_source
|
|
122
|
+
client = self._get_sync_client()
|
|
123
|
+
prefixed = [add_prefix(c) for c in codes]
|
|
124
|
+
return self._sources[source].fetch_sync(client, prefixed)
|
|
125
|
+
|
|
126
|
+
def get_all_sync(self, source: SourceName | None = None) -> pd.DataFrame:
|
|
127
|
+
"""同步获取全市场行情快照"""
|
|
128
|
+
return self.get_real_sync(self._stock_codes, source)
|
|
129
|
+
|
|
130
|
+
def close_sync(self) -> None:
|
|
131
|
+
"""关闭同步客户端"""
|
|
132
|
+
if self._sync_client and not self._sync_client.is_closed:
|
|
133
|
+
self._sync_client.close()
|
|
134
|
+
|
|
135
|
+
def __enter__(self):
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def __exit__(self, *args):
|
|
139
|
+
self.close_sync()
|
|
File without changes
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""数据源抽象基类,封装分批并发请求逻辑"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from qdata_quote.types import empty_dataframe
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseSource(abc.ABC):
|
|
15
|
+
"""行情数据源基类
|
|
16
|
+
|
|
17
|
+
子类需实现 _build_url、_get_headers、_parse。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
batch_size: int = 800
|
|
21
|
+
|
|
22
|
+
@abc.abstractmethod
|
|
23
|
+
def _build_url(self, prefixed_codes: list[str]) -> str:
|
|
24
|
+
"""根据股票代码列表构建请求 URL"""
|
|
25
|
+
|
|
26
|
+
@abc.abstractmethod
|
|
27
|
+
def _get_headers(self) -> dict:
|
|
28
|
+
"""返回请求头"""
|
|
29
|
+
|
|
30
|
+
@abc.abstractmethod
|
|
31
|
+
def _parse(self, text: str) -> pd.DataFrame:
|
|
32
|
+
"""解析响应文本为统一格式的 DataFrame"""
|
|
33
|
+
|
|
34
|
+
def _split_batches(self, codes: list[str]) -> list[list[str]]:
|
|
35
|
+
"""将代码列表按 batch_size 分批"""
|
|
36
|
+
if len(codes) <= self.batch_size:
|
|
37
|
+
return [codes]
|
|
38
|
+
return [
|
|
39
|
+
codes[i : i + self.batch_size]
|
|
40
|
+
for i in range(0, len(codes), self.batch_size)
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
async def fetch(
|
|
44
|
+
self, client: httpx.AsyncClient, codes: list[str]
|
|
45
|
+
) -> pd.DataFrame:
|
|
46
|
+
"""异步获取指定股票行情"""
|
|
47
|
+
batches = self._split_batches(codes)
|
|
48
|
+
tasks = [self._fetch_batch_async(client, batch) for batch in batches]
|
|
49
|
+
results = await asyncio.gather(*tasks)
|
|
50
|
+
dfs = [df for df in results if df is not None and not df.empty]
|
|
51
|
+
if not dfs:
|
|
52
|
+
return empty_dataframe()
|
|
53
|
+
return pd.concat(dfs)
|
|
54
|
+
|
|
55
|
+
async def _fetch_batch_async(
|
|
56
|
+
self, client: httpx.AsyncClient, codes: list[str]
|
|
57
|
+
) -> pd.DataFrame | None:
|
|
58
|
+
"""异步获取单个批次,失败重试一次"""
|
|
59
|
+
url = self._build_url(codes)
|
|
60
|
+
headers = self._get_headers()
|
|
61
|
+
for attempt in range(2):
|
|
62
|
+
try:
|
|
63
|
+
response = await client.get(url, headers=headers)
|
|
64
|
+
return self._parse(response.text)
|
|
65
|
+
except Exception:
|
|
66
|
+
if attempt == 0:
|
|
67
|
+
continue
|
|
68
|
+
return None
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def fetch_sync(
|
|
72
|
+
self, client: httpx.Client, codes: list[str]
|
|
73
|
+
) -> pd.DataFrame:
|
|
74
|
+
"""同步获取指定股票行情"""
|
|
75
|
+
batches = self._split_batches(codes)
|
|
76
|
+
dfs = []
|
|
77
|
+
for batch in batches:
|
|
78
|
+
df = self._fetch_batch_sync(client, batch)
|
|
79
|
+
if df is not None and not df.empty:
|
|
80
|
+
dfs.append(df)
|
|
81
|
+
if not dfs:
|
|
82
|
+
return empty_dataframe()
|
|
83
|
+
return pd.concat(dfs)
|
|
84
|
+
|
|
85
|
+
def _fetch_batch_sync(
|
|
86
|
+
self, client: httpx.Client, codes: list[str]
|
|
87
|
+
) -> pd.DataFrame | None:
|
|
88
|
+
"""同步获取单个批次,失败重试一次"""
|
|
89
|
+
url = self._build_url(codes)
|
|
90
|
+
headers = self._get_headers()
|
|
91
|
+
for attempt in range(2):
|
|
92
|
+
try:
|
|
93
|
+
response = client.get(url, headers=headers)
|
|
94
|
+
return self._parse(response.text)
|
|
95
|
+
except Exception:
|
|
96
|
+
if attempt == 0:
|
|
97
|
+
continue
|
|
98
|
+
return None
|
|
99
|
+
return None
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""新浪行情数据源"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from qdata_quote.sources.base import BaseSource
|
|
11
|
+
from qdata_quote.types import (
|
|
12
|
+
DATA_COLUMNS,
|
|
13
|
+
empty_dataframe,
|
|
14
|
+
safe_float,
|
|
15
|
+
safe_int,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SinaSource(BaseSource):
|
|
20
|
+
"""新浪免费行情源"""
|
|
21
|
+
|
|
22
|
+
# 匹配带前缀的股票代码和 29 个数值字段 + 2 个日期时间字段
|
|
23
|
+
_pattern = re.compile(
|
|
24
|
+
r"(\w{2}\d+)=[^\s]([^\s,]+?)"
|
|
25
|
+
+ r",([\.\d]+)" * 29
|
|
26
|
+
+ r",([-\.\d:]+)" * 2
|
|
27
|
+
)
|
|
28
|
+
_empty_pattern = re.compile(r'(\w{2}\d+)="";')
|
|
29
|
+
|
|
30
|
+
def _build_url(self, prefixed_codes: list[str]) -> str:
|
|
31
|
+
"""构建请求 URL"""
|
|
32
|
+
codes_str = ",".join(prefixed_codes)
|
|
33
|
+
return f"http://hq.sinajs.cn/rn={int(time.time() * 1000)}&list={codes_str}"
|
|
34
|
+
|
|
35
|
+
def _get_headers(self) -> dict:
|
|
36
|
+
return {
|
|
37
|
+
"Referer": "http://finance.sina.com.cn/",
|
|
38
|
+
"User-Agent": (
|
|
39
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
40
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
41
|
+
"Chrome/120.0.0.0 Safari/537.36"
|
|
42
|
+
),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def _parse(self, text: str) -> pd.DataFrame:
|
|
46
|
+
"""解析新浪行情响应,返回统一格式的 DataFrame"""
|
|
47
|
+
# 移除空数据行
|
|
48
|
+
text = self._empty_pattern.sub("", text)
|
|
49
|
+
text = text.replace(" ", "")
|
|
50
|
+
|
|
51
|
+
rows = []
|
|
52
|
+
for match in self._pattern.finditer(text):
|
|
53
|
+
g = match.groups()
|
|
54
|
+
try:
|
|
55
|
+
row = {
|
|
56
|
+
"name": g[1],
|
|
57
|
+
"open": safe_float(g[2]),
|
|
58
|
+
"close": safe_float(g[3]),
|
|
59
|
+
"now": safe_float(g[4]),
|
|
60
|
+
"high": safe_float(g[5]),
|
|
61
|
+
"low": safe_float(g[6]),
|
|
62
|
+
"buy": safe_float(g[7]),
|
|
63
|
+
"sell": safe_float(g[8]),
|
|
64
|
+
"volume": safe_int(g[9]),
|
|
65
|
+
"turnover": safe_float(g[10]),
|
|
66
|
+
"bid1_volume": safe_int(g[11]),
|
|
67
|
+
"bid1": safe_float(g[12]),
|
|
68
|
+
"bid2_volume": safe_int(g[13]),
|
|
69
|
+
"bid2": safe_float(g[14]),
|
|
70
|
+
"bid3_volume": safe_int(g[15]),
|
|
71
|
+
"bid3": safe_float(g[16]),
|
|
72
|
+
"bid4_volume": safe_int(g[17]),
|
|
73
|
+
"bid4": safe_float(g[18]),
|
|
74
|
+
"bid5_volume": safe_int(g[19]),
|
|
75
|
+
"bid5": safe_float(g[20]),
|
|
76
|
+
"ask1_volume": safe_int(g[21]),
|
|
77
|
+
"ask1": safe_float(g[22]),
|
|
78
|
+
"ask2_volume": safe_int(g[23]),
|
|
79
|
+
"ask2": safe_float(g[24]),
|
|
80
|
+
"ask3_volume": safe_int(g[25]),
|
|
81
|
+
"ask3": safe_float(g[26]),
|
|
82
|
+
"ask4_volume": safe_int(g[27]),
|
|
83
|
+
"ask4": safe_float(g[28]),
|
|
84
|
+
"ask5_volume": safe_int(g[29]),
|
|
85
|
+
"ask5": safe_float(g[30]),
|
|
86
|
+
"datetime": f"{g[31]} {g[32]}",
|
|
87
|
+
}
|
|
88
|
+
rows.append((g[0], row))
|
|
89
|
+
except (IndexError, ValueError):
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if not rows:
|
|
93
|
+
return empty_dataframe()
|
|
94
|
+
|
|
95
|
+
codes, data = zip(*rows)
|
|
96
|
+
df = pd.DataFrame(list(data), index=list(codes))
|
|
97
|
+
df.index.name = "code"
|
|
98
|
+
# 确保所有统一列都存在(新浪缺失的字段自动为 NaN)
|
|
99
|
+
for col in DATA_COLUMNS:
|
|
100
|
+
if col not in df.columns:
|
|
101
|
+
df[col] = float("nan")
|
|
102
|
+
df = df[DATA_COLUMNS]
|
|
103
|
+
return df
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""腾讯行情数据源"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from qdata_quote.sources.base import BaseSource
|
|
10
|
+
from qdata_quote.types import (
|
|
11
|
+
DATA_COLUMNS,
|
|
12
|
+
empty_dataframe,
|
|
13
|
+
safe_float,
|
|
14
|
+
safe_int,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TencentSource(BaseSource):
|
|
19
|
+
"""腾讯免费行情源"""
|
|
20
|
+
|
|
21
|
+
batch_size: int = 200
|
|
22
|
+
|
|
23
|
+
_code_pattern = re.compile(r"(?<=_)\w+")
|
|
24
|
+
|
|
25
|
+
def _build_url(self, prefixed_codes: list[str]) -> str:
|
|
26
|
+
"""构建请求 URL"""
|
|
27
|
+
codes_str = ",".join(prefixed_codes)
|
|
28
|
+
return f"http://qt.gtimg.cn/q={codes_str}"
|
|
29
|
+
|
|
30
|
+
def _get_headers(self) -> dict:
|
|
31
|
+
return {
|
|
32
|
+
"User-Agent": (
|
|
33
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
34
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
35
|
+
"Chrome/120.0.0.0 Safari/537.36"
|
|
36
|
+
),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def _parse(self, text: str) -> pd.DataFrame:
|
|
40
|
+
"""解析腾讯行情响应,返回统一格式的 DataFrame"""
|
|
41
|
+
rows = []
|
|
42
|
+
for segment in text.split(";"):
|
|
43
|
+
segment = segment.strip().rstrip('"')
|
|
44
|
+
if not segment:
|
|
45
|
+
continue
|
|
46
|
+
fields = segment.split("~")
|
|
47
|
+
if len(fields) <= 49:
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
# 从 v_sh600000="1 中提取带前缀的代码
|
|
51
|
+
code_match = self._code_pattern.search(fields[0])
|
|
52
|
+
if not code_match:
|
|
53
|
+
continue
|
|
54
|
+
code = code_match.group()
|
|
55
|
+
|
|
56
|
+
row = {
|
|
57
|
+
"name": fields[1],
|
|
58
|
+
"open": safe_float(fields[5]),
|
|
59
|
+
"close": safe_float(fields[4]),
|
|
60
|
+
"now": safe_float(fields[3]),
|
|
61
|
+
"high": safe_float(fields[33]),
|
|
62
|
+
"low": safe_float(fields[34]),
|
|
63
|
+
"volume": safe_int(fields[6]) * 100, # 手 → 股
|
|
64
|
+
"turnover": safe_float(fields[37]) * 10000, # 万 → 元
|
|
65
|
+
"bid1": safe_float(fields[9]),
|
|
66
|
+
"bid1_volume": safe_int(fields[10]) * 100,
|
|
67
|
+
"bid2": safe_float(fields[11]),
|
|
68
|
+
"bid2_volume": safe_int(fields[12]) * 100,
|
|
69
|
+
"bid3": safe_float(fields[13]),
|
|
70
|
+
"bid3_volume": safe_int(fields[14]) * 100,
|
|
71
|
+
"bid4": safe_float(fields[15]),
|
|
72
|
+
"bid4_volume": safe_int(fields[16]) * 100,
|
|
73
|
+
"bid5": safe_float(fields[17]),
|
|
74
|
+
"bid5_volume": safe_int(fields[18]) * 100,
|
|
75
|
+
"ask1": safe_float(fields[19]),
|
|
76
|
+
"ask1_volume": safe_int(fields[20]) * 100,
|
|
77
|
+
"ask2": safe_float(fields[21]),
|
|
78
|
+
"ask2_volume": safe_int(fields[22]) * 100,
|
|
79
|
+
"ask3": safe_float(fields[23]),
|
|
80
|
+
"ask3_volume": safe_int(fields[24]) * 100,
|
|
81
|
+
"ask4": safe_float(fields[25]),
|
|
82
|
+
"ask4_volume": safe_int(fields[26]) * 100,
|
|
83
|
+
"ask5": safe_float(fields[27]),
|
|
84
|
+
"ask5_volume": safe_int(fields[28]) * 100,
|
|
85
|
+
"datetime": fields[30],
|
|
86
|
+
"change": safe_float(fields[31]),
|
|
87
|
+
"change_pct": safe_float(fields[32]),
|
|
88
|
+
"amplitude": safe_float(fields[43]),
|
|
89
|
+
"pe_dynamic": _safe_float_at(fields, 52),
|
|
90
|
+
"pe_static": _safe_float_at(fields, 53),
|
|
91
|
+
"pb": safe_float(fields[46]),
|
|
92
|
+
"total_market_cap": safe_float(fields[45]),
|
|
93
|
+
"circulating_market_cap": safe_float(fields[44]),
|
|
94
|
+
"volume_ratio": _safe_float_at(fields, 49),
|
|
95
|
+
"bid_ask_ratio": _safe_float_at(fields, 50),
|
|
96
|
+
"avg_price": _safe_float_at(fields, 51),
|
|
97
|
+
"limit_up": safe_float(fields[47]),
|
|
98
|
+
"limit_down": safe_float(fields[48]),
|
|
99
|
+
}
|
|
100
|
+
rows.append((code, row))
|
|
101
|
+
except (IndexError, ValueError):
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
if not rows:
|
|
105
|
+
return empty_dataframe()
|
|
106
|
+
|
|
107
|
+
codes, data = zip(*rows)
|
|
108
|
+
df = pd.DataFrame(list(data), index=list(codes))
|
|
109
|
+
df.index.name = "code"
|
|
110
|
+
for col in DATA_COLUMNS:
|
|
111
|
+
if col not in df.columns:
|
|
112
|
+
df[col] = float("nan")
|
|
113
|
+
df = df[DATA_COLUMNS]
|
|
114
|
+
return df
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _safe_float_at(fields: list[str], index: int) -> float:
|
|
118
|
+
"""安全获取指定索引的浮点值,越界返回 NaN"""
|
|
119
|
+
try:
|
|
120
|
+
return safe_float(fields[index])
|
|
121
|
+
except IndexError:
|
|
122
|
+
return float("nan")
|
qdata_quote/types.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""统一字段定义与工具函数"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
# 统一 DataFrame 列定义(code 作为 index,不在 columns 中)
|
|
8
|
+
UNIFIED_COLUMNS = [
|
|
9
|
+
"code",
|
|
10
|
+
"name",
|
|
11
|
+
"open",
|
|
12
|
+
"close",
|
|
13
|
+
"now",
|
|
14
|
+
"high",
|
|
15
|
+
"low",
|
|
16
|
+
"buy",
|
|
17
|
+
"sell",
|
|
18
|
+
"volume",
|
|
19
|
+
"turnover",
|
|
20
|
+
"bid1",
|
|
21
|
+
"bid2",
|
|
22
|
+
"bid3",
|
|
23
|
+
"bid4",
|
|
24
|
+
"bid5",
|
|
25
|
+
"bid1_volume",
|
|
26
|
+
"bid2_volume",
|
|
27
|
+
"bid3_volume",
|
|
28
|
+
"bid4_volume",
|
|
29
|
+
"bid5_volume",
|
|
30
|
+
"ask1",
|
|
31
|
+
"ask2",
|
|
32
|
+
"ask3",
|
|
33
|
+
"ask4",
|
|
34
|
+
"ask5",
|
|
35
|
+
"ask1_volume",
|
|
36
|
+
"ask2_volume",
|
|
37
|
+
"ask3_volume",
|
|
38
|
+
"ask4_volume",
|
|
39
|
+
"ask5_volume",
|
|
40
|
+
"datetime",
|
|
41
|
+
"change",
|
|
42
|
+
"change_pct",
|
|
43
|
+
"amplitude",
|
|
44
|
+
"pe_dynamic",
|
|
45
|
+
"pe_static",
|
|
46
|
+
"pb",
|
|
47
|
+
"total_market_cap",
|
|
48
|
+
"circulating_market_cap",
|
|
49
|
+
"volume_ratio",
|
|
50
|
+
"bid_ask_ratio",
|
|
51
|
+
"avg_price",
|
|
52
|
+
"limit_up",
|
|
53
|
+
"limit_down",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
# DataFrame 的数据列(不含 code,因为 code 是 index)
|
|
57
|
+
DATA_COLUMNS = UNIFIED_COLUMNS[1:]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def safe_float(value: str) -> float:
|
|
61
|
+
"""安全转换为 float,失败返回 NaN"""
|
|
62
|
+
try:
|
|
63
|
+
return float(value)
|
|
64
|
+
except (ValueError, TypeError):
|
|
65
|
+
return float("nan")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def safe_int(value: str) -> float:
|
|
69
|
+
"""安全转换为 int,失败返回 NaN(用 float 承载以支持 NaN)"""
|
|
70
|
+
try:
|
|
71
|
+
return int(value)
|
|
72
|
+
except (ValueError, TypeError):
|
|
73
|
+
return float("nan")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_stock_prefix(code: str) -> str:
|
|
77
|
+
"""根据股票代码判断市场前缀 (sh/sz/bj)"""
|
|
78
|
+
bj_head = ("43", "83", "87", "92")
|
|
79
|
+
sh_head = ("5", "6", "7", "9", "110", "113", "118", "132", "204")
|
|
80
|
+
if code.startswith(("sh", "sz", "bj")):
|
|
81
|
+
return code[:2]
|
|
82
|
+
if code.startswith(bj_head):
|
|
83
|
+
return "bj"
|
|
84
|
+
if code.startswith(sh_head):
|
|
85
|
+
return "sh"
|
|
86
|
+
return "sz"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def add_prefix(code: str) -> str:
|
|
90
|
+
"""为股票代码添加市场前缀"""
|
|
91
|
+
prefix = get_stock_prefix(code)
|
|
92
|
+
pure = code[-6:] if code.startswith(("sh", "sz", "bj")) else code
|
|
93
|
+
return f"{prefix}{pure}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def empty_dataframe() -> pd.DataFrame:
|
|
97
|
+
"""返回带统一列结构的空 DataFrame"""
|
|
98
|
+
df = pd.DataFrame(columns=DATA_COLUMNS)
|
|
99
|
+
df.index.name = "code"
|
|
100
|
+
return df
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qdata-quote
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 实时行情采集服务,支持新浪和腾讯数据源
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: pandas>=2.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# qdata-quote
|
|
15
|
+
|
|
16
|
+
实时行情采集服务,支持新浪和腾讯数据源。
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
qdata_quote/__init__.py,sha256=XyZmOoiDQYPsv-rZw7MlKAh5wc7UiST0DZpDIiMoEe8,62
|
|
2
|
+
qdata_quote/bench.py,sha256=H4HcRXT2nhyYuccBDzYXucnHyAU-Lz_f4hZDtsw8KHY,3153
|
|
3
|
+
qdata_quote/service.py,sha256=_LA3dgYcrZhE7DQmIb7WWN7gJ0ZRl4u-npvMGT9Fydg,4390
|
|
4
|
+
qdata_quote/types.py,sha256=pvgN1kZ6kIQSs8yOUCY7LYUXRVAo5EyLLxbmluGEGoU,2199
|
|
5
|
+
qdata_quote/sources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
qdata_quote/sources/base.py,sha256=fpERTmDaI3nOcP9m25vZJX7VFBHekLF-TZWPCyRaRsI,3082
|
|
7
|
+
qdata_quote/sources/sina.py,sha256=rOU_BxWtSo7w8vVxR4ywIPjHVS5e4-UHFfcHRZ3-jsI,3563
|
|
8
|
+
qdata_quote/sources/tencent.py,sha256=HpNdsYaqPWPz8fKSMVM_ysahJ7Px6R83bII6GAWj7to,4628
|
|
9
|
+
qdata_quote-0.1.0.dist-info/METADATA,sha256=U86WYdvZd7NddhKk6BhSOBvQRsU3EOTlIsnzJAcQtxQ,456
|
|
10
|
+
qdata_quote-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
qdata_quote-0.1.0.dist-info/RECORD,,
|