# src/analysis/analysis_utils.py (修改 calculate_metrics 函数) import matplotlib import pandas as pd import numpy as np import matplotlib.pyplot as plt from typing import List, Dict, Any from ..core_data import PortfolioSnapshot, Trade, Bar def calculate_metrics( snapshots: List[PortfolioSnapshot], trades: List[Trade], initial_capital: float ) -> Dict[str, Any]: """ 纯函数:根据投资组合快照和交易历史计算关键绩效指标。 Args: snapshots (List[PortfolioSnapshot]): 投资组合快照列表。 trades (List[Trade]): 交易历史记录列表。 initial_capital (float): 初始资金。 Returns: Dict[str, Any]: 包含各种绩效指标的字典。 """ if not snapshots: return { "总收益率": 0.0, "年化收益率": 0.0, "最大回撤": 0.0, "夏普比率": 0.0, "卡玛比率": 0.0, "胜率": 0.0, "盈亏比": 0.0, "总交易次数": len(trades), "盈利交易次数": 0, "亏损交易次数": 0, "平均每次盈利": 0.0, "平均每次亏损": 0.0, "交易成本": 0.0, "总实现盈亏": 0.0, } df_values = pd.DataFrame( [{"datetime": s.datetime, "total_value": s.total_value} for s in snapshots] ).set_index("datetime") df_returns = df_values["total_value"].pct_change().fillna(0) final_value = df_values["total_value"].iloc[-1] total_return = (final_value / initial_capital) - 1 total_days = (df_values.index.max() - df_values.index.min()).days if total_days > 0: if total_return > -1: annualized_return = (1 + total_return) ** (252 / total_days) - 1 else: annualized_return = -9999999 else: annualized_return = 0.0 print(f'total_return: {total_return}, annualized_return:{annualized_return}, 252 / total_days:{252 / total_days}') rolling_max = df_values["total_value"].cummax() daily_drawdown = (rolling_max - df_values["total_value"]) / rolling_max max_drawdown = daily_drawdown.max() excess_daily_returns = df_returns daily_volatility = excess_daily_returns.std() if daily_volatility > 0: if total_return > -1: sharpe_ratio = np.sqrt(252) * (excess_daily_returns.mean() / daily_volatility) else: sharpe_ratio = -10 else: sharpe_ratio = 0.0 if max_drawdown > 0: calmar_ratio = annualized_return / max_drawdown else: calmar_ratio = float("inf") total_commissions = sum(t.commission for t in trades) # --- 重新计算交易相关指标 --- realized_pnl_trades = [ t.realized_pnl for t in trades if t.is_close_trade ] # 只关注平仓交易的盈亏 winning_pnl = [pnl for pnl in realized_pnl_trades if pnl > 0] losing_pnl = [pnl for pnl in realized_pnl_trades if pnl < 0] winning_count = len(winning_pnl) losing_count = len(losing_pnl) total_closed_trades = winning_count + losing_count total_profit_per_trade = sum(winning_pnl) total_loss_per_trade = sum(losing_pnl) # sum of negative values avg_profit_per_trade = ( total_profit_per_trade / winning_count if winning_count > 0 else 0.0 ) avg_loss_per_trade = ( total_loss_per_trade / losing_count if losing_count > 0 else 0.0 ) # 这是负值 win_rate = winning_count / total_closed_trades if total_closed_trades > 0 else 0.0 # 盈亏比 = 平均盈利 / 平均亏损的绝对值 profit_loss_ratio = ( abs(avg_profit_per_trade / avg_loss_per_trade) if avg_loss_per_trade != 0 else float("inf") ) total_realized_pnl = sum(realized_pnl_trades) return { "初始资金": initial_capital, "最终资金": final_value, "总收益率": total_return, "年化收益率": annualized_return, "最大回撤": max_drawdown, "夏普比率": sharpe_ratio, "卡玛比率": calmar_ratio, "总交易次数": len(trades), # 所有的买卖交易 "交易成本": total_commissions, "总实现盈亏": total_realized_pnl, # 新增 "胜率": win_rate, "盈亏比": profit_loss_ratio, "盈利交易次数": winning_count, "亏损交易次数": losing_count, "平均每次盈利": avg_profit_per_trade, "平均每次亏损": avg_loss_per_trade, # 这个值是负数 "initial_capital": initial_capital, "final_capital": final_value, "total_return": total_return, "annualized_return": annualized_return, "max_drawdown": max_drawdown, "sharpe_ratio": sharpe_ratio, "calmar_ratio": calmar_ratio, "total_trades": len(trades), # All buy and sell trades "transaction_costs": total_commissions, "total_realized_pnl": total_realized_pnl, # New "win_rate": win_rate, "profit_loss_ratio": profit_loss_ratio, "winning_trades_count": winning_count, "losing_trades_count": losing_count, "avg_profit_per_trade": avg_profit_per_trade, "avg_loss_per_trade": avg_loss_per_trade, # This value is negative } def plot_equity_and_drawdown_chart( snapshots: List[PortfolioSnapshot], initial_capital: float, title: str = "Portfolio Equity and Drawdown Curve", ) -> None: """ Plots the portfolio equity curve and drawdown. X-axis points are equally spaced. Args: snapshots (List[PortfolioSnapshot]): List of portfolio snapshots. initial_capital (float): Initial capital. title (str): Title of the chart. """ if not snapshots: print("No portfolio snapshots available to plot equity and drawdown.") return df_equity = pd.DataFrame( [{"datetime": s.datetime, "total_value": s.total_value} for s in snapshots] ) equity_curve = df_equity["total_value"] / initial_capital rolling_max = equity_curve.cummax() drawdown = (rolling_max - equity_curve) / rolling_max plt.style.use("seaborn-v0_8-darkgrid") fig, (ax1, ax2) = plt.subplots( 2, 1, figsize=(14, 10), sharex=True, gridspec_kw={"height_ratios": [3, 1]} ) x_axis_indices = np.arange(len(df_equity)) # Equity Curve Plot ax1.plot( x_axis_indices, equity_curve, label="Equity Curve", color="blue", linewidth=1.5 ) ax1.set_ylabel("Equity", fontsize=12) ax1.legend(loc="upper left") ax1.grid(True) ax1.set_title(title, fontsize=16) # Drawdown Curve Plot ax2.fill_between(x_axis_indices, 0, drawdown, color="red", alpha=0.3) ax2.plot( x_axis_indices, drawdown, color="red", linewidth=1.0, linestyle="--", label="Drawdown", ) ax2.set_ylabel("Drawdown Rate", fontsize=12) ax2.set_xlabel("Data Point Index (Date Labels Below)", fontsize=12) ax2.set_title("Portfolio Drawdown Curve", fontsize=14) ax2.legend(loc="upper left") ax2.grid(True) ax2.set_ylim(0, max(drawdown.max() * 1.1, 0.05)) # Set X-axis ticks to show actual dates at intervals num_ticks = 10 if len(df_equity) > 0: tick_positions = np.linspace(0, len(df_equity) - 1, num_ticks, dtype=int) tick_labels = [ df_equity["datetime"].iloc[i].strftime("%Y-%m-%d %H:%M") for i in tick_positions ] ax1.set_xticks(tick_positions) ax1.set_xticklabels(tick_labels, rotation=45, ha="right") plt.tight_layout() plt.show() def plot_close_price_chart(bars: List[Bar], title: str = "Close Price Chart") -> None: """ Plots the underlying asset's close price. X-axis points are equally spaced. Args: bars (List[Bar]): List of all processed Bar data. title (str): Title of the chart. """ if not bars: print("No bar data available to plot close price.") return df_prices = pd.DataFrame( [{"datetime": b.datetime, "close_price": b.close} for b in bars] ) plt.style.use("seaborn-v0_8-darkgrid") fig, ax = plt.subplots(1, 1, figsize=(14, 7)) # Single subplot x_axis_indices = np.arange(len(df_prices)) ax.plot( x_axis_indices, df_prices["close_price"], label="Close Price", color="orange", linewidth=1.5, ) ax.set_ylabel("Price", fontsize=12) ax.set_xlabel("Data Point Index (Date Labels Below)", fontsize=12) ax.set_title(title, fontsize=16) ax.legend(loc="upper left") ax.grid(True) # Set X-axis ticks to show actual dates at intervals num_ticks = 10 if len(df_prices) > 0: tick_positions = np.linspace(0, len(df_prices) - 1, num_ticks, dtype=int) tick_labels = [ df_prices["datetime"].iloc[i].strftime("%Y-%m-%d %H:%M") for i in tick_positions ] ax.set_xticks(tick_positions) ax.set_xticklabels(tick_labels, rotation=45, ha="right") plt.tight_layout() plt.show() # 辅助函数:计算单笔交易的盈亏 def calculate_trade_pnl( trade: Trade, entry_price: float, exit_price: float, direction: str ) -> float: if direction == "LONG": pnl = (exit_price - entry_price) * trade.volume elif direction == "SHORT": pnl = (entry_price - exit_price) * trade.volume else: pnl = 0.0 return pnl