hamuna-quant-cli 0.1.11__tar.gz → 0.1.12__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.12}/PKG-INFO +1 -1
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/__init__.py +1 -1
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/__main__.py +5 -59
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/akquant_runner.py +13 -53
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/akquant_schema_adapter.py +113 -17
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/live/qmt_broker.py +100 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/live/qmt_market.py +80 -21
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/live/runner.py +142 -77
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/server_client.py +4 -9
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli.egg-info/PKG-INFO +1 -1
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/pyproject.toml +1 -1
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/MANIFEST.in +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/README.md +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/README.md +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/_market_fallback.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/_metrics_15.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/akquant_data_adapter.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/base_strategy.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/live/__init__.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/live/loader.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/prebuilt_downloader.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/prebuilt_resolver.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/qmt_translator.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/__init__.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/backtest.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/cache.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/discipline.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/http_client.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/s3client.py +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/scripts/server.json +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli.egg-info/SOURCES.txt +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli.egg-info/requires.txt +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/setup.cfg +0 -0
- {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/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)
|
|
@@ -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.12}/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
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
bridge_server 端点:
|
|
15
15
|
GET /data/snapshot?securities=<sym1>,<sym2>,... → 全推实时 tick (QMT get_full_tick)
|
|
16
|
-
GET /data/history?security=<sym>&period=<p>&count=<n>&fq=<fwd/back/None>
|
|
16
|
+
GET /data/history?security=<sym>&period=<p>&count=<n>&fq=<fwd/back/qfq/None>
|
|
17
17
|
→ 历史 K 线 (QMT get_market_data_ex)
|
|
18
18
|
GET /health → 探活
|
|
19
19
|
|
|
@@ -75,12 +75,14 @@ def _make_market_gateway() -> Any:
|
|
|
75
75
|
poll_interval: float = 30.0, # 默认 30s/帧 (≈2/min) — 用户决策: 不打 QMT 限流; 想更快自己 CLI 透传
|
|
76
76
|
timeout: float = 5.0,
|
|
77
77
|
symbols: list[str] | None = None, # 启动时 auto-subscribe (来自 build_qmt_market 的 symbols)
|
|
78
|
+
subscribe: bool = False, # paper=False / broker_live=True — 透传给 /data/history
|
|
78
79
|
) -> None:
|
|
79
80
|
self._feed = feed
|
|
80
81
|
self._http = _HTTP(base_url, timeout=timeout)
|
|
81
82
|
self._period = period
|
|
82
83
|
self._bar_count = bar_count
|
|
83
84
|
self._poll_interval = max(0.05, float(poll_interval))
|
|
85
|
+
self._subscribe = bool(subscribe)
|
|
84
86
|
# 启动时 auto-subscribe: build_qmt_market(symbols=...) 传来的就是 akquant run_live
|
|
85
87
|
# instruments — 立即装订到 _subscribed, 不依赖 akquant forwarder (实测发现 forwarder
|
|
86
88
|
# 在 functional mode 下安装时机晚于 strategy.subscribe, 错过首次订阅)
|
|
@@ -152,7 +154,19 @@ def _make_market_gateway() -> Any:
|
|
|
152
154
|
while True:
|
|
153
155
|
try:
|
|
154
156
|
bars = self._fetch_bars(sym)
|
|
155
|
-
|
|
157
|
+
# Round 26 诊断: 区分 warmup bar (历史回填) vs live bar (新生成)
|
|
158
|
+
# 避免 warmup 期间 emit 跟 live 期间 emit 混在一起 (debug 时方便定位).
|
|
159
|
+
print(f"[qmt-market] warmup {sym}: 拉 {len(bars)} 根 "
|
|
160
|
+
f"(period={self._period}, bar_count={self._bar_count})",
|
|
161
|
+
flush=True)
|
|
162
|
+
for i, bar in enumerate(bars):
|
|
163
|
+
if i < 3 or i == len(bars) - 1:
|
|
164
|
+
# 只打印头 3 + 末 1 根, 避免刷屏
|
|
165
|
+
print(f"[qmt-market] warmup {sym} bar[{i}] "
|
|
166
|
+
f"time={bar.get('time')} "
|
|
167
|
+
f"close={bar.get('close')} "
|
|
168
|
+
f"volume={bar.get('volume')}",
|
|
169
|
+
flush=True)
|
|
156
170
|
self._emit_bar(bar)
|
|
157
171
|
if bars:
|
|
158
172
|
self._last_bar_signatures[sym] = _bar_signature(bars[-1])
|
|
@@ -189,21 +203,44 @@ def _make_market_gateway() -> Any:
|
|
|
189
203
|
time.sleep(sleep_for)
|
|
190
204
|
|
|
191
205
|
def _poll_once(self) -> None:
|
|
206
|
+
"""一次 poll 拉 ticks + bars — 各自独立 try/except, 互不阻塞.
|
|
207
|
+
|
|
208
|
+
Round 26 Bug 修: 原版 _fetch_ticks 抛异常 → 整个 _poll_once 退出,
|
|
209
|
+
bar 拉取永远不到; snapshot timeout 一次 → bar 推送永久缺失.
|
|
210
|
+
改版: ticks / bars 各自 try, 异常只 print + continue, 不中断对方.
|
|
211
|
+
"""
|
|
192
212
|
subs = list(self._subscribed)
|
|
193
213
|
if not subs:
|
|
194
214
|
return
|
|
195
|
-
ticks
|
|
196
|
-
|
|
197
|
-
self.
|
|
215
|
+
# ticks: 独立 try — snapshot timeout 不应阻止 bar 拉取
|
|
216
|
+
try:
|
|
217
|
+
ticks = self._fetch_ticks(subs)
|
|
218
|
+
for t in ticks:
|
|
219
|
+
self._emit_tick(t)
|
|
220
|
+
except Exception as e: # noqa: BLE001
|
|
221
|
+
print(f"[qmt-market] ticks poll error: {type(e).__name__}: {e}",
|
|
222
|
+
flush=True)
|
|
223
|
+
# bars: 独立 try — 单只 sym 失败不影响其它
|
|
198
224
|
for sym in subs:
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
225
|
+
try:
|
|
226
|
+
bars = self._fetch_bars(sym, count=1)
|
|
227
|
+
# Round 29+4 诊断: 看 _fetch_bars 实际返几个 bar + 是不是真的空
|
|
228
|
+
if not bars:
|
|
229
|
+
print(f"[qmt-market] bars poll {sym}: 0 bars (QMT 数据未到?)",
|
|
230
|
+
flush=True)
|
|
231
|
+
continue
|
|
232
|
+
last = bars[-1]
|
|
233
|
+
sig = _bar_signature(last)
|
|
234
|
+
prev = self._last_bar_signatures.get(sym)
|
|
235
|
+
if prev != sig:
|
|
236
|
+
self._last_bar_signatures[sym] = sig
|
|
237
|
+
self._emit_bar(last)
|
|
238
|
+
print(f"[qmt-market] bar emit {sym} time={last.get('time')} "
|
|
239
|
+
f"close={last.get('close')} sig_changed={prev is None}",
|
|
240
|
+
flush=True)
|
|
241
|
+
except Exception as e: # noqa: BLE001
|
|
242
|
+
print(f"[qmt-market] bars poll error {sym}: "
|
|
243
|
+
f"{type(e).__name__}: {e}", flush=True)
|
|
207
244
|
|
|
208
245
|
def _fetch_ticks(self, symbols: list[str]) -> list[dict[str, Any]]:
|
|
209
246
|
"""GET /data/snapshot?securities=sym1,sym2 → list of tick dict.
|
|
@@ -227,13 +264,19 @@ def _make_market_gateway() -> Any:
|
|
|
227
264
|
return ticks
|
|
228
265
|
|
|
229
266
|
def _fetch_bars(self, symbol: str, count: int | None = None) -> list[dict[str, Any]]:
|
|
230
|
-
"""GET /data/history?security=...&period=1d&count=N → list of bar dict (旧→新).
|
|
267
|
+
"""GET /data/history?security=...&period=1d&count=N[&subscribe=T/F] → list of bar dict (旧→新).
|
|
268
|
+
|
|
269
|
+
subscribe: paper=False / broker_live=True (round 28+1 决策) — paper
|
|
270
|
+
一次性拉够 N 根即可, broker_live 必须 True 让 QMT 实时推新 bar,
|
|
271
|
+
避免 count=-1 + 空时间窗只返本地最新 1 条 snapshot 的问题.
|
|
272
|
+
"""
|
|
231
273
|
qmt_sym = _to_qmt_symbol(symbol)
|
|
232
274
|
cnt = count if count is not None else self._bar_count
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
275
|
+
params = {"security": qmt_sym, "period": self._period,
|
|
276
|
+
"count": str(cnt), "fq": "qfq"}
|
|
277
|
+
if self._subscribe:
|
|
278
|
+
params["subscribe"] = "true"
|
|
279
|
+
resp = self._http.get("/data/history", params)
|
|
237
280
|
value = _unwrap_envelope(resp)
|
|
238
281
|
return _bars_from_history(value, symbol)
|
|
239
282
|
|
|
@@ -284,11 +327,18 @@ def _unwrap_envelope(resp: dict[str, Any]) -> Any:
|
|
|
284
327
|
|
|
285
328
|
|
|
286
329
|
def _bar_signature(bar: dict[str, Any]) -> tuple:
|
|
287
|
-
"""bar
|
|
330
|
+
"""bar 摘要 — 推 on_bar 去重用.
|
|
331
|
+
|
|
332
|
+
Round 26 修复: 加 `time` (epoch ms, QMT 全推) 进 tuple. 之前只用
|
|
333
|
+
OHLCV + datetime (YYYYMMDD 字符串), 当 1m/5m 等高频 bar 在同一
|
|
334
|
+
'date' 字符串内 + 早盘 OHLCV 不变时 (e.g. 日线 fallback), signature
|
|
335
|
+
永不变化 → 不 emit. 加 time 字段后即使 OHLCV 不变, 只要 bar 是新的
|
|
336
|
+
(time 不同), 就能 detect.
|
|
337
|
+
"""
|
|
288
338
|
return (
|
|
339
|
+
bar.get("time"), # epoch ms (Round 26 加: 高频 bar 时间戳)
|
|
289
340
|
bar.get("open"), bar.get("high"), bar.get("low"),
|
|
290
341
|
bar.get("close"), bar.get("volume"), bar.get("amount"),
|
|
291
|
-
bar.get("datetime") or bar.get("date") or bar.get("time"),
|
|
292
342
|
)
|
|
293
343
|
|
|
294
344
|
|
|
@@ -426,6 +476,10 @@ def build_qmt_market(feed: Any, symbols: list[str], use_aggregator: bool, **kwar
|
|
|
426
476
|
market_bar_count (default 20) — warmup 拉几根
|
|
427
477
|
market_poll (default 30.0) — 轮询秒数 (用户决策 2026-08-18: ≥30s)
|
|
428
478
|
market_timeout (default 5.0) — HTTP 超时
|
|
479
|
+
market_subscribe (default False) — /data/history subscribe= 参数
|
|
480
|
+
(paper=False, broker_live=True)
|
|
481
|
+
透传方式: --gateway-options
|
|
482
|
+
"market_subscribe=1" (或 True)
|
|
429
483
|
"""
|
|
430
484
|
from akquant.gateway.protocols import GatewayBundle
|
|
431
485
|
|
|
@@ -434,19 +488,24 @@ def build_qmt_market(feed: Any, symbols: list[str], use_aggregator: bool, **kwar
|
|
|
434
488
|
bar_count = int(kwargs.get("market_bar_count") or kwargs.get("bar_count") or 20)
|
|
435
489
|
poll = float(kwargs.get("market_poll") or kwargs.get("poll_interval") or 30.0)
|
|
436
490
|
timeout = float(kwargs.get("market_timeout") or kwargs.get("timeout") or 30.0) # ponytail: 跟 _HTTP.timeout 默认 30s 保持一致 (caller 显式传 timeout=5.0 会盖过这里默认, 但 build_qmt_market 不传 timeout 时必须 30s 才治网络抖动)
|
|
491
|
+
# market_subscribe: CLI 字符串 "1"/"true"/"yes" → True, 其它/缺失 → False
|
|
492
|
+
subscribe_raw = kwargs.get("market_subscribe") or kwargs.get("subscribe") or ""
|
|
493
|
+
subscribe = str(subscribe_raw).strip().lower() in ("1", "true", "yes", "on")
|
|
437
494
|
QmtMarketGateway = _make_market_gateway()
|
|
438
495
|
gw = QmtMarketGateway(
|
|
439
496
|
feed=feed, # akquant DataFeed — 主路径推 tick/bar 用
|
|
440
497
|
base_url=base_url, period=period, bar_count=bar_count,
|
|
441
498
|
poll_interval=poll, timeout=timeout,
|
|
442
499
|
symbols=list(symbols or []), # auto-subscribe (绕开 akquant forwarder 时序 bug)
|
|
500
|
+
subscribe=subscribe,
|
|
443
501
|
)
|
|
444
502
|
return GatewayBundle(
|
|
445
503
|
market_gateway=gw,
|
|
446
504
|
trader_gateway=None, # market-only builder, 不含 trader
|
|
447
505
|
trader_capabilities=None,
|
|
448
506
|
metadata={"broker": "qmt_market", "bridge": "bullettrade_compat",
|
|
449
|
-
"period": period, "bar_count": bar_count, "poll_interval": poll
|
|
507
|
+
"period": period, "bar_count": bar_count, "poll_interval": poll,
|
|
508
|
+
"subscribe": subscribe},
|
|
450
509
|
)
|
|
451
510
|
|
|
452
511
|
|
|
@@ -39,12 +39,80 @@ def parse_kv_pairs(raw: str | None) -> dict[str, str]:
|
|
|
39
39
|
return out
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
# Round 19+2 — market_period 自动从策略 params 推断 (CLI 显式 > 策略 params > 默认 1d).
|
|
43
|
+
# IntParam 0=auto/1d / 1=1d / 2=5m / 3=1m. cli 直读 self.params.market_period, 无 ast/无推断策略.
|
|
44
|
+
_MARKET_PERIOD_MAP = {0: "1d", 1: "1d", 2: "5m", 3: "1m"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_CLI_PERIOD_ALIASES = {"1d", "1m", "5m", "1w", "1mon", "tick"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_market_period(
|
|
51
|
+
spec: StrategySpec, gateway_options: dict[str, str],
|
|
52
|
+
params: dict | None,
|
|
53
|
+
) -> str | None:
|
|
54
|
+
"""决定 driver market_period:
|
|
55
|
+
1) CLI --gateway-options 显式 market_period → 翻译或透传
|
|
56
|
+
- 数字字符串 "0"/"1"/"2"/"3" 走 _MARKET_PERIOD_MAP (跟 IntParam 兼容)
|
|
57
|
+
- period 别名 "1d"/"1m"/"5m"/"1w"/"1mon" 原样透传
|
|
58
|
+
2) 策略 self.params.market_period (skill 在 .py 生成) → 映射
|
|
59
|
+
3) params.json strategy_params.market_period → 同上 (运行时覆盖)
|
|
60
|
+
4) 默认 "1d" (向后兼容)
|
|
61
|
+
|
|
62
|
+
返回 None 表示 CLI 已显式 period 别名, 不需要再 inject (兼容旧契约).
|
|
63
|
+
"""
|
|
64
|
+
if "market_period" in gateway_options:
|
|
65
|
+
cli_value = str(gateway_options["market_period"]).strip()
|
|
66
|
+
# 数字字符串 (IntParam 0/1/2/3) → 翻译成 period 别名
|
|
67
|
+
if cli_value in {"0", "1", "2", "3"}:
|
|
68
|
+
mapped = _MARKET_PERIOD_MAP[int(cli_value)]
|
|
69
|
+
print(
|
|
70
|
+
f"[info] CLI market_period 数字 {cli_value} → {mapped} "
|
|
71
|
+
f"(_MARKET_PERIOD_MAP 翻译)",
|
|
72
|
+
flush=True,
|
|
73
|
+
)
|
|
74
|
+
return mapped
|
|
75
|
+
# period 别名 → 原样透传 (注入到 gateway_options 防止下游错读)
|
|
76
|
+
if cli_value in _CLI_PERIOD_ALIASES:
|
|
77
|
+
gateway_options["market_period"] = cli_value
|
|
78
|
+
return None # CLI 已显式 period 别名, 跳过自动推断
|
|
79
|
+
# 其它非法值 — 不静默 fallback, 报错给用户
|
|
80
|
+
raise LiveRunError(
|
|
81
|
+
f"--gateway-options market_period={cli_value!r} 非法. "
|
|
82
|
+
f"接受 IntParam 数字 0/1/2/3 或 period 别名 "
|
|
83
|
+
f"{sorted(_CLI_PERIOD_ALIASES)}"
|
|
84
|
+
)
|
|
85
|
+
# 优先级 2: 策略 inline params (无 ast, 实例化读 params)
|
|
86
|
+
if spec.mode == "class" and spec.strategy_cls is not None:
|
|
87
|
+
try:
|
|
88
|
+
inst = spec.strategy_cls()
|
|
89
|
+
mp = int(getattr(inst.params, "market_period", 1))
|
|
90
|
+
mapped = _MARKET_PERIOD_MAP.get(mp, "1d")
|
|
91
|
+
print(
|
|
92
|
+
f"[info] market_period 自动推断: {mapped} "
|
|
93
|
+
f"(params.market_period={mp}, 策略 inline 默认)",
|
|
94
|
+
flush=True,
|
|
95
|
+
)
|
|
96
|
+
return mapped
|
|
97
|
+
except Exception as e: # noqa: BLE001
|
|
98
|
+
print(
|
|
99
|
+
f"[warn] market_period 推断失败 ({type(e).__name__}: {e}), "
|
|
100
|
+
f"fallback '1d'",
|
|
101
|
+
flush=True,
|
|
102
|
+
)
|
|
103
|
+
return "1d" # 默认
|
|
104
|
+
|
|
105
|
+
|
|
42
106
|
def _fetch_bridge_history(bridge_url: str, sym: str, period: str, count: int,
|
|
43
|
-
timeout: float = 30.0) -> list[dict[str, Any]]:
|
|
44
|
-
"""GET /data/history?security=<sym>&period=<p>&count=<n>&fq=
|
|
107
|
+
timeout: float = 30.0, subscribe: bool = False) -> list[dict[str, Any]]:
|
|
108
|
+
"""GET /data/history?security=<sym>&period=<p>&count=<n>&fq=qfq[&subscribe=T/F] → list of bar dict (旧→新).
|
|
45
109
|
|
|
46
110
|
bridge 不通 / 超时 / 非 200 直接 raise LiveRunError, 不静默退化 (用户原话:
|
|
47
111
|
策略出错直接报错停止, 不走 mock 兜底).
|
|
112
|
+
|
|
113
|
+
subscribe: paper=False / broker_live=True — paper 不需要实时订阅 (一次性拉够
|
|
114
|
+
N 根 bar 即可), broker_live 需要开 subscribe=True 让 QMT 实时推新 bar, 避免
|
|
115
|
+
count=-1 + start/end 全空时只返回本地最新 1 条 snapshot 的问题.
|
|
48
116
|
"""
|
|
49
117
|
from urllib.parse import urlencode
|
|
50
118
|
from urllib.request import urlopen, Request
|
|
@@ -52,9 +120,10 @@ def _fetch_bridge_history(bridge_url: str, sym: str, period: str, count: int,
|
|
|
52
120
|
canonical = sym.split(".")[0]
|
|
53
121
|
if canonical.lower().startswith(("sh", "sz")):
|
|
54
122
|
canonical = canonical[2:]
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
123
|
+
params = {"security": canonical, "period": period, "count": str(count), "fq": "qfq"}
|
|
124
|
+
if subscribe:
|
|
125
|
+
params["subscribe"] = "true"
|
|
126
|
+
q = urlencode(params)
|
|
58
127
|
url = f"{bridge_url.rstrip('/')}/data/history?{q}"
|
|
59
128
|
try:
|
|
60
129
|
with urlopen(Request(url), timeout=timeout) as resp:
|
|
@@ -91,11 +160,14 @@ def _fetch_bridge_history(bridge_url: str, sym: str, period: str, count: int,
|
|
|
91
160
|
|
|
92
161
|
|
|
93
162
|
def _fetch_bridge_history_batch(bridge_url: str, symbols: list[str], period: str = "1d",
|
|
94
|
-
count: int = 30, timeout: float = 45.0
|
|
163
|
+
count: int = 30, timeout: float = 45.0,
|
|
164
|
+
subscribe: bool = False) -> dict[str, list[dict[str, Any]]]:
|
|
95
165
|
"""GET /data/history?securities=...&multi=true → {sym: [bar dict]} 批量获取.
|
|
96
166
|
|
|
97
167
|
优先使用 bridge_server 的批量 multi 接口 (一次 HTTP 拉多只), 大幅减少
|
|
98
168
|
逐只 HTTP 开销 (300 只从 ~27s 降到 ~1s). 失败时返回 {} 让调用方回退逐只.
|
|
169
|
+
|
|
170
|
+
subscribe 语义同 _fetch_bridge_history (paper=False, broker_live=True).
|
|
99
171
|
"""
|
|
100
172
|
from urllib.parse import urlencode
|
|
101
173
|
from urllib.request import urlopen, Request
|
|
@@ -107,10 +179,11 @@ def _fetch_bridge_history_batch(bridge_url: str, symbols: list[str], period: str
|
|
|
107
179
|
if c.lower().startswith(("sh", "sz")):
|
|
108
180
|
c = c[2:]
|
|
109
181
|
qmt_codes.append(c)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
182
|
+
params = {"securities": ",".join(qmt_codes),
|
|
183
|
+
"period": period, "count": str(count), "fq": "qfq", "multi": "true"}
|
|
184
|
+
if subscribe:
|
|
185
|
+
params["subscribe"] = "true"
|
|
186
|
+
q = urlencode(params)
|
|
114
187
|
url = f"{bridge_url.rstrip('/')}/data/history?{q}"
|
|
115
188
|
try:
|
|
116
189
|
with urlopen(Request(url), timeout=timeout) as resp:
|
|
@@ -140,7 +213,8 @@ _BATCH_CHUNK_SIZE = 50
|
|
|
140
213
|
def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d",
|
|
141
214
|
count: int = 30, timeout: float = 45.0,
|
|
142
215
|
chunk_size: int = _BATCH_CHUNK_SIZE,
|
|
143
|
-
max_workers: int | None = None
|
|
216
|
+
max_workers: int | None = None,
|
|
217
|
+
subscribe: bool = False) -> dict[str, Any]:
|
|
144
218
|
"""实盘 startup: 拉 N sym × N bar 历史, 拼成 {sym: DataFrame} 喂 compute_factors.
|
|
145
219
|
|
|
146
220
|
实盘只支持日线 (1d) — QMT 5m/1m/tick 走 market_broker=qmt_market 的实时 tick,
|
|
@@ -151,6 +225,9 @@ def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d"
|
|
|
151
225
|
2. 每片用批量 multi 接口 (一次 HTTP 拉 ≤50 只)
|
|
152
226
|
3. 多片 ThreadPoolExecutor 并发执行 (默认 min(32, 片数))
|
|
153
227
|
4. 单只失败的片回退逐只 (兼容旧 bridge_server)
|
|
228
|
+
|
|
229
|
+
subscribe 透传给 _fetch_bridge_history / _fetch_bridge_history_batch —
|
|
230
|
+
paper=False, broker_live=True (run_live 内 mode→subscribe 映射).
|
|
154
231
|
"""
|
|
155
232
|
import pandas as _pd
|
|
156
233
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
@@ -173,7 +250,8 @@ def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d"
|
|
|
173
250
|
def _fetch_chunk(chunk: list[str]) -> dict[str, _pd.DataFrame]:
|
|
174
251
|
"""拉一片: 批量优先, 失败逐只. 批量返回的裸码 key 会归一化到标准 symbol."""
|
|
175
252
|
out: dict[str, _pd.DataFrame] = {}
|
|
176
|
-
batch = _fetch_bridge_history_batch(bridge_url, chunk, period, count,
|
|
253
|
+
batch = _fetch_bridge_history_batch(bridge_url, chunk, period, count,
|
|
254
|
+
timeout=timeout, subscribe=subscribe)
|
|
177
255
|
if batch:
|
|
178
256
|
# 归一化批量返回的 key (裸码 → 标准), 并只保留本片请求的
|
|
179
257
|
wanted = set(chunk)
|
|
@@ -186,7 +264,8 @@ def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d"
|
|
|
186
264
|
else:
|
|
187
265
|
missing = chunk
|
|
188
266
|
for sym in missing:
|
|
189
|
-
bars = _fetch_bridge_history(bridge_url, sym, period, count,
|
|
267
|
+
bars = _fetch_bridge_history(bridge_url, sym, period, count,
|
|
268
|
+
timeout=timeout, subscribe=subscribe)
|
|
190
269
|
out[sym] = _pd.DataFrame(bars) if bars else _pd.DataFrame(columns=["open", "high", "low", "close", "volume", "amount"])
|
|
191
270
|
return out
|
|
192
271
|
|
|
@@ -205,7 +284,8 @@ def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d"
|
|
|
205
284
|
chunk = futures[fut]
|
|
206
285
|
for sym in chunk:
|
|
207
286
|
try:
|
|
208
|
-
bars = _fetch_bridge_history(bridge_url, sym, period, count,
|
|
287
|
+
bars = _fetch_bridge_history(bridge_url, sym, period, count,
|
|
288
|
+
timeout=timeout, subscribe=subscribe)
|
|
209
289
|
factors[sym] = _pd.DataFrame(bars) if bars else _pd.DataFrame(columns=["open", "high", "low", "close", "volume", "amount"])
|
|
210
290
|
except Exception:
|
|
211
291
|
factors[sym] = _pd.DataFrame(columns=["open", "high", "low", "close", "volume", "amount"])
|
|
@@ -354,11 +434,19 @@ def _with_live_history_depth(
|
|
|
354
434
|
def on_start(self) -> _Any:
|
|
355
435
|
ret_val = super().on_start()
|
|
356
436
|
try:
|
|
357
|
-
|
|
437
|
+
# Round 25+ 修复: calendar 是 bridge 历史 (count=30 默认, 全过去日期) —
|
|
438
|
+
# 若不过滤 today, akquant scheduler 启动期会一次性回放所有过去 schedule,
|
|
439
|
+
# 导致 on_timer FIRED 在启动瞬间触发 (实测 b95upfgju 12:13:55 rebalance),
|
|
440
|
+
# 错把启动当 14:55 调仓时机. 过滤 day >= today 让 wrapper 只注未来/今日.
|
|
441
|
+
today_str = _dt.date.today().strftime("%Y-%m-%d")
|
|
442
|
+
future_fridays = [d for d in fridays if d >= today_str]
|
|
443
|
+
for day in future_fridays:
|
|
358
444
|
y, m, d = day.split("-")
|
|
359
445
|
_schedule(self, _dt.datetime(int(y), int(m), int(d), 14, 55), "rebalance")
|
|
360
446
|
print(
|
|
361
|
-
f"[info] 周五 14:55 重排定时器注入: {len(
|
|
447
|
+
f"[info] 周五 14:55 重排定时器注入: {len(future_fridays)} 个 "
|
|
448
|
+
f"(过去 {len(fridays) - len(future_fridays)} 个已跳过, "
|
|
449
|
+
f"未来 {future_fridays})",
|
|
362
450
|
flush=True,
|
|
363
451
|
)
|
|
364
452
|
except Exception as e: # noqa: BLE001 — 定时器失败不应让 on_start 崩
|
|
@@ -561,6 +649,10 @@ def run_live(
|
|
|
561
649
|
"""
|
|
562
650
|
gateway_options = parse_kv_pairs(gateway_options_raw)
|
|
563
651
|
duration = _live_safety_defaults(mode, broker, gateway_options, duration)
|
|
652
|
+
# Round 19+2 — market_period 自动从策略 params 推断 (CLI 显式覆盖最高优先级).
|
|
653
|
+
resolved_mp = _resolve_market_period(spec, gateway_options, params)
|
|
654
|
+
if resolved_mp is not None:
|
|
655
|
+
gateway_options["market_period"] = resolved_mp
|
|
564
656
|
# replay broker 的 bars 数据 — 供 v2 architecture compute_factors 复用
|
|
565
657
|
replay_bars_df = None
|
|
566
658
|
# 真实交易日历 (YYYY-MM-DD, 从 bars_df / bridge 历史提取) — 注入 strategy wrapper
|
|
@@ -664,6 +756,38 @@ def run_live(
|
|
|
664
756
|
"如不需要预计算因子, 走 functional mode 兼容路径."
|
|
665
757
|
)
|
|
666
758
|
|
|
759
|
+
# Round 25+ 修复: 交易日历 (周五 14:55 重排定时器) 必须在 wants_precompute
|
|
760
|
+
# 分支外独立拉 — 策略层 schedule_daily("15:00", "trigger") 周末也会触发,
|
|
761
|
+
# 真周五重排需要从 bridge 真实历史抽交易日历 (跳过周末/节假日). 之前逻辑把
|
|
762
|
+
# _fetch_live_factors 放在 wants_precompute else 分支内, 无 compute_factors
|
|
763
|
+
# 策略直接跳过 → calendar=[] → fridays=[] → 0 个周五百排定时器 (实测 bn9d2aa9u).
|
|
764
|
+
rebalance_calendar: list[str] | None = None
|
|
765
|
+
raw_factors: dict[str, Any] = {} # 兜底初值: 下面 if 不走时空 dict
|
|
766
|
+
if qmt_base_url or has_replay_data:
|
|
767
|
+
try:
|
|
768
|
+
if has_replay_data:
|
|
769
|
+
raw_factors = replay_bars_df
|
|
770
|
+
else:
|
|
771
|
+
# paper=False / broker_live=True — paper 不需要实时订阅, broker_live
|
|
772
|
+
# 必须 True 才能让 QMT 实时推新 bar (避免 count=-1 + 空时间窗只返
|
|
773
|
+
# 本地最新 1 条 snapshot 的问题). qmt_market.go live_mode 同语义.
|
|
774
|
+
raw_factors = _fetch_live_factors(qmt_base_url, symbols,
|
|
775
|
+
subscribe=(mode == "broker_live"))
|
|
776
|
+
rebalance_calendar = _calendar_from_df(raw_factors, symbols)
|
|
777
|
+
if rebalance_calendar:
|
|
778
|
+
print(
|
|
779
|
+
f"[info] 交易日历注入: {len(rebalance_calendar)} days, "
|
|
780
|
+
f"{len(_friday_date_strings(rebalance_calendar))} 周五 → 14:55 重排定时器",
|
|
781
|
+
flush=True,
|
|
782
|
+
)
|
|
783
|
+
except LiveRunError:
|
|
784
|
+
raise
|
|
785
|
+
except Exception as e:
|
|
786
|
+
raise LiveRunError(
|
|
787
|
+
f"实盘拉历史失败 (rebalance_calendar): {type(e).__name__}: {e} "
|
|
788
|
+
f"(用户原话: 出错直接报错停止, 不走 mock 兜底)"
|
|
789
|
+
) from e
|
|
790
|
+
|
|
667
791
|
if not wants_precompute:
|
|
668
792
|
print(
|
|
669
793
|
f"[info] class mode 无 compute_factors/filter_symbols — 跳过预计算, "
|
|
@@ -675,25 +799,7 @@ def run_live(
|
|
|
675
799
|
if hasattr(strat_inst, "compute_factors"):
|
|
676
800
|
# 实盘 startup: 拉 N sym × N bar 历史, 拼 {sym: DataFrame} 喂 compute_factors
|
|
677
801
|
# qmt: bridge /data/history; replay: 本地 prebuilt bars (paper trading).
|
|
678
|
-
|
|
679
|
-
if has_replay_data:
|
|
680
|
-
raw_factors = replay_bars_df
|
|
681
|
-
else:
|
|
682
|
-
raw_factors = _fetch_live_factors(qmt_base_url, symbols)
|
|
683
|
-
rebalance_calendar = _calendar_from_df(raw_factors, symbols)
|
|
684
|
-
if rebalance_calendar:
|
|
685
|
-
print(
|
|
686
|
-
f"[info] 交易日历注入: {len(rebalance_calendar)} days, "
|
|
687
|
-
f"{len(_friday_date_strings(rebalance_calendar))} 周五 → 14:55 重排定时器",
|
|
688
|
-
flush=True,
|
|
689
|
-
)
|
|
690
|
-
except LiveRunError:
|
|
691
|
-
raise
|
|
692
|
-
except Exception as e:
|
|
693
|
-
raise LiveRunError(
|
|
694
|
-
f"实盘拉历史失败: {type(e).__name__}: {e} "
|
|
695
|
-
f"(用户原话: 出错直接报错停止, 不走 mock 兜底)"
|
|
696
|
-
) from e
|
|
802
|
+
# raw_factors / rebalance_calendar 已在上面分支统一拉, 此处复用.
|
|
697
803
|
try:
|
|
698
804
|
factors = strat_inst.compute_factors(raw_factors) or {}
|
|
699
805
|
except Exception as e:
|
|
@@ -791,45 +897,4 @@ def run_live(
|
|
|
791
897
|
f"market_broker={market_broker or '<single>'} "
|
|
792
898
|
f"symbols={symbols or '<all>'} duration={duration}", flush=True)
|
|
793
899
|
print(f"[info] gateway_options={gateway_options}", flush=True)
|
|
794
|
-
_akquant_run_live(**kwargs)
|
|
795
|
-
|
|
796
|
-
# ===== Round 18.62 P0-1: replay metrics 产出 =====
|
|
797
|
-
# live run 跑完后, 拿同一份 bars + strategy_cls 跑一次 mini-backtest 拿 metrics,
|
|
798
|
-
# 落 runs/{strategy_id}/replay_result.json (跟 cmd_run 同 13-key schema, to_hamuna_result).
|
|
799
|
-
# 用途: commiter cmd_commit --replay-result 拿此文件 vs 回测 result.json 算 sharpe diff.
|
|
800
|
-
# 仅 broker=replay 时跑 (其他 broker 真盘无意义, 也没 bars 数据).
|
|
801
|
-
if broker == "replay" and spec.mode == "class" and spec.strategy_cls is not None:
|
|
802
|
-
try:
|
|
803
|
-
from akquant import run_backtest as _akquant_run_backtest # noqa: F401
|
|
804
|
-
from ..akquant_schema_adapter import to_hamuna_result
|
|
805
|
-
from datetime import datetime as _dt
|
|
806
|
-
# strategy_id 来自 run_live kwargs 顶层 (Round 18.42+ commiter 必传);
|
|
807
|
-
# 若没有 (裸 live run 调) 退到 /tmp/replay_result.json, 不污染 runs/.
|
|
808
|
-
strategy_id = kwargs.get("strategy_id") or "adhoc"
|
|
809
|
-
out_dir = Path("runs") / strategy_id
|
|
810
|
-
out_dir.mkdir(parents=True, exist_ok=True)
|
|
811
|
-
out_path = out_dir / "replay_result.json"
|
|
812
|
-
# 用 replay_bars_df + strategy_cls 跑 mini-backtest. 同一份数据 → 可比.
|
|
813
|
-
mini_kwargs = dict(kwargs)
|
|
814
|
-
mini_kwargs.pop("live", None) # 防混入 live flag
|
|
815
|
-
# to_hamuna_result 需 akquant BacktestResult 对象; mini-backtest 走同一 strategy_cls
|
|
816
|
-
# + 同一份 bars, 拿 metrics 对象 → to_hamuna_result 落 13-key schema.
|
|
817
|
-
bt_result = _akquant_run_backtest(
|
|
818
|
-
strategy_cls=spec.strategy_cls,
|
|
819
|
-
bars=replay_bars_df,
|
|
820
|
-
initial_cash=initial_cash or 100000.0,
|
|
821
|
-
)
|
|
822
|
-
cfg = {"period": {"start": str(replay_bars_df['date'].min())[:10] if hasattr(replay_bars_df, 'columns') else "",
|
|
823
|
-
"end": str(_dt.now().date())}}
|
|
824
|
-
result_dict = to_hamuna_result(bt_result, cfg)
|
|
825
|
-
out_path.write_text(json.dumps(result_dict, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
826
|
-
m = result_dict.get("metrics", {})
|
|
827
|
-
print(f"[info] replay metrics: sharpe={m.get('sharpe'):.3f} "
|
|
828
|
-
f"total_return={m.get('total_return', 0):.2%} trades={len(result_dict.get('trades', []))} "
|
|
829
|
-
f"→ {out_path}", flush=True)
|
|
830
|
-
except ImportError:
|
|
831
|
-
# akquant 未装 / to_hamuna_result 不可用 — 跳过, 不阻塞 live run
|
|
832
|
-
print("[warn] P0-1: akquant / to_hamuna_result 未装, 跳过 replay metrics 产出", flush=True)
|
|
833
|
-
except Exception as e:
|
|
834
|
-
# mini-backtest 失败不致命 — live run 已成功, 只 warn
|
|
835
|
-
print(f"[warn] P0-1: replay metrics 产出失败 ({type(e).__name__}: {e}), 跳过", flush=True)
|
|
900
|
+
_akquant_run_live(**kwargs)
|
{hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/server_client.py
RENAMED
|
@@ -72,25 +72,20 @@ def _api_call(method: str, path: str, body: dict | None = None) -> dict:
|
|
|
72
72
|
return envelope
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
def upload_backtest_result(strategy_id: str, result: dict,
|
|
76
|
-
"""PUT /api/v1/strategies/{id}/result → 上传 BacktestResult-shape dict +
|
|
75
|
+
def upload_backtest_result(strategy_id: str, result: dict, version: str | None = None) -> dict:
|
|
76
|
+
"""PUT /api/v1/strategies/{id}/result → 上传 BacktestResult-shape dict + version.
|
|
77
77
|
|
|
78
78
|
兼容 akquant_runner.run_akquant_backtest() 输出 (13 顶层 key + metrics 子 dict 15 key + ...),
|
|
79
79
|
先做轻清洗: NaN/Inf → None (json 标准不允许), 后传给 server.
|
|
80
80
|
|
|
81
|
-
Round 18.62 P0-1: replay_metrics (Sharpe/TotalReturn/WinRate/Trades/DiffPct/RunAt) —
|
|
82
|
-
跟 result 平级, server handler 二次校验 diff 防绕过. None 时不传该 key (老调用方不破).
|
|
83
81
|
Round 18.62 P1-1: version (X.Y.Z semver) — cli 默认 patch 自动 bump (cmd_commit step 1.4
|
|
84
82
|
拿 server latest_version 算新 version), server handler 二次校验 semver 格式 + 严格递增.
|
|
85
83
|
老调用方 (无 version kwarg) → 不传该 key → server 422 (P1-1 强校验).
|
|
84
|
+
Round 18.63: replay_metrics / P0-1 gate 移除 (用户决策) — 上传不再需要 replay.
|
|
86
85
|
"""
|
|
87
86
|
payload = _sanitize_for_json(result)
|
|
88
|
-
#
|
|
89
|
-
# (即使 replay_metrics=None / version=None 也要 wrap, 防止 server handler 422 invalid shape).
|
|
90
|
-
# 此改动在 P1-1 同步实施, P0-1 Case F 已知问题修复.
|
|
87
|
+
# 无条件 wrap `{"result": ...}` 让 server 解析形状一致.
|
|
91
88
|
body: dict = {"result": payload}
|
|
92
|
-
if replay_metrics is not None:
|
|
93
|
-
body["replay_metrics"] = _sanitize_for_json(replay_metrics)
|
|
94
89
|
if version is not None:
|
|
95
90
|
body["version"] = version
|
|
96
91
|
return _api_call('PUT', f'/api/v1/strategies/{strategy_id}/result', body)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hamuna-quant-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.12"
|
|
8
8
|
description = "Hamuna A 股回测 + 实盘统一 CLI (akquant 0.3.x). 替代 v1 自建 driver + QMT-style 策略规范."
|
|
9
9
|
readme = "hamuna_quant_cli/README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/_test_akquant_parity.py
RENAMED
|
File without changes
|
{hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/akquant_data_adapter.py
RENAMED
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/cross_sectional_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|