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.
- hamuna_quant_cli/README.md +117 -0
- hamuna_quant_cli/__init__.py +17 -0
- hamuna_quant_cli/__main__.py +978 -0
- hamuna_quant_cli/_market_fallback.py +82 -0
- hamuna_quant_cli/_metrics_15.py +342 -0
- hamuna_quant_cli/_test_akquant_parity.py +530 -0
- hamuna_quant_cli/akquant_data_adapter.py +295 -0
- hamuna_quant_cli/akquant_runner.py +620 -0
- hamuna_quant_cli/akquant_schema_adapter.py +443 -0
- hamuna_quant_cli/base_strategy.py +80 -0
- hamuna_quant_cli/cross_sectional_helpers.py +118 -0
- hamuna_quant_cli/live/__init__.py +25 -0
- hamuna_quant_cli/live/loader.py +121 -0
- hamuna_quant_cli/live/qmt_broker.py +683 -0
- hamuna_quant_cli/live/qmt_market.py +448 -0
- hamuna_quant_cli/live/runner.py +449 -0
- hamuna_quant_cli/prebuilt_downloader.py +263 -0
- hamuna_quant_cli/prebuilt_resolver.py +470 -0
- hamuna_quant_cli/qmt_translator.py +609 -0
- hamuna_quant_cli/runtime/__init__.py +2 -0
- hamuna_quant_cli/runtime/backtest.py +38 -0
- hamuna_quant_cli/runtime/cache.py +255 -0
- hamuna_quant_cli/runtime/discipline.py +359 -0
- hamuna_quant_cli/runtime/http_client.py +209 -0
- hamuna_quant_cli/runtime/s3client.py +109 -0
- hamuna_quant_cli/runtime/server_client.py +285 -0
- hamuna_quant_cli/scripts/server.json +4 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/METADATA +154 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/RECORD +32 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/WHEEL +5 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/entry_points.txt +2 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""hamuna_quant_cli._market_fallback — 内联最小 v1 market API 替代.
|
|
2
|
+
|
|
3
|
+
为什么: 历史 v2 代码 `from strategy_cli.fundamental.data import market` 走 v1 skill
|
|
4
|
+
(hamuna-strategy), pip install hamuna-quant-cli 后 v1 不可见 → ImportError.
|
|
5
|
+
这里把 v1 market.get_market_data_ex 的"只 prebuilt parquet"小份实现搬过来,
|
|
6
|
+
远程数据源 (容维 / Tushare 等) 留给 v1 skill 走.
|
|
7
|
+
|
|
8
|
+
接口契约 (对齐 v1 market.get_market_data_ex):
|
|
9
|
+
get_market_data_ex(codes: list[str], period: str, start: str, end: str) -> dict[str, list[dict]]
|
|
10
|
+
codes: ['600000.SH', '000001.SZ']
|
|
11
|
+
period: '1d' (其他抛 NotImplementedError)
|
|
12
|
+
start/end: 'YYYYMMDD'
|
|
13
|
+
返: {sym: [{date|time, open, high, low, close, volume, ...}, ...]}
|
|
14
|
+
|
|
15
|
+
内部走 prebuilt_resolver.resolve (pyarrow filter pushdown), 失败 → NotImplementedError
|
|
16
|
+
提示用户先用 v1 skill 或自己下载 prebuilt parquet.
|
|
17
|
+
|
|
18
|
+
Ponytail: 这里只覆盖 v1 的"日线 + prebuilt"路径; 多周期 / 实时行情仍走 v1.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_market_data_ex(
|
|
26
|
+
stock_code: list[str],
|
|
27
|
+
period: str,
|
|
28
|
+
start_time: str,
|
|
29
|
+
end_time: str,
|
|
30
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
31
|
+
"""v1 market API 兼容 shim: 只走 prebuilt parquet, 远程 API 不覆盖.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
stock_code: 标的代码 list (e.g. ['600000.SH', '000001.SZ'])
|
|
35
|
+
period: '1d' (其他抛 NotImplementedError)
|
|
36
|
+
start_time / end_time: 'YYYYMMDD'
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
{sym: [{date|time, open, high, low, close, volume, ...}, ...]}
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
NotImplementedError: period 不是 '1d', 或 dataset 不存在
|
|
43
|
+
FileNotFoundError: prebuilt parquet 缺数据
|
|
44
|
+
"""
|
|
45
|
+
if period != "1d":
|
|
46
|
+
raise NotImplementedError(
|
|
47
|
+
f"_market_fallback 仅支持 1d, period={period} 需走 v1 skill (hamuna-strategy)")
|
|
48
|
+
|
|
49
|
+
if not stock_code:
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
from .prebuilt_resolver import resolve
|
|
53
|
+
|
|
54
|
+
df, _src = resolve(list(stock_code), start_time, end_time, period="1d")
|
|
55
|
+
if df is None or df.empty:
|
|
56
|
+
# 与 v1 路径一致: 抛 FileNotFoundError, 让上层走"换 dataset / 离线导入"分支
|
|
57
|
+
raise FileNotFoundError(
|
|
58
|
+
f"未在 prebuilt dataset 找到 {stock_code} ({start_time}~{end_time}). "
|
|
59
|
+
f"先用 `hamuna_quant_cli dataset fetch --symbols ...` 下载或走 v1 skill "
|
|
60
|
+
f"(`hamuna-strategy`) 的容维 fallback.")
|
|
61
|
+
|
|
62
|
+
# df → v1 形状: {sym: [{date: int YYYYMMDD, open, high, low, close, volume}, ...]}
|
|
63
|
+
out: dict[str, list[dict[str, Any]]] = {}
|
|
64
|
+
for sym, sub in df.groupby("symbol", sort=False):
|
|
65
|
+
rows: list[dict[str, Any]] = []
|
|
66
|
+
for _, row in sub.iterrows():
|
|
67
|
+
ts = row["date"]
|
|
68
|
+
# ts: Timestamp → 'YYYYMMDD' int (对齐 v1 容维 market 输出)
|
|
69
|
+
if hasattr(ts, "strftime"):
|
|
70
|
+
date_int = int(ts.strftime("%Y%m%d"))
|
|
71
|
+
else:
|
|
72
|
+
date_int = int(str(ts)[:8].replace("-", ""))
|
|
73
|
+
rows.append({
|
|
74
|
+
"date": date_int,
|
|
75
|
+
"open": float(row["open"]),
|
|
76
|
+
"high": float(row["high"]),
|
|
77
|
+
"low": float(row["low"]),
|
|
78
|
+
"close": float(row["close"]),
|
|
79
|
+
"volume": float(row["volume"]),
|
|
80
|
+
})
|
|
81
|
+
out[str(sym)] = rows
|
|
82
|
+
return out
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""hamuna-strategy-v2 单点 15 metric 实现 — 自有命名空间版本 (B2).
|
|
2
|
+
|
|
3
|
+
来源: 原仓根 `hamuna_quant_cli/references/_metrics_15.py` (Phase B, 算法 frozen).
|
|
4
|
+
本文件是 wholesale copy, 算法逐函数与原仓根一致 (B 重写不动数值层).
|
|
5
|
+
迁移原因: v2 skill 未来独立分发, 必须自带 metrics 实现, 不依赖仓根
|
|
6
|
+
hamuna_quant_cli (那是 Phase B 公用层, 非 skill 一部分).
|
|
7
|
+
|
|
8
|
+
对齐保证:
|
|
9
|
+
- 算法逐函数复刻原仓根 _metrics_15
|
|
10
|
+
- NaN 纪律: NaN/null = 计算 bug, 不上抛. 安全 getter 缺失字段填 NaN.
|
|
11
|
+
- 单位: total_return/annual_return/volatility/sortino/calmar/var_95/...
|
|
12
|
+
全部"小数"形式 (0.05 = 5%), win_rate 0~1, profit_loss_ratio/profit_factor 比率.
|
|
13
|
+
|
|
14
|
+
API:
|
|
15
|
+
compute_all(nav_curve, trades) -> dict[str, float]
|
|
16
|
+
含 benchmark_total_return=NaN, excess_return=NaN (akquant 单策略无自动基准).
|
|
17
|
+
|
|
18
|
+
Ponytail ceiling: 本文件不暴露给仓根, 只供本包内 `hamuna_quant_cli.references.*`
|
|
19
|
+
自引用. v1 driver (`skills/hamuna-strategy/strategy_cli/runtime/metrics.py`)
|
|
20
|
+
是另一套独立实现, 数值层 < 1e-6 差异, 不可互换.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
from collections import defaultdict
|
|
26
|
+
from datetime import date as _date, datetime
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
# hamuna metrics 15 keys (与 server model.BacktestResult 对齐)
|
|
31
|
+
HAMUNA_METRICS_15 = frozenset({
|
|
32
|
+
'total_return', 'annual_return', 'sharpe', 'max_drawdown', 'volatility',
|
|
33
|
+
'win_rate', 'profit_loss_ratio', 'avg_holding_period',
|
|
34
|
+
'benchmark_total_return', 'excess_return',
|
|
35
|
+
'sortino', 'calmar', 'var_95', 'profit_factor',
|
|
36
|
+
'annual_volatility',
|
|
37
|
+
})
|
|
38
|
+
TRADING_DAYS_PER_YEAR = 252
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _nan() -> float:
|
|
42
|
+
return float('nan')
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _daily_ret(nav: list[float]) -> np.ndarray:
|
|
46
|
+
"""nav[t]/nav[t-1] - 1; 第 1 个 = 0。"""
|
|
47
|
+
arr = np.asarray(nav, dtype=float)
|
|
48
|
+
if len(arr) < 2:
|
|
49
|
+
return np.zeros_like(arr)
|
|
50
|
+
ret = arr[1:] / arr[:-1] - 1.0
|
|
51
|
+
return np.concatenate([[0.0], ret])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _date_to_d(date_str: str) -> _date:
|
|
55
|
+
"""'YYYYMMDD' → date."""
|
|
56
|
+
return _date(int(date_str[:4]), int(date_str[4:6]), int(date_str[6:8]))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---- 收益 / 风险 (M1: 6 条) ----
|
|
60
|
+
|
|
61
|
+
def total_return(nav: list[float]) -> float:
|
|
62
|
+
if len(nav) < 2:
|
|
63
|
+
return 0.0
|
|
64
|
+
return nav[-1] / nav[0] - 1.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def annual_return(nav: list[float]) -> float:
|
|
68
|
+
if len(nav) < 2:
|
|
69
|
+
return 0.0
|
|
70
|
+
days = len(nav) - 1
|
|
71
|
+
if days <= 0:
|
|
72
|
+
return 0.0
|
|
73
|
+
return (1.0 + total_return(nav)) ** (TRADING_DAYS_PER_YEAR / days) - 1.0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def volatility(nav: list[float]) -> float:
|
|
77
|
+
"""年化波动率 = std(daily_ret) * sqrt(252)。"""
|
|
78
|
+
ret = _daily_ret(nav)
|
|
79
|
+
if len(ret) < 2:
|
|
80
|
+
return 0.0
|
|
81
|
+
return float(np.nanstd(ret, ddof=1) * math.sqrt(TRADING_DAYS_PER_YEAR))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def sharpe(nav: list[float], rf: float = 0.0) -> float:
|
|
85
|
+
"""年化夏普 = mean(daily_ret) / std(daily_ret) * sqrt(252); rf 默认 0。"""
|
|
86
|
+
ret = _daily_ret(nav)
|
|
87
|
+
if len(ret) < 2:
|
|
88
|
+
return 0.0
|
|
89
|
+
excess = ret - rf / TRADING_DAYS_PER_YEAR
|
|
90
|
+
sd = np.nanstd(excess, ddof=1)
|
|
91
|
+
if sd == 0 or np.isnan(sd):
|
|
92
|
+
return 0.0
|
|
93
|
+
return float(np.nanmean(excess) / sd * math.sqrt(TRADING_DAYS_PER_YEAR))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def max_drawdown(nav: list[float]) -> float:
|
|
97
|
+
"""最大回撤 (正数, 0.05 = 5%)。"""
|
|
98
|
+
if not nav:
|
|
99
|
+
return 0.0
|
|
100
|
+
arr = np.asarray(nav, dtype=float)
|
|
101
|
+
peak = np.maximum.accumulate(arr)
|
|
102
|
+
dd = 1.0 - arr / peak
|
|
103
|
+
return float(np.nanmax(dd))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---- 交易统计 (M1: 3 条) ----
|
|
107
|
+
|
|
108
|
+
def _pair_trades(trades: list[dict]) -> list[tuple[dict, dict, float]]:
|
|
109
|
+
"""FIFO 配对 buy/sell → (buy, sell, pnl)。"""
|
|
110
|
+
holdings: dict[str, list[dict]] = defaultdict(list)
|
|
111
|
+
pairs: list[tuple[dict, dict, float]] = []
|
|
112
|
+
for t in trades:
|
|
113
|
+
sym = t['symbol']
|
|
114
|
+
if t['side'] == 'buy':
|
|
115
|
+
holdings[sym].append({'orig': t, 'qty_left': t['qty']})
|
|
116
|
+
elif t['side'] == 'sell':
|
|
117
|
+
t_left = t['qty']
|
|
118
|
+
while holdings[sym] and t_left > 0:
|
|
119
|
+
buy = holdings[sym][0]
|
|
120
|
+
matched_qty = min(buy['qty_left'], t_left)
|
|
121
|
+
pnl = (t['price'] - buy['orig']['price']) * matched_qty
|
|
122
|
+
sell_view = {**t, 'qty': matched_qty}
|
|
123
|
+
pairs.append((buy['orig'], sell_view, pnl))
|
|
124
|
+
buy['qty_left'] -= matched_qty
|
|
125
|
+
t_left -= matched_qty
|
|
126
|
+
if buy['qty_left'] == 0:
|
|
127
|
+
holdings[sym].pop(0)
|
|
128
|
+
return pairs
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def win_rate(trades: list[dict]) -> float:
|
|
132
|
+
"""win_rate 0~1 (decimal, 与 v1 strategy_cli metrics 算法对齐)."""
|
|
133
|
+
pairs = _pair_trades(trades)
|
|
134
|
+
if not pairs:
|
|
135
|
+
return _nan()
|
|
136
|
+
wins = sum(1 for _, _, pnl in pairs if pnl > 0)
|
|
137
|
+
return wins / len(pairs)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def profit_loss_ratio(trades: list[dict]) -> float:
|
|
141
|
+
"""profit_loss_ratio = mean(wins) / abs(mean(losses)). 无 wins 或 losses → NaN."""
|
|
142
|
+
pairs = _pair_trades(trades)
|
|
143
|
+
if not pairs:
|
|
144
|
+
return _nan()
|
|
145
|
+
wins = [pnl for _, _, pnl in pairs if pnl > 0]
|
|
146
|
+
losses = [pnl for _, _, pnl in pairs if pnl < 0]
|
|
147
|
+
if not wins or not losses:
|
|
148
|
+
return _nan()
|
|
149
|
+
return float(np.mean(wins) / abs(np.mean(losses)))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def avg_holding_period(trades: list[dict]) -> float:
|
|
153
|
+
"""平均持仓自然日 (calendar days)."""
|
|
154
|
+
pairs = _pair_trades(trades)
|
|
155
|
+
if not pairs:
|
|
156
|
+
return _nan()
|
|
157
|
+
days = [(_date_to_d(s['date']) - _date_to_d(b['date'])).days for b, s, _ in pairs]
|
|
158
|
+
return float(np.mean(days))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---- M2 新增 4 条 ----
|
|
162
|
+
|
|
163
|
+
def sortino(nav: list[float], rf: float = 0.0) -> float:
|
|
164
|
+
"""Sortino = mean(excess) / downside_std * sqrt(252)."""
|
|
165
|
+
ret = _daily_ret(nav)
|
|
166
|
+
if len(ret) < 2:
|
|
167
|
+
return 0.0
|
|
168
|
+
excess = ret - rf / TRADING_DAYS_PER_YEAR
|
|
169
|
+
downside = excess[excess < 0]
|
|
170
|
+
if len(downside) == 0:
|
|
171
|
+
return 0.0
|
|
172
|
+
sd = np.sqrt(np.mean(downside ** 2))
|
|
173
|
+
if sd == 0:
|
|
174
|
+
return 0.0
|
|
175
|
+
return float(np.nanmean(excess) / sd * math.sqrt(TRADING_DAYS_PER_YEAR))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def calmar(nav: list[float]) -> float:
|
|
179
|
+
"""Calmar = annual_return / max_drawdown. 无回撤时 NaN."""
|
|
180
|
+
mdd = max_drawdown(nav)
|
|
181
|
+
if mdd == 0:
|
|
182
|
+
return _nan()
|
|
183
|
+
return annual_return(nav) / mdd
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def var_95(nav: list[float]) -> float:
|
|
187
|
+
"""VaR (95%) = -5% 分位日收益 (正数表示风险大小, 0.02 = 2%)."""
|
|
188
|
+
ret = _daily_ret(nav)
|
|
189
|
+
if len(ret) < 2:
|
|
190
|
+
return _nan()
|
|
191
|
+
return float(-np.percentile(ret, 5))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def profit_factor(trades: list[dict]) -> float:
|
|
195
|
+
"""profit_factor = sum(wins) / sum(|losses|). 无亏损或无成交时 NaN."""
|
|
196
|
+
pairs = _pair_trades(trades)
|
|
197
|
+
if not pairs:
|
|
198
|
+
return _nan()
|
|
199
|
+
wins = sum(pnl for _, _, pnl in pairs if pnl > 0)
|
|
200
|
+
losses = sum(abs(pnl) for _, _, pnl in pairs if pnl < 0)
|
|
201
|
+
if losses == 0 or wins == 0:
|
|
202
|
+
return _nan()
|
|
203
|
+
return wins / losses
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---- 入口 ----
|
|
207
|
+
|
|
208
|
+
def compute_all(nav_curve: list[dict], trades: list[dict]) -> dict[str, float]:
|
|
209
|
+
"""算全部 15 条 metrics.
|
|
210
|
+
|
|
211
|
+
输入: nav_curve = [{date, nav}], trades = [{symbol, side, price, qty, date}, ...].
|
|
212
|
+
输出: {metric_key: float}, NaN 表示不适用.
|
|
213
|
+
|
|
214
|
+
benchmark_total_return / excess_return 永远 NaN (akquant 单策略无自动基准).
|
|
215
|
+
"""
|
|
216
|
+
nav = [p['nav'] for p in nav_curve]
|
|
217
|
+
return {
|
|
218
|
+
# M1 (10)
|
|
219
|
+
'total_return': total_return(nav),
|
|
220
|
+
'annual_return': annual_return(nav),
|
|
221
|
+
'sharpe': sharpe(nav),
|
|
222
|
+
'max_drawdown': max_drawdown(nav),
|
|
223
|
+
'volatility': volatility(nav),
|
|
224
|
+
'win_rate': win_rate(trades),
|
|
225
|
+
'profit_loss_ratio': profit_loss_ratio(trades),
|
|
226
|
+
'avg_holding_period': avg_holding_period(trades),
|
|
227
|
+
'benchmark_total_return': _nan(),
|
|
228
|
+
'excess_return': _nan(),
|
|
229
|
+
# M2 (4)
|
|
230
|
+
'sortino': sortino(nav),
|
|
231
|
+
'calmar': calmar(nav),
|
|
232
|
+
'var_95': var_95(nav),
|
|
233
|
+
'profit_factor': profit_factor(trades),
|
|
234
|
+
# alias
|
|
235
|
+
'annual_volatility': volatility(nav),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---- 月度聚合 ----
|
|
240
|
+
|
|
241
|
+
def monthly_bars(nav_curve: list[dict]) -> list[dict]:
|
|
242
|
+
"""按月汇总: monthly_bars[k] = {month: 'YYYY-MM', return, drawdown_bar}."""
|
|
243
|
+
if not nav_curve:
|
|
244
|
+
return []
|
|
245
|
+
by_month: dict[str, dict] = {}
|
|
246
|
+
for p in nav_curve:
|
|
247
|
+
m = p['date'][:6]
|
|
248
|
+
if m not in by_month:
|
|
249
|
+
by_month[m] = {'first': p, 'last': p}
|
|
250
|
+
else:
|
|
251
|
+
by_month[m]['last'] = p
|
|
252
|
+
bars = []
|
|
253
|
+
for m in sorted(by_month.keys()):
|
|
254
|
+
first, last = by_month[m]['first'], by_month[m]['last']
|
|
255
|
+
ret = last['nav'] / first['nav'] - 1 if first['nav'] else 0.0
|
|
256
|
+
bars.append({
|
|
257
|
+
'month': f'{m[:4]}-{m[4:6]}',
|
|
258
|
+
'return': float(ret),
|
|
259
|
+
'drawdown_bar': float(max(0.0, -ret)),
|
|
260
|
+
})
|
|
261
|
+
return bars
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def monthly_metrics(nav_curve: list[dict]) -> dict[str, dict[str, float]]:
|
|
265
|
+
"""UI 月度热力图: {YYYY: {MM: ret}}."""
|
|
266
|
+
bars = monthly_bars(nav_curve)
|
|
267
|
+
out: dict[str, dict[str, float]] = {}
|
|
268
|
+
for b in bars:
|
|
269
|
+
y, mo = b['month'].split('-')
|
|
270
|
+
out.setdefault(y, {})[mo] = b['return']
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _selfcheck() -> None:
|
|
275
|
+
"""mock 测试: 已知 nav + trades 序列 → 验 15 keys + 边界.
|
|
276
|
+
|
|
277
|
+
与原仓根 `hamuna_quant_cli/references/_metrics_15.py` 同测试集, 输出应
|
|
278
|
+
完全一致 (B 重写 parity 要求).
|
|
279
|
+
|
|
280
|
+
🧪 单元测试用已知 nav + trades 序列. 显式门控: 仅当 `HAMUNA_SELFTEST=1` 时执行,
|
|
281
|
+
防止 Agent 误调以为是 prod 数据校验. 调用方式:
|
|
282
|
+
HAMUNA_SELFTEST=1 python -c "from ..._metrics_15 import _selfcheck; _selfcheck()"
|
|
283
|
+
"""
|
|
284
|
+
import os
|
|
285
|
+
if os.environ.get("HAMUNA_SELFTEST") != "1":
|
|
286
|
+
raise RuntimeError(
|
|
287
|
+
"_selfcheck() 用 mock nav+trades 验 metrics 公式, 不用于 prod 数据校验. "
|
|
288
|
+
"设置 HAMUNA_SELFTEST=1 显式启用."
|
|
289
|
+
)
|
|
290
|
+
# 1) nav_curve 长度 < 2 → total_return = 0
|
|
291
|
+
m = compute_all([], [])
|
|
292
|
+
assert set(m.keys()) == HAMUNA_METRICS_15, f'metrics key 缺/多: {set(m.keys()) ^ HAMUNA_METRICS_15}'
|
|
293
|
+
assert m['total_return'] == 0.0
|
|
294
|
+
assert m['benchmark_total_return'] != m['benchmark_total_return'] # NaN
|
|
295
|
+
print(f'OK: 15 metrics keys 对齐, 空输入 → total=0 + bench=NaN')
|
|
296
|
+
|
|
297
|
+
# 2) 单调递增 nav → total_return > 0, sharpe > 0
|
|
298
|
+
nav_curve = [{'date': f'2024{i:02d}02', 'nav': 100 + i} for i in range(1, 7)]
|
|
299
|
+
m = compute_all(nav_curve, [])
|
|
300
|
+
assert m['total_return'] > 0
|
|
301
|
+
assert m['sharpe'] > 0
|
|
302
|
+
assert m['max_drawdown'] == 0 # 单调无回撤
|
|
303
|
+
print(f"OK: 单调递增 nav → total={m['total_return']:.4f}, sharpe={m['sharpe']:.2f}, mdd=0")
|
|
304
|
+
|
|
305
|
+
# 3) trades: 1 win + 1 loss → win_rate=0.5, pl=1.0, pf=1.0
|
|
306
|
+
trades = [
|
|
307
|
+
{'date': '20240101', 'symbol': 'X', 'side': 'buy', 'price': 10.0, 'qty': 100},
|
|
308
|
+
{'date': '20240110', 'symbol': 'X', 'side': 'sell', 'price': 11.0, 'qty': 100},
|
|
309
|
+
{'date': '20240111', 'symbol': 'X', 'side': 'buy', 'price': 12.0, 'qty': 100},
|
|
310
|
+
{'date': '20240120', 'symbol': 'X', 'side': 'sell', 'price': 11.0, 'qty': 100},
|
|
311
|
+
]
|
|
312
|
+
m = compute_all(nav_curve, trades)
|
|
313
|
+
assert abs(m['win_rate'] - 0.5) < 1e-6, f'win_rate 应 0.5, 实际 {m["win_rate"]}'
|
|
314
|
+
assert abs(m['profit_loss_ratio'] - 1.0) < 1e-6, f'pl 应 1.0, 实际 {m["profit_loss_ratio"]}'
|
|
315
|
+
assert abs(m['profit_factor'] - 1.0) < 1e-6, f'pf 应 1.0, 实际 {m["profit_factor"]}'
|
|
316
|
+
print(f"OK: 1 win + 1 loss → win_rate={m['win_rate']:.2f}, pl={m['profit_loss_ratio']:.2f}, "
|
|
317
|
+
f"pf={m['profit_factor']:.2f}")
|
|
318
|
+
|
|
319
|
+
# 4) 全胜 → win_rate=1, pl=NaN, pf=NaN (修复前 bug: pf=1e11)
|
|
320
|
+
trades_win = trades[:2]
|
|
321
|
+
m = compute_all(nav_curve, trades_win)
|
|
322
|
+
assert m['win_rate'] == 1.0
|
|
323
|
+
assert m['profit_loss_ratio'] != m['profit_loss_ratio'] # NaN
|
|
324
|
+
assert m['profit_factor'] != m['profit_factor'] # NaN
|
|
325
|
+
print(f"OK: 全胜边界 win_rate=1, pl=NaN, pf=NaN")
|
|
326
|
+
|
|
327
|
+
# 5) 全败 → win_rate=0, pl=NaN, pf=NaN
|
|
328
|
+
trades_all_loss = [
|
|
329
|
+
{'date': '20240101', 'symbol': 'X', 'side': 'buy', 'price': 10.0, 'qty': 100},
|
|
330
|
+
{'date': '20240110', 'symbol': 'X', 'side': 'sell', 'price': 9.0, 'qty': 100},
|
|
331
|
+
{'date': '20240111', 'symbol': 'X', 'side': 'buy', 'price': 12.0, 'qty': 100},
|
|
332
|
+
{'date': '20240120', 'symbol': 'X', 'side': 'sell', 'price': 11.0, 'qty': 100},
|
|
333
|
+
]
|
|
334
|
+
m = compute_all(nav_curve, trades_all_loss)
|
|
335
|
+
assert m['win_rate'] == 0.0
|
|
336
|
+
assert m['profit_loss_ratio'] != m['profit_loss_ratio'] # NaN
|
|
337
|
+
assert m['profit_factor'] != m['profit_factor'] # NaN
|
|
338
|
+
print(f"OK: 全败边界 win_rate=0, pl=NaN, pf=NaN")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == '__main__':
|
|
342
|
+
_selfcheck()
|