hamuna-quant-cli 0.1.11__tar.gz → 0.1.13__tar.gz
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-0.1.11 → hamuna_quant_cli-0.1.13}/PKG-INFO +1 -1
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/__init__.py +1 -1
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/__main__.py +6 -60
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/akquant_runner.py +13 -53
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/akquant_schema_adapter.py +113 -17
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/live/qmt_broker.py +100 -0
- hamuna_quant_cli-0.1.13/hamuna_quant_cli/live/qmt_market.py +820 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/live/runner.py +153 -77
- hamuna_quant_cli-0.1.13/hamuna_quant_cli/live/server_market_client.py +311 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/runtime/http_client.py +32 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/runtime/server_client.py +4 -9
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli.egg-info/PKG-INFO +1 -1
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli.egg-info/SOURCES.txt +1 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/pyproject.toml +1 -1
- hamuna_quant_cli-0.1.11/hamuna_quant_cli/live/qmt_market.py +0 -461
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/MANIFEST.in +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/README.md +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/README.md +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/_market_fallback.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/_metrics_15.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/akquant_data_adapter.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/base_strategy.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/live/__init__.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/live/loader.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/prebuilt_downloader.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/prebuilt_resolver.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/qmt_translator.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/runtime/__init__.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/runtime/backtest.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/runtime/cache.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/runtime/discipline.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/runtime/s3client.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/scripts/server.json +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli.egg-info/requires.txt +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/setup.cfg +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/tests/test_live_safety.py +0 -0
|
@@ -332,9 +332,8 @@ def cmd_commit(args) -> int:
|
|
|
332
332
|
bundle = 4 文件 (strategy + result + config + params). multipart params 字段
|
|
333
333
|
= 整个 params.json 内容 (ADR-0025, desktop 实盘配置源).
|
|
334
334
|
|
|
335
|
-
Round 18.
|
|
336
|
-
|
|
337
|
-
server `UpdateStrategyResult` handler 二次校验防绕过.
|
|
335
|
+
Round 18.63: replay gate (P0-1) 移除 (用户决策) — 上传不再要求 replay_result,
|
|
336
|
+
`--replay-result` / `--skip-replay-gate` 参数已删.
|
|
338
337
|
|
|
339
338
|
Round 18.62 P1-1: 新增 step 1.4 version bump — `GET /strategies/{id}` 拿
|
|
340
339
|
`latest_version` (无 → "0.0.0"); 默认 patch 自动 bump (`0.0.0 → 0.0.1`),
|
|
@@ -345,7 +344,6 @@ def cmd_commit(args) -> int:
|
|
|
345
344
|
from .runtime.server_client import ServerError
|
|
346
345
|
import io
|
|
347
346
|
import json
|
|
348
|
-
import os
|
|
349
347
|
import re
|
|
350
348
|
import tarfile
|
|
351
349
|
|
|
@@ -367,16 +365,6 @@ def cmd_commit(args) -> int:
|
|
|
367
365
|
if params_path and not params_path.exists():
|
|
368
366
|
print(f"params.json 不存在: {params_path} (--params 可选)", file=sys.stderr)
|
|
369
367
|
return 2
|
|
370
|
-
# Round 18.62 P0-1: replay_result.json 必填 (除非 --skip-replay-gate qa 通道).
|
|
371
|
-
replay_path = Path(args.replay_result) if args.replay_result else None
|
|
372
|
-
if not args.skip_replay_gate:
|
|
373
|
-
if replay_path is None:
|
|
374
|
-
print("ERROR: P0-1: 必须传 --replay-result, 先跑 live run --broker replay 拿 metrics "
|
|
375
|
-
"(或 --skip-replay-gate qa 通道)", file=sys.stderr)
|
|
376
|
-
return 2
|
|
377
|
-
if not replay_path.exists():
|
|
378
|
-
print(f"replay_result.json 不存在: {args.replay_result}", file=sys.stderr)
|
|
379
|
-
return 2
|
|
380
368
|
|
|
381
369
|
# ----- Round 18.62 P1-1 step 1.4: version bump -----
|
|
382
370
|
# cli 先 GET /strategies/{id} 拿 latest_version (无 → "0.0.0"); 默认 patch 自动 bump,
|
|
@@ -407,7 +395,7 @@ def cmd_commit(args) -> int:
|
|
|
407
395
|
bundle_out = Path(args.bundle_out) if args.bundle_out else Path(f"runs/{sid}/bundle.tar.gz")
|
|
408
396
|
bundle_out.parent.mkdir(parents=True, exist_ok=True)
|
|
409
397
|
|
|
410
|
-
print("=== Step 1/3: 打包 bundle (strategy + result + config + params +
|
|
398
|
+
print("=== Step 1/3: 打包 bundle (strategy + result + config + params + version) ===",
|
|
411
399
|
file=sys.stderr)
|
|
412
400
|
with tarfile.open(bundle_out, "w:gz") as tar:
|
|
413
401
|
tar.add(strategy_path, arcname=strategy_path.name)
|
|
@@ -416,9 +404,6 @@ def cmd_commit(args) -> int:
|
|
|
416
404
|
tar.add(cfg_path, arcname=cfg_path.name)
|
|
417
405
|
if params_path:
|
|
418
406
|
tar.add(params_path, arcname=params_path.name)
|
|
419
|
-
# Round 18.62 P0-1: replay_result.json 也打进去, 方便回溯 / 审计.
|
|
420
|
-
if replay_path:
|
|
421
|
-
tar.add(replay_path, arcname=replay_path.name)
|
|
422
407
|
# Round 18.62 P1-1: version.json 内存写, audit 用 (server 不读, 仅本地回溯).
|
|
423
408
|
ver_bytes = json.dumps({"version": new_version}, ensure_ascii=False).encode("utf-8")
|
|
424
409
|
ver_info = tarfile.TarInfo(name="version.json")
|
|
@@ -426,40 +411,7 @@ def cmd_commit(args) -> int:
|
|
|
426
411
|
tar.addfile(ver_info, io.BytesIO(ver_bytes))
|
|
427
412
|
print(f" ✓ bundle = {bundle_out} ({bundle_out.stat().st_size} bytes)", file=sys.stderr)
|
|
428
413
|
|
|
429
|
-
|
|
430
|
-
# 必在 step 2 (PUT) 之前 — gate fail 后 step 2-3 都不跑, 不留半上传状态.
|
|
431
|
-
replay_metrics: dict | None = None
|
|
432
|
-
if not args.skip_replay_gate:
|
|
433
|
-
try:
|
|
434
|
-
bt = json.loads(result_path.read_text(encoding="utf-8"))
|
|
435
|
-
rp = json.loads(replay_path.read_text(encoding="utf-8")) # type: ignore[union-attr]
|
|
436
|
-
except json.JSONDecodeError as e:
|
|
437
|
-
print(f" ✗ result/replay JSON 解析失败: {e}", file=sys.stderr)
|
|
438
|
-
return 2
|
|
439
|
-
bt_sharpe = float(bt.get("metrics", {}).get("sharpe", 0.0))
|
|
440
|
-
rp_sharpe = float(rp.get("metrics", {}).get("sharpe", 0.0))
|
|
441
|
-
diff_pct = abs(rp_sharpe - bt_sharpe) / max(abs(bt_sharpe), 0.01)
|
|
442
|
-
try:
|
|
443
|
-
threshold = float(os.environ.get("HAMUNA_REPLAY_GATE_PCT", "0.20"))
|
|
444
|
-
except ValueError:
|
|
445
|
-
threshold = 0.20
|
|
446
|
-
if diff_pct > threshold:
|
|
447
|
-
print(f"ERROR: replay gate diff={diff_pct:.2%} > {threshold:.0%} "
|
|
448
|
-
f"(backtest.sharpe={bt_sharpe:.4f} vs replay.sharpe={rp_sharpe:.4f})",
|
|
449
|
-
file=sys.stderr)
|
|
450
|
-
return 5
|
|
451
|
-
replay_metrics = {
|
|
452
|
-
"sharpe": rp_sharpe,
|
|
453
|
-
"total_return": float(rp.get("metrics", {}).get("total_return", 0.0)),
|
|
454
|
-
"win_rate": float(rp.get("metrics", {}).get("win_rate", 0.0)),
|
|
455
|
-
"trades": len(rp.get("trades", [])),
|
|
456
|
-
"diff_pct": diff_pct,
|
|
457
|
-
"run_at": (rp.get("period") or {}).get("end"),
|
|
458
|
-
}
|
|
459
|
-
print(f" ✓ replay gate pass (diff={diff_pct:.2%} <= {threshold:.0%}, "
|
|
460
|
-
f"replay.sharpe={rp_sharpe:.4f})", file=sys.stderr)
|
|
461
|
-
|
|
462
|
-
print("=== Step 2/3: PUT /strategies/:id/result (metrics + replay_metrics + version) ===",
|
|
414
|
+
print("=== Step 2/3: PUT /strategies/:id/result (metrics + version) ===",
|
|
463
415
|
file=sys.stderr)
|
|
464
416
|
try:
|
|
465
417
|
result = json.loads(result_path.read_text(encoding="utf-8"))
|
|
@@ -468,7 +420,7 @@ def cmd_commit(args) -> int:
|
|
|
468
420
|
return 2
|
|
469
421
|
try:
|
|
470
422
|
resp = runtime.server_client.upload_backtest_result(
|
|
471
|
-
sid, result,
|
|
423
|
+
sid, result, version=new_version,
|
|
472
424
|
)
|
|
473
425
|
except runtime.server_client.ServerError as e:
|
|
474
426
|
print(f" ✗ 上传 metrics 失败: {e}", file=sys.stderr)
|
|
@@ -646,7 +598,7 @@ def _bridge_kline(server: str, symbols: list[str], days: int) -> list[dict]:
|
|
|
646
598
|
for sym in symbols:
|
|
647
599
|
url = (
|
|
648
600
|
f"{server}/data/history?security={urllib.parse.quote(sym)}"
|
|
649
|
-
f"&period=1d&count={days}&fq=None"
|
|
601
|
+
f"&period=1d&count={days}&fq=None&subscribe=false"
|
|
650
602
|
)
|
|
651
603
|
try:
|
|
652
604
|
with urllib.request.urlopen(url, timeout=8) as resp:
|
|
@@ -1036,12 +988,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1036
988
|
cm.add_argument("strategy_id", help="server 端 strategy_id")
|
|
1037
989
|
cm.add_argument("--strategy", help="akquant strategy .py 路径 (默认 <id>.py)")
|
|
1038
990
|
cm.add_argument("--result", required=True, help="metrics result.json 路径")
|
|
1039
|
-
# Round 18.62 P0-1: replay gate. --replay-result 必填 (除非 --skip-replay-gate).
|
|
1040
|
-
cm.add_argument("--replay-result", default=None,
|
|
1041
|
-
help="replay_result.json 路径 (P0-1 必填; 先跑 live run --broker replay 拿 metrics, "
|
|
1042
|
-
"或 --skip-replay-gate 跳过)")
|
|
1043
|
-
cm.add_argument("--skip-replay-gate", action="store_true",
|
|
1044
|
-
help="[qa 临时] 跳过 replay diff gate (非 dev 默认)")
|
|
1045
991
|
# Round 18.62 P1-1: version bump. 默认 patch 自动 (从 server latest_version +0.0.1),
|
|
1046
992
|
# 显式 --major / --minor opt-in. 互斥.
|
|
1047
993
|
cm.add_argument("--major", action="store_true",
|
|
@@ -46,63 +46,23 @@ class StrategyParamsError(RuntimeError):
|
|
|
46
46
|
|
|
47
47
|
# ---- strategy loader (沿 driver._load_strategy 模式, 但导 Strategy 类不导 handlebar) ----
|
|
48
48
|
def _with_backtest_timer_rebalance(cls: type) -> type:
|
|
49
|
-
"""
|
|
50
|
-
|
|
51
|
-
Round 18.32
|
|
52
|
-
`
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
- `on_start` 时 `_trading_days` 未注入 (engine.py:3107 on_start 在 :3502 注入
|
|
56
|
-
之前), `self.trading_days` 恒空 — 所以**延迟到首个 on_bar** 再注册.
|
|
57
|
-
- 首个 bar 时从 `self.trading_days` 筛周五, 逐周注册 `schedule(周五14:55, 'rebalance')`
|
|
58
|
-
→ 引擎按 bar 边界触发 `on_timer('rebalance')`, ctx.current_time 即该周五.
|
|
59
|
-
- 策略已定义 on_timer 处理 'rebalance' → 与实盘完全同路径; 未定义 → base no-op, 无害.
|
|
60
|
-
- 只包有 on_timer 的策略 (无 on_timer 时 on_cross_section 仍是唯一回测调仓钩子,
|
|
61
|
-
注入 schedule 白费且可能引发无预期 on_timer 调用).
|
|
62
|
-
|
|
63
|
-
ponytail: 与 live 端 `_with_live_history_depth` 互补 — live 用外部日历注入
|
|
64
|
-
(replay bars / bridge 历史), 回测用引擎 trading_days. 两端最终都注册
|
|
65
|
-
`schedule(周五14:55, 'rebalance')` → `on_timer`, 真正同机制.
|
|
66
|
-
"""
|
|
67
|
-
import datetime as _dt
|
|
49
|
+
"""回测端 on_cross_section 调仓路径 — 与实盘 schedule_daily+on_timer 配对.
|
|
50
|
+
|
|
51
|
+
Round 24 真相修正 (前 Round 18.32 wrapper 声称 schedule+on_timer 注入, 但
|
|
52
|
+
`akquant.collect_cross_section_timer_entries` 只查 strategy 上
|
|
53
|
+
`_trading_day_cross_section_timestamps` 字段, 完全不消费 `strategy_scheduler`
|
|
54
|
+
里的 _schedule 注册 — 实测 0 trade). 真触发路径只有 on_cross_section.
|
|
68
55
|
|
|
69
|
-
|
|
56
|
+
本 wrapper 改为: 不再吞 on_cross_section, 让策略层的 on_cross_section 自由
|
|
57
|
+
处理调仓 (用户策略层用 weekday/dedup 判定周频, Round 24 DIR-003 同型).
|
|
58
|
+
策略层若无 on_cross_section, 不 wrap (直接返回 cls, 让 engine 走 on_cross_section
|
|
59
|
+
默认 no-op, 不影响).
|
|
60
|
+
"""
|
|
61
|
+
if cls is None or "on_cross_section" not in getattr(cls, "__dict__", {}):
|
|
70
62
|
return cls
|
|
71
|
-
from akquant.strategy_scheduler import schedule as _schedule
|
|
72
63
|
|
|
73
64
|
class _BacktestTimerRebalance(cls):
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def on_bar(self, bar) -> Any:
|
|
77
|
-
ret_val = super().on_bar(bar)
|
|
78
|
-
if not type(self)._bt_timer_registered:
|
|
79
|
-
type(self)._bt_timer_registered = True
|
|
80
|
-
try:
|
|
81
|
-
fridays = [ts for ts in self.trading_days if ts.weekday() == 4]
|
|
82
|
-
for ts in fridays:
|
|
83
|
-
_schedule(self, ts.replace(hour=14, minute=55), "rebalance")
|
|
84
|
-
print(
|
|
85
|
-
f"[info] 回测周五 schedule+on_timer 注入: {len(fridays)} 个 "
|
|
86
|
-
f"({[ts.strftime('%m-%d') for ts in fridays]})",
|
|
87
|
-
file=sys.stderr,
|
|
88
|
-
)
|
|
89
|
-
except Exception as e: # noqa: BLE001 — 注入失败不崩, 回测走原路径
|
|
90
|
-
print(
|
|
91
|
-
f"[warn] 回测周五 schedule 注入失败: {e}",
|
|
92
|
-
file=sys.stderr,
|
|
93
|
-
)
|
|
94
|
-
return ret_val
|
|
95
|
-
|
|
96
|
-
def on_cross_section(self, trading_date, timestamp) -> Any:
|
|
97
|
-
"""吞掉 on_cross_section — schedule 定时器已接管调仓 (Round 18.32).
|
|
98
|
-
|
|
99
|
-
策略类同时有 on_cross_section (回测旧路径) 和 on_timer (实盘新路径) 时,
|
|
100
|
-
同一个周五**两个钩子都会触发** → 重复下单 (实测: 07-06 批 20→22 trades,
|
|
101
|
-
000006/000035 各重复买一次). 本 wrapper 把 on_cross_section 变 no-op,
|
|
102
|
-
让回测统一走 schedule+on_timer, 与实盘完全同路径. 若策略没有
|
|
103
|
-
on_cross_section, 本方法不干扰 (未重写 → 走父类 no-op, 同样无害).
|
|
104
|
-
"""
|
|
105
|
-
return None
|
|
65
|
+
pass
|
|
106
66
|
|
|
107
67
|
_BacktestTimerRebalance.__name__ = cls.__name__
|
|
108
68
|
_BacktestTimerRebalance.__qualname__ = cls.__qualname__
|
{hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/akquant_schema_adapter.py
RENAMED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
一致 (B 重写不动数值层). 迁移原因: v2 skill 未来独立分发, 不依赖仓根.
|
|
9
9
|
|
|
10
10
|
字段映射 (15 metrics):
|
|
11
|
-
total_return ← r.metrics.total_return_pct / 100
|
|
12
|
-
annual_return ← r.metrics.
|
|
11
|
+
total_return ← r.metrics.total_return_pct / 100 (% → ratio)
|
|
12
|
+
annual_return ← r.metrics.annualized_return (0.3.52, 小数)
|
|
13
|
+
或 r.metrics.annual_return_pct / 100 (0.3.41 legacy, %)
|
|
13
14
|
sharpe ← r.metrics.sharpe_ratio
|
|
14
15
|
max_drawdown ← r.metrics.max_drawdown_pct / 100
|
|
15
16
|
volatility ← 自算 (np.std(daily_ret) * sqrt(252), 沿 metrics.py:63)
|
|
16
|
-
win_rate ← r.metrics.win_rate
|
|
17
|
+
win_rate ← r.metrics.win_rate / 100 (% → 0~1; 0 平仓 → None)
|
|
17
18
|
profit_loss_ratio ← 自算 from trades_df (FIFO pair, 沿 metrics.py:104)
|
|
18
19
|
avg_holding_period ← 自算 calendar days (沿 metrics.py:155)
|
|
19
20
|
benchmark_total_return ← NaN (akquant 单策略无自动基准, 见 bench_20260813_201349.json)
|
|
@@ -24,6 +25,11 @@
|
|
|
24
25
|
profit_factor ← 自算 from trades_df (沿 metrics.py:192)
|
|
25
26
|
annual_volatility ← alias of volatility
|
|
26
27
|
|
|
28
|
+
> ⚠ 0.3.41 → 0.3.52 键名漂移 (2026-09-02 真回测校准):
|
|
29
|
+
> `annual_return_pct` → `annualized_return` (且单位从 % 变为小数);
|
|
30
|
+
> `final_value` → `end_market_value`; `win_rate` 两版都是 %, 需 /100.
|
|
31
|
+
> 读取统一走 `_metric(obj, _METRIC_ALIASES[i])`, 两版兼容.
|
|
32
|
+
|
|
27
33
|
equity_curve: r.equity_curve() Series → [{date:'YYYYMMDD', nav: float}, ...]
|
|
28
34
|
首日补 initial_capital (driver.py:103-106 同款)
|
|
29
35
|
|
|
@@ -37,7 +43,8 @@ monthly_bars / monthly_metrics: 自算 (沿 metrics.py:242+)
|
|
|
37
43
|
|
|
38
44
|
initial_capital / final_capital / avg_holding_period / suggestions:
|
|
39
45
|
- initial_capital = cfg['init_capital']
|
|
40
|
-
- final_capital = r.metrics.
|
|
46
|
+
- final_capital = r.metrics.end_market_value (0.3.52) / final_value (0.3.41),
|
|
47
|
+
或 equity_curve 末点 (fallback)
|
|
41
48
|
- avg_holding_period = metrics 内同名字段
|
|
42
49
|
- suggestions = [] (Phase B 最小化; driver._suggestions 不复用)
|
|
43
50
|
|
|
@@ -97,6 +104,36 @@ def _safe_get(obj: Any, key: str, default: float = float('nan')) -> float:
|
|
|
97
104
|
return f
|
|
98
105
|
|
|
99
106
|
|
|
107
|
+
# akquant 0.3.41 → 0.3.52 键名漂移 (2026-09-02 真回测校准):
|
|
108
|
+
# 0.3.41 用 `annual_return_pct` (%, /100) / `final_value`;
|
|
109
|
+
# 0.3.52 改为 `annualized_return` (小数, 直接取) / `end_market_value`.
|
|
110
|
+
# `win_rate` 两个版本都是 % (有平仓时 70.0 = 70%), Hamuna 契约要 0~1 → /100.
|
|
111
|
+
_METRIC_ALIASES: list[tuple[tuple[str, float], ...]] = [
|
|
112
|
+
# (候选键名, 换算 scale) — 键存在即取, scale 对齐 Hamuna ratio 契约
|
|
113
|
+
(('total_return_pct', 1.0 / 100.0),), # % → ratio
|
|
114
|
+
(('annualized_return', 1.0), ('annual_return_pct', 1.0 / 100.0)), # 0.3.52 小数 | 0.3.41 %
|
|
115
|
+
(('sharpe_ratio', 1.0),),
|
|
116
|
+
(('max_drawdown_pct', 1.0 / 100.0),), # % → ratio
|
|
117
|
+
(('win_rate', 1.0 / 100.0),), # % → ratio (0~1)
|
|
118
|
+
(('end_market_value', 1.0), ('final_value', 1.0)), # 0.3.52 | 0.3.41
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _metric(obj: Any, aliases: tuple[tuple[str, float], ...],
|
|
123
|
+
default: float = float('nan')) -> float:
|
|
124
|
+
"""按候选键名读取 metric, 兼容版本键名漂移.
|
|
125
|
+
|
|
126
|
+
aliases: ((键名, scale), ...) 按优先序; 取第一个在 obj 上真实存在的键,
|
|
127
|
+
返回值 = 原值 × scale. 全部缺失 → default (NaN). 与 _safe_get 的
|
|
128
|
+
"missing 与 0.0 不混淆" 语义一致 (float 转换失败/NaN/Inf 也返回 default).
|
|
129
|
+
"""
|
|
130
|
+
for key, scale in aliases:
|
|
131
|
+
v = _safe_get(obj, key, default=float('nan'))
|
|
132
|
+
if not math.isnan(v):
|
|
133
|
+
return v * scale
|
|
134
|
+
return default
|
|
135
|
+
|
|
136
|
+
|
|
100
137
|
def _nan() -> float:
|
|
101
138
|
return float('nan')
|
|
102
139
|
|
|
@@ -206,13 +243,19 @@ def to_hamuna_result(akquant_r: Any, cfg: dict) -> dict:
|
|
|
206
243
|
equity_curve = _to_daily(eq_curve, initial_capital)
|
|
207
244
|
nav_values = [p['nav'] for p in equity_curve]
|
|
208
245
|
|
|
209
|
-
# 15 metrics
|
|
246
|
+
# 15 metrics — 键名走 `_metric` 别名 (0.3.41/0.3.52 兼容, 见 _METRIC_ALIASES)
|
|
210
247
|
m = getattr(akquant_r, 'metrics', None)
|
|
211
|
-
total_return =
|
|
212
|
-
annual_return =
|
|
213
|
-
sharpe =
|
|
214
|
-
mdd =
|
|
215
|
-
win_rate =
|
|
248
|
+
total_return = _metric(m, _METRIC_ALIASES[0], 0.0) if m else _nan()
|
|
249
|
+
annual_return = _metric(m, _METRIC_ALIASES[1]) if m else _nan()
|
|
250
|
+
sharpe = _metric(m, _METRIC_ALIASES[2]) if m else _nan()
|
|
251
|
+
mdd = _metric(m, _METRIC_ALIASES[3]) if m else _nan()
|
|
252
|
+
win_rate = _metric(m, _METRIC_ALIASES[4]) if m else _nan()
|
|
253
|
+
# 0 closed pair (trades_df 空) 时 AKQuant metrics.win_rate 为 0.0, 但 Hamuna 契约
|
|
254
|
+
# 要 None (backtest-result.md: 0 trades → win_rate=None). trades_df 才是 closed
|
|
255
|
+
# pair 源 (executions_df 含 buy-only 流水, 不能用来判"有无已平仓交易").
|
|
256
|
+
closed_pair_empty = trades_df is None or trades_df.empty
|
|
257
|
+
if closed_pair_empty:
|
|
258
|
+
win_rate = _nan()
|
|
216
259
|
# 自算
|
|
217
260
|
volatility = _volatility(nav_values) if nav_values else 0.0
|
|
218
261
|
sortino = _sortino(nav_values) if nav_values else 0.0
|
|
@@ -247,8 +290,8 @@ def to_hamuna_result(akquant_r: Any, cfg: dict) -> dict:
|
|
|
247
290
|
monthly_bars = _monthly_bars(equity_curve)
|
|
248
291
|
monthly_metrics = _monthly_metrics(equity_curve)
|
|
249
292
|
|
|
250
|
-
# final_capital
|
|
251
|
-
final_capital =
|
|
293
|
+
# final_capital: end_market_value (0.3.52) / final_value (0.3.41) → fallback equity 末点
|
|
294
|
+
final_capital = _metric(m, _METRIC_ALIASES[5], float('nan')) if m else _nan()
|
|
252
295
|
if math.isnan(final_capital) and equity_curve:
|
|
253
296
|
final_capital = equity_curve[-1]['nav']
|
|
254
297
|
final_capital = None if isinstance(final_capital, float) and math.isnan(final_capital) else final_capital
|
|
@@ -434,14 +477,29 @@ def _json_safe(o: Any) -> Any:
|
|
|
434
477
|
场景: akquant orders_df 等原始曲面的 limit_price/stop_price 常见 NaN
|
|
435
478
|
(市价单/未触发单). 直接进 result 会让后端 json.loads 崩.
|
|
436
479
|
Round 18.16 真数据验证抓到的真实泄漏 (Round 3 NaN 待办真载体).
|
|
480
|
+
Round 18.62+ 实测补漏: DataFrame.to_dict 会把 DatetimeIndex/时间列转成
|
|
481
|
+
pandas.Timestamp / datetime — json.dumps 抛 TypeError, 一并转 ISO 字符串.
|
|
437
482
|
"""
|
|
438
|
-
import
|
|
483
|
+
import datetime as _dt
|
|
439
484
|
if isinstance(o, float):
|
|
440
485
|
return None if (math.isnan(o) or math.isinf(o)) else o
|
|
441
486
|
if isinstance(o, dict):
|
|
442
487
|
return {k: _json_safe(v) for k, v in o.items()}
|
|
443
488
|
if isinstance(o, list):
|
|
444
489
|
return [_json_safe(x) for x in o]
|
|
490
|
+
# pandas.Timestamp 是 datetime 子类 → 先判 Timestamp 再判 datetime, 顺序无所谓
|
|
491
|
+
if isinstance(o, _dt.datetime):
|
|
492
|
+
return o.isoformat()
|
|
493
|
+
if isinstance(o, _dt.date):
|
|
494
|
+
return o.isoformat()
|
|
495
|
+
if isinstance(o, _dt.timedelta):
|
|
496
|
+
# orders_df.duration = exit - entry (timedelta) — JSON 无原生类型, 转秒
|
|
497
|
+
return o.total_seconds()
|
|
498
|
+
if hasattr(o, 'isoformat'): # 兜底: np.datetime64 等
|
|
499
|
+
try:
|
|
500
|
+
return o.isoformat()
|
|
501
|
+
except Exception: # noqa: BLE001
|
|
502
|
+
pass
|
|
445
503
|
return o
|
|
446
504
|
|
|
447
505
|
|
|
@@ -475,12 +533,19 @@ def _selfcheck() -> None:
|
|
|
475
533
|
"设置 HAMUNA_SELFTEST=1 显式启用."
|
|
476
534
|
)
|
|
477
535
|
class _MockMetrics:
|
|
536
|
+
"""0.3.52 真实键 (2026-09-02 实测校准):
|
|
537
|
+
- total_return_pct = % (5.0 表示 5%) → ratio 0.05
|
|
538
|
+
- annualized_return = 小数 (0.12 表示 12%), 无 annual_return_pct
|
|
539
|
+
- max_drawdown_pct = % (8.0 表示 8%) → ratio 0.08
|
|
540
|
+
- win_rate = % (60.0 表示 60%) → ratio 0.6; 无 closed pair 时为 0.0
|
|
541
|
+
- end_market_value (金额), 无 final_value
|
|
542
|
+
"""
|
|
478
543
|
total_return_pct = 5.0
|
|
479
|
-
|
|
544
|
+
annualized_return = 0.12
|
|
480
545
|
sharpe_ratio = 1.2
|
|
481
546
|
max_drawdown_pct = 8.0
|
|
482
|
-
win_rate = 0
|
|
483
|
-
|
|
547
|
+
win_rate = 60.0
|
|
548
|
+
end_market_value = 1_050_000.0
|
|
484
549
|
|
|
485
550
|
class _MockEquityCurve:
|
|
486
551
|
def __init__(self):
|
|
@@ -509,6 +574,9 @@ def _selfcheck() -> None:
|
|
|
509
574
|
# NaN 泄漏回归: orders_df 的 limit_price 常态 NaN (市价单), 必须被 _json_safe 清掉
|
|
510
575
|
orders_df = [{'symbol': '600000.SH', 'limit_price': float('nan'),
|
|
511
576
|
'stop_price': float('nan'), 'filled': 100}]
|
|
577
|
+
# Timestamp 泄漏回归 (Round 18.62+ 实测): exposure_df 的 date 列 to_dict
|
|
578
|
+
# 后是 pandas.Timestamp, json.dumps 会 TypeError — 必须转 ISO 字符串.
|
|
579
|
+
exposure_df = [{'date': pd.Timestamp('2024-01-05'), 'gross': 1.2}]
|
|
512
580
|
|
|
513
581
|
cfg = {
|
|
514
582
|
'backtest_start': '20240101',
|
|
@@ -521,6 +589,14 @@ def _selfcheck() -> None:
|
|
|
521
589
|
# 15 metrics
|
|
522
590
|
assert set(result['metrics'].keys()) == HAMUNA_METRICS_15, (
|
|
523
591
|
f'metrics key 缺/多: {set(result["metrics"].keys()) ^ HAMUNA_METRICS_15}')
|
|
592
|
+
# 单位转换断言 (0.3.52 键名/单位校准, 2026-09-02):
|
|
593
|
+
# total_return_pct=5.0(%) → 0.05; annualized_return=0.12(小数) → 0.12;
|
|
594
|
+
# max_drawdown_pct=8.0(%) → 0.08; win_rate=60.0(%) → 0.6.
|
|
595
|
+
mt = result['metrics']
|
|
596
|
+
assert abs(mt['total_return'] - 0.05) < 1e-9, mt['total_return']
|
|
597
|
+
assert abs(mt['annual_return'] - 0.12) < 1e-9, mt['annual_return']
|
|
598
|
+
assert abs(mt['max_drawdown'] - 0.08) < 1e-9, mt['max_drawdown']
|
|
599
|
+
assert abs(mt['win_rate'] - 0.6) < 1e-9, mt['win_rate']
|
|
524
600
|
# 13 顶层 key (driver.py:113-128: metrics/equity_curve/benchmark_curve/trades/universe/
|
|
525
601
|
# period/params/monthly_bars/monthly_metrics/initial_capital/final_capital/avg_holding_period/suggestions)
|
|
526
602
|
expected_top = {'metrics', 'equity_curve', 'benchmark_curve', 'trades', 'universe',
|
|
@@ -543,10 +619,30 @@ def _selfcheck() -> None:
|
|
|
543
619
|
odf = result['params']['_akquant_extra']['orders_df']
|
|
544
620
|
assert odf[0]['limit_price'] is None, f'limit_price 应 None, got {odf[0]["limit_price"]!r}'
|
|
545
621
|
assert odf[0]['stop_price'] is None
|
|
622
|
+
# Timestamp 泄漏回归 (Round 18.62+): 时间值必须转 ISO 字符串, 否则 json.dumps TypeError
|
|
623
|
+
edf = result['params']['_akquant_extra']['exposure_df']
|
|
624
|
+
assert edf[0]['date'] == '2024-01-05T00:00:00', f'Timestamp 应转 ISO, got {edf[0]["date"]!r}'
|
|
546
625
|
import json
|
|
547
|
-
json.dumps(result) # strict JSON 序列化 — NaN 会 raise ValueError
|
|
626
|
+
json.dumps(result) # strict JSON 序列化 — NaN 会 raise ValueError, Timestamp 会 TypeError
|
|
548
627
|
print('OK: akquant_schema_adapter 15 metrics + 13 顶层 key + 7 trades fields + NaN 清理 全对齐')
|
|
549
628
|
|
|
629
|
+
# 0.3.41 legacy 键兼容 (annual_return_pct=12.0 % → 0.12; final_value=1050000):
|
|
630
|
+
class _LegacyMetrics:
|
|
631
|
+
total_return_pct = 5.0
|
|
632
|
+
annual_return_pct = 12.0
|
|
633
|
+
sharpe_ratio = 1.2
|
|
634
|
+
max_drawdown_pct = 8.0
|
|
635
|
+
win_rate = 60.0
|
|
636
|
+
final_value = 1_050_000.0
|
|
637
|
+
|
|
638
|
+
class _LegacyResult(_MockResult):
|
|
639
|
+
metrics = _LegacyMetrics()
|
|
640
|
+
|
|
641
|
+
legacy = to_hamuna_result(_LegacyResult(), cfg)
|
|
642
|
+
assert abs(legacy['metrics']['annual_return'] - 0.12) < 1e-9, legacy['metrics']['annual_return']
|
|
643
|
+
assert legacy['final_capital'] == 1_050_000.0, legacy['final_capital']
|
|
644
|
+
print('OK: 0.3.41 legacy 键 (annual_return_pct / final_value) 兼容')
|
|
645
|
+
|
|
550
646
|
|
|
551
647
|
if __name__ == '__main__':
|
|
552
648
|
_selfcheck()
|
|
@@ -66,6 +66,7 @@ def _import_akquant_gateway():
|
|
|
66
66
|
# (transient failure 自动重试; 4xx/业务错误不重试)
|
|
67
67
|
# ============================================================
|
|
68
68
|
import json
|
|
69
|
+
import time
|
|
69
70
|
from typing import Any
|
|
70
71
|
|
|
71
72
|
import requests
|
|
@@ -383,6 +384,19 @@ def _make_trader_gateway():
|
|
|
383
384
|
if order_id:
|
|
384
385
|
self.record_broker_order(order_id, req.client_order_id)
|
|
385
386
|
# 即便 order_id 空 (bridge 的 submit_unknown), 也返回它拿到的 ref, 让上层知道.
|
|
387
|
+
if not order_id:
|
|
388
|
+
# Round 26 修复: bridge passorder_return=0 时 order_id/order_ref 都空,
|
|
389
|
+
# 但 bridge 已经把 qmt_user_order_id 写到了 QMT m_strRemark (Round 27 后
|
|
390
|
+
# bridge 会主动 poll 找真 order_id 并写到响应里). 此处 fallback:
|
|
391
|
+
# 1) qmt_user_order_id 优先 (bridge 内的稳定 key, 跨重启也保留)
|
|
392
|
+
# 2) passorder_return 兜底 (xtquant passorder 返回值, 0/正整数都允许)
|
|
393
|
+
# 任一非空都 record_broker_order, 让 akquant _sync_order_id_mapping
|
|
394
|
+
# (line 2072 of akquant/_runner.py) 不会因 broker_order_id 空而跳过.
|
|
395
|
+
fallback_id = (str(data.get("qmt_user_order_id") or "")
|
|
396
|
+
or str(data.get("passorder_return") or ""))
|
|
397
|
+
if fallback_id and fallback_id not in ("0", "0.0"):
|
|
398
|
+
self.record_broker_order(fallback_id, req.client_order_id)
|
|
399
|
+
return fallback_id
|
|
386
400
|
return order_id or str(data.get("passorder_return") or "")
|
|
387
401
|
|
|
388
402
|
# ----- 撤单 -----
|
|
@@ -511,6 +525,92 @@ def _make_trader_gateway():
|
|
|
511
525
|
except BrokerHTTPError:
|
|
512
526
|
return False
|
|
513
527
|
|
|
528
|
+
# ----- Round 26 补齐: akquant Protocol sync_* 方法 -----
|
|
529
|
+
# akquant BrokerRecovery (live/_runner.py:1800) 每 30s 调 sync_open_orders /
|
|
530
|
+
# sync_today_trades, 期望返回 UnifiedOrderSnapshot / UnifiedTrade 列表.
|
|
531
|
+
# qmt 路径默认 TraderGatewayBase.sync_* 返 [], 导致 on_order/on_trade 永远
|
|
532
|
+
# 收不到回报 (Round 26 0 trade 主因之一). 这里 override 走 query_* + 反查表.
|
|
533
|
+
#
|
|
534
|
+
# Round 28+ — bridge ensure_cache 同步阻塞老问题 cascade:
|
|
535
|
+
# bridge _QMT_EXECUTOR 4 线程被 download_history_data 占满 → /orders HTTP hang
|
|
536
|
+
# → sync_open_orders 每 30s 触发 query_order 累积 5s×N → on_order 永远收不到.
|
|
537
|
+
# Fix: TTL local cache (30s 内复用, 不重复 HTTP) + HTTP 失败返 last known good
|
|
538
|
+
# (不抛, 降级). 配合 bridge ensure_cache 异步化 (Round 28+ 改动 #1).
|
|
539
|
+
_sync_cache_ttl: float = 30.0
|
|
540
|
+
_sync_orders_cache: tuple = (0.0, []) # (monotonic_ts, result_list)
|
|
541
|
+
_sync_trades_cache: tuple = (0.0, [])
|
|
542
|
+
|
|
543
|
+
def sync_open_orders(self): # type: ignore[override]
|
|
544
|
+
"""调 query_order per known broker_order_id, 聚合未完成订单.
|
|
545
|
+
|
|
546
|
+
Round 28+: TTL cache + 失败降级返 last known good.
|
|
547
|
+
|
|
548
|
+
已知 broker_order_id 来源:
|
|
549
|
+
1) self._client_id_by_broker (record_broker_order 写入, TraderGatewayBase)
|
|
550
|
+
2) self._broker_to_client_order_ids (akquant 内部维护)
|
|
551
|
+
任一找不到的 fallback 调 query_order(client_order_id=client_oid).
|
|
552
|
+
"""
|
|
553
|
+
if not self._connected:
|
|
554
|
+
return []
|
|
555
|
+
# Round 28+: TTL cache — 30s 内复用避免 bridge hang 时重复 HTTP
|
|
556
|
+
now = time.monotonic()
|
|
557
|
+
ts, cached = self._sync_orders_cache
|
|
558
|
+
if cached and (now - ts) < self._sync_cache_ttl:
|
|
559
|
+
return cached
|
|
560
|
+
ak = _import_akquant_gateway()
|
|
561
|
+
UnifiedOrderSnapshot = ak["UnifiedOrderSnapshot"]
|
|
562
|
+
known_ids: set[str] = set()
|
|
563
|
+
client_map = getattr(self, "_client_id_by_broker", {})
|
|
564
|
+
known_ids.update(str(k) for k in client_map.keys() if k)
|
|
565
|
+
snapshots: list = []
|
|
566
|
+
seen: set[str] = set()
|
|
567
|
+
any_success = False
|
|
568
|
+
for bid in known_ids:
|
|
569
|
+
if not bid or bid in seen:
|
|
570
|
+
continue
|
|
571
|
+
seen.add(bid)
|
|
572
|
+
try:
|
|
573
|
+
snap = self.query_order(bid)
|
|
574
|
+
if snap is not None:
|
|
575
|
+
snapshots.append(snap)
|
|
576
|
+
any_success = True
|
|
577
|
+
except Exception as e: # noqa: BLE001
|
|
578
|
+
print(f"[qmt-broker] sync_open_orders {bid} error: "
|
|
579
|
+
f"{type(e).__name__}: {e}", flush=True)
|
|
580
|
+
# Round 28+: 全失败且有 cache → 降级返 cache (桥 hang 时不抛, 保 fire 不死)
|
|
581
|
+
if not any_success and cached:
|
|
582
|
+
print(f"[qmt-broker] sync_open_orders 全失败, 降级返 cache "
|
|
583
|
+
f"({len(cached)} 条, age={now - ts:.1f}s)", flush=True)
|
|
584
|
+
return cached
|
|
585
|
+
# 成功 → 更新 cache
|
|
586
|
+
self._sync_orders_cache = (now, snapshots)
|
|
587
|
+
return snapshots
|
|
588
|
+
|
|
589
|
+
def sync_today_trades(self): # type: ignore[override]
|
|
590
|
+
"""调 query_trades (无 since) 拉当日所有成交, 30s 周期.
|
|
591
|
+
|
|
592
|
+
Round 28+: TTL cache + 失败降级.
|
|
593
|
+
"""
|
|
594
|
+
if not self._connected:
|
|
595
|
+
return []
|
|
596
|
+
now = time.monotonic()
|
|
597
|
+
ts, cached = self._sync_trades_cache
|
|
598
|
+
if cached and (now - ts) < self._sync_cache_ttl:
|
|
599
|
+
return cached
|
|
600
|
+
try:
|
|
601
|
+
result = self.query_trades(since=None)
|
|
602
|
+
self._sync_trades_cache = (now, list(result))
|
|
603
|
+
return result
|
|
604
|
+
except Exception as e: # noqa: BLE001
|
|
605
|
+
if cached:
|
|
606
|
+
print(f"[qmt-broker] sync_today_trades HTTP 失败, 降级返 cache "
|
|
607
|
+
f"({len(cached)} 条, age={now - ts:.1f}s): "
|
|
608
|
+
f"{type(e).__name__}: {e}", flush=True)
|
|
609
|
+
return cached
|
|
610
|
+
print(f"[qmt-broker] sync_today_trades error: "
|
|
611
|
+
f"{type(e).__name__}: {e}", flush=True)
|
|
612
|
+
return []
|
|
613
|
+
|
|
514
614
|
return QmtTraderGateway
|
|
515
615
|
|
|
516
616
|
|