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
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:
- Converts prices using ticker-specific conversion rates
- Retrieves scaled symbol metadata (lot sizes, pip values, etc.)
- Configures commission calculation based on ticker category:
- Absolute commission per contract for >=1
- Percentage commission for <1
- Initializes either:
- FractionalBacktest for fractional lot sizes
- Standard Backtest for whole lot sizes
- Executes the strategy with all parameters and constraints
- 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
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:
- Runs the core strategy backtest using run_strategy()
- Generates equity curve plots if plot_path is specified
- Creates QuantStats performance reports if save_report=True
- Computes trade-level metrics:
- Percentage returns relative to account equity
- Trade durations in days
- Win/loss segmentation
- Calculates advanced statistics:
- Equity curve stability ratio (linear regression R²)
- Winrate binomial p-value
- Return distribution metrics (skew, kurtosis)
- 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
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:
- Checks if the ticker category requires conversion (Forex, Metals, Crypto, Exotics)
- 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
- For USD-prefixed pairs (e.g., USDJPY):
- Applies direct inverse (1/USDJPY)
- For non-convertible categories:
- Sets conversion rate to 1 (no conversion)
- 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
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 )
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).
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
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
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
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