app.backbone.utils.wfo_utils

  1import os
  2from app.backbone.entities.ticker import Ticker
  3from app.backbone.entities.timeframe import Timeframe
  4from app.backbone.utils.general_purpose import transformar_a_uno
  5from unittest.mock import patch
  6from backtesting import Backtest
  7from backtesting.lib import FractionalBacktest
  8import pandas as pd
  9import plotly.express as px
 10from backtesting._stats import compute_stats
 11import numpy as np
 12from sklearn.linear_model import LinearRegression
 13import MetaTrader5 as mt5
 14from app.backbone.utils.get_data import get_data
 15import quantstats as qs
 16from scipy.stats import binom, jarque_bera, skew, kurtosis
 17
 18np.seterr(divide="ignore")
 19
 20def run_strategy(
 21    strategy,
 22    ticker: Ticker,
 23    timeframe: Timeframe,
 24    prices: pd.DataFrame,
 25    initial_cash: float,
 26    margin: float,
 27    risk_free_rate:float=0,
 28    risk=None,
 29    opt_params=None,
 30    metatrader_name=None,
 31    timezone=None,
 32):
 33    """
 34    Executes a backtest for a trading strategy with proper market metadata and commission handling.
 35
 36    This function prepares the trading environment by:
 37    - Converting prices to the correct denomination
 38    - Loading symbol-specific trading constraints
 39    - Setting up commission structures
 40    - Selecting the appropriate backtest engine (fractional or standard)
 41    - Running the strategy with all configured parameters
 42
 43    Steps performed:
 44    1. Converts prices using ticker-specific conversion rates
 45    2. Retrieves scaled symbol metadata (lot sizes, pip values, etc.)
 46    3. Configures commission calculation based on ticker category:
 47        - Absolute commission per contract for >=1
 48        - Percentage commission for <1
 49    4. Initializes either:
 50        - FractionalBacktest for fractional lot sizes
 51        - Standard Backtest for whole lot sizes
 52    5. Executes the strategy with all parameters and constraints
 53    6. Returns performance statistics and the backtest engine instance
 54
 55    Parameters:
 56    - strategy: Trading strategy class/function to backtest
 57    - ticker (Ticker): Financial instrument being traded
 58    - timeframe (Timeframe): Time interval for the backtest
 59    - prices (pd.DataFrame): OHLC price data
 60    - initial_cash (float): Starting capital
 61    - margin (float): Margin requirement (1/leverage)
 62    - risk_free_rate (float, optional): Risk-free rate for Sharpe ratio. Defaults to 0.
 63    - risk (float, optional): Risk percentage per trade. Defaults to None.
 64    - opt_params (dict, optional): Optimization parameters. Defaults to None.
 65    - metatrader_name (str, optional): MT5 symbol name. Defaults to None.
 66    - timezone (str, optional): Timezone for trade timestamps. Defaults to None.
 67
 68    Returns:
 69    - tuple: Contains:
 70        - stats: Backtest performance statistics (pd.Series/dict)
 71        - bt_train: The backtest engine instance (for further analysis)
 72
 73    Side effects:
 74    - Makes MT5 API call to get symbol info (via mt5.symbol_info)
 75    - Modifies the input prices DataFrame with conversion rates
 76    - May log warnings about lot size rounding in backtest engine
 77
 78    Notes:
 79    - Commission is applied both on entry and exit (hence /2 in calculation)
 80    - Fractional backtesting is automatically used when minimum_fraction < 1
 81    - All trading constraints (lot sizes, steps) come from broker metadata
 82    - The backtest engine handles spread as a fixed value from ticker.Spread
 83    - Returned stats typically include Sharpe ratio, drawdown, trade counts etc.
 84    - The bt_train object can be used to access trade-by-trade details
 85    """
 86
 87    prices = get_conversion_rate(prices, ticker, timeframe)
 88    
 89    (
 90        scaled_pip_value,
 91        scaled_minimum_lot,
 92        scaled_maximum_lot,
 93        scaled_contract_volume,
 94        minimum_fraction,
 95        volume_step,
 96    ) = get_scaled_symbol_metadata(ticker.Name)
 97
 98    bt_train = None
 99    info = mt5.symbol_info(ticker.Name)
100
101    if ticker.Category.Commission >= 1:
102        commission = lambda size, price: abs(size) * (ticker.Category.Commission / 2) / info.trade_contract_size
103    else:
104        commission = lambda size, price: abs(size) * price * ((ticker.Category.Commission / 2) / 100)
105
106    if minimum_fraction < 1:
107        bt_train = FractionalBacktest(
108            prices, 
109            strategy,
110            commission=commission, 
111            cash=initial_cash, 
112            margin=margin,
113            fractional_unit=minimum_fraction,
114            spread=ticker.Spread
115        )
116
117    else:
118        bt_train = Backtest(
119            prices, 
120            strategy,
121            commission=commission, 
122            cash=initial_cash, 
123            margin=margin, 
124            spread=ticker.Spread
125        )
126
127    stats = bt_train.run(
128        risk_free_rate=risk_free_rate,
129        pip_value=scaled_pip_value,
130        minimum_lot=scaled_minimum_lot,
131        maximum_lot=scaled_maximum_lot,
132        contract_volume=scaled_contract_volume,
133        volume_step=volume_step,
134        risk=risk,
135        ticker=ticker,
136        opt_params=opt_params,
137        metatrader_name=metatrader_name,
138        timezone=timezone,
139        minimum_fraction=minimum_fraction
140    )
141
142    return stats, bt_train
143
144def run_strategy_and_get_performances(
145    strategy,
146    ticker: Ticker,
147    timeframe: Timeframe,
148    prices: pd.DataFrame,
149    initial_cash: float,
150    risk_free_rate: float,
151    margin: float,
152    risk=None,
153    plot_path=None,
154    file_name=None,
155    opt_params=None,
156    save_report=False
157):
158    
159    """
160    Executes a trading strategy backtest and computes comprehensive performance metrics,
161    with optional visualization and reporting capabilities.
162
163    This function extends the basic backtest by:
164    - Generating detailed performance statistics
165    - Calculating advanced metrics (stability ratio, Jarque-Bera, etc.)
166    - Producing visualizations and HTML reports
167    - Segmenting trade analytics (long/short, winning/losing trades)
168    - Computing risk-adjusted return metrics
169
170    Steps performed:
171    1. Runs the core strategy backtest using run_strategy()
172    2. Generates equity curve plots if plot_path is specified
173    3. Creates QuantStats performance reports if save_report=True
174    4. Computes trade-level metrics:
175        - Percentage returns relative to account equity
176        - Trade durations in days
177        - Win/loss segmentation
178    5. Calculates advanced statistics:
179        - Equity curve stability ratio (linear regression R²)
180        - Winrate binomial p-value
181        - Return distribution metrics (skew, kurtosis)
182    6. Compiles results into three structured DataFrames:
183        - Strategy-level performance metrics
184        - Detailed trade performance analytics
185        - Raw backtest statistics
186
187    Parameters:
188    - strategy: Trading strategy implementation
189    - ticker (Ticker): Financial instrument configuration
190    - timeframe (Timeframe): Backtesting time interval
191    - prices (pd.DataFrame): OHLC price data
192    - initial_cash (float): Starting capital
193    - risk_free_rate (float): Risk-free rate for Sharpe ratio
194    - margin (float): Margin requirement (1/leverage)
195    - risk (float, optional): Risk percentage per trade. Default=None.
196    - plot_path (str, optional): Directory to save plots. Default=None.
197    - file_name (str, optional): Base name for output files. Default=None.
198    - opt_params (dict, optional): Optimization parameters. Default=None.
199    - save_report (bool, optional): Whether to save QuantStats report. Default=False.
200
201    Returns:
202    - tuple: Three DataFrames containing:
203        - df_stats (pd.DataFrame): Strategy performance metrics (1 row)
204        - trade_performance (pd.DataFrame): Aggregated trade analytics (1 row)
205        - stats: Raw backtest statistics object
206
207    Side effects:
208    - Creates plot files in plot_path if specified:
209        - Interactive equity curve plot (.html)
210        - QuantStats performance report (if save_report=True)
211    - May create directories if they don't exist
212
213    Notes:
214    - Trade returns are calculated as percentage of equity at entry
215    - Duration is converted to whole days for consistency
216    - Stability ratio measures equity curve linearity (higher = smoother)
217    - Winrate p-value tests if winrate could occur by chance
218    - Jarque-Bera tests return distribution normality
219    - All metrics are rounded to 3 decimal places
220    - Missing values are filled with 0 for robustness
221    - Separate metrics are provided for long/short positions
222    """
223    
224    stats, bt_train = run_strategy(
225        strategy=strategy,
226        ticker=ticker,
227        timeframe=timeframe,
228        prices=prices,
229        initial_cash=initial_cash,
230        risk_free_rate=risk_free_rate,
231        margin=margin,
232        risk=risk,
233        opt_params=opt_params,
234    )
235
236    if plot_path:
237        if not os.path.exists(plot_path):
238            os.makedirs(plot_path)
239            
240        bt_train.plot(
241            filename=os.path.join(plot_path, file_name + '.html'), 
242            resample=False, 
243            open_browser=False
244        )
245
246        if save_report:
247            returns = stats._equity_curve['Equity'].pct_change().dropna()
248            qs.reports.html(
249                returns, 
250                output=os.path.join(plot_path, 'reports', file_name + '.html'), 
251                title=file_name, 
252                rf=risk_free_rate
253            )
254
255    equity_curve = stats._equity_curve
256    trades = stats._trades
257    
258    trades = pd.merge(
259        trades,
260        equity_curve['Equity'],
261        left_on='ExitTime',
262        right_index=True,
263        how='inner'
264    )
265    
266    trades['ReturnPct'] = (trades['NetPnL'] / trades['Equity'].shift(1)) * 100
267    if len(trades) > 0:
268        trades.loc[0, 'ReturnPct'] = (trades.loc[0, 'NetPnL'] / initial_cash) * 100
269
270    trades['Duration'] = pd.to_timedelta(trades['Duration'])
271    trades['Duration'] = (trades['Duration'].dt.total_seconds() // 3600 // 24).astype(int)
272      
273    stats._trades = trades.round(5)
274    
275    winning_trades = trades[trades["NetPnL"]>=0]
276    losing_trades = trades[trades["NetPnL"]<0]
277
278    long_trades = trades[trades["Size"] >= 0]
279    short_trades = trades[trades["Size"] < 0]
280    
281    long_winning_trades = long_trades[long_trades["NetPnL"] >= 0]
282    long_losing_trades = long_trades[long_trades["NetPnL"] < 0]
283    
284    short_winning_trades = short_trades[short_trades["NetPnL"] >= 0]
285    short_losing_trades = short_trades[short_trades["NetPnL"] < 0]
286    
287    equity_curve = equity_curve["Equity"].values
288    
289    x = np.arange(len(equity_curve)).reshape(-1, 1)
290    reg = LinearRegression().fit(x, equity_curve)
291    stability_ratio = reg.score(x, equity_curve)
292
293    stats["Duration"] = pd.to_timedelta(stats["Duration"])
294
295    winrate_p_value = calculate_binomial_p_value(
296        n=trades.shape[0], 
297        k=winning_trades.shape[0]
298    )
299
300    returns = trades['Equity'].pct_change().dropna()  # Elimina NaN del primer valor
301
302    jb_stat, jb_p_value = jarque_bera(returns)
303    skew_value = skew(returns)
304    kurtosis_value = kurtosis(returns, fisher=True)  # True para exceso sobre normal
305
306    df_stats = pd.DataFrame(
307        {
308            "StabilityRatio": [stability_ratio],
309            "Trades": [stats["# Trades"]],
310            "Return": [stats["Return [%]"]],
311            "Drawdown": [np.abs(stats["Max. Drawdown [%]"])],
312            "RreturnDd": [stats["Return [%]"] / np.abs(stats["Max. Drawdown [%]"])],
313            "WinRate": [stats["Win Rate [%]"]],
314            "Duration": [stats["Duration"].days],
315
316            "ExposureTime": [stats["Exposure Time [%]"]],
317            "KellyCriterion": [stats["Kelly Criterion"]],
318            "WinratePValue": [winrate_p_value],
319            "SharpeRatio": [stats["Sharpe Ratio"]],
320
321            "JarqueBeraStat": [jb_stat],
322            "JarqueBeraPValue": [jb_p_value],
323            "Skew": [skew_value],
324            "Kurtosis": [kurtosis_value],
325
326        }
327    )
328    
329    df_stats["StabilityWeightedRar"] = (df_stats["Return"] / (1 + df_stats["Drawdown"])) * np.log(1 + df_stats["Trades"]) * stability_ratio
330    df_stats = df_stats.fillna(0).round(3)
331
332    consecutive_wins, consecutive_losses = max_consecutive_wins_and_losses(trades)
333    
334    trade_performance = pd.DataFrame(
335        {
336            # General
337            "MeanReturnPct":[trades.ReturnPct.mean()],
338            "StdReturnPct":[trades.ReturnPct.std()],
339            "MeanTradeDuration":[trades['Duration'].mean()],
340            "StdTradeDuration":[trades['Duration'].std()],
341
342            "MeanWinningReturnPct":[winning_trades.ReturnPct.mean()],
343            "StdWinningReturnPct":[winning_trades.ReturnPct.std()],
344
345            "MeanLosingReturnPct":[losing_trades.ReturnPct.mean()],
346            "StdLosingReturnPct":[losing_trades.ReturnPct.std()],
347
348            # Longs
349            "LongWinrate": [(long_winning_trades.size / long_trades.size) * 100 if long_trades.size > 0 else 0],
350            "LongMeanReturnPct": [long_trades.ReturnPct.mean()],
351            "LongStdReturnPct": [long_trades.ReturnPct.std()],
352            
353            "WinLongMeanReturnPct": [long_winning_trades.ReturnPct.mean()],
354            "WinLongStdReturnPct": [long_winning_trades.ReturnPct.std()],
355            "LoseLongMeanReturnPct": [long_losing_trades.ReturnPct.mean()],
356            "LoseLongStdReturnPct": [long_losing_trades.ReturnPct.std()],
357            
358            # Shorts
359            "ShortWinrate": [(short_winning_trades.size / short_trades.size) * 100 if short_trades.size > 0 else 0],
360            "ShortMeanReturnPct": [short_trades.ReturnPct.mean()],
361            "ShortStdReturnPct": [short_trades.ReturnPct.std()],
362            
363            "WinShortMeanReturnPct": [short_winning_trades.ReturnPct.mean()],
364            "WinShortStdReturnPct": [short_winning_trades.ReturnPct.std()],
365            "LoseShortMeanReturnPct": [short_losing_trades.ReturnPct.mean()],
366            "LoseShortStdReturnPct": [short_losing_trades.ReturnPct.std()],
367
368            # Otras metricas
369            "ProfitFactor": [stats["Profit Factor"]],
370            "WinRate": [stats["Win Rate [%]"]],
371            "ConsecutiveWins": [consecutive_wins],
372            "ConsecutiveLosses": [consecutive_losses],
373            "LongCount": [long_trades.shape[0]],
374            "ShortCount": [short_trades.shape[0]],
375        }
376    ).fillna(0)
377
378    return df_stats, trade_performance.round(3), stats
379
380def get_conversion_rate(prices: pd.DataFrame, ticker: Ticker, timeframe: Timeframe):
381    """
382    Calculates and applies currency conversion rates to price data for non-USD denominated instruments.
383
384    This function handles currency conversion for Forex, Metals, Crypto, and Exotics by:
385    - Identifying if the instrument needs conversion (non-USD or inverse USD pairs)
386    - Finding the appropriate USD-based counterpart pair
387    - Applying direct or inverse rates as needed
388    - Merging conversion rates with the original price data
389
390    Steps performed:
391    1. Checks if the ticker category requires conversion (Forex, Metals, Crypto, Exotics)
392    2. For non-USD pairs (e.g., EURGBP):
393       - Attempts to find the USD-quoted version (GBPUSD)
394       - Falls back to inverse pair (USDGBP) if direct not available
395       - Calculates inverse rates when needed
396    3. For USD-prefixed pairs (e.g., USDJPY):
397       - Applies direct inverse (1/USDJPY)
398    4. For non-convertible categories:
399       - Sets conversion rate to 1 (no conversion)
400    5. Merges conversion rates with original prices via forward-fill
401
402    Parameters:
403    - prices (pd.DataFrame): OHLC price data with DateTime index
404    - ticker (Ticker): Instrument information including:
405        - Name (e.g., 'EURGBP', 'USDJPY')
406        - Category (determines if conversion needed)
407    - timeframe (Timeframe): Used to fetch conversion rates at matching intervals
408
409    Returns:
410    - pd.DataFrame: Original prices with added 'ConversionRate' column:
411        - 1.0 for non-convertible instruments
412        - Direct rate for USD-prefixed pairs
413        - Cross-calculated rate for other Forex pairs
414
415    Raises:
416    - Exception: When no valid conversion pair can be found for a non-USD instrument
417
418    Notes:
419    - Conversion rates are forward-filled to handle mismatched timestamps
420    - Always targets USD conversion (assumes USD is account currency)
421    - For pairs like EURGBP, conversion goes through GBPUSD first
422    - Metals (XAUUSD) and Crypto (BTCUSD) follow same logic as Forex
423    - The function preserves all original price columns
424    """
425    categories = ['Forex', 'Metals', 'Crypto', 'Exotics'] # <-- Ajustar
426    
427    if ticker.Category.Name in categories:
428        date_from = prices.index[0]
429        date_to = prices.index[-1]
430        
431        if 'USD' not in ticker.Name: # Por ejemplo CHFNZD
432            # En este caso tengo que buscar idealmente NZDUSD 
433            quoted_currency = ticker.Name[3:]
434
435            # Caso ideal
436            asosiated_ticker = quoted_currency + 'USD' # <-- aca deberia ir la divisa de la cuenta
437            usd_prices = get_data(asosiated_ticker, timeframe.MetaTraderNumber, date_from, date_to)
438
439            if usd_prices.empty:
440                asosiated_ticker = 'USD' + quoted_currency
441                usd_prices = get_data(asosiated_ticker, timeframe.MetaTraderNumber, date_from, date_to)
442
443                if usd_prices.empty:
444                    raise Exception("Can't calculate Conversion rate")
445
446                usd_prices['Open'] = 1 / usd_prices['Open']
447
448            usd_prices = usd_prices[['Open']]
449            usd_prices = usd_prices.rename(columns={'Open':'ConversionRate'})
450            usd_prices.index = pd.to_datetime(usd_prices.index)
451
452            prices = pd.merge(
453                left=prices,
454                right=usd_prices,
455                how='left',
456                right_index=True,
457                left_index=True
458            ).ffill()
459            
460        elif ticker.Name.startswith('USD'):
461            prices['ConversionRate'] = 1 / prices['Open']
462    
463    else:
464        prices['ConversionRate'] = 1
465        
466    return prices
467
468def optimization_function(stats):
469    return (
470        (stats["Return [%]"] / (1 + (-1 * stats["Max. Drawdown [%]"])))
471    )
472
473def plot_full_equity_curve(df_equity, title):
474
475    fig = px.line(x=df_equity.index, y=df_equity.Equity)
476    fig.update_layout(title=title, xaxis_title="Date", yaxis_title="Equity")
477    fig.update_traces(textposition="bottom right")
478    fig.show()
479
480def get_scaled_symbol_metadata(ticker: str, metatrader=None):
481
482    if metatrader:
483        info = metatrader.symbol_info(ticker)
484    else:
485        if not mt5.initialize():
486            print("initialize() failed, error code =", mt5.last_error())
487            quit()
488        info = mt5.symbol_info(ticker)
489    contract_volume = info.trade_contract_size
490    minimum_lot = info.volume_min
491    maximum_lot = info.volume_max
492    pip_value = info.trade_tick_size
493    minimum_units = contract_volume * minimum_lot
494    volume_step = info.volume_step
495
496    minimum_fraction = transformar_a_uno(minimum_units)
497
498    scaled_contract_volume = contract_volume / minimum_fraction
499
500    scaled_pip_value = pip_value * minimum_fraction
501    scaled_minimum_lot = minimum_lot / minimum_fraction
502    scaled_maximum_lot = maximum_lot / minimum_fraction
503
504    return (
505        scaled_pip_value,
506        scaled_minimum_lot,
507        scaled_maximum_lot,
508        scaled_contract_volume,
509        minimum_fraction,
510        volume_step
511    )
512
513def calculate_binomial_p_value(n, k, p=0.5):
514    """
515    Calcula el p-valor para la hipótesis nula de que la probabilidad de ganar es p (por defecto 0.5),
516    dado que se observaron k trades ganadores en n trades.
517
518    Retorna la probabilidad de obtener al menos k éxitos por azar (cola superior).
519    """
520    if k > n:
521        raise ValueError("k no puede ser mayor que n")
522    
523    p_valor = 1 - binom.cdf(k - 1, n, p)
524    return round(p_valor, 3)
525
526
527def max_consecutive_wins_and_losses(df_trades: pd.DataFrame):
528    trades_copy = df_trades.copy()
529
530    # Create a boolean column to identify if it was a winning trade
531    trades_copy['win'] = trades_copy['PnL'] > 0
532
533    # Detect changes in streaks (win -> loss or loss -> win)
534    trades_copy['streak_id'] = (trades_copy['win'] != trades_copy['win'].shift()).cumsum()
535
536    # Group by each streak and count its length
537    streaks = trades_copy.groupby(['streak_id', 'win']).size().reset_index(name='duration')
538
539    # Filter and count how many streaks of each type exist
540    winning_streaks = streaks[streaks['win'] == True]
541    losing_streaks = streaks[streaks['win'] == False]
542
543    max_consecutive_wins = winning_streaks['duration'].max() if not winning_streaks.empty else 0
544    max_consecutive_losses = losing_streaks['duration'].max() if not losing_streaks.empty else 0
545
546    return max_consecutive_wins, max_consecutive_losses
547
548def walk_forward(
549    strategy,
550    data_full,
551    warmup_bars,
552    lookback_bars=28 * 1440,
553    validation_bars=7 * 1440,
554    params=None,
555    cash=15_000,
556    commission=0.0002,
557    margin=1 / 30,
558    verbose=False,
559):
560
561    optimized_params_history = {}
562    stats_master = []
563    equity_final = None
564
565    # Iniciar el índice en el final del primer lookback
566
567    i = lookback_bars + warmup_bars
568
569    while i < len(data_full):
570
571        train_data = data_full.iloc[i - lookback_bars - warmup_bars : i]
572
573        if verbose:
574            print(f"train from {train_data.index[0]} to {train_data.index[-1]}")
575        bt_training = Backtest(
576            train_data, strategy, cash=cash, commission=commission, margin=margin
577        )
578
579        with patch("backtesting.backtesting._tqdm", lambda *args, **kwargs: args[0]):
580            stats_training = bt_training.optimize(**params)
581        remaining_bars = len(data_full) - i
582        current_validation_bars = min(validation_bars, remaining_bars)
583
584        validation_data = data_full.iloc[i - warmup_bars : i + current_validation_bars]
585
586        validation_date = validation_data.index[warmup_bars]
587
588        if verbose:
589            print(f"validate from {validation_date} to {validation_data.index[-1]}")
590        bt_validation = Backtest(
591            validation_data,
592            strategy,
593            cash=cash if equity_final is None else equity_final,
594            commission=commission,
595            margin=margin,
596        )
597
598        validation_params = {
599            param: getattr(stats_training._strategy, param)
600            for param in params.keys()
601            if param != "maximize"
602        }
603
604        optimized_params_history[validation_date] = validation_params
605
606        if verbose:
607            print(validation_params)
608        stats_validation = bt_validation.run(**validation_params)
609
610        equity_final = stats_validation["Equity Final [$]"]
611
612        if verbose:
613            print(f"equity final: {equity_final}")
614            print("=" * 32)
615        stats_master.append(stats_validation)
616
617        # Mover el índice `i` al final del período de validación actual
618
619        i += current_validation_bars
620    wfo_stats = get_wfo_stats(stats_master, warmup_bars, data_full)
621
622    return wfo_stats, optimized_params_history
623
624def get_wfo_stats(stats, warmup_bars, ohcl_data):
625    trades = pd.DataFrame(
626        columns=[
627            "Size",
628            "EntryBar",
629            "ExitBar",
630            "EntryPrice",
631            "ExitPrice",
632            "PnL",
633            "ReturnPct",
634            "EntryTime",
635            "ExitTime",
636            "Duration",
637        ]
638    )
639    for stat in stats:
640        trades = pd.concat([trades, stat._trades])
641    trades.EntryBar = trades.EntryBar.astype(int)
642    trades.ExitBar = trades.ExitBar.astype(int)
643
644    equity_curves = pd.DataFrame(columns=["Equity", "DrawdownPct", "DrawdownDuration"])
645    for stat in stats:
646        equity_curves = pd.concat(
647            [equity_curves, stat["_equity_curve"].iloc[warmup_bars:]]
648        )
649    wfo_stats = compute_stats(
650        trades=trades,  # broker.closed_trades,
651        equity=equity_curves.Equity,
652        ohlc_data=ohcl_data,
653        risk_free_rate=0.0,
654        strategy_instance=None,  # strategy,
655    )
656
657    wfo_stats["_equity"] = equity_curves
658    wfo_stats["_trades"] = trades
659
660    return wfo_stats
661
662def run_wfo(
663    strategy,
664    ticker,
665    interval,
666    prices: pd.DataFrame,
667    initial_cash: float,
668    commission: float,
669    margin: float,
670    optim_func,
671    params: dict,
672    lookback_bars: int,
673    warmup_bars: int,
674    validation_bars: int,
675    plot=True,
676    risk:None=float,
677):
678
679    (
680        scaled_pip_value,
681        scaled_minimum_lot,
682        scaled_maximum_lot,
683        scaled_contract_volume,
684        minimum_fraction,
685        trade_tick_value_loss,
686        volume_step
687    ) = get_scaled_symbol_metadata(ticker)
688
689    scaled_prices = prices.copy()
690    scaled_prices.loc[:, ["Open", "High", "Low", "Close"]] = (
691        scaled_prices.loc[:, ["Open", "High", "Low", "Close"]].copy() * minimum_fraction
692    )
693
694    params["minimum_lot"] = [scaled_minimum_lot]
695    params["maximum_lot"] = [scaled_maximum_lot]
696    params["contract_volume"] = [scaled_contract_volume]
697    params["pip_value"] = [scaled_pip_value]
698    params["trade_tick_value_loss"] = [trade_tick_value_loss]
699    params["volume_step"] = [volume_step]
700    params["risk"] = [risk]
701
702    params["maximize"] = optim_func
703
704    wfo_stats, optimized_params_history = walk_forward(
705        strategy,
706        scaled_prices,
707        lookback_bars=lookback_bars,
708        validation_bars=validation_bars,
709        warmup_bars=warmup_bars,
710        params=params,
711        commission=commission,
712        margin=margin,
713        cash=initial_cash,
714        verbose=False,
715    )
716
717    df_equity = wfo_stats["_equity"]
718    df_trades = wfo_stats["_trades"]
719
720    if plot:
721        plot_full_equity_curve(df_equity, title=f"{ticker}, {interval}")
722    # Calculo el stability ratio
723
724    x = np.arange(df_equity.shape[0]).reshape(-1, 1)
725    reg = LinearRegression().fit(x, df_equity.Equity)
726    stability_ratio = reg.score(x, df_equity.Equity)
727
728    # Extraigo metricas
729
730    df_stats = pd.DataFrame(
731        {
732            "strategy": [strategy.__name__],
733            "ticker": [ticker],
734            "interval": [interval],
735            "stability_ratio": [stability_ratio],
736            "return": [wfo_stats["Return [%]"]],
737            "final_eq": [wfo_stats["Equity Final [$]"]],
738            "drawdown": [wfo_stats["Max. Drawdown [%]"]],
739            "drawdown_duration": [wfo_stats["Max. Drawdown Duration"]],
740            "win_rate": [wfo_stats["Win Rate [%]"]],
741            "sharpe_ratio": [wfo_stats["Sharpe Ratio"]],
742            "trades": [df_trades.shape[0]],
743            "avg_trade_percent": [wfo_stats["Avg. Trade [%]"]],
744            "exposure": [wfo_stats["Exposure Time [%]"]],
745            "final_equity": [wfo_stats["Equity Final [$]"]],
746            "Duration": [wfo_stats["Duration"]],
747        }
748    )
749
750    return wfo_stats, df_stats, optimized_params_history
def run_strategy( strategy, ticker: app.backbone.entities.ticker.Ticker, timeframe: app.backbone.entities.timeframe.Timeframe, prices: pandas.core.frame.DataFrame, initial_cash: float, margin: float, risk_free_rate: float = 0, risk=None, opt_params=None, metatrader_name=None, timezone=None):
 21def run_strategy(
 22    strategy,
 23    ticker: Ticker,
 24    timeframe: Timeframe,
 25    prices: pd.DataFrame,
 26    initial_cash: float,
 27    margin: float,
 28    risk_free_rate:float=0,
 29    risk=None,
 30    opt_params=None,
 31    metatrader_name=None,
 32    timezone=None,
 33):
 34    """
 35    Executes a backtest for a trading strategy with proper market metadata and commission handling.
 36
 37    This function prepares the trading environment by:
 38    - Converting prices to the correct denomination
 39    - Loading symbol-specific trading constraints
 40    - Setting up commission structures
 41    - Selecting the appropriate backtest engine (fractional or standard)
 42    - Running the strategy with all configured parameters
 43
 44    Steps performed:
 45    1. Converts prices using ticker-specific conversion rates
 46    2. Retrieves scaled symbol metadata (lot sizes, pip values, etc.)
 47    3. Configures commission calculation based on ticker category:
 48        - Absolute commission per contract for >=1
 49        - Percentage commission for <1
 50    4. Initializes either:
 51        - FractionalBacktest for fractional lot sizes
 52        - Standard Backtest for whole lot sizes
 53    5. Executes the strategy with all parameters and constraints
 54    6. Returns performance statistics and the backtest engine instance
 55
 56    Parameters:
 57    - strategy: Trading strategy class/function to backtest
 58    - ticker (Ticker): Financial instrument being traded
 59    - timeframe (Timeframe): Time interval for the backtest
 60    - prices (pd.DataFrame): OHLC price data
 61    - initial_cash (float): Starting capital
 62    - margin (float): Margin requirement (1/leverage)
 63    - risk_free_rate (float, optional): Risk-free rate for Sharpe ratio. Defaults to 0.
 64    - risk (float, optional): Risk percentage per trade. Defaults to None.
 65    - opt_params (dict, optional): Optimization parameters. Defaults to None.
 66    - metatrader_name (str, optional): MT5 symbol name. Defaults to None.
 67    - timezone (str, optional): Timezone for trade timestamps. Defaults to None.
 68
 69    Returns:
 70    - tuple: Contains:
 71        - stats: Backtest performance statistics (pd.Series/dict)
 72        - bt_train: The backtest engine instance (for further analysis)
 73
 74    Side effects:
 75    - Makes MT5 API call to get symbol info (via mt5.symbol_info)
 76    - Modifies the input prices DataFrame with conversion rates
 77    - May log warnings about lot size rounding in backtest engine
 78
 79    Notes:
 80    - Commission is applied both on entry and exit (hence /2 in calculation)
 81    - Fractional backtesting is automatically used when minimum_fraction < 1
 82    - All trading constraints (lot sizes, steps) come from broker metadata
 83    - The backtest engine handles spread as a fixed value from ticker.Spread
 84    - Returned stats typically include Sharpe ratio, drawdown, trade counts etc.
 85    - The bt_train object can be used to access trade-by-trade details
 86    """
 87
 88    prices = get_conversion_rate(prices, ticker, timeframe)
 89    
 90    (
 91        scaled_pip_value,
 92        scaled_minimum_lot,
 93        scaled_maximum_lot,
 94        scaled_contract_volume,
 95        minimum_fraction,
 96        volume_step,
 97    ) = get_scaled_symbol_metadata(ticker.Name)
 98
 99    bt_train = None
100    info = mt5.symbol_info(ticker.Name)
101
102    if ticker.Category.Commission >= 1:
103        commission = lambda size, price: abs(size) * (ticker.Category.Commission / 2) / info.trade_contract_size
104    else:
105        commission = lambda size, price: abs(size) * price * ((ticker.Category.Commission / 2) / 100)
106
107    if minimum_fraction < 1:
108        bt_train = FractionalBacktest(
109            prices, 
110            strategy,
111            commission=commission, 
112            cash=initial_cash, 
113            margin=margin,
114            fractional_unit=minimum_fraction,
115            spread=ticker.Spread
116        )
117
118    else:
119        bt_train = Backtest(
120            prices, 
121            strategy,
122            commission=commission, 
123            cash=initial_cash, 
124            margin=margin, 
125            spread=ticker.Spread
126        )
127
128    stats = bt_train.run(
129        risk_free_rate=risk_free_rate,
130        pip_value=scaled_pip_value,
131        minimum_lot=scaled_minimum_lot,
132        maximum_lot=scaled_maximum_lot,
133        contract_volume=scaled_contract_volume,
134        volume_step=volume_step,
135        risk=risk,
136        ticker=ticker,
137        opt_params=opt_params,
138        metatrader_name=metatrader_name,
139        timezone=timezone,
140        minimum_fraction=minimum_fraction
141    )
142
143    return stats, bt_train

Executes a backtest for a trading strategy with proper market metadata and commission handling.

This function prepares the trading environment by:

  • Converting prices to the correct denomination
  • Loading symbol-specific trading constraints
  • Setting up commission structures
  • Selecting the appropriate backtest engine (fractional or standard)
  • Running the strategy with all configured parameters

Steps performed:

  1. Converts prices using ticker-specific conversion rates
  2. Retrieves scaled symbol metadata (lot sizes, pip values, etc.)
  3. Configures commission calculation based on ticker category:
    • Absolute commission per contract for >=1
    • Percentage commission for <1
  4. Initializes either:
    • FractionalBacktest for fractional lot sizes
    • Standard Backtest for whole lot sizes
  5. Executes the strategy with all parameters and constraints
  6. Returns performance statistics and the backtest engine instance

Parameters:

  • strategy: Trading strategy class/function to backtest
  • ticker (Ticker): Financial instrument being traded
  • timeframe (Timeframe): Time interval for the backtest
  • prices (pd.DataFrame): OHLC price data
  • initial_cash (float): Starting capital
  • margin (float): Margin requirement (1/leverage)
  • risk_free_rate (float, optional): Risk-free rate for Sharpe ratio. Defaults to 0.
  • risk (float, optional): Risk percentage per trade. Defaults to None.
  • opt_params (dict, optional): Optimization parameters. Defaults to None.
  • metatrader_name (str, optional): MT5 symbol name. Defaults to None.
  • timezone (str, optional): Timezone for trade timestamps. Defaults to None.

Returns:

  • tuple: Contains:
    • stats: Backtest performance statistics (pd.Series/dict)
    • bt_train: The backtest engine instance (for further analysis)

Side effects:

  • Makes MT5 API call to get symbol info (via mt5.symbol_info)
  • Modifies the input prices DataFrame with conversion rates
  • May log warnings about lot size rounding in backtest engine

Notes:

  • Commission is applied both on entry and exit (hence /2 in calculation)
  • Fractional backtesting is automatically used when minimum_fraction < 1
  • All trading constraints (lot sizes, steps) come from broker metadata
  • The backtest engine handles spread as a fixed value from ticker.Spread
  • Returned stats typically include Sharpe ratio, drawdown, trade counts etc.
  • The bt_train object can be used to access trade-by-trade details
def run_strategy_and_get_performances( strategy, ticker: app.backbone.entities.ticker.Ticker, timeframe: app.backbone.entities.timeframe.Timeframe, prices: pandas.core.frame.DataFrame, initial_cash: float, risk_free_rate: float, margin: float, risk=None, plot_path=None, file_name=None, opt_params=None, save_report=False):
145def run_strategy_and_get_performances(
146    strategy,
147    ticker: Ticker,
148    timeframe: Timeframe,
149    prices: pd.DataFrame,
150    initial_cash: float,
151    risk_free_rate: float,
152    margin: float,
153    risk=None,
154    plot_path=None,
155    file_name=None,
156    opt_params=None,
157    save_report=False
158):
159    
160    """
161    Executes a trading strategy backtest and computes comprehensive performance metrics,
162    with optional visualization and reporting capabilities.
163
164    This function extends the basic backtest by:
165    - Generating detailed performance statistics
166    - Calculating advanced metrics (stability ratio, Jarque-Bera, etc.)
167    - Producing visualizations and HTML reports
168    - Segmenting trade analytics (long/short, winning/losing trades)
169    - Computing risk-adjusted return metrics
170
171    Steps performed:
172    1. Runs the core strategy backtest using run_strategy()
173    2. Generates equity curve plots if plot_path is specified
174    3. Creates QuantStats performance reports if save_report=True
175    4. Computes trade-level metrics:
176        - Percentage returns relative to account equity
177        - Trade durations in days
178        - Win/loss segmentation
179    5. Calculates advanced statistics:
180        - Equity curve stability ratio (linear regression R²)
181        - Winrate binomial p-value
182        - Return distribution metrics (skew, kurtosis)
183    6. Compiles results into three structured DataFrames:
184        - Strategy-level performance metrics
185        - Detailed trade performance analytics
186        - Raw backtest statistics
187
188    Parameters:
189    - strategy: Trading strategy implementation
190    - ticker (Ticker): Financial instrument configuration
191    - timeframe (Timeframe): Backtesting time interval
192    - prices (pd.DataFrame): OHLC price data
193    - initial_cash (float): Starting capital
194    - risk_free_rate (float): Risk-free rate for Sharpe ratio
195    - margin (float): Margin requirement (1/leverage)
196    - risk (float, optional): Risk percentage per trade. Default=None.
197    - plot_path (str, optional): Directory to save plots. Default=None.
198    - file_name (str, optional): Base name for output files. Default=None.
199    - opt_params (dict, optional): Optimization parameters. Default=None.
200    - save_report (bool, optional): Whether to save QuantStats report. Default=False.
201
202    Returns:
203    - tuple: Three DataFrames containing:
204        - df_stats (pd.DataFrame): Strategy performance metrics (1 row)
205        - trade_performance (pd.DataFrame): Aggregated trade analytics (1 row)
206        - stats: Raw backtest statistics object
207
208    Side effects:
209    - Creates plot files in plot_path if specified:
210        - Interactive equity curve plot (.html)
211        - QuantStats performance report (if save_report=True)
212    - May create directories if they don't exist
213
214    Notes:
215    - Trade returns are calculated as percentage of equity at entry
216    - Duration is converted to whole days for consistency
217    - Stability ratio measures equity curve linearity (higher = smoother)
218    - Winrate p-value tests if winrate could occur by chance
219    - Jarque-Bera tests return distribution normality
220    - All metrics are rounded to 3 decimal places
221    - Missing values are filled with 0 for robustness
222    - Separate metrics are provided for long/short positions
223    """
224    
225    stats, bt_train = run_strategy(
226        strategy=strategy,
227        ticker=ticker,
228        timeframe=timeframe,
229        prices=prices,
230        initial_cash=initial_cash,
231        risk_free_rate=risk_free_rate,
232        margin=margin,
233        risk=risk,
234        opt_params=opt_params,
235    )
236
237    if plot_path:
238        if not os.path.exists(plot_path):
239            os.makedirs(plot_path)
240            
241        bt_train.plot(
242            filename=os.path.join(plot_path, file_name + '.html'), 
243            resample=False, 
244            open_browser=False
245        )
246
247        if save_report:
248            returns = stats._equity_curve['Equity'].pct_change().dropna()
249            qs.reports.html(
250                returns, 
251                output=os.path.join(plot_path, 'reports', file_name + '.html'), 
252                title=file_name, 
253                rf=risk_free_rate
254            )
255
256    equity_curve = stats._equity_curve
257    trades = stats._trades
258    
259    trades = pd.merge(
260        trades,
261        equity_curve['Equity'],
262        left_on='ExitTime',
263        right_index=True,
264        how='inner'
265    )
266    
267    trades['ReturnPct'] = (trades['NetPnL'] / trades['Equity'].shift(1)) * 100
268    if len(trades) > 0:
269        trades.loc[0, 'ReturnPct'] = (trades.loc[0, 'NetPnL'] / initial_cash) * 100
270
271    trades['Duration'] = pd.to_timedelta(trades['Duration'])
272    trades['Duration'] = (trades['Duration'].dt.total_seconds() // 3600 // 24).astype(int)
273      
274    stats._trades = trades.round(5)
275    
276    winning_trades = trades[trades["NetPnL"]>=0]
277    losing_trades = trades[trades["NetPnL"]<0]
278
279    long_trades = trades[trades["Size"] >= 0]
280    short_trades = trades[trades["Size"] < 0]
281    
282    long_winning_trades = long_trades[long_trades["NetPnL"] >= 0]
283    long_losing_trades = long_trades[long_trades["NetPnL"] < 0]
284    
285    short_winning_trades = short_trades[short_trades["NetPnL"] >= 0]
286    short_losing_trades = short_trades[short_trades["NetPnL"] < 0]
287    
288    equity_curve = equity_curve["Equity"].values
289    
290    x = np.arange(len(equity_curve)).reshape(-1, 1)
291    reg = LinearRegression().fit(x, equity_curve)
292    stability_ratio = reg.score(x, equity_curve)
293
294    stats["Duration"] = pd.to_timedelta(stats["Duration"])
295
296    winrate_p_value = calculate_binomial_p_value(
297        n=trades.shape[0], 
298        k=winning_trades.shape[0]
299    )
300
301    returns = trades['Equity'].pct_change().dropna()  # Elimina NaN del primer valor
302
303    jb_stat, jb_p_value = jarque_bera(returns)
304    skew_value = skew(returns)
305    kurtosis_value = kurtosis(returns, fisher=True)  # True para exceso sobre normal
306
307    df_stats = pd.DataFrame(
308        {
309            "StabilityRatio": [stability_ratio],
310            "Trades": [stats["# Trades"]],
311            "Return": [stats["Return [%]"]],
312            "Drawdown": [np.abs(stats["Max. Drawdown [%]"])],
313            "RreturnDd": [stats["Return [%]"] / np.abs(stats["Max. Drawdown [%]"])],
314            "WinRate": [stats["Win Rate [%]"]],
315            "Duration": [stats["Duration"].days],
316
317            "ExposureTime": [stats["Exposure Time [%]"]],
318            "KellyCriterion": [stats["Kelly Criterion"]],
319            "WinratePValue": [winrate_p_value],
320            "SharpeRatio": [stats["Sharpe Ratio"]],
321
322            "JarqueBeraStat": [jb_stat],
323            "JarqueBeraPValue": [jb_p_value],
324            "Skew": [skew_value],
325            "Kurtosis": [kurtosis_value],
326
327        }
328    )
329    
330    df_stats["StabilityWeightedRar"] = (df_stats["Return"] / (1 + df_stats["Drawdown"])) * np.log(1 + df_stats["Trades"]) * stability_ratio
331    df_stats = df_stats.fillna(0).round(3)
332
333    consecutive_wins, consecutive_losses = max_consecutive_wins_and_losses(trades)
334    
335    trade_performance = pd.DataFrame(
336        {
337            # General
338            "MeanReturnPct":[trades.ReturnPct.mean()],
339            "StdReturnPct":[trades.ReturnPct.std()],
340            "MeanTradeDuration":[trades['Duration'].mean()],
341            "StdTradeDuration":[trades['Duration'].std()],
342
343            "MeanWinningReturnPct":[winning_trades.ReturnPct.mean()],
344            "StdWinningReturnPct":[winning_trades.ReturnPct.std()],
345
346            "MeanLosingReturnPct":[losing_trades.ReturnPct.mean()],
347            "StdLosingReturnPct":[losing_trades.ReturnPct.std()],
348
349            # Longs
350            "LongWinrate": [(long_winning_trades.size / long_trades.size) * 100 if long_trades.size > 0 else 0],
351            "LongMeanReturnPct": [long_trades.ReturnPct.mean()],
352            "LongStdReturnPct": [long_trades.ReturnPct.std()],
353            
354            "WinLongMeanReturnPct": [long_winning_trades.ReturnPct.mean()],
355            "WinLongStdReturnPct": [long_winning_trades.ReturnPct.std()],
356            "LoseLongMeanReturnPct": [long_losing_trades.ReturnPct.mean()],
357            "LoseLongStdReturnPct": [long_losing_trades.ReturnPct.std()],
358            
359            # Shorts
360            "ShortWinrate": [(short_winning_trades.size / short_trades.size) * 100 if short_trades.size > 0 else 0],
361            "ShortMeanReturnPct": [short_trades.ReturnPct.mean()],
362            "ShortStdReturnPct": [short_trades.ReturnPct.std()],
363            
364            "WinShortMeanReturnPct": [short_winning_trades.ReturnPct.mean()],
365            "WinShortStdReturnPct": [short_winning_trades.ReturnPct.std()],
366            "LoseShortMeanReturnPct": [short_losing_trades.ReturnPct.mean()],
367            "LoseShortStdReturnPct": [short_losing_trades.ReturnPct.std()],
368
369            # Otras metricas
370            "ProfitFactor": [stats["Profit Factor"]],
371            "WinRate": [stats["Win Rate [%]"]],
372            "ConsecutiveWins": [consecutive_wins],
373            "ConsecutiveLosses": [consecutive_losses],
374            "LongCount": [long_trades.shape[0]],
375            "ShortCount": [short_trades.shape[0]],
376        }
377    ).fillna(0)
378
379    return df_stats, trade_performance.round(3), stats

Executes a trading strategy backtest and computes comprehensive performance metrics, with optional visualization and reporting capabilities.

This function extends the basic backtest by:

  • Generating detailed performance statistics
  • Calculating advanced metrics (stability ratio, Jarque-Bera, etc.)
  • Producing visualizations and HTML reports
  • Segmenting trade analytics (long/short, winning/losing trades)
  • Computing risk-adjusted return metrics

Steps performed:

  1. Runs the core strategy backtest using run_strategy()
  2. Generates equity curve plots if plot_path is specified
  3. Creates QuantStats performance reports if save_report=True
  4. Computes trade-level metrics:
    • Percentage returns relative to account equity
    • Trade durations in days
    • Win/loss segmentation
  5. Calculates advanced statistics:
    • Equity curve stability ratio (linear regression R²)
    • Winrate binomial p-value
    • Return distribution metrics (skew, kurtosis)
  6. Compiles results into three structured DataFrames:
    • Strategy-level performance metrics
    • Detailed trade performance analytics
    • Raw backtest statistics

Parameters:

  • strategy: Trading strategy implementation
  • ticker (Ticker): Financial instrument configuration
  • timeframe (Timeframe): Backtesting time interval
  • prices (pd.DataFrame): OHLC price data
  • initial_cash (float): Starting capital
  • risk_free_rate (float): Risk-free rate for Sharpe ratio
  • margin (float): Margin requirement (1/leverage)
  • risk (float, optional): Risk percentage per trade. Default=None.
  • plot_path (str, optional): Directory to save plots. Default=None.
  • file_name (str, optional): Base name for output files. Default=None.
  • opt_params (dict, optional): Optimization parameters. Default=None.
  • save_report (bool, optional): Whether to save QuantStats report. Default=False.

Returns:

  • tuple: Three DataFrames containing:
    • df_stats (pd.DataFrame): Strategy performance metrics (1 row)
    • trade_performance (pd.DataFrame): Aggregated trade analytics (1 row)
    • stats: Raw backtest statistics object

Side effects:

  • Creates plot files in plot_path if specified:
    • Interactive equity curve plot (.html)
    • QuantStats performance report (if save_report=True)
  • May create directories if they don't exist

Notes:

  • Trade returns are calculated as percentage of equity at entry
  • Duration is converted to whole days for consistency
  • Stability ratio measures equity curve linearity (higher = smoother)
  • Winrate p-value tests if winrate could occur by chance
  • Jarque-Bera tests return distribution normality
  • All metrics are rounded to 3 decimal places
  • Missing values are filled with 0 for robustness
  • Separate metrics are provided for long/short positions
def get_conversion_rate( prices: pandas.core.frame.DataFrame, ticker: app.backbone.entities.ticker.Ticker, timeframe: app.backbone.entities.timeframe.Timeframe):
381def get_conversion_rate(prices: pd.DataFrame, ticker: Ticker, timeframe: Timeframe):
382    """
383    Calculates and applies currency conversion rates to price data for non-USD denominated instruments.
384
385    This function handles currency conversion for Forex, Metals, Crypto, and Exotics by:
386    - Identifying if the instrument needs conversion (non-USD or inverse USD pairs)
387    - Finding the appropriate USD-based counterpart pair
388    - Applying direct or inverse rates as needed
389    - Merging conversion rates with the original price data
390
391    Steps performed:
392    1. Checks if the ticker category requires conversion (Forex, Metals, Crypto, Exotics)
393    2. For non-USD pairs (e.g., EURGBP):
394       - Attempts to find the USD-quoted version (GBPUSD)
395       - Falls back to inverse pair (USDGBP) if direct not available
396       - Calculates inverse rates when needed
397    3. For USD-prefixed pairs (e.g., USDJPY):
398       - Applies direct inverse (1/USDJPY)
399    4. For non-convertible categories:
400       - Sets conversion rate to 1 (no conversion)
401    5. Merges conversion rates with original prices via forward-fill
402
403    Parameters:
404    - prices (pd.DataFrame): OHLC price data with DateTime index
405    - ticker (Ticker): Instrument information including:
406        - Name (e.g., 'EURGBP', 'USDJPY')
407        - Category (determines if conversion needed)
408    - timeframe (Timeframe): Used to fetch conversion rates at matching intervals
409
410    Returns:
411    - pd.DataFrame: Original prices with added 'ConversionRate' column:
412        - 1.0 for non-convertible instruments
413        - Direct rate for USD-prefixed pairs
414        - Cross-calculated rate for other Forex pairs
415
416    Raises:
417    - Exception: When no valid conversion pair can be found for a non-USD instrument
418
419    Notes:
420    - Conversion rates are forward-filled to handle mismatched timestamps
421    - Always targets USD conversion (assumes USD is account currency)
422    - For pairs like EURGBP, conversion goes through GBPUSD first
423    - Metals (XAUUSD) and Crypto (BTCUSD) follow same logic as Forex
424    - The function preserves all original price columns
425    """
426    categories = ['Forex', 'Metals', 'Crypto', 'Exotics'] # <-- Ajustar
427    
428    if ticker.Category.Name in categories:
429        date_from = prices.index[0]
430        date_to = prices.index[-1]
431        
432        if 'USD' not in ticker.Name: # Por ejemplo CHFNZD
433            # En este caso tengo que buscar idealmente NZDUSD 
434            quoted_currency = ticker.Name[3:]
435
436            # Caso ideal
437            asosiated_ticker = quoted_currency + 'USD' # <-- aca deberia ir la divisa de la cuenta
438            usd_prices = get_data(asosiated_ticker, timeframe.MetaTraderNumber, date_from, date_to)
439
440            if usd_prices.empty:
441                asosiated_ticker = 'USD' + quoted_currency
442                usd_prices = get_data(asosiated_ticker, timeframe.MetaTraderNumber, date_from, date_to)
443
444                if usd_prices.empty:
445                    raise Exception("Can't calculate Conversion rate")
446
447                usd_prices['Open'] = 1 / usd_prices['Open']
448
449            usd_prices = usd_prices[['Open']]
450            usd_prices = usd_prices.rename(columns={'Open':'ConversionRate'})
451            usd_prices.index = pd.to_datetime(usd_prices.index)
452
453            prices = pd.merge(
454                left=prices,
455                right=usd_prices,
456                how='left',
457                right_index=True,
458                left_index=True
459            ).ffill()
460            
461        elif ticker.Name.startswith('USD'):
462            prices['ConversionRate'] = 1 / prices['Open']
463    
464    else:
465        prices['ConversionRate'] = 1
466        
467    return prices

Calculates and applies currency conversion rates to price data for non-USD denominated instruments.

This function handles currency conversion for Forex, Metals, Crypto, and Exotics by:

  • Identifying if the instrument needs conversion (non-USD or inverse USD pairs)
  • Finding the appropriate USD-based counterpart pair
  • Applying direct or inverse rates as needed
  • Merging conversion rates with the original price data

Steps performed:

  1. Checks if the ticker category requires conversion (Forex, Metals, Crypto, Exotics)
  2. For non-USD pairs (e.g., EURGBP):
    • Attempts to find the USD-quoted version (GBPUSD)
    • Falls back to inverse pair (USDGBP) if direct not available
    • Calculates inverse rates when needed
  3. For USD-prefixed pairs (e.g., USDJPY):
    • Applies direct inverse (1/USDJPY)
  4. For non-convertible categories:
    • Sets conversion rate to 1 (no conversion)
  5. Merges conversion rates with original prices via forward-fill

Parameters:

  • prices (pd.DataFrame): OHLC price data with DateTime index
  • ticker (Ticker): Instrument information including:
    • Name (e.g., 'EURGBP', 'USDJPY')
    • Category (determines if conversion needed)
  • timeframe (Timeframe): Used to fetch conversion rates at matching intervals

Returns:

  • pd.DataFrame: Original prices with added 'ConversionRate' column:
    • 1.0 for non-convertible instruments
    • Direct rate for USD-prefixed pairs
    • Cross-calculated rate for other Forex pairs

Raises:

  • Exception: When no valid conversion pair can be found for a non-USD instrument

Notes:

  • Conversion rates are forward-filled to handle mismatched timestamps
  • Always targets USD conversion (assumes USD is account currency)
  • For pairs like EURGBP, conversion goes through GBPUSD first
  • Metals (XAUUSD) and Crypto (BTCUSD) follow same logic as Forex
  • The function preserves all original price columns
def optimization_function(stats):
469def optimization_function(stats):
470    return (
471        (stats["Return [%]"] / (1 + (-1 * stats["Max. Drawdown [%]"])))
472    )
def plot_full_equity_curve(df_equity, title):
474def plot_full_equity_curve(df_equity, title):
475
476    fig = px.line(x=df_equity.index, y=df_equity.Equity)
477    fig.update_layout(title=title, xaxis_title="Date", yaxis_title="Equity")
478    fig.update_traces(textposition="bottom right")
479    fig.show()
def get_scaled_symbol_metadata(ticker: str, metatrader=None):
481def get_scaled_symbol_metadata(ticker: str, metatrader=None):
482
483    if metatrader:
484        info = metatrader.symbol_info(ticker)
485    else:
486        if not mt5.initialize():
487            print("initialize() failed, error code =", mt5.last_error())
488            quit()
489        info = mt5.symbol_info(ticker)
490    contract_volume = info.trade_contract_size
491    minimum_lot = info.volume_min
492    maximum_lot = info.volume_max
493    pip_value = info.trade_tick_size
494    minimum_units = contract_volume * minimum_lot
495    volume_step = info.volume_step
496
497    minimum_fraction = transformar_a_uno(minimum_units)
498
499    scaled_contract_volume = contract_volume / minimum_fraction
500
501    scaled_pip_value = pip_value * minimum_fraction
502    scaled_minimum_lot = minimum_lot / minimum_fraction
503    scaled_maximum_lot = maximum_lot / minimum_fraction
504
505    return (
506        scaled_pip_value,
507        scaled_minimum_lot,
508        scaled_maximum_lot,
509        scaled_contract_volume,
510        minimum_fraction,
511        volume_step
512    )
def calculate_binomial_p_value(n, k, p=0.5):
514def calculate_binomial_p_value(n, k, p=0.5):
515    """
516    Calcula el p-valor para la hipótesis nula de que la probabilidad de ganar es p (por defecto 0.5),
517    dado que se observaron k trades ganadores en n trades.
518
519    Retorna la probabilidad de obtener al menos k éxitos por azar (cola superior).
520    """
521    if k > n:
522        raise ValueError("k no puede ser mayor que n")
523    
524    p_valor = 1 - binom.cdf(k - 1, n, p)
525    return round(p_valor, 3)

Calcula el p-valor para la hipótesis nula de que la probabilidad de ganar es p (por defecto 0.5), dado que se observaron k trades ganadores en n trades.

Retorna la probabilidad de obtener al menos k éxitos por azar (cola superior).

def max_consecutive_wins_and_losses(df_trades: pandas.core.frame.DataFrame):
528def max_consecutive_wins_and_losses(df_trades: pd.DataFrame):
529    trades_copy = df_trades.copy()
530
531    # Create a boolean column to identify if it was a winning trade
532    trades_copy['win'] = trades_copy['PnL'] > 0
533
534    # Detect changes in streaks (win -> loss or loss -> win)
535    trades_copy['streak_id'] = (trades_copy['win'] != trades_copy['win'].shift()).cumsum()
536
537    # Group by each streak and count its length
538    streaks = trades_copy.groupby(['streak_id', 'win']).size().reset_index(name='duration')
539
540    # Filter and count how many streaks of each type exist
541    winning_streaks = streaks[streaks['win'] == True]
542    losing_streaks = streaks[streaks['win'] == False]
543
544    max_consecutive_wins = winning_streaks['duration'].max() if not winning_streaks.empty else 0
545    max_consecutive_losses = losing_streaks['duration'].max() if not losing_streaks.empty else 0
546
547    return max_consecutive_wins, max_consecutive_losses
def walk_forward( strategy, data_full, warmup_bars, lookback_bars=40320, validation_bars=10080, params=None, cash=15000, commission=0.0002, margin=0.03333333333333333, verbose=False):
549def walk_forward(
550    strategy,
551    data_full,
552    warmup_bars,
553    lookback_bars=28 * 1440,
554    validation_bars=7 * 1440,
555    params=None,
556    cash=15_000,
557    commission=0.0002,
558    margin=1 / 30,
559    verbose=False,
560):
561
562    optimized_params_history = {}
563    stats_master = []
564    equity_final = None
565
566    # Iniciar el índice en el final del primer lookback
567
568    i = lookback_bars + warmup_bars
569
570    while i < len(data_full):
571
572        train_data = data_full.iloc[i - lookback_bars - warmup_bars : i]
573
574        if verbose:
575            print(f"train from {train_data.index[0]} to {train_data.index[-1]}")
576        bt_training = Backtest(
577            train_data, strategy, cash=cash, commission=commission, margin=margin
578        )
579
580        with patch("backtesting.backtesting._tqdm", lambda *args, **kwargs: args[0]):
581            stats_training = bt_training.optimize(**params)
582        remaining_bars = len(data_full) - i
583        current_validation_bars = min(validation_bars, remaining_bars)
584
585        validation_data = data_full.iloc[i - warmup_bars : i + current_validation_bars]
586
587        validation_date = validation_data.index[warmup_bars]
588
589        if verbose:
590            print(f"validate from {validation_date} to {validation_data.index[-1]}")
591        bt_validation = Backtest(
592            validation_data,
593            strategy,
594            cash=cash if equity_final is None else equity_final,
595            commission=commission,
596            margin=margin,
597        )
598
599        validation_params = {
600            param: getattr(stats_training._strategy, param)
601            for param in params.keys()
602            if param != "maximize"
603        }
604
605        optimized_params_history[validation_date] = validation_params
606
607        if verbose:
608            print(validation_params)
609        stats_validation = bt_validation.run(**validation_params)
610
611        equity_final = stats_validation["Equity Final [$]"]
612
613        if verbose:
614            print(f"equity final: {equity_final}")
615            print("=" * 32)
616        stats_master.append(stats_validation)
617
618        # Mover el índice `i` al final del período de validación actual
619
620        i += current_validation_bars
621    wfo_stats = get_wfo_stats(stats_master, warmup_bars, data_full)
622
623    return wfo_stats, optimized_params_history
def get_wfo_stats(stats, warmup_bars, ohcl_data):
625def get_wfo_stats(stats, warmup_bars, ohcl_data):
626    trades = pd.DataFrame(
627        columns=[
628            "Size",
629            "EntryBar",
630            "ExitBar",
631            "EntryPrice",
632            "ExitPrice",
633            "PnL",
634            "ReturnPct",
635            "EntryTime",
636            "ExitTime",
637            "Duration",
638        ]
639    )
640    for stat in stats:
641        trades = pd.concat([trades, stat._trades])
642    trades.EntryBar = trades.EntryBar.astype(int)
643    trades.ExitBar = trades.ExitBar.astype(int)
644
645    equity_curves = pd.DataFrame(columns=["Equity", "DrawdownPct", "DrawdownDuration"])
646    for stat in stats:
647        equity_curves = pd.concat(
648            [equity_curves, stat["_equity_curve"].iloc[warmup_bars:]]
649        )
650    wfo_stats = compute_stats(
651        trades=trades,  # broker.closed_trades,
652        equity=equity_curves.Equity,
653        ohlc_data=ohcl_data,
654        risk_free_rate=0.0,
655        strategy_instance=None,  # strategy,
656    )
657
658    wfo_stats["_equity"] = equity_curves
659    wfo_stats["_trades"] = trades
660
661    return wfo_stats
def run_wfo( strategy, ticker, interval, prices: pandas.core.frame.DataFrame, initial_cash: float, commission: float, margin: float, optim_func, params: dict, lookback_bars: int, warmup_bars: int, validation_bars: int, plot=True, risk: None = <class 'float'>):
663def run_wfo(
664    strategy,
665    ticker,
666    interval,
667    prices: pd.DataFrame,
668    initial_cash: float,
669    commission: float,
670    margin: float,
671    optim_func,
672    params: dict,
673    lookback_bars: int,
674    warmup_bars: int,
675    validation_bars: int,
676    plot=True,
677    risk:None=float,
678):
679
680    (
681        scaled_pip_value,
682        scaled_minimum_lot,
683        scaled_maximum_lot,
684        scaled_contract_volume,
685        minimum_fraction,
686        trade_tick_value_loss,
687        volume_step
688    ) = get_scaled_symbol_metadata(ticker)
689
690    scaled_prices = prices.copy()
691    scaled_prices.loc[:, ["Open", "High", "Low", "Close"]] = (
692        scaled_prices.loc[:, ["Open", "High", "Low", "Close"]].copy() * minimum_fraction
693    )
694
695    params["minimum_lot"] = [scaled_minimum_lot]
696    params["maximum_lot"] = [scaled_maximum_lot]
697    params["contract_volume"] = [scaled_contract_volume]
698    params["pip_value"] = [scaled_pip_value]
699    params["trade_tick_value_loss"] = [trade_tick_value_loss]
700    params["volume_step"] = [volume_step]
701    params["risk"] = [risk]
702
703    params["maximize"] = optim_func
704
705    wfo_stats, optimized_params_history = walk_forward(
706        strategy,
707        scaled_prices,
708        lookback_bars=lookback_bars,
709        validation_bars=validation_bars,
710        warmup_bars=warmup_bars,
711        params=params,
712        commission=commission,
713        margin=margin,
714        cash=initial_cash,
715        verbose=False,
716    )
717
718    df_equity = wfo_stats["_equity"]
719    df_trades = wfo_stats["_trades"]
720
721    if plot:
722        plot_full_equity_curve(df_equity, title=f"{ticker}, {interval}")
723    # Calculo el stability ratio
724
725    x = np.arange(df_equity.shape[0]).reshape(-1, 1)
726    reg = LinearRegression().fit(x, df_equity.Equity)
727    stability_ratio = reg.score(x, df_equity.Equity)
728
729    # Extraigo metricas
730
731    df_stats = pd.DataFrame(
732        {
733            "strategy": [strategy.__name__],
734            "ticker": [ticker],
735            "interval": [interval],
736            "stability_ratio": [stability_ratio],
737            "return": [wfo_stats["Return [%]"]],
738            "final_eq": [wfo_stats["Equity Final [$]"]],
739            "drawdown": [wfo_stats["Max. Drawdown [%]"]],
740            "drawdown_duration": [wfo_stats["Max. Drawdown Duration"]],
741            "win_rate": [wfo_stats["Win Rate [%]"]],
742            "sharpe_ratio": [wfo_stats["Sharpe Ratio"]],
743            "trades": [df_trades.shape[0]],
744            "avg_trade_percent": [wfo_stats["Avg. Trade [%]"]],
745            "exposure": [wfo_stats["Exposure Time [%]"]],
746            "final_equity": [wfo_stats["Equity Final [$]"]],
747            "Duration": [wfo_stats["Duration"]],
748        }
749    )
750
751    return wfo_stats, df_stats, optimized_params_history