Skip to content

Commit a8b683a

Browse files
committed
feat: implement disk cache flushing in strategy backtest for improved data integrity
1 parent 0b02ccc commit a8b683a

4 files changed

Lines changed: 97 additions & 16 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ data/announcement_pdfs/
2929
data/batch_pricing_cache.json
3030
data/watchlist.json
3131

32-
# 策略回测结果快照 (GUI 运行态产物, 随回测增删/prune, 不入版本库)
32+
# 策略回测运行态产物 (结果快照 + 跨运行磁盘缓存), 随回测增删/prune, 不入版本库
3333
data/strategy_backtest_snapshot.json
34+
data/strategy_backtest_snapshot.bak.json
3435
data/strategy_backtest_snapshots/
36+
data/strategy_backtest_cache/

convertible_bond/gui/controllers/strategy_backtest.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@
2626
build_batch_provider,
2727
parse_bond_codes,
2828
)
29+
from ...backtest_disk_cache import DiskCacheProvider
2930
from ...cb_events import CBEventStore, project_events_path
3031
from ...data_providers import WindDataProvider
3132
from ...historical_terms import (
3233
HistoricalBondDataProvider,
3334
TermsPatchStore,
3435
project_terms_patches_path,
3536
)
37+
from ...paths import data_dir
3638
from ...strategy_backtest import (
3739
ScoreStrategyConfig,
3840
_funding_legacy_alias,
@@ -690,20 +692,26 @@ def _update():
690692
self.strategy_bt_progress.set(pct)
691693
self.after(0, _update)
692694

693-
result = backtest_score_strategy(
694-
provider,
695-
codes,
696-
start_date=start,
697-
end_date=end,
698-
config=config,
699-
terms_cache=None,
700-
admission_config=admission_config,
701-
pricing_snapshot_cache=getattr(self, "_strategy_pricing_cache", None),
702-
progress_cb=progress,
703-
stage_cb=stage_progress,
704-
cancel_cb=cancel_check,
705-
**params,
706-
)
695+
try:
696+
result = backtest_score_strategy(
697+
provider,
698+
codes,
699+
start_date=start,
700+
end_date=end,
701+
config=config,
702+
terms_cache=None,
703+
admission_config=admission_config,
704+
pricing_snapshot_cache=getattr(self, "_strategy_pricing_cache", None),
705+
progress_cb=progress,
706+
stage_cb=stage_progress,
707+
cancel_cb=cancel_check,
708+
**params,
709+
)
710+
finally:
711+
# 跨运行磁盘缓存: 中途取消/异常也落盘已拉取的昂贵数据 (与 CLI 同口径)
712+
flush = getattr(provider, "flush", None)
713+
if callable(flush):
714+
flush()
707715
result_config = dict(result.get("config") or {})
708716
result_config["history_mode"] = history_mode
709717
result["config"] = result_config
@@ -766,7 +774,7 @@ def _build_strategy_provider(self, source):
766774
# 而是 strip 掉 Wind 泄漏的当前状态、改由 cb_events 按 event_date 重建。
767775
# 这些状态本就是离散公告事件 (cb_events 已覆盖 18 类), 事件重建既防未来
768776
# 函数又把每债 Wind 调用压到 ~2 次 (条款批量 wss + close wsd)。
769-
return HistoricalBondDataProvider(
777+
provider = HistoricalBondDataProvider(
770778
WindDataProvider(),
771779
history_store=None,
772780
patch_store=TermsPatchStore(project_terms_patches_path()),
@@ -775,6 +783,9 @@ def _build_strategy_provider(self, source):
775783
merge_admission_status=False,
776784
provider_history_terms=True,
777785
)
786+
# 跨运行磁盘缓存 (与 CLI --cache-dir 同机制): 高保真逐债拉取的 point-in-time
787+
# 条款/历史价落盘复用, 复跑从数小时降到定价时间; 补丁/事件文件一变自动失效。
788+
return DiskCacheProvider(provider, data_dir("strategy_backtest_cache"))
778789

779790
base_provider = build_batch_provider(
780791
source,
@@ -1354,6 +1365,17 @@ def _render_strategy_insight(self, result):
13541365
verdict = "暂无足够收益数据"
13551366
quality = "高" if fallback_ratio <= 0 else ("中" if fallback_ratio <= 0.2 else "低")
13561367

1368+
hints = {
1369+
"结论": "解读铁律: 一切对照「等权基准」——跑不赢基准 = 这套规则没有超额,\n"
1370+
"哪怕绝对收益为正。机会分是复核标记、不是收益预测;\n"
1371+
"单一市场周期的回测不构成策略承诺 (详见 USAGE 4.2 与 README 模型边界)。",
1372+
"最大回撤": "净值从峰值到谷底的最大跌幅 = 历史上最深要忍受的亏损。\n"
1373+
"对照基准回撤判断风险是否换来了收益; 与持仓集中度、现金缓冲相关。",
1374+
"主要贡献": "收益贡献最大的单券。若总收益高度依赖个别券 (尤其强赎/退市收敛券),\n"
1375+
"说明结果偏尾部运气而非系统性能力, 复现性存疑——去「归因」页核对。",
1376+
"数据质量": "条款回退占比 = 用当前条款顶替历史条款的样本比例, >0 有未来信息渗入风险。\n"
1377+
"下结论前先看「数据」子页的补丁覆盖率与缺价跳过数。",
1378+
}
13571379
items = [
13581380
("结论", verdict),
13591381
("最大回撤", (
@@ -1380,6 +1402,10 @@ def _render_strategy_insight(self, result):
13801402
font=(FONT_FAMILY, 13, "bold"), wraplength=260,
13811403
justify="left")
13821404
value_label.pack(anchor="w")
1405+
hint = hints.get(title)
1406+
if hint:
1407+
Tooltip(cell, hint)
1408+
Tooltip(value_label, hint)
13831409

13841410
def _update_wrap(event, label=value_label):
13851411
label.configure(wraplength=max(180, event.width - 24))

convertible_bond/strategy_backtest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,11 @@ def backtest_score_strategy(
764764
pricer_overrides=pricer_overrides,
765765
)
766766

767+
# 跨运行磁盘缓存 (DiskCacheProvider, 经 _BacktestCacheProvider.__getattr__ 链可达)
768+
# 的阶段性落盘句柄: 多小时高保真拉取中途进程被杀也只丢当期数据。
769+
# flush 为原子写且无脏数据时零成本; 链上无 flush 能力时为 None, 安全跳过。
770+
provider_flush = getattr(provider, "flush", None)
771+
767772
for idx, period_start in enumerate(schedule[:-1]):
768773
period_end = schedule[idx + 1]
769774
res = _run_rebalance_period(
@@ -791,6 +796,8 @@ def backtest_score_strategy(
791796
periods.append(res.period)
792797
if progress_cb:
793798
progress_cb(idx + 1, total_periods)
799+
if callable(provider_flush):
800+
provider_flush()
794801

795802
summary = _summarize_strategy(
796803
equity_curve,

tests/test_strategy_backtest.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1761,3 +1761,49 @@ def fake_batch_price(provider_arg, codes, *, valuation_date, **kwargs):
17611761
break
17621762
period_rows.append(r)
17631763
assert len(period_rows) == len(result["periods"]) == 3
1764+
1765+
1766+
def test_score_strategy_flushes_provider_disk_cache_each_period(monkeypatch):
1767+
"""逐期 flush: provider 链上有 flush 能力 (DiskCacheProvider) 时每个调仓期落盘一次。
1768+
1769+
同时守护委托链: 回测内部把 provider 包成 _BacktestCacheProvider, flush 须经
1770+
__getattr__ 透传到内层; 多小时高保真拉取中途进程被杀也只丢当期数据。
1771+
"""
1772+
class FlushingProvider(StrategyFakeProvider):
1773+
def __init__(self):
1774+
super().__init__()
1775+
self.flush_calls = 0
1776+
1777+
def flush(self):
1778+
self.flush_calls += 1
1779+
1780+
provider = FlushingProvider()
1781+
1782+
def fake_batch_price(provider_arg, codes, *, valuation_date, **kwargs):
1783+
return [
1784+
{
1785+
"bond_code": code,
1786+
"status": "ok",
1787+
"S0": 100.0, "K": 100.0, "sigma": 0.30,
1788+
"theoretical_price": 110.0, "market_price": 100.0,
1789+
"deviation": -10.0 / 110.0,
1790+
"credit_rating": "AA+", "outstanding_balance": 10.0, "T": 3.0,
1791+
}
1792+
for code in codes
1793+
]
1794+
1795+
monkeypatch.setattr(
1796+
"convertible_bond.strategy_backtest.batch_price_from_provider_threaded",
1797+
fake_batch_price,
1798+
)
1799+
1800+
backtest_score_strategy(
1801+
provider,
1802+
["113001.SH", "113002.SH", "113003.SH"],
1803+
start_date=date(2025, 1, 2),
1804+
end_date=date(2025, 3, 31),
1805+
config=ScoreStrategyConfig(top_n=1, rebalance_freq="M"),
1806+
)
1807+
1808+
# 2025-01-02 → 2025-03-31 月频共 3 个调仓期, 每期 flush 一次
1809+
assert provider.flush_calls == 3

0 commit comments

Comments
 (0)