hamuna-quant-cli 0.1.0.dev93__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.
- hamuna_quant_cli/README.md +117 -0
- hamuna_quant_cli/__init__.py +17 -0
- hamuna_quant_cli/__main__.py +978 -0
- hamuna_quant_cli/_market_fallback.py +82 -0
- hamuna_quant_cli/_metrics_15.py +342 -0
- hamuna_quant_cli/_test_akquant_parity.py +530 -0
- hamuna_quant_cli/akquant_data_adapter.py +295 -0
- hamuna_quant_cli/akquant_runner.py +620 -0
- hamuna_quant_cli/akquant_schema_adapter.py +443 -0
- hamuna_quant_cli/base_strategy.py +80 -0
- hamuna_quant_cli/cross_sectional_helpers.py +118 -0
- hamuna_quant_cli/live/__init__.py +25 -0
- hamuna_quant_cli/live/loader.py +121 -0
- hamuna_quant_cli/live/qmt_broker.py +683 -0
- hamuna_quant_cli/live/qmt_market.py +448 -0
- hamuna_quant_cli/live/runner.py +449 -0
- hamuna_quant_cli/prebuilt_downloader.py +263 -0
- hamuna_quant_cli/prebuilt_resolver.py +470 -0
- hamuna_quant_cli/qmt_translator.py +609 -0
- hamuna_quant_cli/runtime/__init__.py +2 -0
- hamuna_quant_cli/runtime/backtest.py +38 -0
- hamuna_quant_cli/runtime/cache.py +255 -0
- hamuna_quant_cli/runtime/discipline.py +359 -0
- hamuna_quant_cli/runtime/http_client.py +209 -0
- hamuna_quant_cli/runtime/s3client.py +109 -0
- hamuna_quant_cli/runtime/server_client.py +285 -0
- hamuna_quant_cli/scripts/server.json +4 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/METADATA +154 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/RECORD +32 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/WHEEL +5 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/entry_points.txt +2 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""ADR-0040 Phase C — 自适应 prebuilt dataset resolver (v2 自有版本, B5).
|
|
2
|
+
|
|
3
|
+
来源: 原仓根 `hamuna_quant_cli/references/prebuilt_resolver.py`.
|
|
4
|
+
本文件是 wholesale copy, 算法/filters/prefix 不变. 迁移原因: v2 skill 独立分发.
|
|
5
|
+
|
|
6
|
+
背景:
|
|
7
|
+
旧 _try_prebuilt_parquet 硬编码 `__bundle__all_a_D.parquet`, 用户若只查单股
|
|
8
|
+
(如 ['600000.SH']) → bundle 不存在 / 不含该标 → 全 fallback 到慢速
|
|
9
|
+
market_mod 路径 (~92s). 同时不论 universe 多小, 都把整张 402MB parquet 全部
|
|
10
|
+
读入内存再 pandas filter — 浪费 IO + 内存.
|
|
11
|
+
|
|
12
|
+
设计:
|
|
13
|
+
1) **registry**: 描述本地已知 prebuilt 的覆盖范围 (kind=bundle/single,
|
|
14
|
+
universe / symbol, 路径模板). 用 sklearn 风格 scan → 取第一个匹配的
|
|
15
|
+
prebuilt, 没匹配 → None.
|
|
16
|
+
2) **filter pushdown**: 用 pyarrow `filters=[(stockCode, 'in', codes),
|
|
17
|
+
(time, '>=', s), (time, '<=', e)]`, 只把匹配行解压出内存 (400MB
|
|
18
|
+
→ 1MB 量级). 比 `read_parquet(columns=...)` + pandas isin 省 10×
|
|
19
|
+
IO (实测).
|
|
20
|
+
3) **优先级**: bundle 优先 (一次 IO 覆盖多标); 单股 fallback.
|
|
21
|
+
4) **增量扩展**: 新加 prebuilt (etf_b, convertible_b 等) 只需往
|
|
22
|
+
KNOWN_PREBUILT 加一行.
|
|
23
|
+
|
|
24
|
+
隔离保证:
|
|
25
|
+
- v2 自有: 不依赖 v1 strategy_cli (避免 cycle). 用与 market._CLOUD_UNIVERSE_PREFIX
|
|
26
|
+
同等的 prefix 规则本地复刻.
|
|
27
|
+
- 单文件, 无外部 import (除 stdlib + pyarrow).
|
|
28
|
+
|
|
29
|
+
API:
|
|
30
|
+
resolve(universe, start, end, data_cache_dir=None, period='1d')
|
|
31
|
+
-> (DataFrame | None, source_path: str | None)
|
|
32
|
+
discover(data_cache_dir=None)
|
|
33
|
+
-> list[dict]: 扫盘已知 prebuilt, 报每个的 metadata (含覆盖 symbol 数)
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
import pandas as pd
|
|
41
|
+
|
|
42
|
+
# ---- prebuilt registry ----
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class PrebuiltSpec:
|
|
46
|
+
"""单个 prebuilt 文件的元信息.
|
|
47
|
+
|
|
48
|
+
path_template: 相对 data_cache_dir 的文件名 (或绝对路径)
|
|
49
|
+
kind: 'bundle' = 含多标的 stockCode 列, 'single' = 仅一支股票 (无 stockCode 列)
|
|
50
|
+
"""
|
|
51
|
+
path_template: str # e.g. '__bundle__all_a_D.parquet'
|
|
52
|
+
kind: str # 'bundle' | 'single'
|
|
53
|
+
universe_label: str # e.g. 'all_a' / '600000.SH' — 仅供日志/UI
|
|
54
|
+
# bundle 用: 每个 stockCode 是否在覆盖范围 (按前缀匹配)
|
|
55
|
+
bundle_prefix_predicate: tuple[str, ...] | None = None
|
|
56
|
+
# single 用: 该 parquet 对应的完整带后缀 symbol
|
|
57
|
+
single_symbol: str | None = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def path(self) -> Path:
|
|
61
|
+
# 默认 ~/.hamuna/data_cache/datasets/; 调用方可覆盖 data_cache_dir
|
|
62
|
+
# 这里延迟到 resolve() 时再拼 (避免模块级读 home dir 副作用)
|
|
63
|
+
return Path()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# 已知 prebuilt (按优先级: bundle 优先 → single).
|
|
67
|
+
# bundle_prefix_predicate 与 strategy_cli.fundamental.data.market._CLOUD_UNIVERSE_PREFIX
|
|
68
|
+
# 等价 — 本地复刻避免跨模块 import.
|
|
69
|
+
# all_a: ('60', '68', '00', '30', '40', '43', '83', '87', '88')
|
|
70
|
+
# etf: ('51', '56', '15', '16')
|
|
71
|
+
# convertible_bond: ('11', '12')
|
|
72
|
+
KNOWN_PREBUILT: tuple[PrebuiltSpec, ...] = (
|
|
73
|
+
PrebuiltSpec(
|
|
74
|
+
path_template='__bundle__all_a_D.parquet',
|
|
75
|
+
kind='bundle',
|
|
76
|
+
universe_label='all_a',
|
|
77
|
+
bundle_prefix_predicate=('60', '68', '00', '30', '40', '43', '83', '87', '88'),
|
|
78
|
+
),
|
|
79
|
+
PrebuiltSpec(
|
|
80
|
+
path_template='__bundle__etf_D.parquet',
|
|
81
|
+
kind='bundle',
|
|
82
|
+
universe_label='etf',
|
|
83
|
+
bundle_prefix_predicate=('51', '56', '15', '16'),
|
|
84
|
+
),
|
|
85
|
+
PrebuiltSpec(
|
|
86
|
+
path_template='__bundle__cb_D.parquet',
|
|
87
|
+
kind='bundle',
|
|
88
|
+
universe_label='convertible_bond',
|
|
89
|
+
bundle_prefix_predicate=('11', '12'),
|
|
90
|
+
),
|
|
91
|
+
PrebuiltSpec(
|
|
92
|
+
path_template='000300_D_fq1.parquet',
|
|
93
|
+
kind='single',
|
|
94
|
+
universe_label='000300.SH (HS300 基准)',
|
|
95
|
+
single_symbol='000300.SH',
|
|
96
|
+
),
|
|
97
|
+
PrebuiltSpec(
|
|
98
|
+
path_template='600000_D_fq1.parquet',
|
|
99
|
+
kind='single',
|
|
100
|
+
universe_label='600000.SH',
|
|
101
|
+
single_symbol='600000.SH',
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _default_cache_dir() -> Path:
|
|
107
|
+
return Path.home() / '.hamuna' / 'data_cache' / 'datasets'
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _resolve_path(spec: PrebuiltSpec, cache_dir: Path) -> Path:
|
|
111
|
+
return cache_dir / spec.path_template
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _bundle_covers(spec: PrebuiltSpec, universe: list[str]) -> set[str] | None:
|
|
115
|
+
"""bundle: 计算 universe ∩ 覆盖集 (按 prefix) → 返命中集合.
|
|
116
|
+
|
|
117
|
+
返回 None = spec 不是 bundle; 空集 = 完全不命中; 非空集 = 命中子集.
|
|
118
|
+
"""
|
|
119
|
+
if spec.kind != 'bundle' or spec.bundle_prefix_predicate is None:
|
|
120
|
+
return None
|
|
121
|
+
prefixes = spec.bundle_prefix_predicate
|
|
122
|
+
hit = set()
|
|
123
|
+
for sym in universe:
|
|
124
|
+
bare = sym.split('.')[0]
|
|
125
|
+
if any(bare.startswith(p) for p in prefixes):
|
|
126
|
+
hit.add(sym)
|
|
127
|
+
return hit
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _build_arrow_filters(codes: list[str], start: str, end: str) -> list:
|
|
131
|
+
"""pyarrow Dataset.filter 接受的谓词列表.
|
|
132
|
+
|
|
133
|
+
codes: 期望含后缀 (e.g. '600000.SH'). bundle parquet 的 stockCode 列就
|
|
134
|
+
是这个格式. start/end: 'YYYYMMDD'. time 列是 YYYYMMDDhhmmss int64 →
|
|
135
|
+
起止用 int 比较.
|
|
136
|
+
"""
|
|
137
|
+
s_int = int(start) * 1_000_000 # YYYYMMDD → YYYYMMDD000000
|
|
138
|
+
e_int = int(end) * 1_000_000 + 99_999_999 # 含 end 当日最后 bar
|
|
139
|
+
return [
|
|
140
|
+
('stockCode', 'in', codes),
|
|
141
|
+
('time', '>=', s_int),
|
|
142
|
+
('time', '<=', e_int),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _read_with_pushdown(path: Path, columns: list[str],
|
|
147
|
+
filters: list | None = None) -> pd.DataFrame:
|
|
148
|
+
"""pyarrow filter pushdown 读子集.
|
|
149
|
+
|
|
150
|
+
走 pyarrow.parquet.ParquetDataset + read_table(filters=...) — 只把
|
|
151
|
+
满足谓词的 row group 解压到内存. 与 `pd.read_parquet(columns=...)`
|
|
152
|
+
比, 后者读所有行, 只是稀疏列; 前者真"只读子集".
|
|
153
|
+
|
|
154
|
+
大文件子集提取推荐路径 (400MB → 1MB). filters=None 走 plain read.
|
|
155
|
+
"""
|
|
156
|
+
import pyarrow.parquet as pq
|
|
157
|
+
table = pq.read_table(path, columns=columns, filters=filters)
|
|
158
|
+
return table.to_pandas()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _read_single_with_pushdown(path: Path, start: str, end: str) -> pd.DataFrame | None:
|
|
162
|
+
"""single parquet (无 stockCode 列) → 读 [start, end] 窗口.
|
|
163
|
+
|
|
164
|
+
schema 容错:
|
|
165
|
+
- `time` int64 = YYYYMMDDhhmmss (14 位) → pyarrow filter int 比较
|
|
166
|
+
- `date` int / str = YYYYMMDD (8 位)
|
|
167
|
+
* int64 → pyarrow filter int 比较
|
|
168
|
+
* string → pyarrow 不支持 str/int 跨类型 filter, 退回 pandas str slice + 比较
|
|
169
|
+
- 都没 → 返 None (schema 不兼容)
|
|
170
|
+
"""
|
|
171
|
+
import pyarrow.parquet as pq
|
|
172
|
+
try:
|
|
173
|
+
schema = pq.read_schema(path)
|
|
174
|
+
cols_all = [c for c in ('time', 'date', 'open', 'high', 'low', 'close', 'volume', 'stockCode')
|
|
175
|
+
if c in schema.names]
|
|
176
|
+
time_type = schema.field('time').type if 'time' in schema.names else None
|
|
177
|
+
date_type = schema.field('date').type if 'date' in schema.names else None
|
|
178
|
+
except Exception:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
if time_type is not None:
|
|
182
|
+
s_int = int(start) * 1_000_000
|
|
183
|
+
e_int = int(end) * 1_000_000 + 99_999_999
|
|
184
|
+
try:
|
|
185
|
+
table = pq.read_table(
|
|
186
|
+
path, columns=cols_all,
|
|
187
|
+
filters=[('time', '>=', s_int), ('time', '<=', e_int)],
|
|
188
|
+
)
|
|
189
|
+
except Exception:
|
|
190
|
+
return None
|
|
191
|
+
return table.to_pandas()
|
|
192
|
+
|
|
193
|
+
if date_type is not None:
|
|
194
|
+
# date 列: pyarrow 可对 int 比较; string 列需退回 pandas.
|
|
195
|
+
from pyarrow import types as pat
|
|
196
|
+
if pat.is_integer(date_type):
|
|
197
|
+
try:
|
|
198
|
+
table = pq.read_table(
|
|
199
|
+
path, columns=cols_all,
|
|
200
|
+
filters=[('date', '>=', int(start)), ('date', '<=', int(end))],
|
|
201
|
+
)
|
|
202
|
+
except Exception:
|
|
203
|
+
return None
|
|
204
|
+
return table.to_pandas()
|
|
205
|
+
# string: read full, pandas slice 头 8 位 + int 比较
|
|
206
|
+
try:
|
|
207
|
+
table = pq.read_table(path, columns=cols_all)
|
|
208
|
+
except Exception:
|
|
209
|
+
return None
|
|
210
|
+
df = table.to_pandas()
|
|
211
|
+
if df.empty:
|
|
212
|
+
return None
|
|
213
|
+
d_str = df['date'].astype(str).str[:8]
|
|
214
|
+
mask = (d_str >= start) & (d_str <= end)
|
|
215
|
+
return df[mask]
|
|
216
|
+
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _normalize_bundle_df(df: pd.DataFrame) -> pd.DataFrame:
|
|
221
|
+
"""bundle raw df → akquant-ready schema.
|
|
222
|
+
|
|
223
|
+
time → date (datetime64); stockCode → symbol; 选列.
|
|
224
|
+
"""
|
|
225
|
+
df['date'] = pd.to_datetime(df['time'].astype(str).str[:8], format='%Y%m%d')
|
|
226
|
+
df = df.rename(columns={'stockCode': 'symbol'})
|
|
227
|
+
return df[['date', 'open', 'high', 'low', 'close', 'volume', 'symbol']].reset_index(drop=True)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _normalize_single_df(df: pd.DataFrame, symbol: str) -> pd.DataFrame:
|
|
231
|
+
"""single raw df → akquant-ready schema (注入 symbol 列).
|
|
232
|
+
|
|
233
|
+
容错: df 既有 `time` 又有 `date` 优先用 `time` (更精确).
|
|
234
|
+
date 列必须 datetime64 — akquant utils df_to_arrays 要求 (会找
|
|
235
|
+
["日期","date","datetime","time","timestamp"] 候选列, 但 string 类的
|
|
236
|
+
date 不被认作 timestamp, 需要 pandas 端 to_datetime 转).
|
|
237
|
+
"""
|
|
238
|
+
if 'time' in df.columns:
|
|
239
|
+
df['date'] = pd.to_datetime(df['time'].astype(str).str[:8], format='%Y%m%d')
|
|
240
|
+
elif 'date' in df.columns:
|
|
241
|
+
# date 可能是 YYYYMMDD 字符串 / int, 统一 to_datetime
|
|
242
|
+
df['date'] = pd.to_datetime(df['date'].astype(str).str[:8], format='%Y%m%d')
|
|
243
|
+
else:
|
|
244
|
+
return df # 无 date/time 列, schema 不兼容
|
|
245
|
+
df['symbol'] = symbol
|
|
246
|
+
keep = [c for c in ['date', 'open', 'high', 'low', 'close', 'volume', 'symbol']
|
|
247
|
+
if c in df.columns]
|
|
248
|
+
return df[keep].reset_index(drop=True)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def resolve(universe: list[str], start: str, end: str,
|
|
252
|
+
data_cache_dir: Path | None = None,
|
|
253
|
+
period: str = '1d') -> tuple[pd.DataFrame | None, str | None]:
|
|
254
|
+
"""自适应选 prebuilt + pyarrow filter pushdown 读子集.
|
|
255
|
+
|
|
256
|
+
返回 (df, source_path):
|
|
257
|
+
- df 为 None: 所有 prebuilt 都不命中 (调用方回退 market_mod)
|
|
258
|
+
- source_path 描述命中哪个文件 + kind ('bundle:__bundle__all_a_D' 等)
|
|
259
|
+
|
|
260
|
+
选择策略:
|
|
261
|
+
1) bundle 整包命中 → 一次 IO, filter pushdown 取 universe ∩ 覆盖集
|
|
262
|
+
2) 否则按 universe 中每标的找 single parquet → 每个标一次 IO
|
|
263
|
+
3) 都不命中 → 返 None
|
|
264
|
+
|
|
265
|
+
注: '1d' 之外的 period 当前不支持 (与旧 _try_prebuilt_parquet 一致).
|
|
266
|
+
"""
|
|
267
|
+
if period != '1d':
|
|
268
|
+
return None, None
|
|
269
|
+
cache_dir = data_cache_dir or _default_cache_dir()
|
|
270
|
+
if not universe:
|
|
271
|
+
return None, None
|
|
272
|
+
|
|
273
|
+
# 裸码 → 带后缀 (config 标准写法是裸码; bundle stockCode 是 '600000.SH' 形态,
|
|
274
|
+
# filter 必须带后缀才命中 — 2026-08-19 e2e 修复: run/manifest/parity 全走这里).
|
|
275
|
+
from .akquant_schema_adapter import normalize_symbol
|
|
276
|
+
universe = [normalize_symbol(s) for s in universe]
|
|
277
|
+
|
|
278
|
+
# ---- 1) bundle 整包 (累积式 — 跨 universe 时各 bundle 各取子集, 最后 concat) ----
|
|
279
|
+
bundle_pieces: list[pd.DataFrame] = []
|
|
280
|
+
bundle_sources: list[str] = []
|
|
281
|
+
remaining = set(universe)
|
|
282
|
+
for spec in KNOWN_PREBUILT:
|
|
283
|
+
if spec.kind != 'bundle':
|
|
284
|
+
continue
|
|
285
|
+
path = _resolve_path(spec, cache_dir)
|
|
286
|
+
if not path.exists():
|
|
287
|
+
continue
|
|
288
|
+
hit = _bundle_covers(spec, remaining)
|
|
289
|
+
if not hit:
|
|
290
|
+
continue
|
|
291
|
+
try:
|
|
292
|
+
df = _read_with_pushdown(
|
|
293
|
+
path,
|
|
294
|
+
columns=['time', 'stockCode', 'open', 'high', 'low', 'close', 'volume'],
|
|
295
|
+
filters=_build_arrow_filters(sorted(hit), start, end),
|
|
296
|
+
)
|
|
297
|
+
except Exception: # noqa: BLE001
|
|
298
|
+
return None, None
|
|
299
|
+
if df.empty:
|
|
300
|
+
continue
|
|
301
|
+
bundle_pieces.append(_normalize_bundle_df(df))
|
|
302
|
+
bundle_sources.append(f'{spec.path_template}:{len(hit)}')
|
|
303
|
+
remaining -= hit
|
|
304
|
+
if not remaining:
|
|
305
|
+
break
|
|
306
|
+
if bundle_pieces:
|
|
307
|
+
merged = pd.concat(bundle_pieces, ignore_index=True)
|
|
308
|
+
return merged, 'bundle:' + '+'.join(bundle_sources)
|
|
309
|
+
|
|
310
|
+
# ---- 2) single parquet 拼 ----
|
|
311
|
+
by_symbol = {s: s for s in universe}
|
|
312
|
+
pieces: list[pd.DataFrame] = []
|
|
313
|
+
used: list[str] = []
|
|
314
|
+
for sym in universe:
|
|
315
|
+
for spec in KNOWN_PREBUILT:
|
|
316
|
+
if spec.kind != 'single' or spec.single_symbol != sym:
|
|
317
|
+
continue
|
|
318
|
+
path = _resolve_path(spec, cache_dir)
|
|
319
|
+
if not path.exists():
|
|
320
|
+
continue
|
|
321
|
+
raw = _read_single_with_pushdown(path, start, end)
|
|
322
|
+
if raw is None or raw.empty:
|
|
323
|
+
break
|
|
324
|
+
# single parquet 可能没 stockCode 列 (旧 schema) — 显式注入
|
|
325
|
+
if 'stockCode' not in raw.columns:
|
|
326
|
+
raw = _normalize_single_df(raw, sym)
|
|
327
|
+
else:
|
|
328
|
+
raw = _normalize_bundle_df(raw)
|
|
329
|
+
pieces.append(raw)
|
|
330
|
+
used.append(sym)
|
|
331
|
+
break
|
|
332
|
+
else:
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
if not pieces:
|
|
336
|
+
return None, None
|
|
337
|
+
df = pd.concat(pieces, ignore_index=True)
|
|
338
|
+
return df, f'single:{",".join(used)}'
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def discover(data_cache_dir: Path | None = None) -> list[dict]:
|
|
342
|
+
"""扫盘已知 prebuilt → 报每个 metadata (path/exists/size).
|
|
343
|
+
|
|
344
|
+
供 CLI / smoke 打印 prebuilt 状态. 不读 parquet 内容, 只 stat.
|
|
345
|
+
"""
|
|
346
|
+
cache_dir = data_cache_dir or _default_cache_dir()
|
|
347
|
+
out: list[dict] = []
|
|
348
|
+
for spec in KNOWN_PREBUILT:
|
|
349
|
+
path = _resolve_path(spec, cache_dir)
|
|
350
|
+
out.append({
|
|
351
|
+
'path': str(path),
|
|
352
|
+
'exists': path.exists(),
|
|
353
|
+
'size_mb': round(path.stat().st_size / 1e6, 1) if path.exists() else 0.0,
|
|
354
|
+
'kind': spec.kind,
|
|
355
|
+
'universe_label': spec.universe_label,
|
|
356
|
+
})
|
|
357
|
+
return out
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _selfcheck() -> None:
|
|
361
|
+
"""冒烟:
|
|
362
|
+
1) discover() 不抛
|
|
363
|
+
2) resolve(全 A 子集, 1y) → 命中 __bundle__all_a_D, df 非空 + pyarrow filter 推下行
|
|
364
|
+
3) resolve(单股 600000.SH) → 命中 600000_D_fq1.parquet (新增能力)
|
|
365
|
+
4) resolve([未知标]) → None
|
|
366
|
+
"""
|
|
367
|
+
cache_dir = _default_cache_dir()
|
|
368
|
+
discovered = discover(cache_dir)
|
|
369
|
+
assert isinstance(discovered, list) and len(discovered) >= 3, \
|
|
370
|
+
f'KNOWN_PREBUILT 至少 3 个 (bundle+2 single), 实际 {len(discovered)}'
|
|
371
|
+
print(f'OK: discover() → {len(discovered)} known prebuilt')
|
|
372
|
+
for d in discovered:
|
|
373
|
+
flag = '✓' if d['exists'] else '✗'
|
|
374
|
+
print(f' {flag} {d["path"]} ({d["size_mb"]} MB, {d["kind"]})')
|
|
375
|
+
|
|
376
|
+
# ---- 2) bundle 命中 ----
|
|
377
|
+
bundle_path = cache_dir / '__bundle__all_a_D.parquet'
|
|
378
|
+
if bundle_path.exists():
|
|
379
|
+
# 拿全 A 池 stockCode list (复用 _try_prebuilt_parquet 风格, 但要全量)
|
|
380
|
+
all_codes_df = pd.read_parquet(bundle_path, columns=['stockCode'])
|
|
381
|
+
codes = sorted(all_codes_df['stockCode'].unique().tolist())
|
|
382
|
+
sub = codes[:10] # 前 10 标的, 验证 resolver 真过滤
|
|
383
|
+
df, src = resolve(sub, '20240701', '20250731', data_cache_dir=cache_dir)
|
|
384
|
+
assert df is not None and src is not None and src.startswith('bundle:'), \
|
|
385
|
+
f'bundle 路径应命中, src={src}'
|
|
386
|
+
assert df['symbol'].nunique() <= len(sub), \
|
|
387
|
+
f'filter pushdown 失效? unique_syms={df["symbol"].nunique()} > sub={len(sub)}'
|
|
388
|
+
# 验证 df 真的只有 sub 里那些标的
|
|
389
|
+
assert set(df['symbol'].unique()) <= set(sub), \
|
|
390
|
+
f'filter 漏标: {set(df["symbol"].unique()) - set(sub)}'
|
|
391
|
+
print(f'OK: bundle 命中 + filter pushdown (子集 {len(sub)} 标 → {df["symbol"].nunique()} 唯一, src={src})')
|
|
392
|
+
else:
|
|
393
|
+
print('SKIP: __bundle__all_a_D 不存在, bundle 路径未测')
|
|
394
|
+
|
|
395
|
+
# ---- 3) single 命中 — bundle 优先策略下 600000.SH 会被 bundle 抢先命中;
|
|
396
|
+
# 这里直接测 _read_single_with_pushdown (走 single IO + filter pushdown).
|
|
397
|
+
single_path = cache_dir / '600000_D_fq1.parquet'
|
|
398
|
+
if single_path.exists():
|
|
399
|
+
raw = _read_single_with_pushdown(single_path, '20240701', '20250731')
|
|
400
|
+
assert raw is not None and not raw.empty, 'single 路径应非空'
|
|
401
|
+
# 测 full normalize (注入 symbol + date)
|
|
402
|
+
norm = _normalize_single_df(raw, '600000.SH')
|
|
403
|
+
assert norm['symbol'].unique().tolist() == ['600000.SH']
|
|
404
|
+
assert list(norm.columns) == ['date', 'open', 'high', 'low', 'close', 'volume', 'symbol']
|
|
405
|
+
print(f'OK: _read_single_with_pushdown + normalize (600000.SH → {len(norm)} rows)')
|
|
406
|
+
# 单股场景通过 resolve() 也会被 bundle 抢命中, 这是预期 (bundle 优先).
|
|
407
|
+
df, src = resolve(['600000.SH'], '20240701', '20250731', data_cache_dir=cache_dir)
|
|
408
|
+
assert df is not None and src is not None
|
|
409
|
+
print(f'OK: resolve([600000.SH]) → bundle 优先 (src={src})')
|
|
410
|
+
else:
|
|
411
|
+
print('SKIP: 600000_D_fq1 不存在, single 路径未测')
|
|
412
|
+
|
|
413
|
+
# ---- 4) 完全不命中 → None ----
|
|
414
|
+
df, src = resolve(['999999.SH'], '20240101', '20241231', data_cache_dir=cache_dir)
|
|
415
|
+
assert df is None, f'未知标应 None, 实际 df 非空 ({len(df)} rows)'
|
|
416
|
+
print(f'OK: 不命中标 → (None, None)')
|
|
417
|
+
|
|
418
|
+
# ---- 5) prefix 隔离 — 直接验 KNOWN_PREBUILT prefix 谓词, 不读 parquet (无需数据) ----
|
|
419
|
+
from ..prebuilt_resolver import _bundle_covers, KNOWN_PREBUILT as _KP
|
|
420
|
+
by_label = {s.universe_label: s for s in _KP}
|
|
421
|
+
if 'all_a' in by_label and 'etf' in by_label and 'convertible_bond' in by_label:
|
|
422
|
+
all_a_spec = by_label['all_a']
|
|
423
|
+
etf_spec = by_label['etf']
|
|
424
|
+
cb_spec = by_label['convertible_bond']
|
|
425
|
+
assert _bundle_covers(all_a_spec, ['113021.SH']) == set(), \
|
|
426
|
+
'all_a bundle 不应覆盖 CB 11x'
|
|
427
|
+
assert _bundle_covers(all_a_spec, ['510500.SH']) == set(), \
|
|
428
|
+
'all_a bundle 不应覆盖 ETF 51x'
|
|
429
|
+
assert _bundle_covers(etf_spec, ['510500.SH']) == {'510500.SH'}
|
|
430
|
+
assert _bundle_covers(etf_spec, ['600000.SH']) == set(), \
|
|
431
|
+
'etf bundle 不应覆盖 A 股 60x'
|
|
432
|
+
assert _bundle_covers(cb_spec, ['113021.SH']) == {'113021.SH'}
|
|
433
|
+
assert _bundle_covers(cb_spec, ['600000.SH']) == set()
|
|
434
|
+
print('OK: prefix 隔离 (all_a/etf/cb 三域互不交叉) — KNOWN_PREBUILT 注册项行为正确')
|
|
435
|
+
else:
|
|
436
|
+
print('SKIP: prefix 隔离 (KNOWN_PREBUILT 缺 etf/cb/all_a 注册)')
|
|
437
|
+
|
|
438
|
+
# ---- 6) 跨 bundle 合并 — 仅当本地同时有 ≥2 个 bundle 时测 (否则 SKIP) ----
|
|
439
|
+
bundle_specs_real = [s for s in _KP if s.kind == 'bundle' and (cache_dir / s.path_template).exists()]
|
|
440
|
+
if len(bundle_specs_real) >= 2:
|
|
441
|
+
# 拿每个 bundle 的真实 stockCode, 跨 bundle 拼 universe
|
|
442
|
+
all_codes: list[str] = []
|
|
443
|
+
for s in bundle_specs_real[:2]:
|
|
444
|
+
df_b = pd.read_parquet(cache_dir / s.path_template, columns=['stockCode'])
|
|
445
|
+
all_codes.append(df_b['stockCode'].iloc[0]) # 每 bundle 取第一个
|
|
446
|
+
df, src = resolve(all_codes, '20240101', '20241231', data_cache_dir=cache_dir)
|
|
447
|
+
if df is not None:
|
|
448
|
+
print(f'OK: 跨 bundle 合并 (本地 {len(bundle_specs_real)} bundle → {df["symbol"].nunique()} 唯一标, src={src})')
|
|
449
|
+
else:
|
|
450
|
+
print(f'SKIP: 跨 bundle 合并 (resolve 返 None, src={src})')
|
|
451
|
+
else:
|
|
452
|
+
print(f'SKIP: 跨 bundle 合并 (本地仅 {len(bundle_specs_real)} 个 bundle 真存在, 不足 2 个无法测跨 bundle)')
|
|
453
|
+
|
|
454
|
+
# ---- 7) 真实本地无 etf/cb bundle → None (验证 KNOWN_PREBUILT 注册但不误命中) ----
|
|
455
|
+
df, src = resolve(['510500.SH'], '20240101', '20241231', data_cache_dir=cache_dir)
|
|
456
|
+
if (cache_dir / '__bundle__etf_D.parquet').exists():
|
|
457
|
+
print(f'SKIP: 本地已有 etf bundle, 此 case 应改走 ETF 命中 ({src})')
|
|
458
|
+
else:
|
|
459
|
+
assert df is None, f'本地无 etf bundle 应 None, 实际 src={src}'
|
|
460
|
+
print('OK: 本地无 etf bundle → (None, None) — fallback market_mod')
|
|
461
|
+
df, src = resolve(['113021.SH'], '20240101', '20241231', data_cache_dir=cache_dir)
|
|
462
|
+
if (cache_dir / '__bundle__cb_D.parquet').exists():
|
|
463
|
+
print(f'SKIP: 本地已有 cb bundle, 此 case 应改走 CB 命中 ({src})')
|
|
464
|
+
else:
|
|
465
|
+
assert df is None, f'本地无 cb bundle 应 None, 实际 src={src}'
|
|
466
|
+
print('OK: 本地无 cb bundle → (None, None) — fallback market_mod')
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
if __name__ == '__main__':
|
|
470
|
+
_selfcheck()
|