qka 2.4.7__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.
Files changed (32) hide show
  1. {qka-2.4.7 → qka-2.4.8.dev3}/PKG-INFO +1 -1
  2. {qka-2.4.7 → qka-2.4.8.dev3}/qka/core/backtest.py +6 -2
  3. {qka-2.4.7 → qka-2.4.8.dev3}/qka/core/data.py +141 -47
  4. {qka-2.4.7 → qka-2.4.8.dev3}/qka/core/indicator.py +55 -4
  5. {qka-2.4.7 → qka-2.4.8.dev3}/qka/core/report.py +2 -1
  6. {qka-2.4.7 → qka-2.4.8.dev3}/.github/workflows/release.yml +0 -0
  7. {qka-2.4.7 → qka-2.4.8.dev3}/.gitignore +0 -0
  8. {qka-2.4.7 → qka-2.4.8.dev3}/.vscode/settings.json +0 -0
  9. {qka-2.4.7 → qka-2.4.8.dev3}/CHANGELOG.md +0 -0
  10. {qka-2.4.7 → qka-2.4.8.dev3}/LICENSE +0 -0
  11. {qka-2.4.7 → qka-2.4.8.dev3}/README.md +0 -0
  12. {qka-2.4.7 → qka-2.4.8.dev3}/pyproject.toml +0 -0
  13. {qka-2.4.7 → qka-2.4.8.dev3}/qka/__init__.py +0 -0
  14. {qka-2.4.7 → qka-2.4.8.dev3}/qka/cli.py +0 -0
  15. {qka-2.4.7 → qka-2.4.8.dev3}/qka/core/__init__.py +0 -0
  16. {qka-2.4.7 → qka-2.4.8.dev3}/qka/core/accessor.py +0 -0
  17. {qka-2.4.7 → qka-2.4.8.dev3}/qka/core/analysis.py +0 -0
  18. {qka-2.4.7 → qka-2.4.8.dev3}/qka/core/broker.py +0 -0
  19. {qka-2.4.7 → qka-2.4.8.dev3}/qka/core/simulate.py +0 -0
  20. {qka-2.4.7 → qka-2.4.8.dev3}/qka/core/sizing.py +0 -0
  21. {qka-2.4.7 → qka-2.4.8.dev3}/qka/core/strategy.py +0 -0
  22. {qka-2.4.7 → qka-2.4.8.dev3}/qka/core/tdx.py +0 -0
  23. {qka-2.4.7 → qka-2.4.8.dev3}/qka/utils/__init__.py +0 -0
  24. {qka-2.4.7 → qka-2.4.8.dev3}/qka/utils/logger.py +0 -0
  25. {qka-2.4.7 → qka-2.4.8.dev3}/skills/qka/SKILL.md +0 -0
  26. {qka-2.4.7 → qka-2.4.8.dev3}/skills/qka/references/analysis.md +0 -0
  27. {qka-2.4.7 → qka-2.4.8.dev3}/skills/qka/references/backtest.md +0 -0
  28. {qka-2.4.7 → qka-2.4.8.dev3}/skills/qka/references/broker.md +0 -0
  29. {qka-2.4.7 → qka-2.4.8.dev3}/skills/qka/references/data.md +0 -0
  30. {qka-2.4.7 → qka-2.4.8.dev3}/skills/qka/references/sizing.md +0 -0
  31. {qka-2.4.7 → qka-2.4.8.dev3}/skills/qka/references/strategy.md +0 -0
  32. {qka-2.4.7 → qka-2.4.8.dev3}/tools/generate_api_ref.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: qka
3
- Version: 2.4.7
3
+ Version: 2.4.8.dev3
4
4
  Summary: QKA(快量化 / Quant Kit for A-shares)- 简洁易用的 A 股量化回测框架
5
5
  Project-URL: Home, https://github.com/zsrl/qka
6
6
  Project-URL: Repository, https://github.com/zsrl/qka
@@ -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
- # 夏普比率(无风险利率假设 3%)
234
- risk_free_rate = 0.03
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
- start_after = (cache_max + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
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
- 「缺最新数据」这件事按本时段去重(见 _data_period):一天以数据生成时刻
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
- if cache_max < pd.Timestamp.now().floor('D'):
811
- return not self._checked_recently(path)
812
- return False
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: 无风险利率(年化),默认 0
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: 无风险利率(年化),默认 0
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