Skip to content

Commit 8cf2bc1

Browse files
committed
feat: enhance strategy backtest visualization and improve data handling
1 parent 154b379 commit 8cf2bc1

1 file changed

Lines changed: 145 additions & 43 deletions

File tree

convertible_bond/gui/controllers/strategy_backtest.py

Lines changed: 145 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1812,9 +1812,9 @@ def _render_strategy_attribution(self, result):
18121812
stretch_weights={"名称": 1.7, "贡献(%)": 0.7, "期数": 0.4},
18131813
)
18141814

1815-
# 贡献瀑布图 + 月度收益热力图
1816-
self._strategy_section_title(frame, "年度收益 / 个券贡献", 3, 0)
1817-
self._strategy_section_title(frame, "月度收益分布", 3, 1)
1815+
# 个券贡献瀑布图 + 月度/全年收益热力图 (年度收益已并入热力图右端「全年」列)
1816+
self._strategy_section_title(frame, "个券贡献", 3, 0)
1817+
self._strategy_section_title(frame, "月度 / 全年收益", 3, 1)
18181818
self._render_attribution_charts(
18191819
frame, 4,
18201820
top_contribs, top_detractors,
@@ -1825,40 +1825,26 @@ def _render_strategy_attribution(self, result):
18251825
def _render_attribution_charts(self, frame, row,
18261826
top_contribs, top_detractors,
18271827
yearly_returns, monthly_returns):
1828-
"""左: 年度收益表 + 贡献瀑布; 右: 月度收益热力图."""
1828+
"""左: 个券贡献瀑布图(独占整列); 右: 月度收益热力图 + 全年列(紧凑置顶)."""
18291829
bg_card_color = get_color(BG_CARD)
18301830
bg_input_color = get_color(BG_INPUT)
18311831
text_dim_color = get_color(TEXT_DIM)
18321832
text_color = get_color(TEXT)
18331833
border_color = get_color(BORDER)
18341834
green_color = get_color(GREEN)
18351835
red_color = get_color(RED)
1836-
accent_color = get_color(ACCENT)
18371836

18381837
chart_shell = ctk.CTkFrame(frame, fg_color="transparent")
18391838
chart_shell.grid(row=row, column=0, columnspan=2, sticky="nsew", padx=4, pady=(0, 8))
1840-
chart_shell.grid_columnconfigure(0, weight=5, minsize=760)
1841-
chart_shell.grid_columnconfigure(1, weight=4, minsize=600)
1839+
chart_shell.grid_columnconfigure(0, weight=1, uniform="attr_chart")
1840+
chart_shell.grid_columnconfigure(1, weight=1, uniform="attr_chart")
18421841
chart_shell.grid_rowconfigure(0, weight=1)
18431842

1844-
# 左列: 年度收益表 + 贡献瀑布图
1843+
# ── 左列: 个券贡献瀑布图 (年度表已并入右侧「全年」列, 此处独占整列加宽) ──
18451844
left = ctk.CTkFrame(chart_shell, fg_color="transparent")
18461845
left.grid(row=0, column=0, sticky="nsew", padx=(0, 8), pady=0)
18471846
left.grid_columnconfigure(0, weight=1)
1848-
left.grid_rowconfigure(0, weight=0)
1849-
left.grid_rowconfigure(1, weight=1)
1850-
1851-
yearly_table_height = min(4, max(2, len(yearly_returns) or 1))
1852-
self._render_strategy_small_tree(
1853-
left, 0, 0,
1854-
["period", "return"],
1855-
["年份", "收益(%)"],
1856-
[90, 90],
1857-
[[row_d.get("period", ""), self._fmt_strategy_pct(row_d.get("return"), sign=True)]
1858-
for row_d in yearly_returns],
1859-
max_height=yearly_table_height,
1860-
yscroll=len(yearly_returns) > yearly_table_height,
1861-
)
1847+
left.grid_rowconfigure(0, weight=1)
18621848

18631849
waterfall_items = (
18641850
[(r.get("bond_name") or r.get("bond_code", "")[:6],
@@ -1870,15 +1856,16 @@ def _render_attribution_charts(self, frame, row,
18701856
waterfall_items.sort(key=lambda x: x[1], reverse=True)
18711857
if waterfall_items:
18721858
wf_frame = ctk.CTkFrame(left, fg_color="transparent")
1873-
wf_frame.grid(row=1, column=0, sticky="nsew", padx=4, pady=4)
1859+
wf_frame.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
18741860
wf_frame.grid_columnconfigure(0, weight=1)
18751861
wf_frame.grid_rowconfigure(0, weight=1)
1876-
fig_wf = Figure(figsize=(7.0, 3.7), dpi=100, facecolor=bg_card_color)
1862+
wf_h = max(2.8, 0.34 * len(waterfall_items) + 1.1)
1863+
fig_wf = Figure(figsize=(7.2, wf_h), dpi=100, facecolor=bg_card_color)
18771864
ax_wf = fig_wf.add_subplot(111, facecolor=bg_input_color)
18781865
names = [n[:6] for n, _ in waterfall_items]
18791866
vals = [v * 100 for _, v in waterfall_items]
18801867
wf_colors = [green_color if v >= 0 else red_color for v in vals]
1881-
ax_wf.barh(range(len(names)), vals, color=wf_colors, alpha=0.8, height=0.65)
1868+
ax_wf.barh(range(len(names)), vals, color=wf_colors, alpha=0.85, height=0.66)
18821869
ax_wf.set_yticks(range(len(names)))
18831870
ax_wf.set_yticklabels(names, fontsize=8, color=text_color)
18841871
ax_wf.set_xlabel("贡献 (%)", color=text_dim_color, fontsize=9)
@@ -1888,7 +1875,15 @@ def _render_attribution_charts(self, frame, row,
18881875
for spine in ax_wf.spines.values():
18891876
spine.set_color(border_color)
18901877
ax_wf.invert_yaxis()
1891-
fig_wf.subplots_adjust(left=0.24, right=0.98, bottom=0.16, top=0.96)
1878+
# 数值标签直接标在条端, 省去对照刻度
1879+
span = (max(vals) - min(vals)) or 1.0
1880+
pad = 0.012 * span
1881+
for yi, v in enumerate(vals):
1882+
ax_wf.text(v + (pad if v >= 0 else -pad), yi, f"{v:+.1f}",
1883+
va="center", ha="left" if v >= 0 else "right",
1884+
fontsize=7.5, color=text_dim_color)
1885+
ax_wf.margins(x=0.12)
1886+
fig_wf.subplots_adjust(left=0.20, right=0.97, bottom=0.16, top=0.97)
18921887
canvas_wf = FigureCanvasTkAgg(fig_wf, master=wf_frame)
18931888
canvas_wf.draw()
18941889
canvas_wf.get_tk_widget().grid(row=0, column=0, sticky="nsew")
@@ -1897,17 +1892,19 @@ def _render_attribution_charts(self, frame, row,
18971892
ctk.CTkLabel(
18981893
left, text="暂无贡献数据", text_color=TEXT_DIM,
18991894
font=(FONT_FAMILY, 12),
1900-
).grid(row=1, column=0, sticky="nsew", padx=12, pady=12)
1895+
).grid(row=0, column=0, sticky="nsew", padx=12, pady=12)
19011896

1902-
# 右列: 月度收益热力图
1897+
# ── 右列: 月度收益热力图 + 全年列 (紧凑置顶, 不随行高拉伸) ──
19031898
right = ctk.CTkFrame(chart_shell, fg_color="transparent")
19041899
right.grid(row=0, column=1, sticky="nsew", padx=(8, 0), pady=0)
19051900
right.grid_columnconfigure(0, weight=1)
1906-
right.grid_rowconfigure(0, weight=1)
1901+
right.grid_rowconfigure(0, weight=0) # 热力图按内容高度
1902+
right.grid_rowconfigure(1, weight=1) # 占位行吸收多余高度, 避免色块被纵向拉伸
19071903

19081904
if not monthly_returns:
19091905
ctk.CTkLabel(right, text="暂无月度数据", text_color=TEXT_DIM,
1910-
font=(FONT_FAMILY, 12)).grid(row=0, column=0, padx=12, pady=12)
1906+
font=(FONT_FAMILY, 12)).grid(row=0, column=0, sticky="new",
1907+
padx=12, pady=12)
19111908
return
19121909

19131910
year_month_map: dict[int, dict[int, float]] = {}
@@ -1925,7 +1922,8 @@ def _render_attribution_charts(self, frame, row,
19251922

19261923
if not year_month_map:
19271924
ctk.CTkLabel(right, text="月度数据解析为空", text_color=TEXT_DIM,
1928-
font=(FONT_FAMILY, 12)).grid(row=0, column=0, padx=12, pady=12)
1925+
font=(FONT_FAMILY, 12)).grid(row=0, column=0, sticky="new",
1926+
padx=12, pady=12)
19291927
return
19301928

19311929
years = sorted(year_month_map.keys())
@@ -1935,15 +1933,47 @@ def _render_attribution_charts(self, frame, row,
19351933
if 1 <= m <= 12:
19361934
data[yi, m - 1] = v * 100
19371935

1938-
fig_hm = Figure(figsize=(6.4, max(3.0, 0.75 * len(years) + 1.5)), dpi=100,
1939-
facecolor=bg_card_color)
1940-
ax_hm = fig_hm.add_subplot(111, facecolor=bg_input_color)
1941-
vmax = max(3.0, float(np.nanmax(np.abs(data)))) if np.any(np.isfinite(data)) else 5.0
1936+
# 全年收益: 优先取 yearly_returns, 缺失年份用当年月度复利兜底
1937+
annual_map: dict[int, float] = {}
1938+
for row_d in yearly_returns or []:
1939+
try:
1940+
y = int(str(row_d.get("period", "")).split("-")[0])
1941+
except (ValueError, IndexError):
1942+
continue
1943+
rv = row_d.get("return")
1944+
if rv is None:
1945+
continue
1946+
try:
1947+
annual_map[y] = float(rv) * 100
1948+
except (TypeError, ValueError):
1949+
continue
1950+
annual = np.full((len(years), 1), np.nan)
1951+
for yi, y in enumerate(years):
1952+
if y in annual_map:
1953+
annual[yi, 0] = annual_map[y]
1954+
elif year_month_map[y]:
1955+
comp = 1.0
1956+
for v in year_month_map[y].values():
1957+
comp *= (1.0 + v)
1958+
annual[yi, 0] = (comp - 1.0) * 100
1959+
1960+
n_years = len(years)
1961+
fig_h = 1.25 + 0.6 * n_years
1962+
fig_hm = Figure(figsize=(6.6, fig_h), dpi=100, facecolor=bg_card_color)
1963+
gs = fig_hm.add_gridspec(
1964+
1, 2, width_ratios=[12, 1.5], wspace=0.08,
1965+
left=0.085, right=0.9, bottom=0.42 / fig_h, top=1 - 0.26 / fig_h)
1966+
ax_hm = fig_hm.add_subplot(gs[0, 0], facecolor=bg_input_color)
1967+
ax_yr = fig_hm.add_subplot(gs[0, 1], facecolor=bg_input_color, sharey=ax_hm)
19421968
cmap = LinearSegmentedColormap.from_list("rg", [red_color, bg_input_color, green_color])
1969+
1970+
# 月度色块 (独立标尺, 不被全年大幅收益拉爆色阶)
1971+
vmax = max(3.0, float(np.nanmax(np.abs(data)))) if np.any(np.isfinite(data)) else 5.0
19431972
im = ax_hm.imshow(data, aspect="auto", cmap=cmap, vmin=-vmax, vmax=vmax,
19441973
interpolation="nearest")
19451974
ax_hm.set_xticks(range(12))
1946-
ax_hm.set_xticklabels([f"{m+1}月" for m in range(12)], fontsize=7, color=text_dim_color)
1975+
ax_hm.set_xticklabels([f"{m+1}月" for m in range(12)], fontsize=7,
1976+
color=text_dim_color)
19471977
ax_hm.set_yticks(range(len(years)))
19481978
ax_hm.set_yticklabels([str(y) for y in years], fontsize=8, color=text_color)
19491979
ax_hm.tick_params(length=0)
@@ -1954,15 +1984,33 @@ def _render_attribution_charts(self, frame, row,
19541984
val = data[yi, mi]
19551985
if np.isfinite(val):
19561986
ax_hm.text(mi, yi, f"{val:+.1f}", ha="center", va="center",
1957-
fontsize=7, color=text_color,
1987+
fontsize=6.5, color=text_color,
19581988
fontweight="bold" if abs(val) >= vmax * 0.5 else "normal")
1959-
cb = fig_hm.colorbar(im, ax=ax_hm, fraction=0.03, pad=0.04)
1989+
1990+
# 全年色块 (独立标尺 + 边框分隔, 视觉上与月度区分)
1991+
a_vmax = (max(5.0, float(np.nanmax(np.abs(annual))))
1992+
if np.any(np.isfinite(annual)) else 10.0)
1993+
ax_yr.imshow(annual, aspect="auto", cmap=cmap, vmin=-a_vmax, vmax=a_vmax,
1994+
interpolation="nearest")
1995+
ax_yr.set_xticks([0])
1996+
ax_yr.set_xticklabels(["全年"], fontsize=7.5, color=text_color)
1997+
ax_yr.tick_params(length=0, labelleft=False)
1998+
for spine in ax_yr.spines.values():
1999+
spine.set_visible(True)
2000+
spine.set_color(border_color)
2001+
spine.set_linewidth(0.8)
2002+
for yi in range(len(years)):
2003+
val = annual[yi, 0]
2004+
if np.isfinite(val):
2005+
ax_yr.text(0, yi, f"{val:+.1f}", ha="center", va="center",
2006+
fontsize=8, color=text_color, fontweight="bold")
2007+
2008+
cb = fig_hm.colorbar(im, ax=[ax_hm, ax_yr], fraction=0.045, pad=0.04)
19602009
cb.ax.tick_params(colors=text_dim_color, labelsize=7)
1961-
cb.set_label("%", color=text_dim_color, fontsize=8)
1962-
fig_hm.subplots_adjust(left=0.08, right=0.90, bottom=0.13, top=0.96)
2010+
cb.set_label("月度 %", color=text_dim_color, fontsize=8)
19632011
canvas_hm = FigureCanvasTkAgg(fig_hm, master=right)
19642012
canvas_hm.draw()
1965-
canvas_hm.get_tk_widget().grid(row=0, column=0, sticky="nsew")
2013+
canvas_hm.get_tk_widget().grid(row=0, column=0, sticky="new")
19662014
self._strategy_bt_heatmap_fig = fig_hm
19672015

19682016
def _render_strategy_risk_panel(self, result):
@@ -1982,9 +2030,13 @@ def _render_strategy_risk_panel(self, result):
19822030
frame.grid_rowconfigure(4, minsize=300)
19832031

19842032
# ── Row 0: 稳健性指标条 ──────────────────────────────────
2033+
# 按时间口径的统计(胜率/最好最差单期/收益分布/滚动风险)剔除首尾残桩区间,
2034+
# 避免不足整周期的几天与整月等权混算; 残桩盈亏仍计入净值/总收益。
2035+
stat_periods = self._strategy_full_periods(periods)
2036+
dropped_stub_periods = len(periods) - len(stat_periods)
19852037
returns = [
19862038
float(p.get("period_return"))
1987-
for p in periods
2039+
for p in stat_periods
19882040
if p.get("period_return") is not None and np.isfinite(p.get("period_return"))
19892041
]
19902042
win_rate = (sum(1 for r in returns if r > 0) / len(returns)) if returns else None
@@ -2041,6 +2093,15 @@ def _render_strategy_risk_panel(self, result):
20412093
ctk.CTkLabel(left, text="🟢 暂无明显风险提示", text_color=TEXT_DIM,
20422094
font=(FONT_FAMILY, 11)).pack(anchor="w", pady=(3, 0))
20432095

2096+
if dropped_stub_periods > 0:
2097+
ctk.CTkLabel(
2098+
left,
2099+
text=(f"ℹ️ 回测首尾不足整周期的 {dropped_stub_periods} 个残桩区间已从"
2100+
"按期统计(胜率/单期/分布/滚动)中剔除, 其盈亏仍计入总收益"),
2101+
text_color=TEXT_DIM, font=(FONT_FAMILY, 11),
2102+
justify="left", wraplength=460,
2103+
).pack(anchor="w", pady=(3, 0))
2104+
20442105
dd_items = [
20452106
("回撤区间", f"{summary.get('max_drawdown_start') or '—'}{summary.get('max_drawdown_end') or '—'}"),
20462107
("持续天数", f"{summary.get('max_drawdown_days') or 0} 天 (最长 {summary.get('longest_drawdown_days') or 0} 天)"),
@@ -2128,7 +2189,7 @@ def _render_strategy_risk_panel(self, result):
21282189
self._strategy_bt_dist_fig = fig_dist
21292190

21302191
worst_rows = []
2131-
for period in sorted(periods, key=lambda p: float(p.get("period_return") or 0.0))[:8]:
2192+
for period in sorted(stat_periods, key=lambda p: float(p.get("period_return") or 0.0))[:8]:
21322193
period_return = period.get("period_return")
21332194
benchmark = period.get("benchmark_return")
21342195
excess = (
@@ -2153,8 +2214,49 @@ def _render_strategy_risk_panel(self, result):
21532214
stretch_weights={"持仓": 6.0, "区间": 1.2, "收益": 0.3, "超额": 0.3, "换手": 0.3},
21542215
)
21552216

2217+
@staticmethod
2218+
def _strategy_period_days(period) -> int | None:
2219+
"""区间跨度天数; start/end 为 date 对象(实时结果)或 ISO 字符串(快照)."""
2220+
sd = period.get("start_date")
2221+
ed = period.get("end_date")
2222+
if isinstance(sd, date) and isinstance(ed, date):
2223+
return (ed - sd).days
2224+
try:
2225+
return (date.fromisoformat(str(ed)) - date.fromisoformat(str(sd))).days
2226+
except (ValueError, TypeError):
2227+
return None
2228+
2229+
@classmethod
2230+
def _strategy_full_periods(cls, periods, *, stub_ratio: float = 0.5):
2231+
"""剔除回测首尾"残桩"区间后的列表, 仅用于按期收益分布口径.
2232+
2233+
``build_rebalance_schedule`` 强制把 start/end 日纳入调仓边界, 当结束(或起始)
2234+
日紧邻上一个边界时会产生几天的残桩区间。它与整周期等权进入按期收益统计
2235+
(滚动波动率/Sharpe/胜率/收益分布)会造成末端跳变, 故在这些"按时间口径"
2236+
的统计中剔除。
2237+
2238+
注意: 仅剔除"基于区间收益、隐含等长假设"的统计; 净值/总收益/年化波动
2239+
(日频 MTM) 以及按事件计的换手/现金权重均不受影响 —— 残桩的盈亏与那次
2240+
真实调仓仍如实计入。中间区间按构造必为完整周期, 故只检查首尾两期。
2241+
"""
2242+
if len(periods) <= 2:
2243+
return list(periods)
2244+
lengths = [cls._strategy_period_days(p) for p in periods]
2245+
valid = [d for d in lengths if d is not None and d > 0]
2246+
if not valid:
2247+
return list(periods)
2248+
threshold = float(np.median(valid)) * stub_ratio
2249+
start, end = 0, len(periods)
2250+
if lengths[0] is not None and lengths[0] < threshold:
2251+
start = 1
2252+
if lengths[-1] is not None and lengths[-1] < threshold and (end - 1) > start:
2253+
end -= 1
2254+
return list(periods[start:end])
2255+
21562256
def _render_rolling_risk_chart(self, frame, grid_row, periods, equity_curve):
21572257
"""滚动波动率 + 滚动 Sharpe 折线图."""
2258+
# 与风险面板口径一致: 剔除首尾残桩区间, 避免等权滚动统计在末端跳变
2259+
periods = self._strategy_full_periods(periods)
21582260
returns = [float(p.get("period_return") or 0.0) for p in periods]
21592261
if len(returns) < 4:
21602262
ctk.CTkLabel(frame, text="区间不足 4 期, 无法计算滚动风险",

0 commit comments

Comments
 (0)