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)
class TestService:
 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.

db_service
backtest_service
config_service
def run_montecarlo_test( self, bot_performance_id: int, n_simulations: int, threshold_ruin: float) -> app.backbone.services.operation_result.OperationResult:
 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).

def run_luck_test( self, bot_performance_id, trades_percent_to_remove) -> app.backbone.services.operation_result.OperationResult:
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.

def get_luck_test_equity_curve( self, bot_performance_id, remove_only_good_luck=False) -> app.backbone.services.operation_result.OperationResult:
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.

def run_random_test( self, bot_performance_id, n_iterations) -> app.backbone.services.operation_result.OperationResult:
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'.

def run_correlation_test( self, bot_performance_id: int) -> app.backbone.services.operation_result.OperationResult:
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.

def run_t_test(self, bot_performance_id: int):
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).