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,443 @@
1
+ """ADR-0040 Phase B — akquant 结果 schema adapter (v2 自有版本, B1).
2
+
3
+ 把 akquant BacktestResult 折成 hamuna `backtest_result.json` 13-key dict
4
+ (沿 driver.py:113-128 模板, 见 references/backtest-result.md).
5
+
6
+ 来源: 原仓根 `hamuna_quant_cli/references/akquant_schema_adapter.py` (Phase B).
7
+ 本文件是 wholesale copy, 算法/字段映射/trades_df→executions_df 改动 与原仓根
8
+ 一致 (B 重写不动数值层). 迁移原因: v2 skill 未来独立分发, 不依赖仓根.
9
+
10
+ 字段映射 (15 metrics):
11
+ total_return ← r.metrics.total_return_pct / 100
12
+ annual_return ← r.metrics.annual_return_pct / 100 (akquant 直接给)
13
+ sharpe ← r.metrics.sharpe_ratio
14
+ max_drawdown ← r.metrics.max_drawdown_pct / 100
15
+ volatility ← 自算 (np.std(daily_ret) * sqrt(252), 沿 metrics.py:63)
16
+ win_rate ← r.metrics.win_rate
17
+ profit_loss_ratio ← 自算 from trades_df (FIFO pair, 沿 metrics.py:104)
18
+ avg_holding_period ← 自算 calendar days (沿 metrics.py:155)
19
+ benchmark_total_return ← NaN (akquant 单策略无自动基准, 见 bench_20260813_201349.json)
20
+ excess_return ← NaN (同上)
21
+ sortino ← 自算 (沿 metrics.py:161)
22
+ calmar ← 自算 (沿 metrics.py:176)
23
+ var_95 ← 自算 (沿 metrics.py:184)
24
+ profit_factor ← 自算 from trades_df (沿 metrics.py:192)
25
+ annual_volatility ← alias of volatility
26
+
27
+ equity_curve: r.equity_curve() Series → [{date:'YYYYMMDD', nav: float}, ...]
28
+ 首日补 initial_capital (driver.py:103-106 同款)
29
+
30
+ trades: 优先 executions_df (含 buy + sell 单独流水, buy-only 策略也能记 buy);
31
+ fallback trades_df (FIFO closed pair). metrics 仍用 trades_df 算
32
+ win_rate / profit_factor (closed pair 才是有效 metric 输入).
33
+
34
+ universe / period / params: 从 cfg 推
35
+
36
+ monthly_bars / monthly_metrics: 自算 (沿 metrics.py:242+)
37
+
38
+ initial_capital / final_capital / avg_holding_period / suggestions:
39
+ - initial_capital = cfg['init_capital']
40
+ - final_capital = r.metrics.final_value (若 akquant 给) 或 equity_curve 末点
41
+ - avg_holding_period = metrics 内同名字段
42
+ - suggestions = [] (Phase B 最小化; driver._suggestions 不复用)
43
+
44
+ NaN 纪律 (backtest-result.md §纪律):
45
+ NaN/null = 计算 bug, 不是策略. 安全 getter (R1 mitigation) 缺失字段填 NaN
46
+ 而不是 KeyError / AttributeError.
47
+
48
+ Ponytail ceiling: 不实现 benchmark alpha/beta/IR 单独导出.
49
+ akquant 多出字段 (exposure_df / attribution_df / capacity_df / cash_curve / orders_df / ...)
50
+ 落到 result['params']['_akquant_extra'] 子键 (optional, UI 不消费).
51
+ """
52
+ from __future__ import annotations
53
+
54
+ import math
55
+ from typing import Any
56
+
57
+ import numpy as np
58
+ import pandas as pd
59
+
60
+ # hamuna metrics 15 keys (driver.py + metrics.py:218-237, 与 server model.BacktestResult 对齐)
61
+ # v2 自有 _metrics_15 (B2 搬入, 算法与原仓根一致)
62
+ from ._metrics_15 import HAMUNA_METRICS_15 # noqa: E402 re-export
63
+
64
+ TRADING_DAYS_PER_YEAR = 252
65
+
66
+ # 单点 metric 实现 (volatility/sortino/calmar/var_95 + 4 trade-derived);
67
+ # 见 `hamuna_quant_cli.references._metrics_15` — 与 v1 `strategy_cli.runtime.metrics` 算法对齐.
68
+ # 这里只 import, 不再独立实现 (R-metrics-dedup).
69
+ from ._metrics_15 import (
70
+ volatility as _volatility,
71
+ sortino as _sortino,
72
+ calmar as _calmar,
73
+ var_95 as _var_95,
74
+ win_rate as _win_rate,
75
+ profit_loss_ratio as _profit_loss_ratio,
76
+ avg_holding_period as _avg_holding_period,
77
+ profit_factor as _profit_factor,
78
+ )
79
+
80
+
81
+ # ---- safe-getter (R1 mitigation: akquant schema drift 兜底) ----
82
+ def _safe_get(obj: Any, key: str, default: float = float('nan')) -> float:
83
+ """attr 不存在或为 None → 返回 default (NaN). 数值转 float."""
84
+ try:
85
+ v = getattr(obj, key)
86
+ except AttributeError:
87
+ return default
88
+ if v is None:
89
+ return default
90
+ try:
91
+ f = float(v)
92
+ except (TypeError, ValueError):
93
+ return default
94
+ if math.isnan(f) or math.isinf(f):
95
+ return default
96
+ return f
97
+
98
+
99
+ def _nan() -> float:
100
+ return float('nan')
101
+
102
+
103
+ # ---- 自算函数 (volatility/sortino/calmar/var_95 + 4 trade-derived)
104
+ # 全部从 `hamuna_quant_cli.references._metrics_15` 单点 import (上面 import 块).
105
+ # ----
106
+
107
+
108
+ # ---- equity_curve Series → list[{date:'YYYYMMDD', nav}] ----
109
+ def _to_daily(series_or_df: Any, initial_capital: float) -> list[dict]:
110
+ """akquant r.equity_curve() → list[{date, nav}]. 首点补 initial_capital (driver.py:103-106)."""
111
+ if series_or_df is None:
112
+ return []
113
+ if isinstance(series_or_df, pd.DataFrame):
114
+ if series_or_df.empty:
115
+ return []
116
+ s = series_or_df.iloc[:, 0] if series_or_df.shape[1] == 1 else series_or_df.squeeze()
117
+ else:
118
+ s = series_or_df
119
+ if s is None or len(s) == 0:
120
+ return []
121
+ out: list[dict] = []
122
+ for ts, val in s.items():
123
+ if pd.isna(val):
124
+ continue
125
+ # ts: datetime / str → 'YYYYMMDD'
126
+ if hasattr(ts, 'strftime'):
127
+ d = ts.strftime('%Y%m%d')
128
+ else:
129
+ d = str(ts)[:8].replace('-', '')
130
+ try:
131
+ v = float(val)
132
+ except (TypeError, ValueError):
133
+ continue
134
+ if math.isnan(v) or math.isinf(v):
135
+ continue
136
+ out.append({'date': d, 'nav': v})
137
+ if not out:
138
+ return out
139
+ # 首点补 initial_capital (driver.py:103-106 同款)
140
+ if abs(out[0]['nav'] - initial_capital) > 1e-6:
141
+ out = [{'date': out[0]['date'], 'nav': initial_capital}] + out
142
+ return out
143
+
144
+
145
+ # ---- trades_df → hamuna 7-field trades ----
146
+ def _trades_df_to_list(trades_df: pd.DataFrame | None,
147
+ symbol_names: dict[str, str] | None = None) -> list[dict]:
148
+ """akquant trades_df → hamuna 7-field trades dict (含 symbol_name).
149
+
150
+ symbol_names: 外部注入的 {symbol: stockName} 映射 (从 prebuilt bundle 的
151
+ stockName 列聚合). hamuna_strategy (QMT 路径) 端 driver._lookup_symbol_name
152
+ 同款行为 — 走本地 dataset → 容维 fallback. 这里只接外部注入, 避免在
153
+ schema_adapter 里引入跨模块依赖.
154
+ """
155
+ if trades_df is None or trades_df.empty:
156
+ return []
157
+ out: list[dict] = []
158
+ for _, row in trades_df.iterrows():
159
+ ts = row.get('entry_time') or row.get('date') or row.get('time') or row.get('timestamp')
160
+ if pd.isna(ts):
161
+ continue
162
+ if hasattr(ts, 'strftime'):
163
+ d = ts.strftime('%Y%m%d')
164
+ else:
165
+ d = str(ts)[:8].replace('-', '')
166
+ try:
167
+ sym = str(row.get('symbol', ''))
168
+ name = (symbol_names or {}).get(sym) or None
169
+ out.append({
170
+ 'date': d,
171
+ 'symbol': sym,
172
+ 'side': str(row.get('side', 'buy')),
173
+ 'price': float(row.get('entry_price', row.get('price', 0.0))),
174
+ 'qty': float(row.get('quantity', row.get('qty', 0))),
175
+ 'symbol_name': name,
176
+ })
177
+ except (TypeError, ValueError):
178
+ continue
179
+ return out
180
+
181
+
182
+ # ---- 主入口 ----
183
+ def to_hamuna_result(akquant_r: Any, cfg: dict) -> dict:
184
+ """akquant BacktestResult → hamuna 13-key dict (driver.py:113 模板)."""
185
+ initial_capital = float(cfg.get('init_capital', 1_000_000.0))
186
+ universe = _cfg_universe(cfg)
187
+ period = {'start': cfg['backtest_start'], 'end': cfg['backtest_end']}
188
+
189
+ # trades_df + equity_curve 提取
190
+ trades_df = getattr(akquant_r, 'trades_df', None)
191
+ if trades_df is None and hasattr(akquant_r, 'trades'):
192
+ td = akquant_r.trades
193
+ trades_df = td if isinstance(td, pd.DataFrame) else None
194
+ # executions_df 优先 (含 buy + sell 单独流水, buy-only 策略也能记 buy;
195
+ # trades_df 只含 closed pair, buyhold 之类无 sell → 空). metrics 仍用
196
+ # trades_df 算 win_rate / profit_factor (closed pair 才是有效 metric 输入).
197
+ exec_df = getattr(akquant_r, 'executions_df', None)
198
+ useful_df = exec_df if (exec_df is not None and not exec_df.empty) else trades_df
199
+ # symbol_names: 优先 cfg 注入, fallback None (trades 里 symbol_name=None)
200
+ symbol_names = cfg.get('_symbol_names') if isinstance(cfg, dict) else None
201
+ trades = _trades_df_to_list(useful_df, symbol_names=symbol_names)
202
+ eq_curve = getattr(akquant_r, 'equity_curve', None)
203
+ if callable(eq_curve):
204
+ eq_curve = eq_curve()
205
+ equity_curve = _to_daily(eq_curve, initial_capital)
206
+ nav_values = [p['nav'] for p in equity_curve]
207
+
208
+ # 15 metrics
209
+ m = getattr(akquant_r, 'metrics', None)
210
+ total_return = _safe_get(m, 'total_return_pct', 0.0) / 100.0 if m else _nan()
211
+ annual_return = _safe_get(m, 'annual_return_pct', _nan()) / 100.0 if m else _nan()
212
+ sharpe = _safe_get(m, 'sharpe_ratio', _nan()) if m else _nan()
213
+ mdd = _safe_get(m, 'max_drawdown_pct', _nan()) / 100.0 if m else _nan()
214
+ win_rate = _safe_get(m, 'win_rate', _nan()) if m else _nan()
215
+ # 自算
216
+ volatility = _volatility(nav_values) if nav_values else 0.0
217
+ sortino = _sortino(nav_values) if nav_values else 0.0
218
+ calmar = _calmar(nav_values) if nav_values else _nan()
219
+ var_95 = _var_95(nav_values) if nav_values else _nan()
220
+ profit_loss_ratio = _profit_loss_ratio(trades)
221
+ avg_holding = _avg_holding_period(trades)
222
+ profit_factor = _profit_factor(trades)
223
+
224
+ metrics = {
225
+ 'total_return': total_return,
226
+ 'annual_return': annual_return,
227
+ 'sharpe': sharpe,
228
+ 'max_drawdown': mdd,
229
+ 'volatility': volatility,
230
+ 'win_rate': win_rate,
231
+ 'profit_loss_ratio': profit_loss_ratio,
232
+ 'avg_holding_period': avg_holding,
233
+ 'benchmark_total_return': _nan(),
234
+ 'excess_return': _nan(),
235
+ 'sortino': sortino,
236
+ 'calmar': calmar,
237
+ 'var_95': var_95,
238
+ 'profit_factor': profit_factor,
239
+ 'annual_volatility': volatility,
240
+ }
241
+ # NaN → None 给 JSON (后端 model 拒绝 NaN literal)
242
+ metrics_clean = {k: (None if isinstance(v, float) and math.isnan(v) else v)
243
+ for k, v in metrics.items()}
244
+
245
+ # 月度聚合
246
+ monthly_bars = _monthly_bars(equity_curve)
247
+ monthly_metrics = _monthly_metrics(equity_curve)
248
+
249
+ # final_capital
250
+ final_capital = _safe_get(m, 'final_value', float('nan')) if m else _nan()
251
+ if math.isnan(final_capital) and equity_curve:
252
+ final_capital = equity_curve[-1]['nav']
253
+ final_capital = None if isinstance(final_capital, float) and math.isnan(final_capital) else final_capital
254
+
255
+ # params: cfg 快照 (排除 backtest_start/end)
256
+ params = {k: v for k, v in cfg.items() if k not in ('backtest_start', 'backtest_end')}
257
+
258
+ # akquant 多出字段落到 _akquant_extra (UI 不消费)
259
+ extra = _extra(akquant_r)
260
+ if extra:
261
+ params['_akquant_extra'] = extra
262
+
263
+ return {
264
+ 'metrics': metrics_clean,
265
+ 'equity_curve': equity_curve,
266
+ 'benchmark_curve': [], # akquant 单策略无自动基准 (bench_20260813_201349.json §cons)
267
+ 'trades': trades,
268
+ 'universe': universe,
269
+ 'period': period,
270
+ 'params': params,
271
+ 'monthly_bars': monthly_bars,
272
+ 'monthly_metrics': monthly_metrics,
273
+ 'initial_capital': initial_capital,
274
+ 'final_capital': final_capital,
275
+ 'avg_holding_period': metrics_clean['avg_holding_period'],
276
+ 'suggestions': [],
277
+ }
278
+
279
+
280
+ def normalize_symbol(symbol: str) -> str:
281
+ """6 位裸码 → 交易所带后缀形态 (bundle stockCode '600000.SH').
282
+
283
+ 幂等: 已带 .SH/.SZ/.BJ 后缀 / SH600000 前缀形态 → 原样返回.
284
+ 与 __main__._normalize_to_full 同映射 (60/68/11/13 → SH, 其余 → SZ).
285
+ """
286
+ s = symbol.strip().upper()
287
+ if s.endswith(('.SH', '.SZ', '.BJ')):
288
+ return symbol
289
+ if len(s) > 2 and s[:2] in ('SH', 'SZ', 'BJ'):
290
+ s = s[2:]
291
+ if len(s) != 6 or not s.isdigit():
292
+ return symbol
293
+ if s.startswith(('60', '68', '11', '13')):
294
+ return f'{s}.SH'
295
+ return f'{s}.SZ'
296
+
297
+
298
+ def _cfg_universe(cfg: dict) -> list[str]:
299
+ """cfg['pool'] / cfg['universe'] → 展开标的代码 list.
300
+
301
+ 三种形态:
302
+ - cfg['pool'] = [...] → 直接 list
303
+ - cfg['pool'] = {板块: {codes:[]}} → 展开 codes / sub_universe
304
+ - cfg['universe'] = [...] → fallback (v2 cfg 主要形态; 与 runner._universe_from_cfg 同语义)
305
+ 输出统一归一为带后缀形态 (与 bundle stockCode / trades.symbol 对齐).
306
+ """
307
+ pool = cfg.get('pool')
308
+ if pool is None:
309
+ pool = cfg.get('universe') or []
310
+ if isinstance(pool, list):
311
+ return [normalize_symbol(s) for s in pool]
312
+ out: list[str] = []
313
+ for v in pool.values() if isinstance(pool, dict) else []:
314
+ if isinstance(v, dict):
315
+ codes = v.get('codes') or v.get('sub_universe') or []
316
+ out.extend(codes)
317
+ elif isinstance(v, list):
318
+ out.extend(v)
319
+ return [normalize_symbol(s) for s in sorted(set(out))]
320
+
321
+
322
+ def _extra(akquant_r: Any) -> dict:
323
+ """akquant 多出字段落到 _akquant_extra (Ponytail ceiling: UI 不消费)."""
324
+ out: dict = {}
325
+ # exposure_df / attribution_df / capacity_df → dict (records) 或 None
326
+ for name in ('exposure_df', 'attribution_df', 'capacity_df',
327
+ 'metrics_df', 'orders_df', 'cash_curve', 'margin_curve'):
328
+ v = getattr(akquant_r, name, None)
329
+ if v is None:
330
+ continue
331
+ if callable(v):
332
+ try:
333
+ v = v()
334
+ except Exception: # noqa: BLE001
335
+ continue
336
+ if isinstance(v, pd.DataFrame):
337
+ try:
338
+ # 限定大小, 避免 params 膨胀
339
+ v = v.head(200)
340
+ out[name] = v.to_dict(orient='records')
341
+ except Exception: # noqa: BLE001
342
+ pass
343
+ elif isinstance(v, (list, dict)):
344
+ out[name] = v
345
+ return out
346
+
347
+
348
+ def _monthly_bars(nav_curve: list[dict]) -> list[dict]:
349
+ """委托 `hamuna_quant_cli.references._metrics_15.monthly_bars` (R-metrics-dedup)."""
350
+ from ._metrics_15 import monthly_bars as _m15_monthly_bars
351
+ return _m15_monthly_bars(nav_curve)
352
+
353
+
354
+ def _monthly_metrics(nav_curve: list[dict]) -> dict:
355
+ """委托 `hamuna_quant_cli.references._metrics_15.monthly_metrics` (R-metrics-dedup).
356
+
357
+ 返回 {YYYY: {MM: ret}} — UI 热力图消费. 旧版本返 nested dict 含 sharpe_partial/trades
358
+ 占位 0 字段, 实际未被 UI 消费, 这里统一走单点 (简化数据形状).
359
+ """
360
+ from ._metrics_15 import monthly_metrics as _m15_monthly_metrics
361
+ return _m15_monthly_metrics(nav_curve)
362
+
363
+
364
+ def _selfcheck() -> None:
365
+ """最小冒烟: 模拟 akquant result → 验 15 metrics key + 13 顶层 key.
366
+
367
+ 🧪 单元测试用 _MockMetrics/_MockEquityCurve/_MockTradesDF. 显式门控: 仅当
368
+ `HAMUNA_SELFTEST=1` 时执行, 防止 Agent 误调以为是 prod 数据校验. 调用方式:
369
+ HAMUNA_SELFTEST=1 python -c "from ...akquant_schema_adapter import _selfcheck; _selfcheck()"
370
+ """
371
+ import os
372
+ if os.environ.get("HAMUNA_SELFTEST") != "1":
373
+ raise RuntimeError(
374
+ "_selfcheck() 用 _Mock* 验 schema, 不用于 prod 数据校验. "
375
+ "设置 HAMUNA_SELFTEST=1 显式启用."
376
+ )
377
+ class _MockMetrics:
378
+ total_return_pct = 5.0
379
+ annual_return_pct = 12.0
380
+ sharpe_ratio = 1.2
381
+ max_drawdown_pct = 8.0
382
+ win_rate = 0.6
383
+ final_value = 1_050_000.0
384
+
385
+ class _MockEquityCurve:
386
+ def __init__(self):
387
+ dates = pd.date_range('2024-01-01', periods=10, freq='D')
388
+ self._s = pd.Series(
389
+ [100_000.0 + i * 5_000 for i in range(10)],
390
+ index=dates,
391
+ )
392
+
393
+ def __call__(self):
394
+ return self._s
395
+
396
+ class _MockTradesDF:
397
+ def __init__(self):
398
+ self._df = pd.DataFrame([
399
+ {'symbol': '600000.SH', 'side': 'buy', 'entry_time': pd.Timestamp('2024-01-02'),
400
+ 'entry_price': 10.0, 'quantity': 100},
401
+ {'symbol': '600000.SH', 'side': 'sell', 'entry_time': pd.Timestamp('2024-01-05'),
402
+ 'entry_price': 11.0, 'quantity': 100},
403
+ ])
404
+
405
+ class _MockResult:
406
+ metrics = _MockMetrics()
407
+ trades_df = _MockTradesDF()._df
408
+ equity_curve = _MockEquityCurve()
409
+
410
+ cfg = {
411
+ 'backtest_start': '20240101',
412
+ 'backtest_end': '20240110',
413
+ 'pool': {'a': {'codes': ['600000.SH']}},
414
+ 'init_capital': 100_000.0,
415
+ }
416
+ result = to_hamuna_result(_MockResult(), cfg)
417
+
418
+ # 15 metrics
419
+ assert set(result['metrics'].keys()) == HAMUNA_METRICS_15, (
420
+ f'metrics key 缺/多: {set(result["metrics"].keys()) ^ HAMUNA_METRICS_15}')
421
+ # 13 顶层 key (driver.py:113-128: metrics/equity_curve/benchmark_curve/trades/universe/
422
+ # period/params/monthly_bars/monthly_metrics/initial_capital/final_capital/avg_holding_period/suggestions)
423
+ expected_top = {'metrics', 'equity_curve', 'benchmark_curve', 'trades', 'universe',
424
+ 'period', 'params', 'monthly_bars', 'monthly_metrics',
425
+ 'initial_capital', 'final_capital', 'avg_holding_period', 'suggestions'}
426
+ assert expected_top.issubset(result.keys()), (
427
+ f'缺顶层 key: {expected_top - set(result.keys())}')
428
+ # equity_curve 首日 = init_capital
429
+ assert result['equity_curve'][0]['date'] == '20240101'
430
+ assert result['equity_curve'][0]['nav'] == 100_000.0
431
+ # trades 7 字段
432
+ assert result['trades'] and set(result['trades'][0].keys()) >= {
433
+ 'date', 'symbol', 'side', 'price', 'qty'}
434
+ # universe 展开
435
+ assert result['universe'] == ['600000.SH']
436
+ # initial_capital / final_capital
437
+ assert result['initial_capital'] == 100_000.0
438
+ assert result['final_capital'] == 1_050_000.0
439
+ print('OK: akquant_schema_adapter 15 metrics + 13 顶层 key + 7 trades fields 全对齐')
440
+
441
+
442
+ if __name__ == '__main__':
443
+ _selfcheck()
@@ -0,0 +1,80 @@
1
+ """v2 skill 策略基类 (2026-08-18 简化版, akquant 0.3.x).
2
+
3
+ 背景:
4
+ v2 skill 不再负责 QMT 格式代码导出。策略直接继承 akquant.Strategy,
5
+ v2 runner 自动 wire compute_factors / filter_symbols 钩子 (回测侧).
6
+ 实盘接入在 desktop/app/src-tauri/resources/hamuna_strategy.py (独立, 不在本 skill 范畴);
7
+ akquant 0.3.x `Strategy` 是双引擎一等公民 — 同一份策略代码天然兼容两边,
8
+ 本 skill 主线仍是离线回测.
9
+
10
+ 策略类推荐形态 (0.3.x 内联参数字段 + 钩子):
11
+ from akquant import Strategy, IntParam
12
+ import pandas as pd
13
+
14
+ class MyStrategy(Strategy):
15
+ fast = IntParam(5, ge=2, le=200)
16
+ slow = IntParam(20, ge=3, le=500)
17
+ universe = ListParam(item_type=str, default=[])
18
+
19
+ def on_start(self):
20
+ for s in self.params.universe:
21
+ self.subscribe(s)
22
+
23
+ def compute_factors(self, df: pd.DataFrame) -> dict[str, pd.DataFrame]:
24
+ \"\"\"可选 — 向量化预计算因子。引擎加载 df 后调用一次。
25
+ 回测传 prebuilt cache; 实盘传构造的 {sym: df} 字典或拼接 DataFrame (列含 symbol)。
26
+ \"\"\"
27
+ return {}
28
+
29
+ def filter_symbols(self, factors: dict[str, pd.DataFrame]) -> list[str]:
30
+ \"\"\"可选 — 基于 factors 过滤最终 universe。df 进入引擎前调用。\"
31
+ \"\"
32
+ return list(factors.keys())
33
+
34
+ def on_bar(self, bar):
35
+ closes = self.get_history(count=self.params.slow, symbol=bar.symbol, field="close")
36
+ if len(closes) < self.params.slow:
37
+ return
38
+ if closes[-1] > closes[-self.params.fast]:
39
+ self.buy(bar.symbol, 100)
40
+
41
+ v2 runner 引擎行为 (回测侧, 实盘见 desktop):
42
+ 1. strat_inst = cls() # 实例化 (走 params 校验)
43
+ 2. hasattr(strat_inst, "compute_factors") → 调一次喂 df (回测: prebuilt DataFrame)
44
+ 3. hasattr(strat_inst, "filter_symbols") → 调一次喂 factors, 限定 universe
45
+ 4. akquant.run_backtest(strategy=strat_cls, data=df_filtered, symbols=filtered, ...)
46
+
47
+ Ponytail ceiling: compute_factors 在引擎加载 df 后调一次 (startup 一次性)。
48
+ on_bar 内通过 self.get_history (akquant 内置 API) 拿历史, 不再触发 compute_factors — 跟 0.3.x
49
+ get_history 纳秒级快照拷贝契合. 后续若要每 bar 重算因子, 走 register_indicator + on_bar.
50
+ """
51
+ from __future__ import annotations
52
+
53
+ import pandas as pd
54
+
55
+ from akquant import Strategy as _AkquantStrategy
56
+
57
+
58
+ class HamunaStrategy(_AkquantStrategy):
59
+ """v2 skill 推荐基类 (可选 — 用户直接继承 akquant.Strategy 也兼容).
60
+
61
+ 默认实现 = no-op, 老策略 (无 compute_factors / filter_symbols) 走默认 no-op,
62
+ 直接用 cfg.universe 跑. 引擎通过 hasattr 检测 — 没这俩方法 = 走 fallback.
63
+ """
64
+
65
+ def compute_factors(self, df: pd.DataFrame) -> dict[str, pd.DataFrame]:
66
+ """向量式预计算因子 (回测 / 实盘启动期各调一次).
67
+
68
+ Args:
69
+ df: 回测 = prebuilt DataFrame (列含 symbol, open, high, low, close, volume);
70
+ 实盘 = bridge_server 拉 N sym 历史拼成的 dict[sym, DataFrame] (或拼接 DataFrame).
71
+
72
+ Returns:
73
+ {sym: DataFrame (含原始 OHLCV + 自定义 factor 列)}.
74
+ 返回 {} = 引擎退到 cfg.universe / --symbols.
75
+ """
76
+ return {}
77
+
78
+ def filter_symbols(self, factors: dict[str, pd.DataFrame]) -> list[str]:
79
+ """基于 factors 过滤最终 universe (df 进入回测 / 实盘前调用)."""
80
+ return list(factors.keys())
@@ -0,0 +1,118 @@
1
+ """cross-sectional 选股 helper — vol 排序 + 周频日历 (v2 自有版本, B3).
2
+
3
+ 来源: 原仓根 `hamuna_quant_cli/references/cross_sectional_helpers.py`.
4
+ 本文件是 wholesale copy, 算法不变. 迁移原因: v2 skill 未来独立分发.
5
+
6
+ API:
7
+ compute_vol_calendar(df, lookback=20, rebalance_weekday=4) -> dict[date_str, dict[sym, vol]]
8
+ 输入 df: long format [date, symbol, close, ...]
9
+ 输出: {'20240105': {'600000.SH': 0.012, ...}, '20240112': {...}}
10
+ - key = 调仓日 (周五, 默认) YYYYMMDD
11
+ - value = 该日截面所有标的的 realized_vol (rolling N 日 std)
12
+ - vol 越小越稳 (low-vol 选股用)
13
+
14
+ 注: cross-sectional ranking 在 strategy 端做 (用 akquant 原生 rebalance_to_topn,
15
+ score = -vol), helper 只算 vol 表 (不挑 top_k, 让 strategy 调 `top_n` 参数即可).
16
+
17
+ Ponytail: 当前只支持日线 (1d) + close-derived vol. 5m/tick 走 strategy_cli run.
18
+ 升级: 加多因子 (momentum / EP) → score 合成.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import pandas as pd
23
+
24
+
25
+ def compute_vol_calendar(
26
+ df: pd.DataFrame,
27
+ lookback: int = 20,
28
+ rebalance_weekday: int = 4, # 0=Mon ... 4=Fri ... 6=Sun
29
+ min_history: int = 30,
30
+ ) -> dict[str, dict[str, float]]:
31
+ """rolling N 日 vol → 周频调仓日 → {sym: vol}.
32
+
33
+ df schema: [date(datetime64), symbol(str), open, high, low, close, volume]
34
+ 返回: 调仓日 (e.g. Friday) 的 {YYYYMMDD: {sym: realized_vol}}
35
+ realized_vol = past N 日 (含当日) 收益率 std (扣当日 pct_change 后 rolling std)
36
+ """
37
+ if df.empty:
38
+ return {}
39
+ if 'date' not in df.columns or 'symbol' not in df.columns or 'close' not in df.columns:
40
+ raise ValueError(f'df 缺必需列 (date/symbol/close), 实际: {list(df.columns)}')
41
+
42
+ df = df.copy().sort_values(['symbol', 'date']).reset_index(drop=True)
43
+ # 日收益率
44
+ df['ret'] = df.groupby('symbol', sort=False)['close'].pct_change()
45
+ # rolling N 日 std (扣当日: shift(1) 后 rolling N, 包含 lookback 个过去值)
46
+ # groupby + rolling 需 reset_index(level=0, drop=True) 把 sym index 降下来
47
+ df['vol'] = (
48
+ df.groupby('symbol', sort=False)['ret']
49
+ .apply(lambda s: s.shift(1).rolling(lookback, min_periods=lookback).std())
50
+ .reset_index(level=0, drop=True)
51
+ )
52
+
53
+ # 调仓日 (周五默认) 的截面
54
+ df['d_key'] = df['date'].dt.strftime('%Y%m%d')
55
+ rebal_mask = df['date'].dt.weekday == rebalance_weekday
56
+ rebal_dates = df.loc[rebal_mask, 'd_key'].unique()
57
+
58
+ calendar: dict[str, dict[str, float]] = {}
59
+ for d in rebal_dates:
60
+ today = df[(df['d_key'] == d) & df['vol'].notna() & (df['vol'] > 0)]
61
+ if len(today) < min_history: # 至少 N 只票有 vol, 否则跳过 (新股密集周)
62
+ continue
63
+ # 用 float vol, jsonable
64
+ calendar[d] = {row['symbol']: float(row['vol']) for _, row in today.iterrows()}
65
+ return calendar
66
+
67
+
68
+ def _selfcheck() -> None:
69
+ """mock df: 2 只标的 × 40 日, 期望周五 (假设有) 输出 2 只票的 vol.
70
+
71
+ 🧪 单元测试用 mock DataFrame. 显式门控: 仅当 `HAMUNA_SELFTEST=1` 时执行,
72
+ 防止 Agent 误调以为是 prod 数据校验. 调用方式:
73
+ HAMUNA_SELFTEST=1 python -c "from ...cross_sectional_helpers import _selfcheck; _selfcheck()"
74
+ """
75
+ import os
76
+ if os.environ.get("HAMUNA_SELFTEST") != "1":
77
+ raise RuntimeError(
78
+ "_selfcheck() 用 mock df 验 vol calendar 算法, 不用于 prod 数据校验. "
79
+ "设置 HAMUNA_SELFTEST=1 显式启用."
80
+ )
81
+ import numpy as np
82
+ dates = pd.date_range('2024-01-01', periods=60, freq='B') # 60 营业日才有 rolling(20) std
83
+ np.random.seed(42)
84
+ df = pd.DataFrame({
85
+ 'date': np.tile(dates, 2),
86
+ 'symbol': ['600000.SH'] * 60 + ['000001.SZ'] * 60,
87
+ 'open': 10.0, 'high': 10.5, 'low': 9.5,
88
+ 'close': np.concatenate([
89
+ 10 + np.cumsum(np.random.normal(0, 0.01, 60)),
90
+ 10 + np.cumsum(np.random.normal(0, 0.02, 60)), # 更高 vol
91
+ ]),
92
+ 'volume': 1000,
93
+ })
94
+ cal = compute_vol_calendar(df, lookback=20, rebalance_weekday=4, min_history=2)
95
+ assert cal, 'calendar 应非空'
96
+ # 至少一个 rebal day
97
+ first_d, vols = next(iter(cal.items()))
98
+ assert '600000.SH' in vols and '000001.SZ' in vols
99
+ # 000001 波动更大 → vol 应 > 600000 (sanity check)
100
+ assert vols['000001.SZ'] > vols['600000.SH'], (
101
+ f'预期 000001 vol > 600000 vol, 实际 {vols}')
102
+ print(f'OK: compute_vol_calendar ({len(cal)} rebal days, e.g. {first_d}: '
103
+ f'600000={vols["600000.SH"]:.4f}, 000001={vols["000001.SZ"]:.4f})')
104
+
105
+ # 边界: 空 df
106
+ assert compute_vol_calendar(pd.DataFrame()) == {}
107
+ print('OK: 空 df → 空 calendar')
108
+
109
+ # 边界: 列缺失
110
+ try:
111
+ compute_vol_calendar(pd.DataFrame({'a': [1], 'b': [2]}))
112
+ raise AssertionError('应 raise ValueError')
113
+ except ValueError:
114
+ print('OK: 缺列 raise ValueError')
115
+
116
+
117
+ if __name__ == '__main__':
118
+ _selfcheck()
@@ -0,0 +1,25 @@
1
+ """hamuna_quant_cli.live — 实盘 CLI (akquant.run_live 薄壳).
2
+
3
+ 子命令: hamuna_quant_cli live run <strategy.py>
4
+ """
5
+ from .loader import (
6
+ StrategyLoadError,
7
+ StrategySpec,
8
+ load_strategy,
9
+ FUNCTIONAL_HOOKS,
10
+ )
11
+ from .runner import (
12
+ LiveRunError,
13
+ run_live,
14
+ parse_kv_pairs,
15
+ )
16
+
17
+ __all__ = [
18
+ "StrategyLoadError",
19
+ "StrategySpec",
20
+ "load_strategy",
21
+ "FUNCTIONAL_HOOKS",
22
+ "LiveRunError",
23
+ "run_live",
24
+ "parse_kv_pairs",
25
+ ]