Coverage for adrs/portfolio.py: 42%

86 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-29 05:07 +0800

1import polars as pl 

2 

3from typing import Protocol, Any 

4from datetime import datetime 

5from pydantic import BaseModel, ConfigDict 

6 

7from adrs.performance.metric import Ratio, Drawdown, Trade 

8 

9 

10class TradePerformance(BaseModel): 

11 largest_loss: float 

12 num_datapoints: int 

13 num_trades: int 

14 avg_holding_time_in_seconds: float 

15 long_trades: int 

16 short_trades: int 

17 win_trades: int 

18 lose_trades: int 

19 win_streak: int 

20 lose_streak: int 

21 win_rate: float 

22 

23 model_config = ConfigDict(extra="allow") 

24 

25 

26class PortfolioPerformance(BaseModel): 

27 sharpe_ratio: float 

28 calmar_ratio: float 

29 sortino_ratio: float 

30 cagr: float 

31 annualized_return: float 

32 total_return: float 

33 min_cumu: float 

34 start_time: datetime 

35 end_time: datetime 

36 max_drawdown: float 

37 max_drawdown_percentage: float 

38 max_drawdown_start_date: datetime 

39 max_drawdown_end_date: datetime 

40 max_drawdown_recover_date: datetime 

41 max_drawdown_max_duration_in_days: float 

42 trades: dict[str, TradePerformance] 

43 metadata: dict[str, Any] 

44 

45 model_config = ConfigDict(extra="allow") 

46 

47 

48class WeightAllocator(Protocol): 

49 def weights(self) -> pl.DataFrame: ... 

50 

51 

52class MeanWeightAllocator: 

53 def __init__( 

54 self, 

55 signal_df: pl.DataFrame, 

56 metadata_df: pl.DataFrame, 

57 ) -> None: 

58 self.signal_df = signal_df 

59 self.metadata_df = metadata_df 

60 

61 def weights(self) -> pl.DataFrame: 

62 n = len(self.metadata_df) 

63 return self.metadata_df.select("custom_id").with_columns( 

64 pl.lit(1.0 / n).alias("weights") 

65 ) 

66 

67 

68class Portfolio: 

69 def __init__( 

70 self, 

71 id: str, 

72 signal_df: pl.DataFrame, 

73 metadata_df: pl.DataFrame, 

74 weight_df: pl.DataFrame, 

75 ) -> None: 

76 self.id = id 

77 self.signal_df = signal_df 

78 self.metadata_df = metadata_df 

79 self.weight_df = weight_df 

80 self.lastest_signal: dict[str, float] = {} 

81 

82 def update_signal(self, id: str, signal: float): 

83 self.lastest_signal[id] = signal 

84 

85 def update_weights(self, weight_df: pl.DataFrame): 

86 self.weight_df = weight_df 

87 

88 def get_signal(self) -> pl.DataFrame: 

89 rows = [] 

90 for custom_id in self.metadata_df["custom_id"].to_list(): 

91 if custom_id in self.lastest_signal: 

92 signal = self.lastest_signal[custom_id] 

93 else: 

94 col = self.signal_df[custom_id] 

95 if col.is_empty(): 

96 raise Exception( 

97 f"Alpha ID {custom_id} was not initialized in portfolio" 

98 ) 

99 signal = col[-1] 

100 

101 rows.append({"custom_id": custom_id, "signal": signal}) 

102 

103 signal = pl.DataFrame(rows) 

104 return ( 

105 self.metadata_df.join(signal, on="custom_id", how="left") 

106 .join(self.weight_df, on="custom_id", how="left") 

107 .with_columns( 

108 (pl.col("signal") * pl.col("weights")).alias("weighted_signal") 

109 ) 

110 .group_by("base_asset") 

111 .agg(pl.col("weighted_signal").sum()) 

112 ) 

113 

114 def backtest( 

115 self, prices_df: pl.DataFrame 

116 ) -> tuple[PortfolioPerformance, pl.DataFrame]: 

117 # check if prices include all base asset 

118 base_assets = self.metadata_df["base_asset"].unique().to_list() 

119 price_cols = [c for c in prices_df.columns if c != "start_time"] 

120 missing_cols = [a for a in base_assets if a not in price_cols] 

121 if missing_cols: 

122 raise ValueError( 

123 f"Price DF did not include the following asset prices {missing_cols}" 

124 ) 

125 

126 merged_pnl: pl.DataFrame | None = None 

127 asset_merged: dict[str, pl.DataFrame | None] = {} 

128 for alpha in self.metadata_df.iter_rows(named=True): 

129 custom_id: str = alpha["custom_id"] 

130 fees: float = alpha["fees"] 

131 price_shift: int = alpha["shift_backtest_candle_minute"] 

132 base_asset: str = alpha["base_asset"] 

133 alpha_weight: float = float( 

134 self.weight_df.filter(pl.col("custom_id") == custom_id)["weights"][0] 

135 ) 

136 

137 alpha_prices = prices_df.select( 

138 pl.col("start_time"), 

139 pl.col(base_asset).shift(-price_shift).alias("price"), 

140 ).drop_nulls() 

141 

142 alpha_signals = self.signal_df.select( 

143 pl.col("start_time"), 

144 pl.col(custom_id).alias("signal"), 

145 ) 

146 

147 pnl_col = f"{custom_id}_pnl" 

148 sig_col = f"{custom_id}_sig" 

149 alpha_full_df = ( 

150 alpha_prices.join(alpha_signals, on="start_time", how="left") 

151 .with_columns( 

152 pl.col("signal").forward_fill().fill_null(strategy="zero"), 

153 pl.col("signal") 

154 .shift(1) 

155 .alias("prev_signal") 

156 .forward_fill() 

157 .fill_null(strategy="zero"), 

158 pl.col("price") 

159 .pct_change() 

160 .alias("returns") 

161 .fill_null(strategy="zero"), 

162 ) 

163 .with_columns( 

164 (pl.col("signal") - pl.col("prev_signal")) 

165 .alias("trade") 

166 .fill_null(strategy="zero") 

167 ) 

168 .with_columns( 

169 ( 

170 pl.col("prev_signal") * pl.col("returns") 

171 - pl.col("trade").abs() * fees / 100 

172 ).alias("pnl") 

173 ) 

174 ) 

175 

176 # accumulate per-asset weighted signal + price for Trade stats 

177 alpha_asset_df = alpha_full_df.select( 

178 pl.col("start_time"), 

179 pl.col("price"), 

180 (pl.col("signal") * alpha_weight).alias(sig_col), 

181 (pl.col("pnl") * alpha_weight).alias(pnl_col), 

182 ) 

183 if base_asset not in asset_merged or asset_merged[base_asset] is None: 

184 asset_merged[base_asset] = alpha_asset_df 

185 else: 

186 asset_merged[base_asset] = ( 

187 asset_merged[base_asset] 

188 .join( 

189 alpha_asset_df.drop("price"), 

190 on="start_time", 

191 how="full", 

192 coalesce=True, 

193 ) 

194 .with_columns( 

195 pl.col(sig_col).forward_fill(), 

196 pl.col(pnl_col).forward_fill(), 

197 ) 

198 ) 

199 

200 alpha_df = alpha_full_df.select( 

201 pl.col("start_time"), 

202 (pl.col("pnl") * alpha_weight).alias(pnl_col), 

203 ) 

204 

205 if merged_pnl is None: 

206 merged_pnl = alpha_df 

207 else: 

208 merged_pnl = merged_pnl.join( 

209 alpha_df, on="start_time", how="full", coalesce=True 

210 ).with_columns(pl.col(pnl_col).forward_fill()) 

211 

212 trade_performances: dict[str, TradePerformance] = {} 

213 for base_asset, asset_df in asset_merged.items(): 

214 assert asset_df is not None 

215 sig_cols = [c for c in asset_df.columns if c.endswith("_sig")] 

216 pnl_cols_asset = [c for c in asset_df.columns if c.endswith("_pnl")] 

217 asset_trade_df = ( 

218 asset_df.select( 

219 pl.col("start_time"), 

220 pl.col("price"), 

221 pl.sum_horizontal(sig_cols).alias("signal"), 

222 pl.sum_horizontal(pnl_cols_asset).alias("pnl"), 

223 ) 

224 .sort("start_time") 

225 .with_columns( 

226 pl.col("signal") 

227 .shift(1) 

228 .alias("prev_signal") 

229 .forward_fill() 

230 .fill_null(strategy="zero"), 

231 pl.col("signal").diff().alias("trade").fill_null(strategy="zero"), 

232 ) 

233 ) 

234 trade_result = Trade().compute(asset_trade_df) 

235 trade_performances[base_asset] = TradePerformance(**trade_result) 

236 

237 assert merged_pnl is not None 

238 pnl_cols = [c for c in merged_pnl.columns if c.endswith("_pnl")] 

239 performance_df = ( 

240 merged_pnl.select( 

241 pl.col("start_time"), 

242 pl.sum_horizontal(pnl_cols).alias("pnl"), 

243 *[pl.col(c) for c in pnl_cols], 

244 ) 

245 .sort("start_time") 

246 .with_columns( 

247 pl.col("pnl").cum_sum().alias("equity").fill_null(strategy="zero"), 

248 ) 

249 ) 

250 

251 start_time = performance_df["start_time"].min() 

252 end_time = performance_df["start_time"].max() 

253 assert isinstance(start_time, datetime) and isinstance(end_time, datetime) 

254 performance: dict[str, Any] = { 

255 "start_time": start_time, 

256 "end_time": end_time, 

257 "trades": trade_performances, 

258 "metadata": {}, 

259 } 

260 for metric in [Ratio(), Drawdown()]: 

261 performance = {**performance, **metric.compute(performance_df)} 

262 

263 return PortfolioPerformance.model_validate(performance), performance_df