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.
@@ -0,0 +1,295 @@
1
+ """ADR-0040 Phase B — akquant 数据 adapter (v2 自有版本, B4).
2
+
3
+ 来源: 原仓根 `hamuna_quant_cli/references/akquant_data_adapter.py`.
4
+ 本文件是 wholesale copy, 算法/clamp/load 不变. 迁移原因: v2 skill 独立分发.
5
+ 唯一改动: `from references.prebuilt_resolver` → `from .prebuilt_resolver`
6
+ (B5 搬入同包); `# strategy_cli.fundamental.data 已被 hamuna_quant_cli 独立化 (Round 14, 2026-08-18)
7
+ (cohabit hack 暂留, 等 hamuna_quant_cli 抽离 v1 依赖再说 — 沿用原 ceiling 注释).
8
+
9
+ 把 hamuna prebuilt parquet / get_market_data_ex 返回的 bar 数据, 转为 akquant DataFrame.
10
+
11
+ 输入 schema (hamuna prebuilt parquet):
12
+ time (int YYYYMMDD) | stockCode (str e.g. '600000.SH') | stockName (str)
13
+ | open | high | low | close | volume (int) | amount (float)
14
+
15
+ 输出 schema (akquant 接受):
16
+ date (datetime64) | open | high | low | close | volume | symbol
17
+
18
+ 字段对齐:
19
+ time → date (转 pd.Timestamp via %Y%m%d)
20
+ stockCode → symbol (保留 .SH/.SZ 后缀, 实测 akquant 接受)
21
+ stockName → drop
22
+ amount → drop (akquant 不消费)
23
+ open/high/low/close/volume → 原样
24
+
25
+ 多 symbol 拼接: 用 pd.concat per-symbol df 长表, symbol 列保留 (akquant 内部 groupby).
26
+
27
+ 涨跌停 clamp (Q1 兜底, **Phase B 默认开**): akquant 0.3.x 实测不原生拒单
28
+ (Q1 probe: orders=1, rejected=0, 不像 QMT 撮合层会拒). 必须上游把 high / low
29
+ clamp 到 ±limit_pct 范围, 否则 backtest 看到超出涨跌停的"不可能价格".
30
+ - `load_prebuilt_to_akquant_with_limits()`: 自动按板别 (60x/00x=10%, 30x/688x=20%)
31
+ clamp 后输出 akquant-ready DataFrame.
32
+ - `clamp_to_price_limit(df, limits)`: 手动指定 limits 字典.
33
+ - 板别规则见 _PRICE_LIMIT_RULES + detect_price_limit().
34
+
35
+ Ponytail ceiling: 当前只支持日线 (1d). 5m/tick 走 v1 strategy_cli run 路径 (QMT 适配).
36
+ """
37
+ from __future__ import annotations
38
+
39
+ from pathlib import Path
40
+ from typing import Iterable
41
+
42
+ import pandas as pd
43
+
44
+ # hamuna prebuilt parquet 必需列 (R2 mitigation: schema 改了直接抛).
45
+ # dataset 走容维 / prebuilt 时字段名已归一为 `date` (market.get_market_data_ex 输出);
46
+ # 直读 prebuilt parquet 才用 `time` (旧 schema, 见 dataset.py:194).
47
+ # symbol 由 per-stock loop 注入, bar dict 不带 (容维 API 不返回).
48
+ REQUIRED_DATE_COLS = {'date', 'time'}
49
+ REQUIRED_OHLCV_COLS = {'open', 'high', 'low', 'close', 'volume'}
50
+ _DROP_COLS = {'stockName', 'amount', 'time', 'stockCode'} # drop 旧 schema 字段 (归一后多余)
51
+
52
+ # A 股涨跌停默认上限 (Phase B 默认值; Q3 测试后可能改 0.10 vs 0.25)
53
+ DEFAULT_PRICE_LIMIT_PCT = 0.10 # 主板 10%; 创业板/科创板 20% 在 parity test 单独测
54
+
55
+ # A 股板别 → 涨跌停上限 (沪市主板 60xxxx / 深市主板 000xxx → 10%;
56
+ # 创业板 30xxxx / 科创板 688xxx → 20%)
57
+ # 沪市主板: 600 / 601 / 603 / 605; 深市主板: 000 / 001 / 002 / 003
58
+ # 创业板: 300 / 301; 科创板: 688 / 689
59
+ # (来源: 上交所/深交所交易规则; ST 股 5% 不在此覆盖, 需手工 cfg 覆盖)
60
+ _PRICE_LIMIT_RULES: dict[str, float] = {
61
+ '600': 0.10, '601': 0.10, '603': 0.10, '605': 0.10,
62
+ '000': 0.10, '001': 0.10, '002': 0.10, '003': 0.10,
63
+ '300': 0.20, '301': 0.20, # 创业板
64
+ '688': 0.20, '689': 0.20, # 科创板
65
+ }
66
+
67
+
68
+ def detect_price_limit(symbol: str) -> float:
69
+ """根据 symbol 前缀判断板别, 返回涨跌停上限 (0.10 / 0.20).
70
+
71
+ symbol 格式: '600000.SH' / '000001.SZ' / '300750.SZ' / '688981.SH'.
72
+ 未知前缀 → DEFAULT_PRICE_LIMIT_PCT (默认 0.10 保守).
73
+ """
74
+ code = symbol.split('.')[0]
75
+ for prefix, limit in _PRICE_LIMIT_RULES.items():
76
+ if code.startswith(prefix):
77
+ return limit
78
+ return DEFAULT_PRICE_LIMIT_PCT
79
+
80
+
81
+ def load_prebuilt_to_akquant(universe: Iterable[str], start: str, end: str,
82
+ period: str = '1d') -> pd.DataFrame:
83
+ """读 prebuilt parquet, 返回 akquant-ready multi-symbol DataFrame (Round 14 重构).
84
+
85
+ universe: 标的代码 list, e.g. ['600000.SH', '000001.SZ']
86
+ start/end: 'YYYYMMDD' 字符串
87
+ period: '1d' (Phase B 仅日线)
88
+
89
+ 数据源: prebuilt_resolver.resolve (pyarrow filter pushdown, fast-path).
90
+ prebuilt 找不到 → raise FileNotFoundError + 引导用户跑 `dataset fetch`.
91
+ """
92
+ if period != '1d':
93
+ raise NotImplementedError(
94
+ f'akquant_data_adapter 仅支持 1d (Phase B), period={period} 待 akquant Phase C')
95
+ codes = list(universe)
96
+ if not codes:
97
+ raise ValueError('universe 不能为空')
98
+
99
+ # 委托 prebuilt_resolver.resolve (pyarrow filter pushdown, fast-path)
100
+ from .prebuilt_resolver import resolve # Round 14: 同包相对 import
101
+ df, _src = resolve(codes, start, end, period=period)
102
+ if df is None or df.empty:
103
+ raise FileNotFoundError(
104
+ f'未取到 bar 数据 (universe={codes}, {start}-{end}, period={period}). '
105
+ f'先用 `hamuna_quant_cli dataset fetch --symbols {",".join(codes[:5])}... '
106
+ f'--start {start} --end {end}` 构建 prebuilt, 再跑回测.'
107
+ )
108
+
109
+ # 字段归一 + schema 校验 (prebuilt_resolver 已返回多 sym 长表, 但需归一旧 schema)
110
+ rename = {}
111
+ if 'date' not in df.columns and 'time' in df.columns:
112
+ rename['time'] = 'date'
113
+ if 'symbol' not in df.columns and 'stockCode' in df.columns:
114
+ rename['stockCode'] = 'symbol'
115
+ if rename:
116
+ df = df.rename(columns=rename)
117
+ cols = set(df.columns)
118
+ if not (cols & REQUIRED_DATE_COLS):
119
+ raise ValueError(
120
+ f'prebuilt 缺 date/time 列 (R2: schema 不兼容). 实际列: {list(df.columns)[:10]}...')
121
+ missing = REQUIRED_OHLCV_COLS - cols
122
+ if missing:
123
+ raise ValueError(
124
+ f'prebuilt 缺 OHLCV 列 {missing} (R2: schema 不兼容). 实际列: {list(df.columns)[:10]}...')
125
+ for c in _DROP_COLS:
126
+ df = df.drop(columns=[c], errors='ignore')
127
+ df['date'] = pd.to_datetime(df['date'].astype(str), format='%Y%m%d')
128
+ df = df[['date', 'open', 'high', 'low', 'close', 'volume', 'symbol']]
129
+ return df.reset_index(drop=True)
130
+
131
+
132
+ def load_prebuilt_to_akquant_with_limits(universe: Iterable[str], start: str, end: str,
133
+ period: str = '1d') -> pd.DataFrame:
134
+ """读 prebuilt + 自动按板别 clamp 涨跌停 (Q1 兜底).
135
+
136
+ 等价于 load_prebuilt_to_akquant + clamp_to_price_limit_per_symbol.
137
+ akquant 0.3.x 实测不原生拒单 (Q1 probe: orders=1, rejected=0), 需上游把
138
+ high / low clamp 到 ±limit_pct 范围, 避免 backtest 看到超出涨跌停的"不可能价格".
139
+
140
+ 板别判断: detect_price_limit() 按 code 前缀 (60x/00x=10%, 30x/688x=20%).
141
+
142
+ fast-path: 优先直读 prebuilt parquet (1× IO, 0.3s for 5004 syms), fallback
143
+ 到 market_mod 路径 (慢, ~92s for 5004 syms).
144
+ """
145
+ df = _try_prebuilt_parquet(universe, start, end, period)
146
+ if df is None:
147
+ df = load_prebuilt_to_akquant(universe, start, end, period)
148
+ limits = {sym: detect_price_limit(sym) for sym in df['symbol'].unique()}
149
+ return clamp_to_price_limit(df, limits)
150
+
151
+
152
+ def clamp_to_price_limit(df: pd.DataFrame, limits: dict[str, float]) -> pd.DataFrame:
153
+ """若 akquant 不原生支持涨跌停 (Q1 探针结果), 用此兜底.
154
+
155
+ limits: {symbol: limit_pct} e.g. {'600000.SH': 0.10, '300750.SZ': 0.20}
156
+
157
+ 算法: 按 symbol groupby, 取上一交易日 close × (1 ± limit) 算 upper/lower bound,
158
+ 把 high/low clamp 进去. open/close 不动 (成交价走 OHLCV 自然).
159
+
160
+ 矢量化: per-symbol limit 拼成 Series 后整体 clip, 避免 Python loop.
161
+ 实测: 5004 syms × 1.8M rows 原 56s → 0.4s.
162
+ """
163
+ if df.empty:
164
+ return df
165
+ out = df.copy()
166
+ # per-symbol limit vector (NaN → default 10%)
167
+ limits_s = out['symbol'].map(limits).fillna(DEFAULT_PRICE_LIMIT_PCT)
168
+ # prev_close per symbol (groupby shift 1)
169
+ prev_close = out.groupby('symbol', sort=False)['close'].shift(1)
170
+ upper = prev_close * (1 + limits_s)
171
+ lower = prev_close * (1 - limits_s)
172
+ out['high'] = out['high'].clip(upper=upper)
173
+ out['low'] = out['low'].clip(lower=lower)
174
+ return out
175
+
176
+
177
+ def _try_prebuilt_parquet(universe: Iterable[str], start: str, end: str,
178
+ period: str = '1d') -> pd.DataFrame | None:
179
+ """尝试自适应直读 prebuilt parquet.
180
+
181
+ 委托 `hamuna_quant_cli.references.prebuilt_resolver.resolve` — 它做两件事:
182
+ 1) 自适应选 bundle (__bundle__all_a_D.parquet 覆盖全 A) 还是单股
183
+ (600000_D_fq1.parquet 等) — 旧实现硬编码 bundle, 单股场景会无谓 fallback
184
+ 2) pyarrow filter pushdown 只解压 universe ∩ window 的行 group —
185
+ 旧实现 `pd.read_parquet(columns=...)` 仍读全部行
186
+
187
+ 返回 None 表示无 prebuilt 命中 (调用方 fallback: 报错 + 引导下载).
188
+ """
189
+ if period != '1d':
190
+ return None
191
+ from .prebuilt_resolver import resolve # B5 搬入同包
192
+ df, _src = resolve(list(universe), start, end, period=period)
193
+ return df
194
+
195
+
196
+ def _selfcheck() -> None:
197
+ """冒烟: (1) 缺数据 raise FileNotFoundError; (2) 板别判断 + clamp 行为对.
198
+
199
+ � 单元测试用 mock DataFrame. 显式门控: 仅当 `HAMUNA_SELFTEST=1` 时执行,
200
+ 防止 Agent 误调 `_selfcheck()` 以为是 prod 数据校验. 调用方式:
201
+ HAMUNA_SELFTEST=1 python -c "from ...akquant_data_adapter import _selfcheck; _selfcheck()"
202
+ """
203
+ import os
204
+ if os.environ.get("HAMUNA_SELFTEST") != "1":
205
+ raise RuntimeError(
206
+ "_selfcheck() 用 mock DataFrame 验行为, 不用于 prod 数据校验. "
207
+ "设置 HAMUNA_SELFTEST=1 显式启用."
208
+ )
209
+ import tempfile
210
+ from pathlib import Path
211
+
212
+ # ---- 1) 板别判断 (无需真实数据, 仅 import + detect_price_limit) ----
213
+ assert detect_price_limit('600000.SH') == 0.10, '沪市主板 60x 应 10%'
214
+ assert detect_price_limit('000001.SZ') == 0.10, '深市主板 00x 应 10%'
215
+ assert detect_price_limit('300750.SZ') == 0.20, '创业板 30x 应 20%'
216
+ assert detect_price_limit('688981.SH') == 0.20, '科创板 688x 应 20%'
217
+ assert detect_price_limit('999999.SH') == 0.10, '未知前缀回退默认 10%'
218
+ print('OK: detect_price_limit 板别判断 (主板10% / 创业板/科创板20%)')
219
+
220
+ # ---- 2) clamp_to_price_limit 行为验证 (合成 mock DataFrame) ----
221
+ mock_df = pd.DataFrame({
222
+ 'date': pd.to_datetime(['20240102', '20240103', '20240104']),
223
+ 'open': [10.0, 10.0, 10.0],
224
+ 'high': [11.5, 12.0, 11.0], # 10% 主板: 11.5 应被 clamp 到 11.0
225
+ 'low': [9.0, 8.5, 9.0], # 10% 主板: 8.5 应被 clamp 到 9.0
226
+ 'close': [10.0, 10.0, 10.0],
227
+ 'volume': [1000, 1000, 1000],
228
+ 'symbol': ['600000.SH'] * 3,
229
+ })
230
+ clamped = clamp_to_price_limit(mock_df, {'600000.SH': 0.10})
231
+ # 第二行: prev_close=10.0, upper=11.0, lower=9.0
232
+ assert clamped.iloc[1]['high'] == 11.0, f'主板 high 应 clamp 到 11.0, 实际 {clamped.iloc[1]["high"]}'
233
+ assert clamped.iloc[1]['low'] == 9.0, f'主板 low 应 clamp 到 9.0, 实际 {clamped.iloc[1]["low"]}'
234
+ print('OK: clamp_to_price_limit 主板 10% clamp 行为正确 (high=12→11, low=8.5→9)')
235
+
236
+ # 创业板 20% 测试
237
+ mock_cyb = mock_df.copy()
238
+ mock_cyb['symbol'] = '300750.SZ'
239
+ clamped_cyb = clamp_to_price_limit(mock_cyb, {'300750.SZ': 0.20})
240
+ # prev_close=10.0, 20%: upper=12.0, lower=8.0 → 12.0 不动, 8.5 不动
241
+ assert clamped_cyb.iloc[1]['high'] == 12.0, f'创业板 20% 12.0 不应 clamp'
242
+ assert clamped_cyb.iloc[1]['low'] == 8.5, f'创业板 20% 8.5 不应 clamp'
243
+ print('OK: clamp_to_price_limit 创业板 20% 不动 (12.0/8.5 在 8-12 范围内)')
244
+
245
+ # ---- 3) 缺数据 (无 prebuilt) → FileNotFoundError / 有数据 → 正常返回 ----
246
+ # 此段不强 assert: dataset 可能已存在 (用户配了 credentials) 也可能缺.
247
+ # 仅验证函数调用不抛非预期异常即可.
248
+ with tempfile.TemporaryDirectory() as tmp:
249
+ old = os.environ.get('HAMUNA_STRATEGIES_ROOT')
250
+ os.environ['HAMUNA_STRATEGIES_ROOT'] = tmp
251
+ try:
252
+ try:
253
+ df = load_prebuilt_to_akquant(['600000.SH'], '20240101', '20240131')
254
+ # 有数据 (已配 credentials + dataset 覆盖) → 验列名
255
+ assert list(df.columns) == ['date', 'open', 'high', 'low', 'close', 'volume', 'symbol']
256
+ assert len(df) > 0
257
+ print(f'OK: akquant_data_adapter 端到端通 (n={len(df)} bars, 含 clamp-ready schema)')
258
+ except FileNotFoundError:
259
+ print('SKIP: akquant_data_adapter 缺数据 (无 prebuilt), 函数契约不破')
260
+ finally:
261
+ if old is None:
262
+ os.environ.pop('HAMUNA_STRATEGIES_ROOT', None)
263
+ else:
264
+ os.environ['HAMUNA_STRATEGIES_ROOT'] = old
265
+
266
+ # ---- 4) schema 常量声明 (R2 mitigation 文档) ----
267
+ assert REQUIRED_DATE_COLS == {'date', 'time'}
268
+ assert REQUIRED_OHLCV_COLS == {'open', 'high', 'low', 'close', 'volume'}
269
+ print('OK: schema 常量二选一规则声明 (R2 mitigation)')
270
+
271
+ # ---- 5) 直读 prebuilt parquet fast-path (50× 提速) ----
272
+ import time
273
+ bundle = Path.home() / '.hamuna/data_cache/datasets/__bundle__all_a_D.parquet'
274
+ if bundle.exists():
275
+ t0 = time.time()
276
+ fast_df = _try_prebuilt_parquet(['600000.SH', '600036.SH'], '20240701', '20251231')
277
+ elapsed = time.time() - t0
278
+ if fast_df is not None and not fast_df.empty:
279
+ assert list(fast_df.columns) == ['date', 'open', 'high', 'low', 'close', 'volume', 'symbol']
280
+ assert len(fast_df) > 0
281
+ print(f'OK: _try_prebuilt_parquet fast-path ({len(fast_df)} rows, {elapsed:.2f}s)')
282
+ # sanity: 时间窗口边界
283
+ d_min = fast_df['date'].min()
284
+ d_max = fast_df['date'].max()
285
+ assert str(d_min)[:10] >= '2024-07-01', f'fast-path 起始日 {d_min} 应 ≥ 2024-07-01'
286
+ assert str(d_max)[:10] <= '2025-12-31', f'fast-path 截止日 {d_max} 应 ≤ 2025-12-31'
287
+ print(f'OK: 时间窗口 [{d_min}, {d_max}]')
288
+ else:
289
+ print('SKIP: _try_prebuilt_parquet 返回空 (universe 不在 bundle?)')
290
+ else:
291
+ print('SKIP: prebuilt bundle 不存在, fast-path 未测')
292
+
293
+
294
+ if __name__ == '__main__':
295
+ _selfcheck()