hamuna-quant-cli 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.
- 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 +297 -0
- hamuna_quant_cli/akquant_runner.py +681 -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 +461 -0
- hamuna_quant_cli/live/runner.py +570 -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 +560 -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.dist-info/METADATA +156 -0
- hamuna_quant_cli-0.1.0.dist-info/RECORD +32 -0
- hamuna_quant_cli-0.1.0.dist-info/WHEEL +5 -0
- hamuna_quant_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hamuna_quant_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""strategy_cli.runtime.cache: 本地缓存 (ADR-015 ~/.hamuna/data_cache)。
|
|
2
|
+
|
|
3
|
+
格式:
|
|
4
|
+
- K 线 (大体积, 频繁): parquet
|
|
5
|
+
- 其他 (基本面/股本/股东/StockCode): JSON
|
|
6
|
+
|
|
7
|
+
TTL 按 endpoint 粒度; 文件 mtime 超 TTL 视为 miss。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
# 默认缓存根: ~/.hamuna/data_cache (符合 ADR-015 §303)
|
|
18
|
+
_CACHE_ROOT = Path.home() / '.hamuna' / 'data_cache'
|
|
19
|
+
|
|
20
|
+
# endpoint 粒度 TTL (秒)
|
|
21
|
+
# K 线 = 准实时 (5min); 财务 = 当日有效 (1d); StockCode = 当日有效 (1d)
|
|
22
|
+
_TTL: dict[str, int] = {
|
|
23
|
+
'StockBars': 300, # 5 min
|
|
24
|
+
'RealTime': 60, # 1 min
|
|
25
|
+
'StockHq': 300,
|
|
26
|
+
'StockFinancial': 86400, # 1 day
|
|
27
|
+
'StockAnnualReport': 86400,
|
|
28
|
+
'StockQuarterlyReport': 86400,
|
|
29
|
+
'StockShareholder': 86400,
|
|
30
|
+
'StockShareCapital': 86400,
|
|
31
|
+
'StockBasicInfo': 86400,
|
|
32
|
+
'StockBond': 86400,
|
|
33
|
+
'ETFInfo': 86400,
|
|
34
|
+
'StockCode': 86400,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_PARQUET_ENDPOINTS = {'StockBars', 'RealTime', 'StockHq'}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cache_dir(endpoint: str) -> Path:
|
|
41
|
+
p = _CACHE_ROOT / endpoint
|
|
42
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return p
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cache_key(params: dict[str, Any]) -> str:
|
|
47
|
+
"""参数 dict → 稳定 hash (sorted keys, urlencoded-style)."""
|
|
48
|
+
items = sorted((k, str(v)) for k, v in params.items() if v is not None)
|
|
49
|
+
raw = '&'.join(f'{k}={v}' for k, v in items)
|
|
50
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_fresh(path: Path, endpoint: str) -> bool:
|
|
54
|
+
if not path.exists():
|
|
55
|
+
return False
|
|
56
|
+
ttl = _TTL.get(endpoint, 3600)
|
|
57
|
+
return (time.time() - path.stat().st_mtime) < ttl
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read(endpoint: str, params: dict[str, Any]) -> dict | list | None:
|
|
61
|
+
"""命中返回解析后 dict/list; miss/过期返回 None。"""
|
|
62
|
+
key = cache_key(params)
|
|
63
|
+
if endpoint in _PARQUET_ENDPOINTS:
|
|
64
|
+
path = cache_dir(endpoint) / f'{key}.parquet'
|
|
65
|
+
if not is_fresh(path, endpoint):
|
|
66
|
+
return None
|
|
67
|
+
return _read_parquet(path)
|
|
68
|
+
path = cache_dir(endpoint) / f'{key}.json'
|
|
69
|
+
if not is_fresh(path, endpoint):
|
|
70
|
+
return None
|
|
71
|
+
with path.open(encoding='utf-8') as f:
|
|
72
|
+
return json.load(f)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def write(endpoint: str, params: dict[str, Any], data: dict | list) -> None:
|
|
76
|
+
key = cache_key(params)
|
|
77
|
+
if endpoint in _PARQUET_ENDPOINTS and isinstance(data, list):
|
|
78
|
+
path = cache_dir(endpoint) / f'{key}.parquet'
|
|
79
|
+
_write_parquet(path, data)
|
|
80
|
+
else:
|
|
81
|
+
path = cache_dir(endpoint) / f'{key}.json'
|
|
82
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2),
|
|
83
|
+
encoding='utf-8')
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def clear(endpoint: str | None = None) -> int:
|
|
87
|
+
"""清除缓存; endpoint=None 清除全部。返回删除文件数。"""
|
|
88
|
+
n = 0
|
|
89
|
+
targets = [_CACHE_ROOT / endpoint] if endpoint else [_CACHE_ROOT]
|
|
90
|
+
for d in targets:
|
|
91
|
+
if not d.exists():
|
|
92
|
+
continue
|
|
93
|
+
for p in d.glob('**/*'):
|
|
94
|
+
if p.is_file():
|
|
95
|
+
p.unlink()
|
|
96
|
+
n += 1
|
|
97
|
+
return n
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---- 回测数据集 (无 TTL, 历史数据不变) ----
|
|
101
|
+
|
|
102
|
+
# 数据集根: ~/.hamuna/data_cache/datasets/<code>_<DateType>_fq<FuQuanType>.parquet
|
|
103
|
+
# 与 5min 缓存 (StockBars/<hash>.parquet) 分开 — 数据集是"回测专用完整历史",
|
|
104
|
+
# 不随单次请求 TTL 过期。key 只含 (code, DateType, FuQuanType), 窗口内切片由读取方做。
|
|
105
|
+
_DATASET_DIR = _CACHE_ROOT / 'datasets'
|
|
106
|
+
|
|
107
|
+
# 回测数据集最小 bar 数 — 用户规约 "至少 3 年 1000 根"。A 股年约 240 交易日,
|
|
108
|
+
# 3 年 ≈ 750 根, 到不了 1000; 故以 1000 根为硬门槛 (约 4.1 年), 自然覆盖 3 年。
|
|
109
|
+
# 转债例外: 历史天然短 (2017 后起量, 多数上市 1-2 年), 硬套 1000 根会让几乎
|
|
110
|
+
# 全部转债每次回测都重建数据集 (慢到不可用)。转债用 240 根 (约 1 年)。
|
|
111
|
+
DATASET_MIN_BARS = 1000
|
|
112
|
+
DATASET_MIN_BARS_CB = 240
|
|
113
|
+
|
|
114
|
+
# 短样本数据集 TTL (秒): 次新股/新转债历史天然不足 DATASET_MIN_BARS, 若"不足即 miss"
|
|
115
|
+
# 会导致每次回测都重建 (实测 889 只转债里 296 只 <240 根, 反复重建 ~74s)。
|
|
116
|
+
# 构建过一次就临时复用, TTL 到期重建以拉取新累积的历史 — 短样本数据在上市初期
|
|
117
|
+
# 会变长, 不能像达标样本那样无 TTL 永久复用。
|
|
118
|
+
DATASET_SHORT_TTL = 7 * 86400 # 7 天
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _is_convertible(stock_code: str) -> bool:
|
|
122
|
+
bare = stock_code.split('.')[0]
|
|
123
|
+
return bare.startswith(('11', '12'))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _min_bars(stock_code: str, date_type: str = 'D') -> int:
|
|
127
|
+
"""按品种+周期取最小 bar 门槛。
|
|
128
|
+
|
|
129
|
+
分钟 (DateType M*) 不走根数门槛 — 分钟 dataset 构建目标 = 回测窗口本身,
|
|
130
|
+
存在即用, 窗口切片由调用方做。日线才用根数门槛 (保证"至少3年/1年"样本)。
|
|
131
|
+
"""
|
|
132
|
+
if date_type.startswith('M'):
|
|
133
|
+
return 1
|
|
134
|
+
if _is_convertible(stock_code):
|
|
135
|
+
return DATASET_MIN_BARS_CB
|
|
136
|
+
return DATASET_MIN_BARS
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _dataset_path(stock_code: str, date_type: str, fu_quan_type: int) -> Path:
|
|
140
|
+
"""code 含后缀 → 剥 (dataset 内部统一裸 6 位, 与容维一致)。"""
|
|
141
|
+
bare = stock_code.split('.')[0]
|
|
142
|
+
return _DATASET_DIR / f'{bare}_{date_type}_fq{fu_quan_type}.parquet'
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def bundle_path(universe_code: str, date_type: str = 'D') -> Path:
|
|
146
|
+
"""server 内置数据集整包落盘缓存 (无 TTL, 历史不变)。
|
|
147
|
+
|
|
148
|
+
布局: <data_cache>/datasets/__bundle__<universe>_<DateType>.parquet
|
|
149
|
+
与单股 dataset (裸 6 位前缀) 区分 — __bundle__ 前缀不会撞股票代码。
|
|
150
|
+
整包下载一次, 同进程内多只股票从同一份抽取, 不重复下载/解析。
|
|
151
|
+
"""
|
|
152
|
+
return _DATASET_DIR / f'__bundle__{universe_code}_{date_type}.parquet'
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def read_dataset(stock_code: str, date_type: str, fu_quan_type: int,
|
|
156
|
+
start: str, end: str) -> list[dict] | None:
|
|
157
|
+
"""读取数据集切片。数据集根数足够 → 返升序窗口切片;否则 None (视为 miss, 重建)。
|
|
158
|
+
|
|
159
|
+
"存在"判定只看数据集根数 >= DATASET_MIN_BARS。切片过滤后为空 (如 start/end
|
|
160
|
+
恰为非交易日) 是正常结果, 不代表数据集缺失 — 若把空切片当 miss, 回测逐 bar
|
|
161
|
+
调非交易日窗口会反复触发重建 (每次卡几秒网络)。
|
|
162
|
+
|
|
163
|
+
无 TTL — 历史 K 线不变。性能: 全量 parquet 用 lru_cache 进程内缓存,
|
|
164
|
+
切片在内存做 — 回测逐 bar 请求不再反复读盘/pyarrow 加载。
|
|
165
|
+
"""
|
|
166
|
+
path = _dataset_path(stock_code, date_type, fu_quan_type)
|
|
167
|
+
if not path.exists():
|
|
168
|
+
return None
|
|
169
|
+
rows = list(_read_dataset_full(stock_code, date_type, fu_quan_type))
|
|
170
|
+
if len(rows) < _min_bars(stock_code, date_type):
|
|
171
|
+
# 短样本 (次新股/新转债历史不足) 不立即重建: 用 TTL 临时复用,
|
|
172
|
+
# 避免每次回测都重新翻页构建 (296 只转债反复重建 ~74s)。到期重建拉新历史。
|
|
173
|
+
if _is_convertible(stock_code) and time.time() - path.stat().st_mtime < DATASET_SHORT_TTL:
|
|
174
|
+
pass # 临时复用短样本
|
|
175
|
+
else:
|
|
176
|
+
return None
|
|
177
|
+
if start:
|
|
178
|
+
# 日线 bar 有 date (YYYYMMDD); 分钟 bar 有 datetime (YYYYMMDDHHMM), 切片按前 8 位
|
|
179
|
+
date_of = lambda r: r.get('date') or r.get('datetime', '')[:8] # noqa: E731
|
|
180
|
+
# 覆盖检查: 缓存最末日期 < 请求 start → 缓存陈旧 (e.g. 之前构建时只拉到旧数据,
|
|
181
|
+
# 后来回测窗口更新), 视为 miss 重建。次新/停更标的例外: 上游日线已到尽头
|
|
182
|
+
# (历史只到某日后停更, e.g. 转债摘牌/退市), 构建拉到最早一根后上游无更多数据,
|
|
183
|
+
# last < start 是正常结果, 每次重建只会拉到同样的旧数据 — 若不放行会死循环重建。
|
|
184
|
+
if _dataset_is_complete(stock_code, date_type, fu_quan_type, rows):
|
|
185
|
+
pass # 已拉满上游 (最早根就是 dataset 首根) → 放行, 不再重建
|
|
186
|
+
else:
|
|
187
|
+
last = date_of(rows[-1])
|
|
188
|
+
if last < start:
|
|
189
|
+
return None
|
|
190
|
+
rows = [r for r in rows if start <= date_of(r) <= end]
|
|
191
|
+
return rows # 可为空 list (非交易日切片); 调用方不应据此重建
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# 上游已拉满标记: dataset 构建拉到最早一根 (次新/停更标的) 时写一个侧车文件。
|
|
195
|
+
# 若无此标记, 覆盖检查对这类标的永远 last < start → 死循环重建 (实测 128044 8x)。
|
|
196
|
+
_COMPLETE_SUFFIX = '.complete'
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _complete_path(stock_code: str, date_type: str, fu_quan_type: int) -> Path:
|
|
200
|
+
return Path(str(_dataset_path(stock_code, date_type, fu_quan_type)) + _COMPLETE_SUFFIX)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _dataset_is_complete(stock_code: str, date_type: str, fu_quan_type: int,
|
|
204
|
+
rows: list[dict]) -> bool:
|
|
205
|
+
"""数据集是否已拉满上游 (构建时翻页无更早数据即视为完整)。
|
|
206
|
+
|
|
207
|
+
无 complete 标记 → False (回落旧覆盖检查)。标记由 build_bars_dataset 在
|
|
208
|
+
翻页 `not fresh: break` (上游无更早数据) 时写入。
|
|
209
|
+
"""
|
|
210
|
+
if not rows:
|
|
211
|
+
return False
|
|
212
|
+
return _complete_path(stock_code, date_type, fu_quan_type).exists()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
from functools import lru_cache # noqa: E402 (import 放函数后, 保持文件顶部简洁)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@lru_cache(maxsize=64)
|
|
219
|
+
def _read_dataset_full(stock_code: str, date_type: str, fu_quan_type: int) -> tuple:
|
|
220
|
+
"""读全量数据集 parquet → tuple (可 hash, 供 lru_cache)。调用方转回 list。"""
|
|
221
|
+
path = _dataset_path(stock_code, date_type, fu_quan_type)
|
|
222
|
+
rows = _read_parquet(path)
|
|
223
|
+
return tuple(rows)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def write_dataset(stock_code: str, date_type: str, fu_quan_type: int,
|
|
227
|
+
rows: list[dict]) -> None:
|
|
228
|
+
"""写入/覆盖数据集 (升序 bars)。"""
|
|
229
|
+
_DATASET_DIR.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
path = _dataset_path(stock_code, date_type, fu_quan_type)
|
|
231
|
+
_write_parquet(path, rows)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---- parquet (依赖 pyarrow/pandas, 可选) ----
|
|
235
|
+
|
|
236
|
+
def _read_parquet(path: Path) -> list[dict]:
|
|
237
|
+
try:
|
|
238
|
+
import pandas as pd
|
|
239
|
+
except ImportError:
|
|
240
|
+
return []
|
|
241
|
+
if not path.exists() or path.stat().st_size == 0:
|
|
242
|
+
# 0 字节/不存在 → 损坏或半截写入 (build 中断/磁盘满) → 当 miss, 调用方重建。
|
|
243
|
+
# 若让 pd.read_parquet 抛 ArrowInvalid, 回测逐 bar 读同一文件会硬崩 (实测 001358)。
|
|
244
|
+
return []
|
|
245
|
+
return pd.read_parquet(path).to_dict(orient='records')
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _write_parquet(path: Path, data: list[dict]) -> None:
|
|
249
|
+
try:
|
|
250
|
+
import pandas as pd
|
|
251
|
+
except ImportError:
|
|
252
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2),
|
|
253
|
+
encoding='utf-8')
|
|
254
|
+
return
|
|
255
|
+
pd.DataFrame(data).to_parquet(path)
|