qka 2.4.7.dev2__tar.gz → 2.4.8.dev3__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.
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/PKG-INFO +1 -1
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/core/backtest.py +6 -2
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/core/data.py +141 -47
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/core/indicator.py +55 -4
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/core/report.py +2 -1
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/.github/workflows/release.yml +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/.gitignore +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/.vscode/settings.json +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/CHANGELOG.md +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/LICENSE +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/README.md +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/pyproject.toml +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/__init__.py +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/cli.py +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/core/__init__.py +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/core/accessor.py +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/core/analysis.py +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/core/broker.py +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/core/simulate.py +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/core/sizing.py +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/core/strategy.py +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/core/tdx.py +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/utils/__init__.py +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/qka/utils/logger.py +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/skills/qka/SKILL.md +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/skills/qka/references/analysis.md +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/skills/qka/references/backtest.md +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/skills/qka/references/broker.md +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/skills/qka/references/data.md +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/skills/qka/references/sizing.md +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/skills/qka/references/strategy.md +0 -0
- {qka-2.4.7.dev2 → qka-2.4.8.dev3}/tools/generate_api_ref.py +0 -0
|
@@ -42,6 +42,10 @@ class Backtest:
|
|
|
42
42
|
self.initial_cash = None # 由 run() 设置
|
|
43
43
|
self.metrics = None
|
|
44
44
|
self._benchmark_data = None
|
|
45
|
+
# 报告里算夏普用的无风险利率(年化,小数)。默认 3%,与历史报告可比。
|
|
46
|
+
# 要换口径就在 run() 之后直接改这个属性。因子那边用 Data(risk_free=...)
|
|
47
|
+
# 可以逐窗口时变,回测是一整段区间的单一指标,这里保持一个可覆写的数。
|
|
48
|
+
self.risk_free_rate = 0.03
|
|
45
49
|
|
|
46
50
|
@property
|
|
47
51
|
def trade_history(self):
|
|
@@ -230,8 +234,8 @@ class Backtest:
|
|
|
230
234
|
# 年化波动率
|
|
231
235
|
annual_vol = daily_returns.std() * np.sqrt(252)
|
|
232
236
|
|
|
233
|
-
#
|
|
234
|
-
risk_free_rate =
|
|
237
|
+
# 夏普比率(无风险利率默认 3%,可通过 self.risk_free_rate 覆写)
|
|
238
|
+
risk_free_rate = self.risk_free_rate
|
|
235
239
|
sharpe = (annual_return - risk_free_rate) / annual_vol if annual_vol > 0 else 0
|
|
236
240
|
|
|
237
241
|
# 最大回撤
|
|
@@ -48,6 +48,40 @@ def _callable_source(fn):
|
|
|
48
48
|
return None
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
_RF_SIG_CACHE = {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _accepts_rf(fn) -> bool:
|
|
55
|
+
"""该指标是否接受 `rf` 参数(α / 夏普接受,其余不接受)。
|
|
56
|
+
|
|
57
|
+
签名解析只做一次并缓存 —— 否则每只票每个因子都要解析一遍,几千次纯浪费。
|
|
58
|
+
"""
|
|
59
|
+
key = (getattr(fn, '__module__', ''), getattr(fn, '__name__', ''), id(fn))
|
|
60
|
+
hit = _RF_SIG_CACHE.get(key)
|
|
61
|
+
if hit is None:
|
|
62
|
+
try:
|
|
63
|
+
hit = 'rf' in inspect.signature(fn).parameters
|
|
64
|
+
except (TypeError, ValueError):
|
|
65
|
+
hit = False
|
|
66
|
+
_RF_SIG_CACHE[key] = hit
|
|
67
|
+
return hit
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _rf_given_positionally(fn, args) -> bool:
|
|
71
|
+
"""因子定义里是否已经按位置把 rf 填掉了,如 `('qka.alpha', 250, 0.02)`。
|
|
72
|
+
|
|
73
|
+
这种写法要继续有效,并且**优先于** Data 的全局 risk_free ——
|
|
74
|
+
否则两边一起给会报 `got multiple values for argument 'rf'`。
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
sig = inspect.signature(fn)
|
|
78
|
+
# 前两个位置参数固定是 df / symbol
|
|
79
|
+
bound = sig.bind_partial(object(), object(), *args)
|
|
80
|
+
except (TypeError, ValueError):
|
|
81
|
+
return False
|
|
82
|
+
return 'rf' in bound.arguments
|
|
83
|
+
|
|
84
|
+
|
|
51
85
|
# ── TickFlow 凭据(进程级,设一次即可)──────────────────────────
|
|
52
86
|
# 放在模块级而不是 `Data` 参数里:「用哪个凭据」是部署级配置,不该每构造一次
|
|
53
87
|
# Data 就传一遍。优先级:set_tickflow_api_key() > 环境变量 > 免费档。
|
|
@@ -93,22 +127,6 @@ def _key_fingerprint(api_key: Optional[str]) -> str:
|
|
|
93
127
|
return hashlib.sha1(api_key.encode('utf-8')).hexdigest()[:12]
|
|
94
128
|
|
|
95
129
|
|
|
96
|
-
# 当日日线数据「可能已经生成」的时刻(本地时间)。一天以它为界切成前后两个时段:
|
|
97
|
-
# 17:00 之前,数据源手里最新的一根还是上一交易日,当天这根根本还没生成;
|
|
98
|
-
# 17:00 之后,当天的已经出完了,要补也就这一次。
|
|
99
|
-
DATA_READY_HOUR = 17
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _data_period(ts) -> tuple:
|
|
103
|
-
"""把时刻归入「数据时段」——(日期,是否已过当日数据生成时刻)。
|
|
104
|
-
|
|
105
|
-
同一个时段内数据源不会多出任何东西,对同一只标的重复询问纯属白跑一趟。
|
|
106
|
-
休市日、节假日尤其明显:日历上没有交易日,缓存永远「缺今天」,若不记时段
|
|
107
|
-
就会每次请求都去问一遍,永远问不到结果。
|
|
108
|
-
"""
|
|
109
|
-
return (ts.date(), ts.hour >= DATA_READY_HOUR)
|
|
110
|
-
|
|
111
|
-
|
|
112
130
|
class Data():
|
|
113
131
|
"""
|
|
114
132
|
数据管理类
|
|
@@ -200,6 +218,8 @@ class Data():
|
|
|
200
218
|
drop_head: int = 0,
|
|
201
219
|
factor_cache: bool = False,
|
|
202
220
|
fill_suspended: bool = False,
|
|
221
|
+
cache_only: bool = False,
|
|
222
|
+
risk_free: Optional[Union[float, "pd.Series"]] = None,
|
|
203
223
|
):
|
|
204
224
|
"""
|
|
205
225
|
初始化数据对象
|
|
@@ -292,6 +312,31 @@ class Data():
|
|
|
292
312
|
变成 0),`returns` 按补齐后的 close 重算;**上市前的空缺仍然留空**。
|
|
293
313
|
只作用于默认(`lazy=False`)路径。
|
|
294
314
|
|
|
315
|
+
cache_only: **只读本地缓存,绝不发起网络请求**(默认 False)。
|
|
316
|
+
纯读场景(回测、看报告、批量分析)请显式传 True —— 否则缓存不全时
|
|
317
|
+
会悄悄联网去补,既慢又不可控。缓存缺失时直接抛 RuntimeError 并列出
|
|
318
|
+
缺哪些标的,不会静默返回半截数据。
|
|
319
|
+
|
|
320
|
+
risk_free: **无风险利率(年化,小数)**,供 α / 夏普等需要它的因子使用。
|
|
321
|
+
不传 → 因子沿用各自默认值(α、夏普均为 0,与老版本行为一致)。
|
|
322
|
+
两种写法:
|
|
323
|
+
|
|
324
|
+
- **标量**:`0.02` —— 全程一个数,简洁但不含时变性
|
|
325
|
+
- **序列**:带日期索引的 `pd.Series`,如中债国债到期收益率曲线。
|
|
326
|
+
因子会取**每个窗口内的平均值**(见 indicator._rf_window_mean)。
|
|
327
|
+
|
|
328
|
+
用的是**短期**国债收益率:长期国债含期限溢价、中途卖出还有价格
|
|
329
|
+
风险,严格说不构成「无风险」,所以不按窗口长度去配长端曲线。
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
y1 = pd.read_csv('cn_bond_yield.csv', index_col='date', parse_dates=True)['y1'] / 100
|
|
333
|
+
data = Data(symbols=['600519.SH'], benchmark='000300.SH',
|
|
334
|
+
factors={'alpha_1y': ('qka.alpha', 250)}, risk_free=y1)
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
注:现值货币时间价值一律按**年化口径**参与运算 —— 因为 α 公式里的 Rp、Rm
|
|
338
|
+
都是「窗口均值 × 252」,rf 必须站在同一个量纲上,不要先除以 252。
|
|
339
|
+
|
|
295
340
|
"""
|
|
296
341
|
# ── 归一化 symbols:str = 真实标的,Simulate 对象 = 模拟标的,可混排 ──
|
|
297
342
|
names, sim_specs = [], {}
|
|
@@ -329,6 +374,11 @@ class Data():
|
|
|
329
374
|
self.factor_cache = bool(factor_cache)
|
|
330
375
|
# 停牌日按前收填充:默认关。见 _fill_suspended_prices
|
|
331
376
|
self.fill_suspended = bool(fill_suspended)
|
|
377
|
+
# 只读模式:一次网络请求都不发(见 _needs_download / get 的缓存校验)
|
|
378
|
+
self.cache_only = bool(cache_only)
|
|
379
|
+
# 无风险利率:标量或带日期索引的序列,供 α / 夏普使用。
|
|
380
|
+
# None = 不传,因子各自用默认值(老行为不变)
|
|
381
|
+
self.risk_free = risk_free
|
|
332
382
|
|
|
333
383
|
# 当前数据源实际支持的扩展字段(源能力不同,按源过滤)
|
|
334
384
|
whitelist = self._extra_fields_whitelist(source)
|
|
@@ -520,23 +570,6 @@ class Data():
|
|
|
520
570
|
z[t] = phi * z[t - 1] + sigma * eps[t]
|
|
521
571
|
return z
|
|
522
572
|
|
|
523
|
-
def _checked_recently(self, path: Path) -> bool:
|
|
524
|
-
"""本时段是否已经问过数据源(以缓存文件的修改时间当记号)。
|
|
525
|
-
|
|
526
|
-
缓存文件每次被写都会刷新修改时间:首次下载、补到新数据是写出来的;
|
|
527
|
-
问了但数据源没给新数据时也会主动刷新一次(见 _download 末尾)。
|
|
528
|
-
两种情形都意味着「本时段已经核对过,数据源不会再给新东西」。
|
|
529
|
-
"""
|
|
530
|
-
if not path.exists():
|
|
531
|
-
return False
|
|
532
|
-
mtime = pd.Timestamp.fromtimestamp(path.stat().st_mtime)
|
|
533
|
-
return _data_period(mtime) == _data_period(pd.Timestamp.now())
|
|
534
|
-
|
|
535
|
-
def _mark_checked(self, path: Path) -> None:
|
|
536
|
-
"""留下「本时段已问过」的记号"""
|
|
537
|
-
if path.exists():
|
|
538
|
-
os.utime(path, None)
|
|
539
|
-
|
|
540
573
|
def _read_verified_from(self, path: Path):
|
|
541
574
|
"""读「已确认起点」;没有记录(老缓存)返回 None"""
|
|
542
575
|
try:
|
|
@@ -690,7 +723,14 @@ class Data():
|
|
|
690
723
|
req_end = (pd.Timestamp(download_end) if download_end
|
|
691
724
|
else pd.Timestamp.now().floor('D'))
|
|
692
725
|
if req_end > cache_max:
|
|
693
|
-
|
|
726
|
+
# 🚨 起点往前多留 5 天,**不要**用 cache_max+1:
|
|
727
|
+
# TickFlow 对「单日区间」会返回空数据(2026-10-20 实测:
|
|
728
|
+
# _fetch('2026-10-20','2026-10-20') → 0 行;
|
|
729
|
+
# _fetch('2026-10-19','2026-10-20') → 正常 1 行)。
|
|
730
|
+
# 只补一个紧邻交易日时就是单日区间 ⇒ 增量永远静默取空。
|
|
731
|
+
# 跨周末/长假时区间自然多天,所以这个坑一直没暴露。
|
|
732
|
+
# 多取的几天下面靠 drop_duplicates(keep='last') 去掉,代价可以忽略。
|
|
733
|
+
start_after = (cache_max - pd.Timedelta(days=5)).strftime("%Y-%m-%d")
|
|
694
734
|
df_after = self._fetch(
|
|
695
735
|
symbol,
|
|
696
736
|
start_date=start_after,
|
|
@@ -702,9 +742,7 @@ class Data():
|
|
|
702
742
|
changed = True
|
|
703
743
|
|
|
704
744
|
if not changed and verified_from == old_verified:
|
|
705
|
-
#
|
|
706
|
-
# 失败(抛异常)时不走到这里,所以网络故障该重试还是会重试。
|
|
707
|
-
self._mark_checked(path)
|
|
745
|
+
# 问过了,数据源这段确实没有新数据;不改动缓存文件,直接返回。
|
|
708
746
|
return path
|
|
709
747
|
|
|
710
748
|
if changed:
|
|
@@ -778,11 +816,12 @@ class Data():
|
|
|
778
816
|
判断股票是否需要网络下载。
|
|
779
817
|
缓存不存在、不覆盖请求范围、或需要拉最新数据时返回 True。
|
|
780
818
|
|
|
781
|
-
|
|
782
|
-
切成前后两段,同一段里问过一次就够了 —— 数据源在这段时间里不会多出东西。
|
|
783
|
-
休市日、节假日的缓存末日永远早于「今天」,若不按时段去重就会每次请求都
|
|
784
|
-
白问一趟,还永远问不到结果。
|
|
819
|
+
纯读场景请传 cache_only=True(见构造函数),那样一次网络请求都不会发。
|
|
785
820
|
"""
|
|
821
|
+
# 只读模式:不管缺不缺都不去网络(缺了由 get() 统一报错)
|
|
822
|
+
if self.cache_only:
|
|
823
|
+
return False
|
|
824
|
+
|
|
786
825
|
path = self.target_dir / f"{symbol}.parquet"
|
|
787
826
|
if not path.exists() or self._cache_missing_extra_fields(path):
|
|
788
827
|
return True
|
|
@@ -805,11 +844,12 @@ class Data():
|
|
|
805
844
|
if download_end is not None:
|
|
806
845
|
return pd.Timestamp(download_end) > cache_max
|
|
807
846
|
|
|
808
|
-
# 无 end_date =
|
|
809
|
-
#
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
847
|
+
# 无 end_date = 要一直要到今天:缓存末日早于今天就得问。
|
|
848
|
+
# 这里曾经按「数据就绪时刻(17:00)」把一天切成两段,靠文件 mtime 判断
|
|
849
|
+
# 「本时段问过没有」来省请求 —— 那是个坏设计:数据源实际就绪时间会飘到
|
|
850
|
+
# 22:00 之后,17:05 问一次空之后就会被判定「问过了」,当天再也取不到。
|
|
851
|
+
# 现在只做确定性判断,要不要联网交给调用方用 cache_only 显式声明。
|
|
852
|
+
return cache_max < pd.Timestamp.now().floor('D')
|
|
813
853
|
|
|
814
854
|
def get(self, lazy: bool = False, start_date: str = None, end_date: str = None):
|
|
815
855
|
"""
|
|
@@ -865,6 +905,21 @@ class Data():
|
|
|
865
905
|
download_start = read_start.strftime("%Y-%m-%d") if read_start is not None else None
|
|
866
906
|
download_end = end_date
|
|
867
907
|
|
|
908
|
+
# 只读模式:先把缓存齐不齐查一遍,缺的直接报错 —— 绝不退回联网。
|
|
909
|
+
if self.cache_only:
|
|
910
|
+
needed = list(self._real_symbols)
|
|
911
|
+
if self.benchmark and self.benchmark not in needed:
|
|
912
|
+
needed.append(self.benchmark)
|
|
913
|
+
missing = [s for s in needed
|
|
914
|
+
if not (self.target_dir / f"{s}.parquet").exists()]
|
|
915
|
+
if missing:
|
|
916
|
+
preview = '、'.join(missing[:8])
|
|
917
|
+
more = f' 等共 {len(missing)} 只' if len(missing) > 8 else ''
|
|
918
|
+
raise RuntimeError(
|
|
919
|
+
f"cache_only=True,但本地没有这些标的的缓存:{preview}{more}。"
|
|
920
|
+
f" 先取数补齐,或改用 cache_only=False。"
|
|
921
|
+
)
|
|
922
|
+
|
|
868
923
|
# 需要下载的真实标的(含基准);模拟标的全在内存生成,永不下载
|
|
869
924
|
all_symbols = list(self._real_symbols)
|
|
870
925
|
if self.benchmark and self.benchmark not in all_symbols:
|
|
@@ -1271,6 +1326,14 @@ class Data():
|
|
|
1271
1326
|
logger.warning(f"qka 内置指标不存在: {fn_name},跳过")
|
|
1272
1327
|
continue
|
|
1273
1328
|
|
|
1329
|
+
# 无风险利率只发给签名里真有 rf 的指标(α / 夏普):
|
|
1330
|
+
# 其它指标传了关键字参数会直接报 unexpected keyword argument。
|
|
1331
|
+
# 因子定义里已经按位置写了 rf 的,以它为准(老的固定写法继续有效)。
|
|
1332
|
+
rf_kwargs = {}
|
|
1333
|
+
if (self.risk_free is not None and _accepts_rf(fn)
|
|
1334
|
+
and not _rf_given_positionally(fn, args)):
|
|
1335
|
+
rf_kwargs['rf'] = self.risk_free
|
|
1336
|
+
|
|
1274
1337
|
# 为每只股票计算
|
|
1275
1338
|
for symbol in (symbols if symbols is not None else self.symbols):
|
|
1276
1339
|
col = f'{symbol}|{col_key}'
|
|
@@ -1282,7 +1345,7 @@ class Data():
|
|
|
1282
1345
|
if c in work.columns:
|
|
1283
1346
|
work[c] = work[c].where(work[c].abs() <= lim)
|
|
1284
1347
|
try:
|
|
1285
|
-
df[col] = fn(work, symbol, *args)
|
|
1348
|
+
df[col] = fn(work, symbol, *args, **rf_kwargs)
|
|
1286
1349
|
except ValueError as e:
|
|
1287
1350
|
logger.warning(f"计算 {col_key} 失败: {e}")
|
|
1288
1351
|
df[col] = np.nan
|
|
@@ -1403,6 +1466,35 @@ class Data():
|
|
|
1403
1466
|
self._factor_plan_cache = plan
|
|
1404
1467
|
return plan
|
|
1405
1468
|
|
|
1469
|
+
def _risk_free_digest(self):
|
|
1470
|
+
"""无风险利率的稳定摘要,进因子配方指纹用。
|
|
1471
|
+
|
|
1472
|
+
序列动辄几千个点,不能整条塞进指纹 —— 取「长度 + 首尾日期 + 数值哈希」,
|
|
1473
|
+
内容一变指纹就变,因子缓存自动失效重算。
|
|
1474
|
+
"""
|
|
1475
|
+
rf = getattr(self, 'risk_free', None)
|
|
1476
|
+
if rf is None:
|
|
1477
|
+
return None
|
|
1478
|
+
if not isinstance(rf, pd.Series):
|
|
1479
|
+
try:
|
|
1480
|
+
return {'scalar': float(rf)}
|
|
1481
|
+
except (TypeError, ValueError):
|
|
1482
|
+
return {'raw': str(rf)}
|
|
1483
|
+
if len(rf) == 0:
|
|
1484
|
+
return {'series': 'empty'}
|
|
1485
|
+
|
|
1486
|
+
s = rf
|
|
1487
|
+
if not isinstance(s.index, pd.DatetimeIndex):
|
|
1488
|
+
s = s.copy()
|
|
1489
|
+
s.index = pd.to_datetime(s.index)
|
|
1490
|
+
values = np.asarray(pd.to_numeric(s, errors='coerce'), dtype='float64')
|
|
1491
|
+
return {
|
|
1492
|
+
'series': hashlib.sha1(np.round(values, 10).tobytes()).hexdigest(),
|
|
1493
|
+
'n': int(len(s)),
|
|
1494
|
+
'first': str(s.index[0].date()),
|
|
1495
|
+
'last': str(s.index[-1].date()),
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1406
1498
|
def _factor_signature(self, name, spec):
|
|
1407
1499
|
"""单个因子的配方指纹(定义 + 数据口径);认不出返回 None。
|
|
1408
1500
|
|
|
@@ -1421,6 +1513,8 @@ class Data():
|
|
|
1421
1513
|
'adjust': self.adjust,
|
|
1422
1514
|
'fill_suspended': self.fill_suspended,
|
|
1423
1515
|
'drop_head': self.drop_head,
|
|
1516
|
+
# 无风险利率变了,α / 夏普的值就变了 —— 必须让指纹跟着变
|
|
1517
|
+
'risk_free': self._risk_free_digest(),
|
|
1424
1518
|
}, sort_keys=True, ensure_ascii=False)
|
|
1425
1519
|
return hashlib.sha1(blob.encode('utf-8')).hexdigest()
|
|
1426
1520
|
|
|
@@ -18,7 +18,7 @@ from qka.core.analysis import _zigzag_core
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def alpha(df: pd.DataFrame, symbol: str, window: int,
|
|
21
|
-
rf: float = 0.0, periods_per_year: int = 252) -> pd.Series:
|
|
21
|
+
rf: float | pd.Series = 0.0, periods_per_year: int = 252) -> pd.Series:
|
|
22
22
|
"""滚动詹森 α(年化)。
|
|
23
23
|
|
|
24
24
|
公式:α = Rp - [Rf + β × (Rm - Rf)]
|
|
@@ -28,11 +28,17 @@ def alpha(df: pd.DataFrame, symbol: str, window: int,
|
|
|
28
28
|
df: 含 {symbol}|returns 和 benchmark|returns 列的 DataFrame
|
|
29
29
|
symbol: 股票代码
|
|
30
30
|
window: 滚动窗口(交易日数)
|
|
31
|
-
rf:
|
|
31
|
+
rf: 无风险利率(**年化**,小数)。两种写法:
|
|
32
|
+
- 标量:全程同一个数,如 0.02
|
|
33
|
+
- 序列(带日期索引):**取该窗口内的平均值**,见 _rf_window_mean
|
|
32
34
|
periods_per_year: 每年交易日数,默认 252
|
|
33
35
|
|
|
34
36
|
Returns:
|
|
35
37
|
pd.Series: 滚动 α 值
|
|
38
|
+
|
|
39
|
+
Note:
|
|
40
|
+
传序列时,窗口内数据不全(如序列首日之前)算不出 α(结果为 NaN)——
|
|
41
|
+
宁可留空,也不拿一个来路不明的常数顶上。
|
|
36
42
|
"""
|
|
37
43
|
returns = df[f'{symbol}|returns']
|
|
38
44
|
bench = _get_benchmark(df)
|
|
@@ -41,6 +47,7 @@ def alpha(df: pd.DataFrame, symbol: str, window: int,
|
|
|
41
47
|
cov = returns.rolling(window).cov(bench)
|
|
42
48
|
var = bench.rolling(window).var()
|
|
43
49
|
beta_series = cov / var
|
|
50
|
+
rf = _rf_window_mean(rf, df.index, window)
|
|
44
51
|
return rp - (rf + beta_series * (rm - rf))
|
|
45
52
|
|
|
46
53
|
|
|
@@ -65,7 +72,7 @@ def beta(df: pd.DataFrame, symbol: str, window: int) -> pd.Series:
|
|
|
65
72
|
|
|
66
73
|
|
|
67
74
|
def sharpe(df: pd.DataFrame, symbol: str, window: int,
|
|
68
|
-
rf: float = 0.0, periods_per_year: int = 252) -> pd.Series:
|
|
75
|
+
rf: float | pd.Series = 0.0, periods_per_year: int = 252) -> pd.Series:
|
|
69
76
|
"""滚动夏普比率(年化)。
|
|
70
77
|
|
|
71
78
|
公式:Sharpe = (Rp - rf) / σp
|
|
@@ -75,7 +82,8 @@ def sharpe(df: pd.DataFrame, symbol: str, window: int,
|
|
|
75
82
|
df: 含 {symbol}|returns 列的 DataFrame
|
|
76
83
|
symbol: 股票代码
|
|
77
84
|
window: 滚动窗口(交易日数)
|
|
78
|
-
rf:
|
|
85
|
+
rf: 无风险利率(**年化**,小数)。标量 = 全程一个数;
|
|
86
|
+
序列 = 取该窗口内的平均值(见 _rf_window_mean)
|
|
79
87
|
periods_per_year: 每年交易日数,默认 252
|
|
80
88
|
|
|
81
89
|
Returns:
|
|
@@ -84,6 +92,7 @@ def sharpe(df: pd.DataFrame, symbol: str, window: int,
|
|
|
84
92
|
returns = df[f'{symbol}|returns']
|
|
85
93
|
rp = returns.rolling(window).mean() * periods_per_year
|
|
86
94
|
vol = returns.rolling(window).std() * np.sqrt(periods_per_year)
|
|
95
|
+
rf = _rf_window_mean(rf, df.index, window)
|
|
87
96
|
return (rp - rf) / vol
|
|
88
97
|
|
|
89
98
|
|
|
@@ -189,6 +198,48 @@ def rolling_zigzag(df: pd.DataFrame, symbol: str, window: int,
|
|
|
189
198
|
return pd.Series(result, index=idx)
|
|
190
199
|
|
|
191
200
|
|
|
201
|
+
def _rf_window_mean(rf, index, window: int):
|
|
202
|
+
"""把无风险利率对齐成「每个窗口内的平均值」。
|
|
203
|
+
|
|
204
|
+
传标量时原样返回(全程一个数,老行为不变);传序列时按下面的规则对齐。
|
|
205
|
+
|
|
206
|
+
为什么取窗口内均值、而不是窗口起点那天的值:
|
|
207
|
+
|
|
208
|
+
rf 用的是**短期**国债(真正的无风险利率,长端含期限溢价,不算无风险)。
|
|
209
|
+
短期利率的含义是「从某天起锁定一年」,只能覆盖一年 —— 拿它去代表一个
|
|
210
|
+
十年窗口的机会成本,等于说「剩下九年没有着落」。
|
|
211
|
+
|
|
212
|
+
取窗口内均值,对应的假设是:这笔钱在整个窗口里一直滚动买入短期无风险
|
|
213
|
+
资产(到期就续上),那么整个窗口的平均年化机会成本就是各期的平均。
|
|
214
|
+
这也是 Fama-French 一脉的标准做法 —— 他们的「逐日扣减」在数学上就是
|
|
215
|
+
这个窗口均值。
|
|
216
|
+
|
|
217
|
+
实现要点:
|
|
218
|
+
- `ffill`:某天没有国债报价(国债休市但股市开市),沿用前一日
|
|
219
|
+
- `min_periods=window`:窗口内数据不全就留空,不拿半截样本硬凑
|
|
220
|
+
- 序列首日之前的窗口自然为空(起点卡 2010)
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
rf: 标量或带日期索引的 Series(年化,小数)
|
|
224
|
+
index: 目标 DataFrame 的索引(日期)
|
|
225
|
+
window: 滚动窗口长度(交易日数)
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
float 或 与 index 对齐的 Series
|
|
229
|
+
"""
|
|
230
|
+
if not isinstance(rf, pd.Series):
|
|
231
|
+
return rf
|
|
232
|
+
|
|
233
|
+
s = rf
|
|
234
|
+
if not isinstance(s.index, pd.DatetimeIndex):
|
|
235
|
+
s = s.copy()
|
|
236
|
+
s.index = pd.to_datetime(s.index)
|
|
237
|
+
if index is not None and len(s) and isinstance(index, pd.DatetimeIndex):
|
|
238
|
+
s = s.reindex(s.index.union(index)).ffill().reindex(index)
|
|
239
|
+
|
|
240
|
+
return s.rolling(window, min_periods=window).mean()
|
|
241
|
+
|
|
242
|
+
|
|
192
243
|
def _get_benchmark(df: pd.DataFrame) -> pd.Series:
|
|
193
244
|
"""获取基准收益率列,不存在则抛异常。"""
|
|
194
245
|
col = 'benchmark|returns'
|
|
@@ -508,6 +508,7 @@ def generate_report(
|
|
|
508
508
|
benchmark_data: Optional[pd.Series] = None,
|
|
509
509
|
strategy_name: str = '未命名策略',
|
|
510
510
|
output_path: Optional[str] = None,
|
|
511
|
+
risk_free: float = 0.03,
|
|
511
512
|
) -> str:
|
|
512
513
|
"""
|
|
513
514
|
生成 HTML 回测报告
|
|
@@ -519,6 +520,7 @@ def generate_report(
|
|
|
519
520
|
benchmark_data: 基准数据(可选)
|
|
520
521
|
strategy_name: 策略名称
|
|
521
522
|
output_path: 输出路径,None 则自动生成
|
|
523
|
+
risk_free: 夏普比率用的无风险利率(年化,小数),默认 3%
|
|
522
524
|
|
|
523
525
|
Returns:
|
|
524
526
|
str: HTML 文件路径
|
|
@@ -541,7 +543,6 @@ def generate_report(
|
|
|
541
543
|
|
|
542
544
|
annual_return = (final / initial) ** (1 / years) - 1 if years > 0 else 0
|
|
543
545
|
annual_vol = float(daily_returns.std() * np.sqrt(252))
|
|
544
|
-
risk_free = 0.03
|
|
545
546
|
sharpe = (annual_return - risk_free) / annual_vol if annual_vol > 0 else 0
|
|
546
547
|
|
|
547
548
|
dd_series = _calc_drawdown_series(daily_returns)
|
|
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
|
|
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
|