app.backbone.services.test_service
1import os 2import numpy as np 3from scipy import stats 4from sklearn.linear_model import LinearRegression 5import yaml 6from app.backbone.database.db_service import DbService 7from app.backbone.entities.bot_performance import BotPerformance 8from app.backbone.entities.bot_trade_performance import BotTradePerformance 9from app.backbone.entities.luck_test import LuckTest 10from app.backbone.entities.metric_wharehouse import MetricWharehouse 11from app.backbone.entities.montecarlo_test import MontecarloTest 12from app.backbone.entities.random_test import RandomTest 13from app.backbone.entities.trade import Trade 14from app.backbone.services.backtest_service import BacktestService 15from app.backbone.services.config_service import ConfigService 16from app.backbone.services.operation_result import OperationResult 17from app.backbone.services.utils import _performance_from_df_to_obj, calculate_sharpe_ratio, get_trade_df_from_db 18from app.backbone.utils.get_data import get_data 19from app.backbone.utils.general_purpose import load_function 20from app.backbone.utils.montecarlo_utils import max_drawdown, monte_carlo_simulation_v2 21from app.backbone.utils.wfo_utils import run_strategy_and_get_performances 22import pandas as pd 23import plotly.express as px 24import plotly.graph_objects as go 25 26 27class TestService: 28 """ 29 Provides a collection of statistical and robustness tests for evaluating trading bot performance. 30 31 This service encapsulates methods that assess the quality, reliability, and randomness of 32 a strategy's results by applying various statistical analyses such as: 33 - t-tests for Sharpe ratio significance 34 - correlation tests between bot returns and the underlying asset 35 - randomization tests against synthetic strategies 36 - luck-based filtering of extreme trades 37 - Monte Carlo simulations to evaluate ruin probabilities 38 39 Attributes: 40 db_service (DbService): Handles database interactions and persistence. 41 backtest_service (BacktestService): Retrieves historical bot performance and trade data. 42 config_service (ConfigService): Provides access to application-wide configuration values (e.g., risk-free rate). 43 44 Notes: 45 - This class is intended to be used as a backend utility for validating trading bot robustness. 46 - Each test method typically returns an `OperationResult` and may generate visualizations or database entries. 47 """ 48 def __init__(self): 49 self.db_service = DbService() 50 self.backtest_service = BacktestService() 51 self.config_service = ConfigService() 52 53 def run_montecarlo_test(self, bot_performance_id:int, n_simulations:int, threshold_ruin:float) -> OperationResult: 54 """ 55 Performs a Monte Carlo simulation to assess the probabilistic risk profile of a bot's equity curve. 56 57 This test runs multiple randomized simulations of the bot’s historical trade sequence to estimate 58 the potential distribution of future outcomes. It focuses on downside risk by evaluating the 59 likelihood of reaching a "ruin" threshold and analyzing performance percentiles. 60 61 Steps performed: 62 - Loads the bot's historical trade and equity data. 63 - Runs `n_simulations` Monte Carlo paths using the historical trade returns. 64 - Calculates key percentile statistics (e.g., 5th, 10th, median, 90th, 95th) over all simulations. 65 - Evaluates whether the equity would fall below the specified `threshold_ruin`. 66 - Stores the test metadata and resulting metrics in the database. 67 68 Parameters: 69 bot_performance_id (int): The ID of the bot performance record to analyze. 70 n_simulations (int): Number of Monte Carlo simulations to run. 71 threshold_ruin (float): Capital threshold considered as "ruin" (e.g., 0.3 = 30% of initial capital). 72 73 Returns: 74 OperationResult: Indicates success and contains a list of `MetricWharehouse` entries 75 with computed statistics. 76 77 Side effects: 78 - Saves a `MontecarloTest` entity in the database linked to the given performance. 79 - Stores all percentile results and associated metadata in the `MetricWharehouse` table. 80 81 Notes: 82 - This test provides a probabilistic view of strategy robustness under randomness and path dependency. 83 - Useful for risk management, especially when assessing worst-case scenarios or capital preservation. 84 - The simulation respects the statistical properties of the original trade distribution (returns and durations). 85 """ 86 performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 87 trades_history = get_trade_df_from_db(performance.TradeHistory, performance_id=performance.Id) 88 89 mc = monte_carlo_simulation_v2( 90 equity_curve=trades_history.Equity, 91 trade_history=trades_history, 92 n_simulations=n_simulations, 93 initial_equity=performance.InitialCash, 94 threshold_ruin=threshold_ruin, 95 return_raw_curves=False, 96 percentiles=[0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95], 97 ) 98 99 mc = mc.round(3).reset_index().rename( 100 columns={'index':'metric'} 101 ) 102 103 mc_long = mc.melt(id_vars=['metric'], var_name='ColumnName', value_name='Value').fillna(0) 104 105 montecarlo_test = MontecarloTest( 106 BotPerformanceId=performance.Id, 107 Simulations=n_simulations, 108 ThresholdRuin=threshold_ruin, 109 ) 110 111 rows = [ 112 MetricWharehouse( 113 Method='Montecarlo', 114 Metric=row['metric'], 115 ColumnName=row['ColumnName'], 116 Value=row['Value'], 117 MontecarloTest=montecarlo_test 118 ) 119 120 for _, row in mc_long.iterrows() 121 ] 122 123 with self.db_service.get_database() as db: 124 self.db_service.create(db, montecarlo_test) 125 self.db_service.create_all(db, rows) 126 127 return OperationResult(ok=True, message=None, item=rows) 128 129 def run_luck_test(self, bot_performance_id, trades_percent_to_remove) -> OperationResult: 130 """ 131 Performs a "luck test" to evaluate the robustness of a bot's performance by removing extreme trades. 132 133 This method simulates the impact of luck by discarding a percentage of the bot's best and worst trades, 134 then recalculating performance metrics based on the remaining data. The goal is to estimate how much 135 of the bot's performance depends on a few outliers. 136 137 Steps performed: 138 - Loads the bot's trade history and determines how many trades to remove based on the given percentage. 139 - Identifies the top `X%` best and worst trades by return percentage. 140 - Filters out these trades and rebuilds the equity curve from the remaining ones. 141 - Calculates new performance metrics: return, drawdown, return/drawdown ratio, winrate, and a custom metric. 142 - Fits a linear regression to the equity curve to compute a stability ratio. 143 - Creates a new `BotPerformance` object based on the filtered trades. 144 - Stores all results, including which trades were marked as "lucky" (best or worst), in the database. 145 - Triggers the generation of a Plotly chart to visualize the filtered performance. 146 147 Parameters: 148 bot_performance_id (int): The ID of the bot performance record to analyze. 149 trades_percent_to_remove (float): Percentage of top and bottom trades (by return) to remove for the test. 150 151 Returns: 152 OperationResult: An object indicating success and containing the resulting `LuckTest` record. 153 154 Side effects: 155 - Updates the trade records in the database to flag trades as top-best or top-worst. 156 - Saves a new `LuckTest` and `BotPerformance` entry reflecting the filtered results. 157 - Generates and stores a visualization of the adjusted performance. 158 159 Notes: 160 - This test helps detect whether a bot's profitability is overly dependent on a few outlier trades. 161 - The stability ratio is derived from the R² of a linear regression on the filtered equity curve. 162 - The custom metric is a weighted risk-adjusted return penalized by drawdown and enhanced by stability. 163 """ 164 performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 165 trades = get_trade_df_from_db(performance.TradeHistory, performance_id=performance.Id) 166 trades_to_remove = round((trades_percent_to_remove/100) * trades.shape[0]) 167 168 top_best_trades = trades.sort_values(by='ReturnPct', ascending=False).head(trades_to_remove) 169 top_worst_trades = trades.sort_values(by='ReturnPct', ascending=False).tail(trades_to_remove) 170 171 filtered_trades = trades[ 172 (~trades['Id'].isin(top_best_trades.Id)) 173 & (~trades['Id'].isin(top_worst_trades.Id)) 174 & (~trades['ReturnPct'].isna()) 175 ].sort_values(by='ExitTime') 176 177 filtered_trades.ReturnPct = filtered_trades.ReturnPct / 100 178 179 filtered_trades['Equity'] = 0 180 filtered_trades['Equity'] = (performance.InitialCash * (1 + filtered_trades.ReturnPct).cumprod()).round(3) 181 182 dd = np.abs(max_drawdown(filtered_trades['Equity'])).round(3) 183 ret = ((filtered_trades.iloc[-1]['Equity'] - filtered_trades.iloc[0]['Equity']) / filtered_trades.iloc[0]['Equity']) * 100 184 ret = round(ret, 3) 185 186 ret_dd = (ret / dd).round(3) 187 188 x = np.arange(filtered_trades.shape[0]).reshape(-1, 1) 189 reg = LinearRegression().fit(x, filtered_trades['Equity']) 190 stability_ratio = round(reg.score(x, filtered_trades['Equity']), 3) 191 192 custom_metric = ((ret / (1 + dd)) * np.log(1 + filtered_trades.shape[0])).round(3) * stability_ratio 193 194 new_winrate = round( 195 (filtered_trades[filtered_trades['NetPnL']>0]['Id'].size / filtered_trades['Id'].size) * 100, 3 196 ) 197 198 luck_test_performance = BotPerformance(**{ 199 'DateFrom': performance.DateFrom, 200 'DateTo': performance.DateTo, 201 'BotId': None, 202 'StabilityRatio': stability_ratio, 203 'Trades': filtered_trades['Id'].size, 204 'Return': ret, 205 'Drawdown': dd, 206 'RreturnDd': ret_dd, 207 'WinRate': new_winrate, 208 'Duration': performance.Duration, 209 'StabilityWeightedRar': custom_metric, 210 'Method': 'luck_test', 211 'InitialCash': performance.InitialCash, 212 'ExposureTime': performance.ExposureTime, 213 'KellyCriterion': performance.KellyCriterion, 214 'WinratePValue': performance.WinratePValue 215 }) 216 217 luck_test = LuckTest(**{ 218 'BotPerformanceId': performance.Id, 219 'TradesPercentToRemove': trades_percent_to_remove, 220 'LuckTestPerformance': luck_test_performance 221 }) 222 223 top_best_trades_id = top_best_trades['Id'].values 224 top_worst_trades_id = top_worst_trades['Id'].values 225 226 with self.db_service.get_database() as db: 227 228 for trade in performance.TradeHistory: 229 if trade.Id in top_best_trades_id: 230 trade.TopBest = True 231 232 if trade.Id in top_worst_trades_id: 233 trade.TopWorst = True 234 235 _ = self.db_service.update(db, Trade, trade) 236 237 luck_test_db = self.db_service.create(db, luck_test) 238 _ = self.db_service.create(db, luck_test_performance) 239 240 self._create_luck_test_plot(bot_performance_id=bot_performance_id) 241 242 return OperationResult(ok=True, message=None, item=luck_test_db) 243 244 def get_luck_test_equity_curve(self, bot_performance_id, remove_only_good_luck=False) -> OperationResult: 245 """ 246 Generates a filtered equity curve by removing lucky trades from a bot's historical performance. 247 248 This method rebuilds the bot's equity curve after excluding trades flagged as either top-performing 249 ("TopBest"), worst-performing ("TopWorst"), or both, depending on the configuration. 250 251 Parameters: 252 bot_performance_id (int): The ID of the bot performance record to analyze. 253 remove_only_good_luck (bool): If True, only the top-performing trades ("TopBest") are removed. 254 If False, both "TopBest" and "TopWorst" trades are removed. 255 256 Returns: 257 OperationResult: Contains the filtered equity curve as a DataFrame with `ExitTime` and `Equity`. 258 259 Notes: 260 - This function is typically used to assess the strategy's robustness by simulating less favorable luck. 261 - Equity is recomputed using cumulative product of filtered returns. 262 """ 263 performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 264 trades = get_trade_df_from_db(performance.TradeHistory, performance_id=performance.Id) 265 266 if remove_only_good_luck: 267 filtered_trades = trades[(trades['TopBest'].isna())].sort_values(by='ExitTime') 268 269 else: 270 filtered_trades = trades[(trades['TopBest'].isna()) & (trades['TopWorst'].isna())].sort_values(by='ExitTime') 271 272 filtered_trades['Equity'] = 0 273 filtered_trades.ReturnPct = filtered_trades.ReturnPct / 100 274 filtered_trades['Equity'] = (performance.InitialCash * (1 + filtered_trades.ReturnPct).cumprod()).round(3) 275 equity = filtered_trades[['ExitTime','Equity']] 276 277 return OperationResult(ok=True, message=None, item=equity) 278 279 def _create_luck_test_plot(self, bot_performance_id) -> OperationResult: 280 """ 281 Generates and saves a Plotly chart comparing the original and "luck test" equity curves. 282 283 This function plots three equity curves: 284 - The original equity curve with all trades. 285 - The equity curve after removing both top and bottom trades (luck-neutral). 286 - The equity curve after removing only top-performing trades (bad-luck only). 287 288 Steps performed: 289 - Retrieves original and filtered trade histories. 290 - Computes equity for each curve. 291 - Plots them using Plotly and exports the figure as a JSON file. 292 293 Parameters: 294 bot_performance_id (int): The ID of the bot performance record to visualize. 295 296 Returns: 297 OperationResult: Indicates success or failure. The chart is saved as a JSON file for rendering. 298 299 Side effects: 300 - Saves the chart to `./app/templates/static/luck_test_plots` with a filename 301 based on the bot's name and performance date range. 302 303 Notes: 304 - Helps visualize how the strategy performs with or without "lucky" trades. 305 - This is a private helper method not intended to be used directly. 306 """ 307 bot_performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 308 bot_performance.TradeHistory = sorted(bot_performance.TradeHistory, key=lambda trade: trade.ExitTime) 309 310 # Equity plot 311 dates = [trade.ExitTime for trade in bot_performance.TradeHistory] 312 equity = [trade.Equity for trade in bot_performance.TradeHistory] 313 314 fig = go.Figure() 315 fig.add_trace(go.Scatter(x=dates, y=equity, 316 mode='lines', 317 name='Equity')) 318 319 print('Calculando curva de luck test') 320 result = self.get_luck_test_equity_curve(bot_performance_id) 321 322 luck_test_equity_curve = result.item 323 print(luck_test_equity_curve) 324 325 print('Calculando curva de luck test (BL)') 326 result = self.get_luck_test_equity_curve(bot_performance_id, remove_only_good_luck=True) 327 if not result.ok: 328 return result 329 330 luck_test_remove_only_good = result.item 331 332 fig.add_trace(go.Scatter(x=luck_test_equity_curve.ExitTime, y=luck_test_equity_curve.Equity, 333 mode='lines', 334 name=f'Luck test')) 335 336 fig.add_trace(go.Scatter(x=luck_test_remove_only_good.ExitTime, y=luck_test_remove_only_good.Equity, 337 mode='lines', 338 name=f'Luck test (BL)')) 339 340 fig.update_layout( 341 xaxis_title='Time', 342 yaxis_title='Equity' 343 ) 344 345 str_date_from = str(bot_performance.DateFrom).replace('-','') 346 str_date_to = str(bot_performance.DateTo).replace('-','') 347 file_name=f'{bot_performance.Bot.Name}_{str_date_from}_{str_date_to}.html' 348 349 print('Guardando grafico') 350 351 plot_path = './app/templates/static/luck_test_plots' 352 353 if not os.path.exists(plot_path): 354 os.mkdir(plot_path) 355 356 json_content = fig.to_json() 357 358 with open(os.path.join(plot_path, file_name), 'w') as f: 359 f.write(json_content) 360 361 def run_random_test(self, bot_performance_id, n_iterations) -> OperationResult: 362 """ 363 Performs a Monte Carlo randomization test to evaluate the statistical significance of a bot's performance. 364 365 This method compares the real bot's performance metrics to those obtained from multiple randomized simulations 366 that mimic the bot's trade frequency, duration, and direction probabilities. The goal is to assess whether 367 the bot’s performance could have occurred by chance. 368 369 Steps performed: 370 - Loads bot performance data, price history, and equity curve. 371 - Extracts empirical trading behavior (probabilities, duration statistics). 372 - Runs a set of randomized backtests using a custom "RandomTrader" strategy to simulate noise-based trades. 373 - Bootstraps real and random returns `n_iterations` times and computes key performance metrics. 374 - For each metric (return, drawdown, return/DD, winrate), calculates: 375 - Mean and standard deviation differences 376 - Z-scores 377 - One-sided p-values (probability that the real strategy performs worse or equal to the random one). 378 - Stores the statistical results in the database. 379 380 Parameters: 381 bot_performance_id (int): The ID of the bot performance record to analyze. 382 n_iterations (int): Number of bootstrap iterations to compare real vs. random performance. 383 384 Returns: 385 OperationResult: An object indicating success. Results are saved to the database. 386 387 Side effects: 388 - Runs multiple randomized backtests using the bot's historical market data. 389 - Saves statistical results in a `RandomTest` table (via `self.db_service`). 390 391 Notes: 392 - This test evaluates whether the strategy adds value beyond what could be expected by random chance. 393 - Performance metrics compared: total return, max drawdown, return-to-drawdown ratio, and winrate. 394 - Assumes the presence of a random strategy class at `'app.backbone.strategies.random_trader.RandomTrader'`. 395 """ 396 bot_performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 397 ticker = bot_performance.Bot.Ticker 398 timeframe = bot_performance.Bot.Timeframe 399 400 with open("./app/configs/leverages.yml", "r") as file_name: 401 leverages = yaml.safe_load(file_name) 402 403 leverage = leverages[ticker.Name] 404 405 strategy_path = 'app.backbone.strategies.random_trader.RandomTrader' 406 407 strategy_func = load_function(strategy_path) 408 409 trade_history = get_trade_df_from_db( 410 bot_performance.TradeHistory, 411 performance_id=bot_performance.Id 412 ) 413 414 long_trades = trade_history[trade_history['Size'] > 0] 415 short_trades = trade_history[trade_history['Size'] < 0] 416 417 date_from = pd.Timestamp(bot_performance.DateFrom, tz="UTC") 418 date_to = pd.Timestamp(bot_performance.DateTo, tz="UTC") 419 420 prices = get_data( 421 ticker.Name, 422 timeframe.MetaTraderNumber, 423 date_from, 424 date_to 425 ) 426 427 prices.index = pd.to_datetime(prices.index) 428 429 prob_trade = len(trade_history) / len(prices) # Probabilidad de realizar un trade 430 prob_long = len(long_trades) / len(trade_history) if len(trade_history) > 0 else 0 431 prob_short = len(short_trades) / len(trade_history) if len(trade_history) > 0 else 0 432 433 trade_history["Duration"] = pd.to_timedelta(trade_history["Duration"]) 434 trade_history["Bars"] = trade_history["ExitBar"] - trade_history["EntryBar"] 435 436 avg_trade_duration = trade_history.Bars.mean() 437 std_trade_duration = trade_history.Bars.std() 438 439 params = { 440 'prob_trade': prob_trade, 441 'prob_long': prob_long, 442 'prob_short': prob_short, 443 'avg_trade_duration': avg_trade_duration, 444 'std_trade_duration': std_trade_duration, 445 } 446 447 risk_free_rate = float(self.config_service.get_by_name('RiskFreeRate').Value) 448 449 450 all_random_trades = pd.DataFrame() 451 for _ in range(10): 452 _, _, stats = run_strategy_and_get_performances( 453 strategy=strategy_func, 454 ticker=ticker, 455 timeframe=timeframe, 456 risk=bot_performance.Bot.Risk, 457 prices=prices, 458 initial_cash=bot_performance.InitialCash, 459 risk_free_rate=risk_free_rate, 460 margin=1 / leverage, 461 opt_params=params 462 ) 463 464 all_random_trades = pd.concat([ 465 all_random_trades, 466 stats._trades 467 ]) 468 469 np.random.seed(42) 470 471 n_real = len(trade_history) 472 473 returns_real = trade_history.ReturnPct / 100 474 returns_rand = all_random_trades.ReturnPct / 100 475 476 metrics = { 477 'return': lambda eq: ((eq[-1] - eq[0]) / eq[0]) * 100, 478 'dd': lambda eq: max_drawdown(eq).round(3), 479 'return_dd': lambda eq: ((eq[-1] - eq[0]) / eq[0]) * 100 / abs(max_drawdown(eq)), 480 'winrate': lambda r: np.mean(r > 0) 481 } 482 483 real_results = {k: [] for k in metrics} 484 rand_results = {k: [] for k in metrics} 485 486 for _ in range(n_iterations): 487 sample_real = np.random.choice(returns_real, size=n_real, replace=True) 488 sample_rand = np.random.choice(returns_rand, size=n_real, replace=True) 489 490 equity_real = bot_performance.InitialCash * (1 + sample_real).cumprod() 491 equity_rand = bot_performance.InitialCash * (1 + sample_rand).cumprod() 492 493 for name, func in metrics.items(): 494 if name == 'winrate': 495 real_results[name].append(func(sample_real)) 496 rand_results[name].append(func(sample_rand)) 497 else: 498 real_results[name].append(func(equity_real)) 499 rand_results[name].append(func(equity_rand)) 500 501 # Evaluación estadística 502 p_values = {} 503 z_scores = {} 504 mean_diffs = {} 505 std_diffs = {} 506 507 for name in metrics: 508 diffs = np.array(real_results[name]) - np.array(rand_results[name]) 509 mean_diff = np.mean(diffs) 510 std_diff = np.std(diffs) 511 z_scores[name] = mean_diff / std_diff 512 p_values[name] = np.mean(diffs <= 0) # test unidireccional, proporción de veces que la estrategia real fue igual o peor que la random. 513 mean_diffs[name] = mean_diff 514 std_diffs[name] = std_diff 515 516 517 with self.db_service.get_database() as db: 518 # Primero, guardar random_test_performance_for_db 519 520 # Ahora que tenemos el ID, podemos asignarlo a random_test 521 random_test = RandomTest( 522 Iterations=n_iterations, 523 BotPerformanceId=bot_performance.Id, 524 ReturnDdMeanDiff=round(mean_diffs['return_dd'], 3), 525 ReturnDdStdDiff=round(std_diffs['return_dd'], 3), 526 ReturnDdPValue=round(p_values['return_dd'], 5), 527 ReturnDdZScore=round(z_scores['return_dd'], 3), 528 529 ReturnMeanDiff=round(mean_diffs['return'], 3), 530 ReturnStdDiff=round(std_diffs['return'], 3), 531 ReturnPValue=round(p_values['return'], 5), 532 ReturnZScore=round(z_scores['return'], 3), 533 534 DrawdownMeanDiff=round(mean_diffs['dd'], 3), 535 DrawdownStdDiff=round(std_diffs['dd'], 3), 536 DrawdownPValue=round(p_values['dd'], 5), 537 DrawdownZScore=round(z_scores['dd'], 3), 538 539 WinrateMeanDiff=round(mean_diffs['winrate'], 3), 540 WinrateStdDiff=round(std_diffs['winrate'], 3), 541 WinratePValue=round(p_values['winrate'], 5), 542 WinrateZScore=round(z_scores['winrate'], 3), 543 ) 544 545 # Guardar random_test ahora con la relación correcta 546 self.db_service.create(db, random_test) 547 548 return OperationResult(ok=True, message=None, item=None) 549 550 def run_correlation_test(self, bot_performance_id: int) -> OperationResult: 551 """ 552 Performs a correlation analysis between a bot's monthly returns and the underlying asset's price variation. 553 554 This method calculates the monthly percentage changes in the bot's equity curve and compares them to 555 the monthly price changes of the underlying instrument. It evaluates both the Pearson correlation 556 coefficient and the coefficient of determination (R²) via linear regression. 557 558 Steps performed: 559 - Retrieves the bot's equity curve and historical price data for the configured ticker and timeframe. 560 - Aggregates both series to monthly frequency and computes percentage variations. 561 - Aligns and fills missing months to ensure matching time indices. 562 - Fits a linear regression model between asset price changes and bot returns. 563 - Computes Pearson's correlation coefficient and R² score. 564 - Generates a Plotly scatter plot with a fitted regression line. 565 - Annotates the plot with the correlation and determination values. 566 - Saves the plot as a JSON file for rendering. 567 568 Parameters: 569 bot_performance_id (int): The ID of the bot performance record to analyze. 570 571 Returns: 572 OperationResult: An object indicating success and containing a DataFrame with: 573 - `correlation`: Pearson correlation coefficient (r) 574 - `determination`: Coefficient of determination (R²) 575 576 Side effects: 577 Saves a Plotly chart as an HTML-ready JSON file in the `./app/templates/static/correlation_plots` directory. 578 The filename includes the bot's name and performance date range. 579 580 Notes: 581 - This analysis helps identify the degree to which the bot's performance is correlated with the underlying asset. 582 - Monthly data is used to smooth out noise and capture general trends. 583 """ 584 585 bot_performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 586 587 trade_history = get_trade_df_from_db( 588 bot_performance.TradeHistory, 589 performance_id=bot_performance.Id 590 ) 591 592 prices = get_data( 593 bot_performance.Bot.Ticker.Name, 594 bot_performance.Bot.Timeframe.MetaTraderNumber, 595 pd.Timestamp(bot_performance.DateFrom, tz="UTC"), 596 pd.Timestamp(bot_performance.DateTo, tz="UTC") 597 ) 598 599 # Transformar el índice al formato mensual 600 trade_history = trade_history.reset_index() 601 equity = trade_history[['ExitTime', 'Equity']] 602 603 equity['month'] = pd.to_datetime(equity['ExitTime']).dt.to_period('M') 604 equity = equity.groupby(by='month').agg({'Equity': 'last'}) 605 equity['perc_diff'] = (equity['Equity'] - equity['Equity'].shift(1)) / equity['Equity'].shift(1) 606 equity.fillna(0, inplace=True) 607 608 # Crear un rango completo de meses con PeriodIndex 609 full_index = pd.period_range(start=equity.index.min(), end=equity.index.max(), freq='M') 610 611 # Reindexar usando el rango completo de PeriodIndex 612 equity = equity.reindex(full_index) 613 equity = equity.ffill() 614 615 prices['month'] = pd.to_datetime(prices.index) 616 prices['month'] = prices['month'].dt.to_period('M') 617 prices = prices.groupby(by='month').agg({'Close':'last'}) 618 prices['perc_diff'] = (prices['Close'] - prices['Close'].shift(1)) / prices['Close'].shift(1) 619 prices.fillna(0, inplace=True) 620 621 prices = prices[prices.index.isin(equity.index)] 622 623 x = np.array(prices['perc_diff']).reshape(-1, 1) 624 y = equity['perc_diff'] 625 626 # Ajustar el modelo de regresión lineal 627 reg = LinearRegression().fit(x, y) 628 determination = reg.score(x, y) 629 correlation = np.corrcoef(prices['perc_diff'], equity['perc_diff'])[0, 1] 630 631 # Predicciones para la recta 632 x_range = np.linspace(x.min(), x.max(), 100).reshape(-1, 1) # Rango de X para la recta 633 y_pred = reg.predict(x_range) # Valores predichos de Y 634 635 result = pd.DataFrame({ 636 'correlation': [correlation], 637 'determination': [determination], 638 }).round(3) 639 640 # Crear el gráfico 641 fig = px.scatter( 642 x=prices['perc_diff'], y=equity['perc_diff'], 643 ) 644 645 # Agregar la recta de regresión 646 fig.add_scatter(x=x_range.flatten(), y=y_pred, mode='lines', name='Regresión Lineal') 647 648 # Personalización 649 fig.update_layout( 650 xaxis_title='Monthly Price Variation', 651 yaxis_title='Monthly Returns' 652 ) 653 654 # Agregar anotación con los valores R² y Pearson 655 fig.add_annotation( 656 x=0.95, # Posición en el gráfico (en unidades de fracción del eje) 657 y=0.95, 658 xref='paper', yref='paper', 659 text=f"<b>r = {correlation:.3f}<br>R² = {determination:.3f}</b>", 660 showarrow=False, 661 font=dict(size=16, color="black"), 662 align="left", 663 bordercolor="black", 664 borderwidth=1, 665 borderpad=4, 666 bgcolor="white", 667 opacity=0.8 668 ) 669 670 str_date_from = str(bot_performance.DateFrom).replace('-','') 671 str_date_to = str(bot_performance.DateTo).replace('-','') 672 file_name=f'{bot_performance.Bot.Name}_{str_date_from}_{str_date_to}.html' 673 674 plot_path='./app/templates/static/correlation_plots' 675 676 if not os.path.exists(plot_path): 677 os.mkdir(plot_path) 678 679 json_content = fig.to_json() 680 681 with open(os.path.join(plot_path, file_name), 'w') as f: 682 f.write(json_content) 683 684 return OperationResult(ok=True, message=False, item=result) 685 686 def run_t_test(self, bot_performance_id: int): 687 """ 688 Performs a rolling Sharpe Ratio analysis with confidence intervals for a given bot's performance. 689 690 This method calculates the monthly returns of the bot based on its equity curve and evaluates 691 the statistical stability of its risk-adjusted performance over time. 692 693 Steps performed: 694 - Extracts the bot's equity history and computes monthly returns. 695 - Tests for normality using the Shapiro-Wilk test. 696 - Computes the cumulative Sharpe Ratio month-by-month. 697 - Builds 95% confidence intervals using either: 698 - Bootstrapping (if returns are not normally distributed), or 699 - Theoretical t-distribution (if returns are normal). 700 - Renders an interactive Plotly chart with the Sharpe Ratio curve and confidence bands. 701 - Annotates the Shapiro-Wilk p-value and saves the chart as a JSON file. 702 703 Parameters: 704 bot_performance_id (int): The ID of the bot performance record to analyze. 705 706 Returns: 707 OperationResult: A success indicator and optional message or payload. 708 709 Side effects: 710 Saves a Plotly chart as an HTML-ready JSON file in the `./app/templates/static/t_test_plots` directory. 711 The filename includes the bot's name and performance date range. 712 713 Notes: 714 - The risk-free rate is retrieved from the configuration under the key "RiskFreeRate". 715 - The Sharpe Ratio is annualized assuming 12 periods (monthly data). 716 - The analysis starts from the second available month (since returns require two data points). 717 """ 718 risk_free_rate = float(self.config_service.get_by_name('RiskFreeRate').Value) 719 720 def bootstrap_sharpe_ci_cumulative(returns, n_bootstrap=1000, confidence=0.95): 721 ci_lower = [] 722 ci_upper = [] 723 sharpe_cumulative = [] 724 725 for i in range(1, len(returns)): 726 current_returns = returns[:i+1] 727 n = len(current_returns) 728 sr_bootstrap = [] 729 730 for _ in range(n_bootstrap): 731 sample = np.random.choice(current_returns, size=n, replace=True) 732 sr = calculate_sharpe_ratio(sample, risk_free_rate=risk_free_rate, trading_periods=12) 733 sr_bootstrap.append(sr) 734 735 lower = np.percentile(sr_bootstrap, (1 - confidence) / 2 * 100) 736 upper = np.percentile(sr_bootstrap, (1 + confidence) / 2 * 100) 737 point_estimate = calculate_sharpe_ratio(current_returns, risk_free_rate=risk_free_rate, trading_periods=12) 738 739 ci_lower.append(lower) 740 ci_upper.append(upper) 741 sharpe_cumulative.append(point_estimate) 742 743 return sharpe_cumulative, ci_lower, ci_upper 744 745 bot_performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 746 747 trade_history = get_trade_df_from_db( 748 bot_performance.TradeHistory, 749 performance_id=bot_performance.Id 750 ) 751 752 # Transformar el índice al formato mensual 753 trade_history = trade_history.reset_index() 754 equity = trade_history[['ExitTime', 'Equity']] 755 756 # Asegurar formato de fecha y agrupar por mes 757 equity['ExitTime'] = pd.to_datetime(equity['ExitTime']) 758 equity['month'] = equity['ExitTime'].dt.to_period('M') 759 equity = equity.groupby(by='month').agg({'Equity': 'last'}) 760 761 # Calcular retornos mensuales 762 returns = equity['Equity'].pct_change().dropna().values 763 764 shapiro_test = stats.shapiro(returns) 765 is_normal = shapiro_test.pvalue > 0.05 766 767 confidence = 0.95 768 if not is_normal: 769 sharpe_cumulative, ci_lower, ci_upper = bootstrap_sharpe_ci_cumulative(returns, confidence=confidence) 770 771 else: 772 sharpe_cumulative = [calculate_sharpe_ratio(returns[:i+1], risk_free_rate=risk_free_rate, trading_periods=12) for i in range(1, len(returns))] 773 ci_lower = [] 774 ci_upper = [] 775 for i in range(1, len(returns)): 776 n = i+1 777 current_returns = returns[:n] 778 sr = calculate_sharpe_ratio(current_returns, risk_free_rate=risk_free_rate, trading_periods=12) 779 se = np.sqrt((1 + 0.5*sr**2) / n) 780 t = stats.t.ppf((1 + confidence)/2, df=n-1) 781 ci_lower.append(sr - t*se) 782 ci_upper.append(sr + t*se) 783 784 x_axis_labels = equity.index[1:].astype(str) 785 786 # Create traces 787 fig = go.Figure() 788 fig.add_trace(go.Scatter(x=x_axis_labels, y=sharpe_cumulative, 789 mode='lines+markers', 790 name='Sharpe Ratio', 791 line=dict(color='blue'))) 792 793 fig.add_trace(go.Scatter(x=x_axis_labels, 794 y=ci_upper, 795 mode='lines', 796 name='Upper limit 95%', 797 line=dict(color='green', dash='dash'))) 798 799 fig.add_trace(go.Scatter(x=x_axis_labels, 800 y=ci_lower,mode='lines', 801 name='Lower Limit 95%', 802 line=dict(color='red', dash='dash'))) 803 804 fig.add_trace(go.Scatter(x=x_axis_labels, 805 y=np.zeros(shape=(len(sharpe_cumulative))), 806 mode='lines', 807 name='Zero', 808 line=dict(color='black'))) 809 810 fig.add_annotation( 811 x=0.95, # Posición en el gráfico (en unidades de fracción del eje) 812 y=0.95, 813 xref='paper', yref='paper', 814 text=f"Shapiro-Wilk p-value: {shapiro_test.pvalue:.4f}", 815 showarrow=False, 816 font=dict(size=12, color="red" if not is_normal else "green"), 817 align="left", 818 bordercolor="black", 819 borderwidth=1, 820 borderpad=4, 821 bgcolor="white", 822 opacity=0.8 823 ) 824 825 str_date_from = str(bot_performance.DateFrom).replace('-','') 826 str_date_to = str(bot_performance.DateTo).replace('-','') 827 file_name=f'{bot_performance.Bot.Name}_{str_date_from}_{str_date_to}.html' 828 829 plot_path='./app/templates/static/t_test_plots' 830 831 if not os.path.exists(plot_path): 832 os.mkdir(plot_path) 833 834 json_content = fig.to_json() 835 836 with open(os.path.join(plot_path, file_name), 'w') as f: 837 f.write(json_content) 838 839 return OperationResult(ok=True, message=False, item=None)
28class TestService: 29 """ 30 Provides a collection of statistical and robustness tests for evaluating trading bot performance. 31 32 This service encapsulates methods that assess the quality, reliability, and randomness of 33 a strategy's results by applying various statistical analyses such as: 34 - t-tests for Sharpe ratio significance 35 - correlation tests between bot returns and the underlying asset 36 - randomization tests against synthetic strategies 37 - luck-based filtering of extreme trades 38 - Monte Carlo simulations to evaluate ruin probabilities 39 40 Attributes: 41 db_service (DbService): Handles database interactions and persistence. 42 backtest_service (BacktestService): Retrieves historical bot performance and trade data. 43 config_service (ConfigService): Provides access to application-wide configuration values (e.g., risk-free rate). 44 45 Notes: 46 - This class is intended to be used as a backend utility for validating trading bot robustness. 47 - Each test method typically returns an `OperationResult` and may generate visualizations or database entries. 48 """ 49 def __init__(self): 50 self.db_service = DbService() 51 self.backtest_service = BacktestService() 52 self.config_service = ConfigService() 53 54 def run_montecarlo_test(self, bot_performance_id:int, n_simulations:int, threshold_ruin:float) -> OperationResult: 55 """ 56 Performs a Monte Carlo simulation to assess the probabilistic risk profile of a bot's equity curve. 57 58 This test runs multiple randomized simulations of the bot’s historical trade sequence to estimate 59 the potential distribution of future outcomes. It focuses on downside risk by evaluating the 60 likelihood of reaching a "ruin" threshold and analyzing performance percentiles. 61 62 Steps performed: 63 - Loads the bot's historical trade and equity data. 64 - Runs `n_simulations` Monte Carlo paths using the historical trade returns. 65 - Calculates key percentile statistics (e.g., 5th, 10th, median, 90th, 95th) over all simulations. 66 - Evaluates whether the equity would fall below the specified `threshold_ruin`. 67 - Stores the test metadata and resulting metrics in the database. 68 69 Parameters: 70 bot_performance_id (int): The ID of the bot performance record to analyze. 71 n_simulations (int): Number of Monte Carlo simulations to run. 72 threshold_ruin (float): Capital threshold considered as "ruin" (e.g., 0.3 = 30% of initial capital). 73 74 Returns: 75 OperationResult: Indicates success and contains a list of `MetricWharehouse` entries 76 with computed statistics. 77 78 Side effects: 79 - Saves a `MontecarloTest` entity in the database linked to the given performance. 80 - Stores all percentile results and associated metadata in the `MetricWharehouse` table. 81 82 Notes: 83 - This test provides a probabilistic view of strategy robustness under randomness and path dependency. 84 - Useful for risk management, especially when assessing worst-case scenarios or capital preservation. 85 - The simulation respects the statistical properties of the original trade distribution (returns and durations). 86 """ 87 performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 88 trades_history = get_trade_df_from_db(performance.TradeHistory, performance_id=performance.Id) 89 90 mc = monte_carlo_simulation_v2( 91 equity_curve=trades_history.Equity, 92 trade_history=trades_history, 93 n_simulations=n_simulations, 94 initial_equity=performance.InitialCash, 95 threshold_ruin=threshold_ruin, 96 return_raw_curves=False, 97 percentiles=[0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95], 98 ) 99 100 mc = mc.round(3).reset_index().rename( 101 columns={'index':'metric'} 102 ) 103 104 mc_long = mc.melt(id_vars=['metric'], var_name='ColumnName', value_name='Value').fillna(0) 105 106 montecarlo_test = MontecarloTest( 107 BotPerformanceId=performance.Id, 108 Simulations=n_simulations, 109 ThresholdRuin=threshold_ruin, 110 ) 111 112 rows = [ 113 MetricWharehouse( 114 Method='Montecarlo', 115 Metric=row['metric'], 116 ColumnName=row['ColumnName'], 117 Value=row['Value'], 118 MontecarloTest=montecarlo_test 119 ) 120 121 for _, row in mc_long.iterrows() 122 ] 123 124 with self.db_service.get_database() as db: 125 self.db_service.create(db, montecarlo_test) 126 self.db_service.create_all(db, rows) 127 128 return OperationResult(ok=True, message=None, item=rows) 129 130 def run_luck_test(self, bot_performance_id, trades_percent_to_remove) -> OperationResult: 131 """ 132 Performs a "luck test" to evaluate the robustness of a bot's performance by removing extreme trades. 133 134 This method simulates the impact of luck by discarding a percentage of the bot's best and worst trades, 135 then recalculating performance metrics based on the remaining data. The goal is to estimate how much 136 of the bot's performance depends on a few outliers. 137 138 Steps performed: 139 - Loads the bot's trade history and determines how many trades to remove based on the given percentage. 140 - Identifies the top `X%` best and worst trades by return percentage. 141 - Filters out these trades and rebuilds the equity curve from the remaining ones. 142 - Calculates new performance metrics: return, drawdown, return/drawdown ratio, winrate, and a custom metric. 143 - Fits a linear regression to the equity curve to compute a stability ratio. 144 - Creates a new `BotPerformance` object based on the filtered trades. 145 - Stores all results, including which trades were marked as "lucky" (best or worst), in the database. 146 - Triggers the generation of a Plotly chart to visualize the filtered performance. 147 148 Parameters: 149 bot_performance_id (int): The ID of the bot performance record to analyze. 150 trades_percent_to_remove (float): Percentage of top and bottom trades (by return) to remove for the test. 151 152 Returns: 153 OperationResult: An object indicating success and containing the resulting `LuckTest` record. 154 155 Side effects: 156 - Updates the trade records in the database to flag trades as top-best or top-worst. 157 - Saves a new `LuckTest` and `BotPerformance` entry reflecting the filtered results. 158 - Generates and stores a visualization of the adjusted performance. 159 160 Notes: 161 - This test helps detect whether a bot's profitability is overly dependent on a few outlier trades. 162 - The stability ratio is derived from the R² of a linear regression on the filtered equity curve. 163 - The custom metric is a weighted risk-adjusted return penalized by drawdown and enhanced by stability. 164 """ 165 performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 166 trades = get_trade_df_from_db(performance.TradeHistory, performance_id=performance.Id) 167 trades_to_remove = round((trades_percent_to_remove/100) * trades.shape[0]) 168 169 top_best_trades = trades.sort_values(by='ReturnPct', ascending=False).head(trades_to_remove) 170 top_worst_trades = trades.sort_values(by='ReturnPct', ascending=False).tail(trades_to_remove) 171 172 filtered_trades = trades[ 173 (~trades['Id'].isin(top_best_trades.Id)) 174 & (~trades['Id'].isin(top_worst_trades.Id)) 175 & (~trades['ReturnPct'].isna()) 176 ].sort_values(by='ExitTime') 177 178 filtered_trades.ReturnPct = filtered_trades.ReturnPct / 100 179 180 filtered_trades['Equity'] = 0 181 filtered_trades['Equity'] = (performance.InitialCash * (1 + filtered_trades.ReturnPct).cumprod()).round(3) 182 183 dd = np.abs(max_drawdown(filtered_trades['Equity'])).round(3) 184 ret = ((filtered_trades.iloc[-1]['Equity'] - filtered_trades.iloc[0]['Equity']) / filtered_trades.iloc[0]['Equity']) * 100 185 ret = round(ret, 3) 186 187 ret_dd = (ret / dd).round(3) 188 189 x = np.arange(filtered_trades.shape[0]).reshape(-1, 1) 190 reg = LinearRegression().fit(x, filtered_trades['Equity']) 191 stability_ratio = round(reg.score(x, filtered_trades['Equity']), 3) 192 193 custom_metric = ((ret / (1 + dd)) * np.log(1 + filtered_trades.shape[0])).round(3) * stability_ratio 194 195 new_winrate = round( 196 (filtered_trades[filtered_trades['NetPnL']>0]['Id'].size / filtered_trades['Id'].size) * 100, 3 197 ) 198 199 luck_test_performance = BotPerformance(**{ 200 'DateFrom': performance.DateFrom, 201 'DateTo': performance.DateTo, 202 'BotId': None, 203 'StabilityRatio': stability_ratio, 204 'Trades': filtered_trades['Id'].size, 205 'Return': ret, 206 'Drawdown': dd, 207 'RreturnDd': ret_dd, 208 'WinRate': new_winrate, 209 'Duration': performance.Duration, 210 'StabilityWeightedRar': custom_metric, 211 'Method': 'luck_test', 212 'InitialCash': performance.InitialCash, 213 'ExposureTime': performance.ExposureTime, 214 'KellyCriterion': performance.KellyCriterion, 215 'WinratePValue': performance.WinratePValue 216 }) 217 218 luck_test = LuckTest(**{ 219 'BotPerformanceId': performance.Id, 220 'TradesPercentToRemove': trades_percent_to_remove, 221 'LuckTestPerformance': luck_test_performance 222 }) 223 224 top_best_trades_id = top_best_trades['Id'].values 225 top_worst_trades_id = top_worst_trades['Id'].values 226 227 with self.db_service.get_database() as db: 228 229 for trade in performance.TradeHistory: 230 if trade.Id in top_best_trades_id: 231 trade.TopBest = True 232 233 if trade.Id in top_worst_trades_id: 234 trade.TopWorst = True 235 236 _ = self.db_service.update(db, Trade, trade) 237 238 luck_test_db = self.db_service.create(db, luck_test) 239 _ = self.db_service.create(db, luck_test_performance) 240 241 self._create_luck_test_plot(bot_performance_id=bot_performance_id) 242 243 return OperationResult(ok=True, message=None, item=luck_test_db) 244 245 def get_luck_test_equity_curve(self, bot_performance_id, remove_only_good_luck=False) -> OperationResult: 246 """ 247 Generates a filtered equity curve by removing lucky trades from a bot's historical performance. 248 249 This method rebuilds the bot's equity curve after excluding trades flagged as either top-performing 250 ("TopBest"), worst-performing ("TopWorst"), or both, depending on the configuration. 251 252 Parameters: 253 bot_performance_id (int): The ID of the bot performance record to analyze. 254 remove_only_good_luck (bool): If True, only the top-performing trades ("TopBest") are removed. 255 If False, both "TopBest" and "TopWorst" trades are removed. 256 257 Returns: 258 OperationResult: Contains the filtered equity curve as a DataFrame with `ExitTime` and `Equity`. 259 260 Notes: 261 - This function is typically used to assess the strategy's robustness by simulating less favorable luck. 262 - Equity is recomputed using cumulative product of filtered returns. 263 """ 264 performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 265 trades = get_trade_df_from_db(performance.TradeHistory, performance_id=performance.Id) 266 267 if remove_only_good_luck: 268 filtered_trades = trades[(trades['TopBest'].isna())].sort_values(by='ExitTime') 269 270 else: 271 filtered_trades = trades[(trades['TopBest'].isna()) & (trades['TopWorst'].isna())].sort_values(by='ExitTime') 272 273 filtered_trades['Equity'] = 0 274 filtered_trades.ReturnPct = filtered_trades.ReturnPct / 100 275 filtered_trades['Equity'] = (performance.InitialCash * (1 + filtered_trades.ReturnPct).cumprod()).round(3) 276 equity = filtered_trades[['ExitTime','Equity']] 277 278 return OperationResult(ok=True, message=None, item=equity) 279 280 def _create_luck_test_plot(self, bot_performance_id) -> OperationResult: 281 """ 282 Generates and saves a Plotly chart comparing the original and "luck test" equity curves. 283 284 This function plots three equity curves: 285 - The original equity curve with all trades. 286 - The equity curve after removing both top and bottom trades (luck-neutral). 287 - The equity curve after removing only top-performing trades (bad-luck only). 288 289 Steps performed: 290 - Retrieves original and filtered trade histories. 291 - Computes equity for each curve. 292 - Plots them using Plotly and exports the figure as a JSON file. 293 294 Parameters: 295 bot_performance_id (int): The ID of the bot performance record to visualize. 296 297 Returns: 298 OperationResult: Indicates success or failure. The chart is saved as a JSON file for rendering. 299 300 Side effects: 301 - Saves the chart to `./app/templates/static/luck_test_plots` with a filename 302 based on the bot's name and performance date range. 303 304 Notes: 305 - Helps visualize how the strategy performs with or without "lucky" trades. 306 - This is a private helper method not intended to be used directly. 307 """ 308 bot_performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 309 bot_performance.TradeHistory = sorted(bot_performance.TradeHistory, key=lambda trade: trade.ExitTime) 310 311 # Equity plot 312 dates = [trade.ExitTime for trade in bot_performance.TradeHistory] 313 equity = [trade.Equity for trade in bot_performance.TradeHistory] 314 315 fig = go.Figure() 316 fig.add_trace(go.Scatter(x=dates, y=equity, 317 mode='lines', 318 name='Equity')) 319 320 print('Calculando curva de luck test') 321 result = self.get_luck_test_equity_curve(bot_performance_id) 322 323 luck_test_equity_curve = result.item 324 print(luck_test_equity_curve) 325 326 print('Calculando curva de luck test (BL)') 327 result = self.get_luck_test_equity_curve(bot_performance_id, remove_only_good_luck=True) 328 if not result.ok: 329 return result 330 331 luck_test_remove_only_good = result.item 332 333 fig.add_trace(go.Scatter(x=luck_test_equity_curve.ExitTime, y=luck_test_equity_curve.Equity, 334 mode='lines', 335 name=f'Luck test')) 336 337 fig.add_trace(go.Scatter(x=luck_test_remove_only_good.ExitTime, y=luck_test_remove_only_good.Equity, 338 mode='lines', 339 name=f'Luck test (BL)')) 340 341 fig.update_layout( 342 xaxis_title='Time', 343 yaxis_title='Equity' 344 ) 345 346 str_date_from = str(bot_performance.DateFrom).replace('-','') 347 str_date_to = str(bot_performance.DateTo).replace('-','') 348 file_name=f'{bot_performance.Bot.Name}_{str_date_from}_{str_date_to}.html' 349 350 print('Guardando grafico') 351 352 plot_path = './app/templates/static/luck_test_plots' 353 354 if not os.path.exists(plot_path): 355 os.mkdir(plot_path) 356 357 json_content = fig.to_json() 358 359 with open(os.path.join(plot_path, file_name), 'w') as f: 360 f.write(json_content) 361 362 def run_random_test(self, bot_performance_id, n_iterations) -> OperationResult: 363 """ 364 Performs a Monte Carlo randomization test to evaluate the statistical significance of a bot's performance. 365 366 This method compares the real bot's performance metrics to those obtained from multiple randomized simulations 367 that mimic the bot's trade frequency, duration, and direction probabilities. The goal is to assess whether 368 the bot’s performance could have occurred by chance. 369 370 Steps performed: 371 - Loads bot performance data, price history, and equity curve. 372 - Extracts empirical trading behavior (probabilities, duration statistics). 373 - Runs a set of randomized backtests using a custom "RandomTrader" strategy to simulate noise-based trades. 374 - Bootstraps real and random returns `n_iterations` times and computes key performance metrics. 375 - For each metric (return, drawdown, return/DD, winrate), calculates: 376 - Mean and standard deviation differences 377 - Z-scores 378 - One-sided p-values (probability that the real strategy performs worse or equal to the random one). 379 - Stores the statistical results in the database. 380 381 Parameters: 382 bot_performance_id (int): The ID of the bot performance record to analyze. 383 n_iterations (int): Number of bootstrap iterations to compare real vs. random performance. 384 385 Returns: 386 OperationResult: An object indicating success. Results are saved to the database. 387 388 Side effects: 389 - Runs multiple randomized backtests using the bot's historical market data. 390 - Saves statistical results in a `RandomTest` table (via `self.db_service`). 391 392 Notes: 393 - This test evaluates whether the strategy adds value beyond what could be expected by random chance. 394 - Performance metrics compared: total return, max drawdown, return-to-drawdown ratio, and winrate. 395 - Assumes the presence of a random strategy class at `'app.backbone.strategies.random_trader.RandomTrader'`. 396 """ 397 bot_performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 398 ticker = bot_performance.Bot.Ticker 399 timeframe = bot_performance.Bot.Timeframe 400 401 with open("./app/configs/leverages.yml", "r") as file_name: 402 leverages = yaml.safe_load(file_name) 403 404 leverage = leverages[ticker.Name] 405 406 strategy_path = 'app.backbone.strategies.random_trader.RandomTrader' 407 408 strategy_func = load_function(strategy_path) 409 410 trade_history = get_trade_df_from_db( 411 bot_performance.TradeHistory, 412 performance_id=bot_performance.Id 413 ) 414 415 long_trades = trade_history[trade_history['Size'] > 0] 416 short_trades = trade_history[trade_history['Size'] < 0] 417 418 date_from = pd.Timestamp(bot_performance.DateFrom, tz="UTC") 419 date_to = pd.Timestamp(bot_performance.DateTo, tz="UTC") 420 421 prices = get_data( 422 ticker.Name, 423 timeframe.MetaTraderNumber, 424 date_from, 425 date_to 426 ) 427 428 prices.index = pd.to_datetime(prices.index) 429 430 prob_trade = len(trade_history) / len(prices) # Probabilidad de realizar un trade 431 prob_long = len(long_trades) / len(trade_history) if len(trade_history) > 0 else 0 432 prob_short = len(short_trades) / len(trade_history) if len(trade_history) > 0 else 0 433 434 trade_history["Duration"] = pd.to_timedelta(trade_history["Duration"]) 435 trade_history["Bars"] = trade_history["ExitBar"] - trade_history["EntryBar"] 436 437 avg_trade_duration = trade_history.Bars.mean() 438 std_trade_duration = trade_history.Bars.std() 439 440 params = { 441 'prob_trade': prob_trade, 442 'prob_long': prob_long, 443 'prob_short': prob_short, 444 'avg_trade_duration': avg_trade_duration, 445 'std_trade_duration': std_trade_duration, 446 } 447 448 risk_free_rate = float(self.config_service.get_by_name('RiskFreeRate').Value) 449 450 451 all_random_trades = pd.DataFrame() 452 for _ in range(10): 453 _, _, stats = run_strategy_and_get_performances( 454 strategy=strategy_func, 455 ticker=ticker, 456 timeframe=timeframe, 457 risk=bot_performance.Bot.Risk, 458 prices=prices, 459 initial_cash=bot_performance.InitialCash, 460 risk_free_rate=risk_free_rate, 461 margin=1 / leverage, 462 opt_params=params 463 ) 464 465 all_random_trades = pd.concat([ 466 all_random_trades, 467 stats._trades 468 ]) 469 470 np.random.seed(42) 471 472 n_real = len(trade_history) 473 474 returns_real = trade_history.ReturnPct / 100 475 returns_rand = all_random_trades.ReturnPct / 100 476 477 metrics = { 478 'return': lambda eq: ((eq[-1] - eq[0]) / eq[0]) * 100, 479 'dd': lambda eq: max_drawdown(eq).round(3), 480 'return_dd': lambda eq: ((eq[-1] - eq[0]) / eq[0]) * 100 / abs(max_drawdown(eq)), 481 'winrate': lambda r: np.mean(r > 0) 482 } 483 484 real_results = {k: [] for k in metrics} 485 rand_results = {k: [] for k in metrics} 486 487 for _ in range(n_iterations): 488 sample_real = np.random.choice(returns_real, size=n_real, replace=True) 489 sample_rand = np.random.choice(returns_rand, size=n_real, replace=True) 490 491 equity_real = bot_performance.InitialCash * (1 + sample_real).cumprod() 492 equity_rand = bot_performance.InitialCash * (1 + sample_rand).cumprod() 493 494 for name, func in metrics.items(): 495 if name == 'winrate': 496 real_results[name].append(func(sample_real)) 497 rand_results[name].append(func(sample_rand)) 498 else: 499 real_results[name].append(func(equity_real)) 500 rand_results[name].append(func(equity_rand)) 501 502 # Evaluación estadística 503 p_values = {} 504 z_scores = {} 505 mean_diffs = {} 506 std_diffs = {} 507 508 for name in metrics: 509 diffs = np.array(real_results[name]) - np.array(rand_results[name]) 510 mean_diff = np.mean(diffs) 511 std_diff = np.std(diffs) 512 z_scores[name] = mean_diff / std_diff 513 p_values[name] = np.mean(diffs <= 0) # test unidireccional, proporción de veces que la estrategia real fue igual o peor que la random. 514 mean_diffs[name] = mean_diff 515 std_diffs[name] = std_diff 516 517 518 with self.db_service.get_database() as db: 519 # Primero, guardar random_test_performance_for_db 520 521 # Ahora que tenemos el ID, podemos asignarlo a random_test 522 random_test = RandomTest( 523 Iterations=n_iterations, 524 BotPerformanceId=bot_performance.Id, 525 ReturnDdMeanDiff=round(mean_diffs['return_dd'], 3), 526 ReturnDdStdDiff=round(std_diffs['return_dd'], 3), 527 ReturnDdPValue=round(p_values['return_dd'], 5), 528 ReturnDdZScore=round(z_scores['return_dd'], 3), 529 530 ReturnMeanDiff=round(mean_diffs['return'], 3), 531 ReturnStdDiff=round(std_diffs['return'], 3), 532 ReturnPValue=round(p_values['return'], 5), 533 ReturnZScore=round(z_scores['return'], 3), 534 535 DrawdownMeanDiff=round(mean_diffs['dd'], 3), 536 DrawdownStdDiff=round(std_diffs['dd'], 3), 537 DrawdownPValue=round(p_values['dd'], 5), 538 DrawdownZScore=round(z_scores['dd'], 3), 539 540 WinrateMeanDiff=round(mean_diffs['winrate'], 3), 541 WinrateStdDiff=round(std_diffs['winrate'], 3), 542 WinratePValue=round(p_values['winrate'], 5), 543 WinrateZScore=round(z_scores['winrate'], 3), 544 ) 545 546 # Guardar random_test ahora con la relación correcta 547 self.db_service.create(db, random_test) 548 549 return OperationResult(ok=True, message=None, item=None) 550 551 def run_correlation_test(self, bot_performance_id: int) -> OperationResult: 552 """ 553 Performs a correlation analysis between a bot's monthly returns and the underlying asset's price variation. 554 555 This method calculates the monthly percentage changes in the bot's equity curve and compares them to 556 the monthly price changes of the underlying instrument. It evaluates both the Pearson correlation 557 coefficient and the coefficient of determination (R²) via linear regression. 558 559 Steps performed: 560 - Retrieves the bot's equity curve and historical price data for the configured ticker and timeframe. 561 - Aggregates both series to monthly frequency and computes percentage variations. 562 - Aligns and fills missing months to ensure matching time indices. 563 - Fits a linear regression model between asset price changes and bot returns. 564 - Computes Pearson's correlation coefficient and R² score. 565 - Generates a Plotly scatter plot with a fitted regression line. 566 - Annotates the plot with the correlation and determination values. 567 - Saves the plot as a JSON file for rendering. 568 569 Parameters: 570 bot_performance_id (int): The ID of the bot performance record to analyze. 571 572 Returns: 573 OperationResult: An object indicating success and containing a DataFrame with: 574 - `correlation`: Pearson correlation coefficient (r) 575 - `determination`: Coefficient of determination (R²) 576 577 Side effects: 578 Saves a Plotly chart as an HTML-ready JSON file in the `./app/templates/static/correlation_plots` directory. 579 The filename includes the bot's name and performance date range. 580 581 Notes: 582 - This analysis helps identify the degree to which the bot's performance is correlated with the underlying asset. 583 - Monthly data is used to smooth out noise and capture general trends. 584 """ 585 586 bot_performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 587 588 trade_history = get_trade_df_from_db( 589 bot_performance.TradeHistory, 590 performance_id=bot_performance.Id 591 ) 592 593 prices = get_data( 594 bot_performance.Bot.Ticker.Name, 595 bot_performance.Bot.Timeframe.MetaTraderNumber, 596 pd.Timestamp(bot_performance.DateFrom, tz="UTC"), 597 pd.Timestamp(bot_performance.DateTo, tz="UTC") 598 ) 599 600 # Transformar el índice al formato mensual 601 trade_history = trade_history.reset_index() 602 equity = trade_history[['ExitTime', 'Equity']] 603 604 equity['month'] = pd.to_datetime(equity['ExitTime']).dt.to_period('M') 605 equity = equity.groupby(by='month').agg({'Equity': 'last'}) 606 equity['perc_diff'] = (equity['Equity'] - equity['Equity'].shift(1)) / equity['Equity'].shift(1) 607 equity.fillna(0, inplace=True) 608 609 # Crear un rango completo de meses con PeriodIndex 610 full_index = pd.period_range(start=equity.index.min(), end=equity.index.max(), freq='M') 611 612 # Reindexar usando el rango completo de PeriodIndex 613 equity = equity.reindex(full_index) 614 equity = equity.ffill() 615 616 prices['month'] = pd.to_datetime(prices.index) 617 prices['month'] = prices['month'].dt.to_period('M') 618 prices = prices.groupby(by='month').agg({'Close':'last'}) 619 prices['perc_diff'] = (prices['Close'] - prices['Close'].shift(1)) / prices['Close'].shift(1) 620 prices.fillna(0, inplace=True) 621 622 prices = prices[prices.index.isin(equity.index)] 623 624 x = np.array(prices['perc_diff']).reshape(-1, 1) 625 y = equity['perc_diff'] 626 627 # Ajustar el modelo de regresión lineal 628 reg = LinearRegression().fit(x, y) 629 determination = reg.score(x, y) 630 correlation = np.corrcoef(prices['perc_diff'], equity['perc_diff'])[0, 1] 631 632 # Predicciones para la recta 633 x_range = np.linspace(x.min(), x.max(), 100).reshape(-1, 1) # Rango de X para la recta 634 y_pred = reg.predict(x_range) # Valores predichos de Y 635 636 result = pd.DataFrame({ 637 'correlation': [correlation], 638 'determination': [determination], 639 }).round(3) 640 641 # Crear el gráfico 642 fig = px.scatter( 643 x=prices['perc_diff'], y=equity['perc_diff'], 644 ) 645 646 # Agregar la recta de regresión 647 fig.add_scatter(x=x_range.flatten(), y=y_pred, mode='lines', name='Regresión Lineal') 648 649 # Personalización 650 fig.update_layout( 651 xaxis_title='Monthly Price Variation', 652 yaxis_title='Monthly Returns' 653 ) 654 655 # Agregar anotación con los valores R² y Pearson 656 fig.add_annotation( 657 x=0.95, # Posición en el gráfico (en unidades de fracción del eje) 658 y=0.95, 659 xref='paper', yref='paper', 660 text=f"<b>r = {correlation:.3f}<br>R² = {determination:.3f}</b>", 661 showarrow=False, 662 font=dict(size=16, color="black"), 663 align="left", 664 bordercolor="black", 665 borderwidth=1, 666 borderpad=4, 667 bgcolor="white", 668 opacity=0.8 669 ) 670 671 str_date_from = str(bot_performance.DateFrom).replace('-','') 672 str_date_to = str(bot_performance.DateTo).replace('-','') 673 file_name=f'{bot_performance.Bot.Name}_{str_date_from}_{str_date_to}.html' 674 675 plot_path='./app/templates/static/correlation_plots' 676 677 if not os.path.exists(plot_path): 678 os.mkdir(plot_path) 679 680 json_content = fig.to_json() 681 682 with open(os.path.join(plot_path, file_name), 'w') as f: 683 f.write(json_content) 684 685 return OperationResult(ok=True, message=False, item=result) 686 687 def run_t_test(self, bot_performance_id: int): 688 """ 689 Performs a rolling Sharpe Ratio analysis with confidence intervals for a given bot's performance. 690 691 This method calculates the monthly returns of the bot based on its equity curve and evaluates 692 the statistical stability of its risk-adjusted performance over time. 693 694 Steps performed: 695 - Extracts the bot's equity history and computes monthly returns. 696 - Tests for normality using the Shapiro-Wilk test. 697 - Computes the cumulative Sharpe Ratio month-by-month. 698 - Builds 95% confidence intervals using either: 699 - Bootstrapping (if returns are not normally distributed), or 700 - Theoretical t-distribution (if returns are normal). 701 - Renders an interactive Plotly chart with the Sharpe Ratio curve and confidence bands. 702 - Annotates the Shapiro-Wilk p-value and saves the chart as a JSON file. 703 704 Parameters: 705 bot_performance_id (int): The ID of the bot performance record to analyze. 706 707 Returns: 708 OperationResult: A success indicator and optional message or payload. 709 710 Side effects: 711 Saves a Plotly chart as an HTML-ready JSON file in the `./app/templates/static/t_test_plots` directory. 712 The filename includes the bot's name and performance date range. 713 714 Notes: 715 - The risk-free rate is retrieved from the configuration under the key "RiskFreeRate". 716 - The Sharpe Ratio is annualized assuming 12 periods (monthly data). 717 - The analysis starts from the second available month (since returns require two data points). 718 """ 719 risk_free_rate = float(self.config_service.get_by_name('RiskFreeRate').Value) 720 721 def bootstrap_sharpe_ci_cumulative(returns, n_bootstrap=1000, confidence=0.95): 722 ci_lower = [] 723 ci_upper = [] 724 sharpe_cumulative = [] 725 726 for i in range(1, len(returns)): 727 current_returns = returns[:i+1] 728 n = len(current_returns) 729 sr_bootstrap = [] 730 731 for _ in range(n_bootstrap): 732 sample = np.random.choice(current_returns, size=n, replace=True) 733 sr = calculate_sharpe_ratio(sample, risk_free_rate=risk_free_rate, trading_periods=12) 734 sr_bootstrap.append(sr) 735 736 lower = np.percentile(sr_bootstrap, (1 - confidence) / 2 * 100) 737 upper = np.percentile(sr_bootstrap, (1 + confidence) / 2 * 100) 738 point_estimate = calculate_sharpe_ratio(current_returns, risk_free_rate=risk_free_rate, trading_periods=12) 739 740 ci_lower.append(lower) 741 ci_upper.append(upper) 742 sharpe_cumulative.append(point_estimate) 743 744 return sharpe_cumulative, ci_lower, ci_upper 745 746 bot_performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 747 748 trade_history = get_trade_df_from_db( 749 bot_performance.TradeHistory, 750 performance_id=bot_performance.Id 751 ) 752 753 # Transformar el índice al formato mensual 754 trade_history = trade_history.reset_index() 755 equity = trade_history[['ExitTime', 'Equity']] 756 757 # Asegurar formato de fecha y agrupar por mes 758 equity['ExitTime'] = pd.to_datetime(equity['ExitTime']) 759 equity['month'] = equity['ExitTime'].dt.to_period('M') 760 equity = equity.groupby(by='month').agg({'Equity': 'last'}) 761 762 # Calcular retornos mensuales 763 returns = equity['Equity'].pct_change().dropna().values 764 765 shapiro_test = stats.shapiro(returns) 766 is_normal = shapiro_test.pvalue > 0.05 767 768 confidence = 0.95 769 if not is_normal: 770 sharpe_cumulative, ci_lower, ci_upper = bootstrap_sharpe_ci_cumulative(returns, confidence=confidence) 771 772 else: 773 sharpe_cumulative = [calculate_sharpe_ratio(returns[:i+1], risk_free_rate=risk_free_rate, trading_periods=12) for i in range(1, len(returns))] 774 ci_lower = [] 775 ci_upper = [] 776 for i in range(1, len(returns)): 777 n = i+1 778 current_returns = returns[:n] 779 sr = calculate_sharpe_ratio(current_returns, risk_free_rate=risk_free_rate, trading_periods=12) 780 se = np.sqrt((1 + 0.5*sr**2) / n) 781 t = stats.t.ppf((1 + confidence)/2, df=n-1) 782 ci_lower.append(sr - t*se) 783 ci_upper.append(sr + t*se) 784 785 x_axis_labels = equity.index[1:].astype(str) 786 787 # Create traces 788 fig = go.Figure() 789 fig.add_trace(go.Scatter(x=x_axis_labels, y=sharpe_cumulative, 790 mode='lines+markers', 791 name='Sharpe Ratio', 792 line=dict(color='blue'))) 793 794 fig.add_trace(go.Scatter(x=x_axis_labels, 795 y=ci_upper, 796 mode='lines', 797 name='Upper limit 95%', 798 line=dict(color='green', dash='dash'))) 799 800 fig.add_trace(go.Scatter(x=x_axis_labels, 801 y=ci_lower,mode='lines', 802 name='Lower Limit 95%', 803 line=dict(color='red', dash='dash'))) 804 805 fig.add_trace(go.Scatter(x=x_axis_labels, 806 y=np.zeros(shape=(len(sharpe_cumulative))), 807 mode='lines', 808 name='Zero', 809 line=dict(color='black'))) 810 811 fig.add_annotation( 812 x=0.95, # Posición en el gráfico (en unidades de fracción del eje) 813 y=0.95, 814 xref='paper', yref='paper', 815 text=f"Shapiro-Wilk p-value: {shapiro_test.pvalue:.4f}", 816 showarrow=False, 817 font=dict(size=12, color="red" if not is_normal else "green"), 818 align="left", 819 bordercolor="black", 820 borderwidth=1, 821 borderpad=4, 822 bgcolor="white", 823 opacity=0.8 824 ) 825 826 str_date_from = str(bot_performance.DateFrom).replace('-','') 827 str_date_to = str(bot_performance.DateTo).replace('-','') 828 file_name=f'{bot_performance.Bot.Name}_{str_date_from}_{str_date_to}.html' 829 830 plot_path='./app/templates/static/t_test_plots' 831 832 if not os.path.exists(plot_path): 833 os.mkdir(plot_path) 834 835 json_content = fig.to_json() 836 837 with open(os.path.join(plot_path, file_name), 'w') as f: 838 f.write(json_content) 839 840 return OperationResult(ok=True, message=False, item=None)
Provides a collection of statistical and robustness tests for evaluating trading bot performance.
This service encapsulates methods that assess the quality, reliability, and randomness of a strategy's results by applying various statistical analyses such as:
- t-tests for Sharpe ratio significance
- correlation tests between bot returns and the underlying asset
- randomization tests against synthetic strategies
- luck-based filtering of extreme trades
- Monte Carlo simulations to evaluate ruin probabilities
Attributes: db_service (DbService): Handles database interactions and persistence. backtest_service (BacktestService): Retrieves historical bot performance and trade data. config_service (ConfigService): Provides access to application-wide configuration values (e.g., risk-free rate).
Notes:
- This class is intended to be used as a backend utility for validating trading bot robustness.
- Each test method typically returns an OperationResult
and may generate visualizations or database entries.
54 def run_montecarlo_test(self, bot_performance_id:int, n_simulations:int, threshold_ruin:float) -> OperationResult: 55 """ 56 Performs a Monte Carlo simulation to assess the probabilistic risk profile of a bot's equity curve. 57 58 This test runs multiple randomized simulations of the bot’s historical trade sequence to estimate 59 the potential distribution of future outcomes. It focuses on downside risk by evaluating the 60 likelihood of reaching a "ruin" threshold and analyzing performance percentiles. 61 62 Steps performed: 63 - Loads the bot's historical trade and equity data. 64 - Runs `n_simulations` Monte Carlo paths using the historical trade returns. 65 - Calculates key percentile statistics (e.g., 5th, 10th, median, 90th, 95th) over all simulations. 66 - Evaluates whether the equity would fall below the specified `threshold_ruin`. 67 - Stores the test metadata and resulting metrics in the database. 68 69 Parameters: 70 bot_performance_id (int): The ID of the bot performance record to analyze. 71 n_simulations (int): Number of Monte Carlo simulations to run. 72 threshold_ruin (float): Capital threshold considered as "ruin" (e.g., 0.3 = 30% of initial capital). 73 74 Returns: 75 OperationResult: Indicates success and contains a list of `MetricWharehouse` entries 76 with computed statistics. 77 78 Side effects: 79 - Saves a `MontecarloTest` entity in the database linked to the given performance. 80 - Stores all percentile results and associated metadata in the `MetricWharehouse` table. 81 82 Notes: 83 - This test provides a probabilistic view of strategy robustness under randomness and path dependency. 84 - Useful for risk management, especially when assessing worst-case scenarios or capital preservation. 85 - The simulation respects the statistical properties of the original trade distribution (returns and durations). 86 """ 87 performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 88 trades_history = get_trade_df_from_db(performance.TradeHistory, performance_id=performance.Id) 89 90 mc = monte_carlo_simulation_v2( 91 equity_curve=trades_history.Equity, 92 trade_history=trades_history, 93 n_simulations=n_simulations, 94 initial_equity=performance.InitialCash, 95 threshold_ruin=threshold_ruin, 96 return_raw_curves=False, 97 percentiles=[0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95], 98 ) 99 100 mc = mc.round(3).reset_index().rename( 101 columns={'index':'metric'} 102 ) 103 104 mc_long = mc.melt(id_vars=['metric'], var_name='ColumnName', value_name='Value').fillna(0) 105 106 montecarlo_test = MontecarloTest( 107 BotPerformanceId=performance.Id, 108 Simulations=n_simulations, 109 ThresholdRuin=threshold_ruin, 110 ) 111 112 rows = [ 113 MetricWharehouse( 114 Method='Montecarlo', 115 Metric=row['metric'], 116 ColumnName=row['ColumnName'], 117 Value=row['Value'], 118 MontecarloTest=montecarlo_test 119 ) 120 121 for _, row in mc_long.iterrows() 122 ] 123 124 with self.db_service.get_database() as db: 125 self.db_service.create(db, montecarlo_test) 126 self.db_service.create_all(db, rows) 127 128 return OperationResult(ok=True, message=None, item=rows)
Performs a Monte Carlo simulation to assess the probabilistic risk profile of a bot's equity curve.
This test runs multiple randomized simulations of the bot’s historical trade sequence to estimate the potential distribution of future outcomes. It focuses on downside risk by evaluating the likelihood of reaching a "ruin" threshold and analyzing performance percentiles.
Steps performed:
- Loads the bot's historical trade and equity data.
- Runs
n_simulations
Monte Carlo paths using the historical trade returns. - Calculates key percentile statistics (e.g., 5th, 10th, median, 90th, 95th) over all simulations.
- Evaluates whether the equity would fall below the specified
threshold_ruin
. - Stores the test metadata and resulting metrics in the database.
Parameters: bot_performance_id (int): The ID of the bot performance record to analyze. n_simulations (int): Number of Monte Carlo simulations to run. threshold_ruin (float): Capital threshold considered as "ruin" (e.g., 0.3 = 30% of initial capital).
Returns:
OperationResult: Indicates success and contains a list of MetricWharehouse
entries
with computed statistics.
Side effects:
- Saves a MontecarloTest
entity in the database linked to the given performance.
- Stores all percentile results and associated metadata in the MetricWharehouse
table.
Notes: - This test provides a probabilistic view of strategy robustness under randomness and path dependency. - Useful for risk management, especially when assessing worst-case scenarios or capital preservation. - The simulation respects the statistical properties of the original trade distribution (returns and durations).
130 def run_luck_test(self, bot_performance_id, trades_percent_to_remove) -> OperationResult: 131 """ 132 Performs a "luck test" to evaluate the robustness of a bot's performance by removing extreme trades. 133 134 This method simulates the impact of luck by discarding a percentage of the bot's best and worst trades, 135 then recalculating performance metrics based on the remaining data. The goal is to estimate how much 136 of the bot's performance depends on a few outliers. 137 138 Steps performed: 139 - Loads the bot's trade history and determines how many trades to remove based on the given percentage. 140 - Identifies the top `X%` best and worst trades by return percentage. 141 - Filters out these trades and rebuilds the equity curve from the remaining ones. 142 - Calculates new performance metrics: return, drawdown, return/drawdown ratio, winrate, and a custom metric. 143 - Fits a linear regression to the equity curve to compute a stability ratio. 144 - Creates a new `BotPerformance` object based on the filtered trades. 145 - Stores all results, including which trades were marked as "lucky" (best or worst), in the database. 146 - Triggers the generation of a Plotly chart to visualize the filtered performance. 147 148 Parameters: 149 bot_performance_id (int): The ID of the bot performance record to analyze. 150 trades_percent_to_remove (float): Percentage of top and bottom trades (by return) to remove for the test. 151 152 Returns: 153 OperationResult: An object indicating success and containing the resulting `LuckTest` record. 154 155 Side effects: 156 - Updates the trade records in the database to flag trades as top-best or top-worst. 157 - Saves a new `LuckTest` and `BotPerformance` entry reflecting the filtered results. 158 - Generates and stores a visualization of the adjusted performance. 159 160 Notes: 161 - This test helps detect whether a bot's profitability is overly dependent on a few outlier trades. 162 - The stability ratio is derived from the R² of a linear regression on the filtered equity curve. 163 - The custom metric is a weighted risk-adjusted return penalized by drawdown and enhanced by stability. 164 """ 165 performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 166 trades = get_trade_df_from_db(performance.TradeHistory, performance_id=performance.Id) 167 trades_to_remove = round((trades_percent_to_remove/100) * trades.shape[0]) 168 169 top_best_trades = trades.sort_values(by='ReturnPct', ascending=False).head(trades_to_remove) 170 top_worst_trades = trades.sort_values(by='ReturnPct', ascending=False).tail(trades_to_remove) 171 172 filtered_trades = trades[ 173 (~trades['Id'].isin(top_best_trades.Id)) 174 & (~trades['Id'].isin(top_worst_trades.Id)) 175 & (~trades['ReturnPct'].isna()) 176 ].sort_values(by='ExitTime') 177 178 filtered_trades.ReturnPct = filtered_trades.ReturnPct / 100 179 180 filtered_trades['Equity'] = 0 181 filtered_trades['Equity'] = (performance.InitialCash * (1 + filtered_trades.ReturnPct).cumprod()).round(3) 182 183 dd = np.abs(max_drawdown(filtered_trades['Equity'])).round(3) 184 ret = ((filtered_trades.iloc[-1]['Equity'] - filtered_trades.iloc[0]['Equity']) / filtered_trades.iloc[0]['Equity']) * 100 185 ret = round(ret, 3) 186 187 ret_dd = (ret / dd).round(3) 188 189 x = np.arange(filtered_trades.shape[0]).reshape(-1, 1) 190 reg = LinearRegression().fit(x, filtered_trades['Equity']) 191 stability_ratio = round(reg.score(x, filtered_trades['Equity']), 3) 192 193 custom_metric = ((ret / (1 + dd)) * np.log(1 + filtered_trades.shape[0])).round(3) * stability_ratio 194 195 new_winrate = round( 196 (filtered_trades[filtered_trades['NetPnL']>0]['Id'].size / filtered_trades['Id'].size) * 100, 3 197 ) 198 199 luck_test_performance = BotPerformance(**{ 200 'DateFrom': performance.DateFrom, 201 'DateTo': performance.DateTo, 202 'BotId': None, 203 'StabilityRatio': stability_ratio, 204 'Trades': filtered_trades['Id'].size, 205 'Return': ret, 206 'Drawdown': dd, 207 'RreturnDd': ret_dd, 208 'WinRate': new_winrate, 209 'Duration': performance.Duration, 210 'StabilityWeightedRar': custom_metric, 211 'Method': 'luck_test', 212 'InitialCash': performance.InitialCash, 213 'ExposureTime': performance.ExposureTime, 214 'KellyCriterion': performance.KellyCriterion, 215 'WinratePValue': performance.WinratePValue 216 }) 217 218 luck_test = LuckTest(**{ 219 'BotPerformanceId': performance.Id, 220 'TradesPercentToRemove': trades_percent_to_remove, 221 'LuckTestPerformance': luck_test_performance 222 }) 223 224 top_best_trades_id = top_best_trades['Id'].values 225 top_worst_trades_id = top_worst_trades['Id'].values 226 227 with self.db_service.get_database() as db: 228 229 for trade in performance.TradeHistory: 230 if trade.Id in top_best_trades_id: 231 trade.TopBest = True 232 233 if trade.Id in top_worst_trades_id: 234 trade.TopWorst = True 235 236 _ = self.db_service.update(db, Trade, trade) 237 238 luck_test_db = self.db_service.create(db, luck_test) 239 _ = self.db_service.create(db, luck_test_performance) 240 241 self._create_luck_test_plot(bot_performance_id=bot_performance_id) 242 243 return OperationResult(ok=True, message=None, item=luck_test_db)
Performs a "luck test" to evaluate the robustness of a bot's performance by removing extreme trades.
This method simulates the impact of luck by discarding a percentage of the bot's best and worst trades, then recalculating performance metrics based on the remaining data. The goal is to estimate how much of the bot's performance depends on a few outliers.
Steps performed:
- Loads the bot's trade history and determines how many trades to remove based on the given percentage.
- Identifies the top
X%
best and worst trades by return percentage. - Filters out these trades and rebuilds the equity curve from the remaining ones.
- Calculates new performance metrics: return, drawdown, return/drawdown ratio, winrate, and a custom metric.
- Fits a linear regression to the equity curve to compute a stability ratio.
- Creates a new
BotPerformance
object based on the filtered trades. - Stores all results, including which trades were marked as "lucky" (best or worst), in the database.
- Triggers the generation of a Plotly chart to visualize the filtered performance.
Parameters: bot_performance_id (int): The ID of the bot performance record to analyze. trades_percent_to_remove (float): Percentage of top and bottom trades (by return) to remove for the test.
Returns:
OperationResult: An object indicating success and containing the resulting LuckTest
record.
Side effects:
- Updates the trade records in the database to flag trades as top-best or top-worst.
- Saves a new LuckTest
and BotPerformance
entry reflecting the filtered results.
- Generates and stores a visualization of the adjusted performance.
Notes: - This test helps detect whether a bot's profitability is overly dependent on a few outlier trades. - The stability ratio is derived from the R² of a linear regression on the filtered equity curve. - The custom metric is a weighted risk-adjusted return penalized by drawdown and enhanced by stability.
245 def get_luck_test_equity_curve(self, bot_performance_id, remove_only_good_luck=False) -> OperationResult: 246 """ 247 Generates a filtered equity curve by removing lucky trades from a bot's historical performance. 248 249 This method rebuilds the bot's equity curve after excluding trades flagged as either top-performing 250 ("TopBest"), worst-performing ("TopWorst"), or both, depending on the configuration. 251 252 Parameters: 253 bot_performance_id (int): The ID of the bot performance record to analyze. 254 remove_only_good_luck (bool): If True, only the top-performing trades ("TopBest") are removed. 255 If False, both "TopBest" and "TopWorst" trades are removed. 256 257 Returns: 258 OperationResult: Contains the filtered equity curve as a DataFrame with `ExitTime` and `Equity`. 259 260 Notes: 261 - This function is typically used to assess the strategy's robustness by simulating less favorable luck. 262 - Equity is recomputed using cumulative product of filtered returns. 263 """ 264 performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 265 trades = get_trade_df_from_db(performance.TradeHistory, performance_id=performance.Id) 266 267 if remove_only_good_luck: 268 filtered_trades = trades[(trades['TopBest'].isna())].sort_values(by='ExitTime') 269 270 else: 271 filtered_trades = trades[(trades['TopBest'].isna()) & (trades['TopWorst'].isna())].sort_values(by='ExitTime') 272 273 filtered_trades['Equity'] = 0 274 filtered_trades.ReturnPct = filtered_trades.ReturnPct / 100 275 filtered_trades['Equity'] = (performance.InitialCash * (1 + filtered_trades.ReturnPct).cumprod()).round(3) 276 equity = filtered_trades[['ExitTime','Equity']] 277 278 return OperationResult(ok=True, message=None, item=equity)
Generates a filtered equity curve by removing lucky trades from a bot's historical performance.
This method rebuilds the bot's equity curve after excluding trades flagged as either top-performing ("TopBest"), worst-performing ("TopWorst"), or both, depending on the configuration.
Parameters: bot_performance_id (int): The ID of the bot performance record to analyze. remove_only_good_luck (bool): If True, only the top-performing trades ("TopBest") are removed. If False, both "TopBest" and "TopWorst" trades are removed.
Returns:
OperationResult: Contains the filtered equity curve as a DataFrame with ExitTime
and Equity
.
Notes: - This function is typically used to assess the strategy's robustness by simulating less favorable luck. - Equity is recomputed using cumulative product of filtered returns.
362 def run_random_test(self, bot_performance_id, n_iterations) -> OperationResult: 363 """ 364 Performs a Monte Carlo randomization test to evaluate the statistical significance of a bot's performance. 365 366 This method compares the real bot's performance metrics to those obtained from multiple randomized simulations 367 that mimic the bot's trade frequency, duration, and direction probabilities. The goal is to assess whether 368 the bot’s performance could have occurred by chance. 369 370 Steps performed: 371 - Loads bot performance data, price history, and equity curve. 372 - Extracts empirical trading behavior (probabilities, duration statistics). 373 - Runs a set of randomized backtests using a custom "RandomTrader" strategy to simulate noise-based trades. 374 - Bootstraps real and random returns `n_iterations` times and computes key performance metrics. 375 - For each metric (return, drawdown, return/DD, winrate), calculates: 376 - Mean and standard deviation differences 377 - Z-scores 378 - One-sided p-values (probability that the real strategy performs worse or equal to the random one). 379 - Stores the statistical results in the database. 380 381 Parameters: 382 bot_performance_id (int): The ID of the bot performance record to analyze. 383 n_iterations (int): Number of bootstrap iterations to compare real vs. random performance. 384 385 Returns: 386 OperationResult: An object indicating success. Results are saved to the database. 387 388 Side effects: 389 - Runs multiple randomized backtests using the bot's historical market data. 390 - Saves statistical results in a `RandomTest` table (via `self.db_service`). 391 392 Notes: 393 - This test evaluates whether the strategy adds value beyond what could be expected by random chance. 394 - Performance metrics compared: total return, max drawdown, return-to-drawdown ratio, and winrate. 395 - Assumes the presence of a random strategy class at `'app.backbone.strategies.random_trader.RandomTrader'`. 396 """ 397 bot_performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 398 ticker = bot_performance.Bot.Ticker 399 timeframe = bot_performance.Bot.Timeframe 400 401 with open("./app/configs/leverages.yml", "r") as file_name: 402 leverages = yaml.safe_load(file_name) 403 404 leverage = leverages[ticker.Name] 405 406 strategy_path = 'app.backbone.strategies.random_trader.RandomTrader' 407 408 strategy_func = load_function(strategy_path) 409 410 trade_history = get_trade_df_from_db( 411 bot_performance.TradeHistory, 412 performance_id=bot_performance.Id 413 ) 414 415 long_trades = trade_history[trade_history['Size'] > 0] 416 short_trades = trade_history[trade_history['Size'] < 0] 417 418 date_from = pd.Timestamp(bot_performance.DateFrom, tz="UTC") 419 date_to = pd.Timestamp(bot_performance.DateTo, tz="UTC") 420 421 prices = get_data( 422 ticker.Name, 423 timeframe.MetaTraderNumber, 424 date_from, 425 date_to 426 ) 427 428 prices.index = pd.to_datetime(prices.index) 429 430 prob_trade = len(trade_history) / len(prices) # Probabilidad de realizar un trade 431 prob_long = len(long_trades) / len(trade_history) if len(trade_history) > 0 else 0 432 prob_short = len(short_trades) / len(trade_history) if len(trade_history) > 0 else 0 433 434 trade_history["Duration"] = pd.to_timedelta(trade_history["Duration"]) 435 trade_history["Bars"] = trade_history["ExitBar"] - trade_history["EntryBar"] 436 437 avg_trade_duration = trade_history.Bars.mean() 438 std_trade_duration = trade_history.Bars.std() 439 440 params = { 441 'prob_trade': prob_trade, 442 'prob_long': prob_long, 443 'prob_short': prob_short, 444 'avg_trade_duration': avg_trade_duration, 445 'std_trade_duration': std_trade_duration, 446 } 447 448 risk_free_rate = float(self.config_service.get_by_name('RiskFreeRate').Value) 449 450 451 all_random_trades = pd.DataFrame() 452 for _ in range(10): 453 _, _, stats = run_strategy_and_get_performances( 454 strategy=strategy_func, 455 ticker=ticker, 456 timeframe=timeframe, 457 risk=bot_performance.Bot.Risk, 458 prices=prices, 459 initial_cash=bot_performance.InitialCash, 460 risk_free_rate=risk_free_rate, 461 margin=1 / leverage, 462 opt_params=params 463 ) 464 465 all_random_trades = pd.concat([ 466 all_random_trades, 467 stats._trades 468 ]) 469 470 np.random.seed(42) 471 472 n_real = len(trade_history) 473 474 returns_real = trade_history.ReturnPct / 100 475 returns_rand = all_random_trades.ReturnPct / 100 476 477 metrics = { 478 'return': lambda eq: ((eq[-1] - eq[0]) / eq[0]) * 100, 479 'dd': lambda eq: max_drawdown(eq).round(3), 480 'return_dd': lambda eq: ((eq[-1] - eq[0]) / eq[0]) * 100 / abs(max_drawdown(eq)), 481 'winrate': lambda r: np.mean(r > 0) 482 } 483 484 real_results = {k: [] for k in metrics} 485 rand_results = {k: [] for k in metrics} 486 487 for _ in range(n_iterations): 488 sample_real = np.random.choice(returns_real, size=n_real, replace=True) 489 sample_rand = np.random.choice(returns_rand, size=n_real, replace=True) 490 491 equity_real = bot_performance.InitialCash * (1 + sample_real).cumprod() 492 equity_rand = bot_performance.InitialCash * (1 + sample_rand).cumprod() 493 494 for name, func in metrics.items(): 495 if name == 'winrate': 496 real_results[name].append(func(sample_real)) 497 rand_results[name].append(func(sample_rand)) 498 else: 499 real_results[name].append(func(equity_real)) 500 rand_results[name].append(func(equity_rand)) 501 502 # Evaluación estadística 503 p_values = {} 504 z_scores = {} 505 mean_diffs = {} 506 std_diffs = {} 507 508 for name in metrics: 509 diffs = np.array(real_results[name]) - np.array(rand_results[name]) 510 mean_diff = np.mean(diffs) 511 std_diff = np.std(diffs) 512 z_scores[name] = mean_diff / std_diff 513 p_values[name] = np.mean(diffs <= 0) # test unidireccional, proporción de veces que la estrategia real fue igual o peor que la random. 514 mean_diffs[name] = mean_diff 515 std_diffs[name] = std_diff 516 517 518 with self.db_service.get_database() as db: 519 # Primero, guardar random_test_performance_for_db 520 521 # Ahora que tenemos el ID, podemos asignarlo a random_test 522 random_test = RandomTest( 523 Iterations=n_iterations, 524 BotPerformanceId=bot_performance.Id, 525 ReturnDdMeanDiff=round(mean_diffs['return_dd'], 3), 526 ReturnDdStdDiff=round(std_diffs['return_dd'], 3), 527 ReturnDdPValue=round(p_values['return_dd'], 5), 528 ReturnDdZScore=round(z_scores['return_dd'], 3), 529 530 ReturnMeanDiff=round(mean_diffs['return'], 3), 531 ReturnStdDiff=round(std_diffs['return'], 3), 532 ReturnPValue=round(p_values['return'], 5), 533 ReturnZScore=round(z_scores['return'], 3), 534 535 DrawdownMeanDiff=round(mean_diffs['dd'], 3), 536 DrawdownStdDiff=round(std_diffs['dd'], 3), 537 DrawdownPValue=round(p_values['dd'], 5), 538 DrawdownZScore=round(z_scores['dd'], 3), 539 540 WinrateMeanDiff=round(mean_diffs['winrate'], 3), 541 WinrateStdDiff=round(std_diffs['winrate'], 3), 542 WinratePValue=round(p_values['winrate'], 5), 543 WinrateZScore=round(z_scores['winrate'], 3), 544 ) 545 546 # Guardar random_test ahora con la relación correcta 547 self.db_service.create(db, random_test) 548 549 return OperationResult(ok=True, message=None, item=None)
Performs a Monte Carlo randomization test to evaluate the statistical significance of a bot's performance.
This method compares the real bot's performance metrics to those obtained from multiple randomized simulations that mimic the bot's trade frequency, duration, and direction probabilities. The goal is to assess whether the bot’s performance could have occurred by chance.
Steps performed:
- Loads bot performance data, price history, and equity curve.
- Extracts empirical trading behavior (probabilities, duration statistics).
- Runs a set of randomized backtests using a custom "RandomTrader" strategy to simulate noise-based trades.
- Bootstraps real and random returns
n_iterations
times and computes key performance metrics. - For each metric (return, drawdown, return/DD, winrate), calculates:
- Mean and standard deviation differences
- Z-scores
- One-sided p-values (probability that the real strategy performs worse or equal to the random one).
- Stores the statistical results in the database.
Parameters: bot_performance_id (int): The ID of the bot performance record to analyze. n_iterations (int): Number of bootstrap iterations to compare real vs. random performance.
Returns: OperationResult: An object indicating success. Results are saved to the database.
Side effects:
- Runs multiple randomized backtests using the bot's historical market data.
- Saves statistical results in a RandomTest
table (via self.db_service
).
Notes:
- This test evaluates whether the strategy adds value beyond what could be expected by random chance.
- Performance metrics compared: total return, max drawdown, return-to-drawdown ratio, and winrate.
- Assumes the presence of a random strategy class at 'app.backbone.strategies.random_trader.RandomTrader'
.
551 def run_correlation_test(self, bot_performance_id: int) -> OperationResult: 552 """ 553 Performs a correlation analysis between a bot's monthly returns and the underlying asset's price variation. 554 555 This method calculates the monthly percentage changes in the bot's equity curve and compares them to 556 the monthly price changes of the underlying instrument. It evaluates both the Pearson correlation 557 coefficient and the coefficient of determination (R²) via linear regression. 558 559 Steps performed: 560 - Retrieves the bot's equity curve and historical price data for the configured ticker and timeframe. 561 - Aggregates both series to monthly frequency and computes percentage variations. 562 - Aligns and fills missing months to ensure matching time indices. 563 - Fits a linear regression model between asset price changes and bot returns. 564 - Computes Pearson's correlation coefficient and R² score. 565 - Generates a Plotly scatter plot with a fitted regression line. 566 - Annotates the plot with the correlation and determination values. 567 - Saves the plot as a JSON file for rendering. 568 569 Parameters: 570 bot_performance_id (int): The ID of the bot performance record to analyze. 571 572 Returns: 573 OperationResult: An object indicating success and containing a DataFrame with: 574 - `correlation`: Pearson correlation coefficient (r) 575 - `determination`: Coefficient of determination (R²) 576 577 Side effects: 578 Saves a Plotly chart as an HTML-ready JSON file in the `./app/templates/static/correlation_plots` directory. 579 The filename includes the bot's name and performance date range. 580 581 Notes: 582 - This analysis helps identify the degree to which the bot's performance is correlated with the underlying asset. 583 - Monthly data is used to smooth out noise and capture general trends. 584 """ 585 586 bot_performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 587 588 trade_history = get_trade_df_from_db( 589 bot_performance.TradeHistory, 590 performance_id=bot_performance.Id 591 ) 592 593 prices = get_data( 594 bot_performance.Bot.Ticker.Name, 595 bot_performance.Bot.Timeframe.MetaTraderNumber, 596 pd.Timestamp(bot_performance.DateFrom, tz="UTC"), 597 pd.Timestamp(bot_performance.DateTo, tz="UTC") 598 ) 599 600 # Transformar el índice al formato mensual 601 trade_history = trade_history.reset_index() 602 equity = trade_history[['ExitTime', 'Equity']] 603 604 equity['month'] = pd.to_datetime(equity['ExitTime']).dt.to_period('M') 605 equity = equity.groupby(by='month').agg({'Equity': 'last'}) 606 equity['perc_diff'] = (equity['Equity'] - equity['Equity'].shift(1)) / equity['Equity'].shift(1) 607 equity.fillna(0, inplace=True) 608 609 # Crear un rango completo de meses con PeriodIndex 610 full_index = pd.period_range(start=equity.index.min(), end=equity.index.max(), freq='M') 611 612 # Reindexar usando el rango completo de PeriodIndex 613 equity = equity.reindex(full_index) 614 equity = equity.ffill() 615 616 prices['month'] = pd.to_datetime(prices.index) 617 prices['month'] = prices['month'].dt.to_period('M') 618 prices = prices.groupby(by='month').agg({'Close':'last'}) 619 prices['perc_diff'] = (prices['Close'] - prices['Close'].shift(1)) / prices['Close'].shift(1) 620 prices.fillna(0, inplace=True) 621 622 prices = prices[prices.index.isin(equity.index)] 623 624 x = np.array(prices['perc_diff']).reshape(-1, 1) 625 y = equity['perc_diff'] 626 627 # Ajustar el modelo de regresión lineal 628 reg = LinearRegression().fit(x, y) 629 determination = reg.score(x, y) 630 correlation = np.corrcoef(prices['perc_diff'], equity['perc_diff'])[0, 1] 631 632 # Predicciones para la recta 633 x_range = np.linspace(x.min(), x.max(), 100).reshape(-1, 1) # Rango de X para la recta 634 y_pred = reg.predict(x_range) # Valores predichos de Y 635 636 result = pd.DataFrame({ 637 'correlation': [correlation], 638 'determination': [determination], 639 }).round(3) 640 641 # Crear el gráfico 642 fig = px.scatter( 643 x=prices['perc_diff'], y=equity['perc_diff'], 644 ) 645 646 # Agregar la recta de regresión 647 fig.add_scatter(x=x_range.flatten(), y=y_pred, mode='lines', name='Regresión Lineal') 648 649 # Personalización 650 fig.update_layout( 651 xaxis_title='Monthly Price Variation', 652 yaxis_title='Monthly Returns' 653 ) 654 655 # Agregar anotación con los valores R² y Pearson 656 fig.add_annotation( 657 x=0.95, # Posición en el gráfico (en unidades de fracción del eje) 658 y=0.95, 659 xref='paper', yref='paper', 660 text=f"<b>r = {correlation:.3f}<br>R² = {determination:.3f}</b>", 661 showarrow=False, 662 font=dict(size=16, color="black"), 663 align="left", 664 bordercolor="black", 665 borderwidth=1, 666 borderpad=4, 667 bgcolor="white", 668 opacity=0.8 669 ) 670 671 str_date_from = str(bot_performance.DateFrom).replace('-','') 672 str_date_to = str(bot_performance.DateTo).replace('-','') 673 file_name=f'{bot_performance.Bot.Name}_{str_date_from}_{str_date_to}.html' 674 675 plot_path='./app/templates/static/correlation_plots' 676 677 if not os.path.exists(plot_path): 678 os.mkdir(plot_path) 679 680 json_content = fig.to_json() 681 682 with open(os.path.join(plot_path, file_name), 'w') as f: 683 f.write(json_content) 684 685 return OperationResult(ok=True, message=False, item=result)
Performs a correlation analysis between a bot's monthly returns and the underlying asset's price variation.
This method calculates the monthly percentage changes in the bot's equity curve and compares them to the monthly price changes of the underlying instrument. It evaluates both the Pearson correlation coefficient and the coefficient of determination (R²) via linear regression.
Steps performed:
- Retrieves the bot's equity curve and historical price data for the configured ticker and timeframe.
- Aggregates both series to monthly frequency and computes percentage variations.
- Aligns and fills missing months to ensure matching time indices.
- Fits a linear regression model between asset price changes and bot returns.
- Computes Pearson's correlation coefficient and R² score.
- Generates a Plotly scatter plot with a fitted regression line.
- Annotates the plot with the correlation and determination values.
- Saves the plot as a JSON file for rendering.
Parameters: bot_performance_id (int): The ID of the bot performance record to analyze.
Returns:
OperationResult: An object indicating success and containing a DataFrame with:
- correlation
: Pearson correlation coefficient (r)
- determination
: Coefficient of determination (R²)
Side effects:
Saves a Plotly chart as an HTML-ready JSON file in the ./app/templates/static/correlation_plots
directory.
The filename includes the bot's name and performance date range.
Notes: - This analysis helps identify the degree to which the bot's performance is correlated with the underlying asset. - Monthly data is used to smooth out noise and capture general trends.
687 def run_t_test(self, bot_performance_id: int): 688 """ 689 Performs a rolling Sharpe Ratio analysis with confidence intervals for a given bot's performance. 690 691 This method calculates the monthly returns of the bot based on its equity curve and evaluates 692 the statistical stability of its risk-adjusted performance over time. 693 694 Steps performed: 695 - Extracts the bot's equity history and computes monthly returns. 696 - Tests for normality using the Shapiro-Wilk test. 697 - Computes the cumulative Sharpe Ratio month-by-month. 698 - Builds 95% confidence intervals using either: 699 - Bootstrapping (if returns are not normally distributed), or 700 - Theoretical t-distribution (if returns are normal). 701 - Renders an interactive Plotly chart with the Sharpe Ratio curve and confidence bands. 702 - Annotates the Shapiro-Wilk p-value and saves the chart as a JSON file. 703 704 Parameters: 705 bot_performance_id (int): The ID of the bot performance record to analyze. 706 707 Returns: 708 OperationResult: A success indicator and optional message or payload. 709 710 Side effects: 711 Saves a Plotly chart as an HTML-ready JSON file in the `./app/templates/static/t_test_plots` directory. 712 The filename includes the bot's name and performance date range. 713 714 Notes: 715 - The risk-free rate is retrieved from the configuration under the key "RiskFreeRate". 716 - The Sharpe Ratio is annualized assuming 12 periods (monthly data). 717 - The analysis starts from the second available month (since returns require two data points). 718 """ 719 risk_free_rate = float(self.config_service.get_by_name('RiskFreeRate').Value) 720 721 def bootstrap_sharpe_ci_cumulative(returns, n_bootstrap=1000, confidence=0.95): 722 ci_lower = [] 723 ci_upper = [] 724 sharpe_cumulative = [] 725 726 for i in range(1, len(returns)): 727 current_returns = returns[:i+1] 728 n = len(current_returns) 729 sr_bootstrap = [] 730 731 for _ in range(n_bootstrap): 732 sample = np.random.choice(current_returns, size=n, replace=True) 733 sr = calculate_sharpe_ratio(sample, risk_free_rate=risk_free_rate, trading_periods=12) 734 sr_bootstrap.append(sr) 735 736 lower = np.percentile(sr_bootstrap, (1 - confidence) / 2 * 100) 737 upper = np.percentile(sr_bootstrap, (1 + confidence) / 2 * 100) 738 point_estimate = calculate_sharpe_ratio(current_returns, risk_free_rate=risk_free_rate, trading_periods=12) 739 740 ci_lower.append(lower) 741 ci_upper.append(upper) 742 sharpe_cumulative.append(point_estimate) 743 744 return sharpe_cumulative, ci_lower, ci_upper 745 746 bot_performance = self.backtest_service.get_bot_performance_by_id(bot_performance_id=bot_performance_id) 747 748 trade_history = get_trade_df_from_db( 749 bot_performance.TradeHistory, 750 performance_id=bot_performance.Id 751 ) 752 753 # Transformar el índice al formato mensual 754 trade_history = trade_history.reset_index() 755 equity = trade_history[['ExitTime', 'Equity']] 756 757 # Asegurar formato de fecha y agrupar por mes 758 equity['ExitTime'] = pd.to_datetime(equity['ExitTime']) 759 equity['month'] = equity['ExitTime'].dt.to_period('M') 760 equity = equity.groupby(by='month').agg({'Equity': 'last'}) 761 762 # Calcular retornos mensuales 763 returns = equity['Equity'].pct_change().dropna().values 764 765 shapiro_test = stats.shapiro(returns) 766 is_normal = shapiro_test.pvalue > 0.05 767 768 confidence = 0.95 769 if not is_normal: 770 sharpe_cumulative, ci_lower, ci_upper = bootstrap_sharpe_ci_cumulative(returns, confidence=confidence) 771 772 else: 773 sharpe_cumulative = [calculate_sharpe_ratio(returns[:i+1], risk_free_rate=risk_free_rate, trading_periods=12) for i in range(1, len(returns))] 774 ci_lower = [] 775 ci_upper = [] 776 for i in range(1, len(returns)): 777 n = i+1 778 current_returns = returns[:n] 779 sr = calculate_sharpe_ratio(current_returns, risk_free_rate=risk_free_rate, trading_periods=12) 780 se = np.sqrt((1 + 0.5*sr**2) / n) 781 t = stats.t.ppf((1 + confidence)/2, df=n-1) 782 ci_lower.append(sr - t*se) 783 ci_upper.append(sr + t*se) 784 785 x_axis_labels = equity.index[1:].astype(str) 786 787 # Create traces 788 fig = go.Figure() 789 fig.add_trace(go.Scatter(x=x_axis_labels, y=sharpe_cumulative, 790 mode='lines+markers', 791 name='Sharpe Ratio', 792 line=dict(color='blue'))) 793 794 fig.add_trace(go.Scatter(x=x_axis_labels, 795 y=ci_upper, 796 mode='lines', 797 name='Upper limit 95%', 798 line=dict(color='green', dash='dash'))) 799 800 fig.add_trace(go.Scatter(x=x_axis_labels, 801 y=ci_lower,mode='lines', 802 name='Lower Limit 95%', 803 line=dict(color='red', dash='dash'))) 804 805 fig.add_trace(go.Scatter(x=x_axis_labels, 806 y=np.zeros(shape=(len(sharpe_cumulative))), 807 mode='lines', 808 name='Zero', 809 line=dict(color='black'))) 810 811 fig.add_annotation( 812 x=0.95, # Posición en el gráfico (en unidades de fracción del eje) 813 y=0.95, 814 xref='paper', yref='paper', 815 text=f"Shapiro-Wilk p-value: {shapiro_test.pvalue:.4f}", 816 showarrow=False, 817 font=dict(size=12, color="red" if not is_normal else "green"), 818 align="left", 819 bordercolor="black", 820 borderwidth=1, 821 borderpad=4, 822 bgcolor="white", 823 opacity=0.8 824 ) 825 826 str_date_from = str(bot_performance.DateFrom).replace('-','') 827 str_date_to = str(bot_performance.DateTo).replace('-','') 828 file_name=f'{bot_performance.Bot.Name}_{str_date_from}_{str_date_to}.html' 829 830 plot_path='./app/templates/static/t_test_plots' 831 832 if not os.path.exists(plot_path): 833 os.mkdir(plot_path) 834 835 json_content = fig.to_json() 836 837 with open(os.path.join(plot_path, file_name), 'w') as f: 838 f.write(json_content) 839 840 return OperationResult(ok=True, message=False, item=None)
Performs a rolling Sharpe Ratio analysis with confidence intervals for a given bot's performance.
This method calculates the monthly returns of the bot based on its equity curve and evaluates the statistical stability of its risk-adjusted performance over time.
Steps performed:
- Extracts the bot's equity history and computes monthly returns.
- Tests for normality using the Shapiro-Wilk test.
- Computes the cumulative Sharpe Ratio month-by-month.
- Builds 95% confidence intervals using either:
- Bootstrapping (if returns are not normally distributed), or
- Theoretical t-distribution (if returns are normal).
- Renders an interactive Plotly chart with the Sharpe Ratio curve and confidence bands.
- Annotates the Shapiro-Wilk p-value and saves the chart as a JSON file.
Parameters: bot_performance_id (int): The ID of the bot performance record to analyze.
Returns: OperationResult: A success indicator and optional message or payload.
Side effects:
Saves a Plotly chart as an HTML-ready JSON file in the ./app/templates/static/t_test_plots
directory.
The filename includes the bot's name and performance date range.
Notes: - The risk-free rate is retrieved from the configuration under the key "RiskFreeRate". - The Sharpe Ratio is annualized assuming 12 periods (monthly data). - The analysis starts from the second available month (since returns require two data points).