@@ -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