borsapy.backtest
Backtest Engine for trading strategy evaluation.
This module provides a framework for backtesting trading strategies on historical OHLCV data with comprehensive performance metrics.
Features:
- Strategy function interface
- Technical indicator integration
- Performance metrics (Sharpe, Sortino, Profit Factor)
- Trade tracking and analysis
- Equity curve generation
- Buy & Hold comparison
Examples:
import borsapy as bp
>>> def rsi_strategy(candle, position, indicators): ... if indicators['rsi'] < 30 and position is None: ... return 'BUY' ... elif indicators['rsi'] > 70 and position == 'long': ... return 'SELL' ... return 'HOLD' >>> result = bp.backtest("THYAO", rsi_strategy, period="1y") >>> print(result.summary()) >>> print(f"Sharpe: {result.sharpe_ratio:.2f}")
1""" 2Backtest Engine for trading strategy evaluation. 3 4This module provides a framework for backtesting trading strategies 5on historical OHLCV data with comprehensive performance metrics. 6 7Features: 8- Strategy function interface 9- Technical indicator integration 10- Performance metrics (Sharpe, Sortino, Profit Factor) 11- Trade tracking and analysis 12- Equity curve generation 13- Buy & Hold comparison 14 15Examples: 16 >>> import borsapy as bp 17 18 >>> def rsi_strategy(candle, position, indicators): 19 ... if indicators['rsi'] < 30 and position is None: 20 ... return 'BUY' 21 ... elif indicators['rsi'] > 70 and position == 'long': 22 ... return 'SELL' 23 ... return 'HOLD' 24 25 >>> result = bp.backtest("THYAO", rsi_strategy, period="1y") 26 >>> print(result.summary()) 27 >>> print(f"Sharpe: {result.sharpe_ratio:.2f}") 28""" 29 30from __future__ import annotations 31 32from collections.abc import Callable 33from dataclasses import dataclass, field 34from datetime import datetime 35from typing import Any, Literal 36 37import numpy as np 38import pandas as pd 39 40__all__ = ["Trade", "BacktestResult", "Backtest", "backtest"] 41 42 43# Strategy signal types 44Signal = Literal["BUY", "SELL", "HOLD"] | None 45Position = Literal["long", "short"] | None 46 47# Strategy function signature 48StrategyFunc = Callable[[dict, Position, dict], Signal] 49 50 51@dataclass 52class Trade: 53 """ 54 Represents a single trade in a backtest. 55 56 Attributes: 57 entry_time: When the trade was opened. 58 entry_price: Price at entry. 59 exit_time: When the trade was closed (None if open). 60 exit_price: Price at exit (None if open). 61 side: Trade direction ('long' or 'short'). 62 shares: Number of shares traded. 63 commission: Total commission paid (entry + exit). 64 """ 65 66 entry_time: datetime 67 entry_price: float 68 exit_time: datetime | None = None 69 exit_price: float | None = None 70 side: Literal["long", "short"] = "long" 71 shares: float = 0.0 72 commission: float = 0.0 73 74 @property 75 def is_closed(self) -> bool: 76 """Check if trade is closed.""" 77 return self.exit_time is not None and self.exit_price is not None 78 79 @property 80 def profit(self) -> float | None: 81 """Calculate profit in currency units (None if open).""" 82 if not self.is_closed: 83 return None 84 assert self.exit_price is not None 85 if self.side == "long": 86 gross = (self.exit_price - self.entry_price) * self.shares 87 else: 88 gross = (self.entry_price - self.exit_price) * self.shares 89 return gross - self.commission 90 91 @property 92 def profit_pct(self) -> float | None: 93 """Calculate profit as percentage (None if open).""" 94 if not self.is_closed or self.entry_price == 0: 95 return None 96 profit = self.profit 97 if profit is None: 98 return None 99 entry_value = self.entry_price * self.shares 100 return (profit / entry_value) * 100 101 102 @property 103 def duration(self) -> float | None: 104 """Trade duration in days (None if open).""" 105 if not self.is_closed: 106 return None 107 assert self.exit_time is not None 108 delta = self.exit_time - self.entry_time 109 return delta.total_seconds() / 86400 # Convert to days 110 111 def to_dict(self) -> dict[str, Any]: 112 """Convert trade to dictionary.""" 113 return { 114 "entry_time": self.entry_time, 115 "entry_price": self.entry_price, 116 "exit_time": self.exit_time, 117 "exit_price": self.exit_price, 118 "side": self.side, 119 "shares": self.shares, 120 "commission": self.commission, 121 "profit": self.profit, 122 "profit_pct": self.profit_pct, 123 "duration": self.duration, 124 } 125 126 127@dataclass 128class BacktestResult: 129 """ 130 Comprehensive backtest results with performance metrics. 131 132 Follows TradingView/Mathieu2301 result format for familiarity. 133 134 Attributes: 135 symbol: Traded symbol. 136 period: Test period (e.g., "1y"). 137 interval: Data interval (e.g., "1d"). 138 strategy_name: Name of the strategy function. 139 initial_capital: Starting capital. 140 commission: Commission rate used. 141 trades: List of executed trades. 142 equity_curve: Daily equity values. 143 drawdown_curve: Daily drawdown values. 144 buy_hold_curve: Buy & hold comparison values. 145 """ 146 147 # Identification 148 symbol: str 149 period: str 150 interval: str 151 strategy_name: str 152 153 # Configuration 154 initial_capital: float 155 commission: float 156 157 # Results 158 trades: list[Trade] = field(default_factory=list) 159 equity_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float)) 160 drawdown_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float)) 161 buy_hold_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float)) 162 163 # === Performance Properties === 164 165 @property 166 def final_equity(self) -> float: 167 """Final portfolio value.""" 168 if self.equity_curve.empty: 169 return self.initial_capital 170 return float(self.equity_curve.iloc[-1]) 171 172 @property 173 def net_profit(self) -> float: 174 """Net profit in currency units.""" 175 return self.final_equity - self.initial_capital 176 177 @property 178 def net_profit_pct(self) -> float: 179 """Net profit as percentage.""" 180 if self.initial_capital == 0: 181 return 0.0 182 return (self.net_profit / self.initial_capital) * 100 183 184 @property 185 def total_trades(self) -> int: 186 """Total number of closed trades.""" 187 return len([t for t in self.trades if t.is_closed]) 188 189 @property 190 def winning_trades(self) -> int: 191 """Number of profitable trades.""" 192 return len([t for t in self.trades if t.is_closed and (t.profit or 0) > 0]) 193 194 @property 195 def losing_trades(self) -> int: 196 """Number of losing trades.""" 197 return len([t for t in self.trades if t.is_closed and (t.profit or 0) <= 0]) 198 199 @property 200 def win_rate(self) -> float: 201 """Percentage of winning trades.""" 202 if self.total_trades == 0: 203 return 0.0 204 return (self.winning_trades / self.total_trades) * 100 205 206 @property 207 def profit_factor(self) -> float: 208 """Ratio of gross profits to gross losses.""" 209 gross_profit = sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) > 0) 210 gross_loss = abs(sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) < 0)) 211 if gross_loss == 0: 212 return float("inf") if gross_profit > 0 else 0.0 213 return gross_profit / gross_loss 214 215 @property 216 def avg_trade(self) -> float: 217 """Average profit per trade.""" 218 closed = [t for t in self.trades if t.is_closed] 219 if not closed: 220 return 0.0 221 return sum(t.profit or 0 for t in closed) / len(closed) 222 223 @property 224 def avg_winning_trade(self) -> float: 225 """Average profit of winning trades.""" 226 winners = [t for t in self.trades if t.is_closed and (t.profit or 0) > 0] 227 if not winners: 228 return 0.0 229 return sum(t.profit or 0 for t in winners) / len(winners) 230 231 @property 232 def avg_losing_trade(self) -> float: 233 """Average loss of losing trades.""" 234 losers = [t for t in self.trades if t.is_closed and (t.profit or 0) < 0] 235 if not losers: 236 return 0.0 237 return sum(t.profit or 0 for t in losers) / len(losers) 238 239 @property 240 def max_consecutive_wins(self) -> int: 241 """Maximum consecutive winning trades.""" 242 return self._max_consecutive(lambda t: (t.profit or 0) > 0) 243 244 @property 245 def max_consecutive_losses(self) -> int: 246 """Maximum consecutive losing trades.""" 247 return self._max_consecutive(lambda t: (t.profit or 0) <= 0) 248 249 def _max_consecutive(self, condition: Callable[[Trade], bool]) -> int: 250 """Helper to find max consecutive trades matching condition.""" 251 closed = [t for t in self.trades if t.is_closed] 252 if not closed: 253 return 0 254 max_count = 0 255 current_count = 0 256 for trade in closed: 257 if condition(trade): 258 current_count += 1 259 max_count = max(max_count, current_count) 260 else: 261 current_count = 0 262 return max_count 263 264 @property 265 def sharpe_ratio(self) -> float: 266 """ 267 Sharpe ratio (risk-adjusted return). 268 269 Assumes 252 trading days and risk-free rate from current 10Y bond. 270 """ 271 if self.equity_curve.empty or len(self.equity_curve) < 2: 272 return float("nan") 273 274 returns = self.equity_curve.pct_change().dropna() 275 if returns.std() == 0: 276 return float("nan") 277 278 # Get risk-free rate 279 try: 280 from borsapy.bond import risk_free_rate 281 282 rf_annual = risk_free_rate() 283 except Exception: 284 rf_annual = 0.30 # Fallback 30% 285 286 rf_daily = rf_annual / 252 287 excess_returns = returns - rf_daily 288 return float(np.sqrt(252) * excess_returns.mean() / excess_returns.std()) 289 290 @property 291 def sortino_ratio(self) -> float: 292 """ 293 Sortino ratio (downside risk-adjusted return). 294 295 Uses downside deviation instead of standard deviation. 296 """ 297 if self.equity_curve.empty or len(self.equity_curve) < 2: 298 return float("nan") 299 300 returns = self.equity_curve.pct_change().dropna() 301 302 # Get risk-free rate 303 try: 304 from borsapy.bond import risk_free_rate 305 306 rf_annual = risk_free_rate() 307 except Exception: 308 rf_annual = 0.30 309 310 rf_daily = rf_annual / 252 311 excess_returns = returns - rf_daily 312 negative_returns = excess_returns[excess_returns < 0] 313 314 if len(negative_returns) == 0 or negative_returns.std() == 0: 315 return float("inf") if excess_returns.mean() > 0 else float("nan") 316 317 downside_std = negative_returns.std() 318 return float(np.sqrt(252) * excess_returns.mean() / downside_std) 319 320 @property 321 def max_drawdown(self) -> float: 322 """Maximum drawdown as percentage.""" 323 if self.drawdown_curve.empty: 324 return 0.0 325 return float(self.drawdown_curve.min()) * 100 326 327 @property 328 def max_drawdown_duration(self) -> int: 329 """Maximum drawdown duration in days.""" 330 if self.equity_curve.empty: 331 return 0 332 333 # Find periods where we're in drawdown 334 running_max = self.equity_curve.cummax() 335 in_drawdown = self.equity_curve < running_max 336 337 max_duration = 0 338 current_duration = 0 339 340 for is_dd in in_drawdown: 341 if is_dd: 342 current_duration += 1 343 max_duration = max(max_duration, current_duration) 344 else: 345 current_duration = 0 346 347 return max_duration 348 349 @property 350 def buy_hold_return(self) -> float: 351 """Buy & hold return as percentage.""" 352 if self.buy_hold_curve.empty: 353 return 0.0 354 first = self.buy_hold_curve.iloc[0] 355 last = self.buy_hold_curve.iloc[-1] 356 if first == 0: 357 return 0.0 358 return ((last - first) / first) * 100 359 360 @property 361 def vs_buy_hold(self) -> float: 362 """Strategy outperformance vs buy & hold (percentage points).""" 363 return self.net_profit_pct - self.buy_hold_return 364 365 @property 366 def calmar_ratio(self) -> float: 367 """Calmar ratio (annualized return / max drawdown).""" 368 if self.max_drawdown == 0: 369 return float("inf") if self.net_profit_pct > 0 else 0.0 370 # Annualize return (assuming 252 trading days) 371 trading_days = len(self.equity_curve) 372 if trading_days == 0: 373 return 0.0 374 annual_return = self.net_profit_pct * (252 / trading_days) 375 return annual_return / abs(self.max_drawdown) 376 377 # === Export Methods === 378 379 @property 380 def trades_df(self) -> pd.DataFrame: 381 """Get trades as DataFrame.""" 382 if not self.trades: 383 return pd.DataFrame( 384 columns=[ 385 "entry_time", 386 "entry_price", 387 "exit_time", 388 "exit_price", 389 "side", 390 "shares", 391 "commission", 392 "profit", 393 "profit_pct", 394 "duration", 395 ] 396 ) 397 return pd.DataFrame([t.to_dict() for t in self.trades]) 398 399 def to_dict(self) -> dict[str, Any]: 400 """ 401 Export results to dictionary. 402 403 Compatible with TradingView/Mathieu2301 format. 404 """ 405 return { 406 # Identification 407 "symbol": self.symbol, 408 "period": self.period, 409 "interval": self.interval, 410 "strategy_name": self.strategy_name, 411 # Configuration 412 "initial_capital": self.initial_capital, 413 "commission": self.commission, 414 # Summary 415 "net_profit": round(self.net_profit, 2), 416 "net_profit_pct": round(self.net_profit_pct, 2), 417 "final_equity": round(self.final_equity, 2), 418 # Trade Statistics 419 "total_trades": self.total_trades, 420 "winning_trades": self.winning_trades, 421 "losing_trades": self.losing_trades, 422 "win_rate": round(self.win_rate, 2), 423 "profit_factor": round(self.profit_factor, 2) if self.profit_factor != float("inf") else "inf", 424 "avg_trade": round(self.avg_trade, 2), 425 "avg_winning_trade": round(self.avg_winning_trade, 2), 426 "avg_losing_trade": round(self.avg_losing_trade, 2), 427 "max_consecutive_wins": self.max_consecutive_wins, 428 "max_consecutive_losses": self.max_consecutive_losses, 429 # Risk Metrics 430 "sharpe_ratio": round(self.sharpe_ratio, 2) if not np.isnan(self.sharpe_ratio) else None, 431 "sortino_ratio": round(self.sortino_ratio, 2) if not np.isnan(self.sortino_ratio) and self.sortino_ratio != float("inf") else None, 432 "calmar_ratio": round(self.calmar_ratio, 2) if self.calmar_ratio != float("inf") else None, 433 "max_drawdown": round(self.max_drawdown, 2), 434 "max_drawdown_duration": self.max_drawdown_duration, 435 # Comparison 436 "buy_hold_return": round(self.buy_hold_return, 2), 437 "vs_buy_hold": round(self.vs_buy_hold, 2), 438 } 439 440 def summary(self) -> str: 441 """ 442 Generate human-readable performance summary. 443 444 Returns: 445 Formatted summary string. 446 """ 447 d = self.to_dict() 448 449 lines = [ 450 "=" * 60, 451 f"BACKTEST RESULTS: {d['symbol']} ({d['strategy_name']})", 452 "=" * 60, 453 f"Period: {d['period']} | Interval: {d['interval']}", 454 f"Initial Capital: {d['initial_capital']:,.2f} TL", 455 f"Commission: {d['commission']*100:.2f}%", 456 "", 457 "--- PERFORMANCE ---", 458 f"Net Profit: {d['net_profit']:,.2f} TL ({d['net_profit_pct']:+.2f}%)", 459 f"Final Equity: {d['final_equity']:,.2f} TL", 460 f"Buy & Hold: {d['buy_hold_return']:+.2f}%", 461 f"vs B&H: {d['vs_buy_hold']:+.2f}%", 462 "", 463 "--- TRADE STATISTICS ---", 464 f"Total Trades: {d['total_trades']}", 465 f"Winning: {d['winning_trades']} | Losing: {d['losing_trades']}", 466 f"Win Rate: {d['win_rate']:.1f}%", 467 f"Profit Factor: {d['profit_factor']}", 468 f"Avg Trade: {d['avg_trade']:,.2f} TL", 469 f"Avg Winner: {d['avg_winning_trade']:,.2f} TL | Avg Loser: {d['avg_losing_trade']:,.2f} TL", 470 f"Max Consecutive Wins: {d['max_consecutive_wins']} | Losses: {d['max_consecutive_losses']}", 471 "", 472 "--- RISK METRICS ---", 473 f"Sharpe Ratio: {d['sharpe_ratio'] if d['sharpe_ratio'] else 'N/A'}", 474 f"Sortino Ratio: {d['sortino_ratio'] if d['sortino_ratio'] else 'N/A'}", 475 f"Calmar Ratio: {d['calmar_ratio'] if d['calmar_ratio'] else 'N/A'}", 476 f"Max Drawdown: {d['max_drawdown']:.2f}%", 477 f"Max DD Duration: {d['max_drawdown_duration']} days", 478 "=" * 60, 479 ] 480 481 return "\n".join(lines) 482 483 484class Backtest: 485 """ 486 Backtest engine for evaluating trading strategies. 487 488 Runs a strategy function over historical data and calculates 489 comprehensive performance metrics. 490 491 Attributes: 492 symbol: Stock symbol to backtest. 493 strategy: Strategy function to evaluate. 494 period: Historical data period. 495 interval: Data interval (e.g., "1d", "1h"). 496 capital: Initial capital. 497 commission: Commission rate per trade (e.g., 0.001 = 0.1%). 498 indicators: List of indicators to calculate. 499 500 Examples: 501 >>> def my_strategy(candle, position, indicators): 502 ... if indicators['rsi'] < 30: 503 ... return 'BUY' 504 ... elif indicators['rsi'] > 70: 505 ... return 'SELL' 506 ... return 'HOLD' 507 508 >>> bt = Backtest("THYAO", my_strategy, period="1y") 509 >>> result = bt.run() 510 >>> print(result.sharpe_ratio) 511 """ 512 513 # Indicator period warmup 514 WARMUP_PERIOD = 50 515 516 def __init__( 517 self, 518 symbol: str, 519 strategy: StrategyFunc, 520 period: str = "1y", 521 interval: str = "1d", 522 capital: float = 100_000.0, 523 commission: float = 0.001, 524 indicators: list[str] | None = None, 525 slippage: float = 0.0, # Future use 526 ): 527 """ 528 Initialize Backtest. 529 530 Args: 531 symbol: Stock symbol (e.g., "THYAO"). 532 strategy: Strategy function with signature: 533 strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None 534 period: Historical data period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y). 535 interval: Data interval (1m, 5m, 15m, 30m, 1h, 4h, 1d). 536 capital: Initial capital in TL. 537 commission: Commission rate per trade (0.001 = 0.1%). 538 indicators: List of indicators to calculate. Options: 539 'rsi', 'rsi_7', 'sma_20', 'sma_50', 'sma_200', 540 'ema_12', 'ema_26', 'ema_50', 'macd', 'bollinger', 541 'atr', 'atr_20', 'stochastic', 'adx' 542 slippage: Slippage per trade (for future use). 543 """ 544 self.symbol = symbol.upper() 545 self.strategy = strategy 546 self.period = period 547 self.interval = interval 548 self.capital = capital 549 self.commission = commission 550 self.indicators = indicators or ["rsi", "sma_20", "ema_12", "macd"] 551 self.slippage = slippage 552 553 # Strategy name for reporting 554 self._strategy_name = getattr(strategy, "__name__", "custom_strategy") 555 556 # Data storage 557 self._df: pd.DataFrame | None = None 558 self._df_with_indicators: pd.DataFrame | None = None 559 560 def _load_data(self) -> pd.DataFrame: 561 """Load historical data from Ticker.""" 562 from borsapy.ticker import Ticker 563 564 ticker = Ticker(self.symbol) 565 df = ticker.history(period=self.period, interval=self.interval) 566 567 if df is None or df.empty: 568 raise ValueError(f"No historical data available for {self.symbol}") 569 570 return df 571 572 def _calculate_indicators(self, df: pd.DataFrame) -> pd.DataFrame: 573 """Add indicator columns to DataFrame.""" 574 from borsapy.technical import ( 575 calculate_adx, 576 calculate_atr, 577 calculate_bollinger_bands, 578 calculate_ema, 579 calculate_macd, 580 calculate_rsi, 581 calculate_sma, 582 calculate_stochastic, 583 ) 584 585 result = df.copy() 586 587 for ind in self.indicators: 588 ind_lower = ind.lower() 589 590 # RSI variants 591 if ind_lower == "rsi": 592 result["rsi"] = calculate_rsi(df, period=14) 593 elif ind_lower.startswith("rsi_"): 594 try: 595 period = int(ind_lower.split("_")[1]) 596 result[f"rsi_{period}"] = calculate_rsi(df, period=period) 597 except (IndexError, ValueError): 598 pass 599 600 # SMA variants 601 elif ind_lower.startswith("sma_"): 602 try: 603 period = int(ind_lower.split("_")[1]) 604 result[f"sma_{period}"] = calculate_sma(df, period=period) 605 except (IndexError, ValueError): 606 pass 607 608 # EMA variants 609 elif ind_lower.startswith("ema_"): 610 try: 611 period = int(ind_lower.split("_")[1]) 612 result[f"ema_{period}"] = calculate_ema(df, period=period) 613 except (IndexError, ValueError): 614 pass 615 616 # MACD 617 elif ind_lower == "macd": 618 macd_df = calculate_macd(df) 619 result["macd"] = macd_df["MACD"] 620 result["macd_signal"] = macd_df["Signal"] 621 result["macd_histogram"] = macd_df["Histogram"] 622 623 # Bollinger Bands 624 elif ind_lower in ("bollinger", "bb"): 625 bb_df = calculate_bollinger_bands(df) 626 result["bb_upper"] = bb_df["BB_Upper"] 627 result["bb_middle"] = bb_df["BB_Middle"] 628 result["bb_lower"] = bb_df["BB_Lower"] 629 630 # ATR variants 631 elif ind_lower == "atr": 632 result["atr"] = calculate_atr(df, period=14) 633 elif ind_lower.startswith("atr_"): 634 try: 635 period = int(ind_lower.split("_")[1]) 636 result[f"atr_{period}"] = calculate_atr(df, period=period) 637 except (IndexError, ValueError): 638 pass 639 640 # Stochastic 641 elif ind_lower in ("stochastic", "stoch"): 642 stoch_df = calculate_stochastic(df) 643 result["stoch_k"] = stoch_df["Stoch_K"] 644 result["stoch_d"] = stoch_df["Stoch_D"] 645 646 # ADX 647 elif ind_lower == "adx": 648 result["adx"] = calculate_adx(df, period=14) 649 650 return result 651 652 def _get_indicators_at(self, idx: int) -> dict[str, float]: 653 """Get indicator values at specific index.""" 654 if self._df_with_indicators is None: 655 return {} 656 657 row = self._df_with_indicators.iloc[idx] 658 indicators = {} 659 660 # Extract all non-OHLCV columns as indicators 661 exclude_cols = {"Open", "High", "Low", "Close", "Volume", "Adj Close"} 662 663 for col in self._df_with_indicators.columns: 664 if col not in exclude_cols: 665 val = row[col] 666 if pd.notna(val): 667 indicators[col] = float(val) 668 669 return indicators 670 671 def _build_candle(self, idx: int) -> dict[str, Any]: 672 """Build candle dict from DataFrame row.""" 673 if self._df is None: 674 return {} 675 676 row = self._df.iloc[idx] 677 timestamp = self._df.index[idx] 678 679 if isinstance(timestamp, pd.Timestamp): 680 timestamp = timestamp.to_pydatetime() 681 682 return { 683 "timestamp": timestamp, 684 "open": float(row["Open"]), 685 "high": float(row["High"]), 686 "low": float(row["Low"]), 687 "close": float(row["Close"]), 688 "volume": float(row.get("Volume", 0)) if "Volume" in row else 0, 689 "_index": idx, 690 } 691 692 def run(self) -> BacktestResult: 693 """ 694 Run the backtest. 695 696 Returns: 697 BacktestResult with all performance metrics. 698 699 Raises: 700 ValueError: If no data available for symbol. 701 """ 702 # Load data 703 self._df = self._load_data() 704 self._df_with_indicators = self._calculate_indicators(self._df) 705 706 # Initialize state 707 cash = self.capital 708 position: Position = None 709 shares = 0.0 710 trades: list[Trade] = [] 711 current_trade: Trade | None = None 712 713 # Track equity curve 714 equity_values = [] 715 dates = [] 716 717 # Buy & hold tracking 718 initial_price = self._df["Close"].iloc[self.WARMUP_PERIOD] 719 bh_shares = self.capital / initial_price 720 721 # Run simulation 722 for idx in range(self.WARMUP_PERIOD, len(self._df)): 723 candle = self._build_candle(idx) 724 indicators = self._get_indicators_at(idx) 725 price = candle["close"] 726 timestamp = candle["timestamp"] 727 728 # Get strategy signal 729 try: 730 signal = self.strategy(candle, position, indicators) 731 except Exception: 732 signal = "HOLD" 733 734 # Execute trades 735 if signal == "BUY" and position is None: 736 # Calculate shares to buy (use all available cash) 737 entry_commission = cash * self.commission 738 available = cash - entry_commission 739 shares = available / price 740 741 current_trade = Trade( 742 entry_time=timestamp, 743 entry_price=price, 744 side="long", 745 shares=shares, 746 commission=entry_commission, 747 ) 748 749 cash = 0.0 750 position = "long" 751 752 elif signal == "SELL" and position == "long" and current_trade is not None: 753 # Close position 754 exit_value = shares * price 755 exit_commission = exit_value * self.commission 756 757 current_trade.exit_time = timestamp 758 current_trade.exit_price = price 759 current_trade.commission += exit_commission 760 761 trades.append(current_trade) 762 763 cash = exit_value - exit_commission 764 shares = 0.0 765 position = None 766 current_trade = None 767 768 # Track equity 769 if position == "long": 770 equity = shares * price 771 else: 772 equity = cash 773 774 equity_values.append(equity) 775 dates.append(timestamp) 776 777 # Close any open position at end 778 if position == "long" and current_trade is not None: 779 final_price = self._df["Close"].iloc[-1] 780 exit_value = shares * final_price 781 exit_commission = exit_value * self.commission 782 783 current_trade.exit_time = self._df.index[-1] 784 if isinstance(current_trade.exit_time, pd.Timestamp): 785 current_trade.exit_time = current_trade.exit_time.to_pydatetime() 786 current_trade.exit_price = final_price 787 current_trade.commission += exit_commission 788 789 trades.append(current_trade) 790 791 # Build curves 792 equity_curve = pd.Series(equity_values, index=pd.DatetimeIndex(dates)) 793 794 # Calculate drawdown curve 795 running_max = equity_curve.cummax() 796 drawdown_curve = (equity_curve - running_max) / running_max 797 798 # Buy & hold curve 799 bh_values = self._df["Close"].iloc[self.WARMUP_PERIOD:] * bh_shares 800 buy_hold_curve = pd.Series(bh_values.values, index=pd.DatetimeIndex(dates)) 801 802 return BacktestResult( 803 symbol=self.symbol, 804 period=self.period, 805 interval=self.interval, 806 strategy_name=self._strategy_name, 807 initial_capital=self.capital, 808 commission=self.commission, 809 trades=trades, 810 equity_curve=equity_curve, 811 drawdown_curve=drawdown_curve, 812 buy_hold_curve=buy_hold_curve, 813 ) 814 815 816def backtest( 817 symbol: str, 818 strategy: StrategyFunc, 819 period: str = "1y", 820 interval: str = "1d", 821 capital: float = 100_000.0, 822 commission: float = 0.001, 823 indicators: list[str] | None = None, 824) -> BacktestResult: 825 """ 826 Run a backtest with a single function call. 827 828 Convenience function that creates a Backtest instance and runs it. 829 830 Args: 831 symbol: Stock symbol (e.g., "THYAO"). 832 strategy: Strategy function with signature: 833 strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None 834 period: Historical data period. 835 interval: Data interval. 836 capital: Initial capital. 837 commission: Commission rate. 838 indicators: List of indicators to calculate. 839 840 Returns: 841 BacktestResult with all performance metrics. 842 843 Examples: 844 >>> def rsi_strategy(candle, position, indicators): 845 ... if indicators.get('rsi', 50) < 30 and position is None: 846 ... return 'BUY' 847 ... elif indicators.get('rsi', 50) > 70 and position == 'long': 848 ... return 'SELL' 849 ... return 'HOLD' 850 851 >>> result = bp.backtest("THYAO", rsi_strategy, period="1y") 852 >>> print(f"Net Profit: {result.net_profit_pct:.2f}%") 853 >>> print(f"Sharpe: {result.sharpe_ratio:.2f}") 854 """ 855 bt = Backtest( 856 symbol=symbol, 857 strategy=strategy, 858 period=period, 859 interval=interval, 860 capital=capital, 861 commission=commission, 862 indicators=indicators, 863 ) 864 return bt.run()
52@dataclass 53class Trade: 54 """ 55 Represents a single trade in a backtest. 56 57 Attributes: 58 entry_time: When the trade was opened. 59 entry_price: Price at entry. 60 exit_time: When the trade was closed (None if open). 61 exit_price: Price at exit (None if open). 62 side: Trade direction ('long' or 'short'). 63 shares: Number of shares traded. 64 commission: Total commission paid (entry + exit). 65 """ 66 67 entry_time: datetime 68 entry_price: float 69 exit_time: datetime | None = None 70 exit_price: float | None = None 71 side: Literal["long", "short"] = "long" 72 shares: float = 0.0 73 commission: float = 0.0 74 75 @property 76 def is_closed(self) -> bool: 77 """Check if trade is closed.""" 78 return self.exit_time is not None and self.exit_price is not None 79 80 @property 81 def profit(self) -> float | None: 82 """Calculate profit in currency units (None if open).""" 83 if not self.is_closed: 84 return None 85 assert self.exit_price is not None 86 if self.side == "long": 87 gross = (self.exit_price - self.entry_price) * self.shares 88 else: 89 gross = (self.entry_price - self.exit_price) * self.shares 90 return gross - self.commission 91 92 @property 93 def profit_pct(self) -> float | None: 94 """Calculate profit as percentage (None if open).""" 95 if not self.is_closed or self.entry_price == 0: 96 return None 97 profit = self.profit 98 if profit is None: 99 return None 100 entry_value = self.entry_price * self.shares 101 return (profit / entry_value) * 100 102 103 @property 104 def duration(self) -> float | None: 105 """Trade duration in days (None if open).""" 106 if not self.is_closed: 107 return None 108 assert self.exit_time is not None 109 delta = self.exit_time - self.entry_time 110 return delta.total_seconds() / 86400 # Convert to days 111 112 def to_dict(self) -> dict[str, Any]: 113 """Convert trade to dictionary.""" 114 return { 115 "entry_time": self.entry_time, 116 "entry_price": self.entry_price, 117 "exit_time": self.exit_time, 118 "exit_price": self.exit_price, 119 "side": self.side, 120 "shares": self.shares, 121 "commission": self.commission, 122 "profit": self.profit, 123 "profit_pct": self.profit_pct, 124 "duration": self.duration, 125 }
Represents a single trade in a backtest.
Attributes: entry_time: When the trade was opened. entry_price: Price at entry. exit_time: When the trade was closed (None if open). exit_price: Price at exit (None if open). side: Trade direction ('long' or 'short'). shares: Number of shares traded. commission: Total commission paid (entry + exit).
75 @property 76 def is_closed(self) -> bool: 77 """Check if trade is closed.""" 78 return self.exit_time is not None and self.exit_price is not None
Check if trade is closed.
80 @property 81 def profit(self) -> float | None: 82 """Calculate profit in currency units (None if open).""" 83 if not self.is_closed: 84 return None 85 assert self.exit_price is not None 86 if self.side == "long": 87 gross = (self.exit_price - self.entry_price) * self.shares 88 else: 89 gross = (self.entry_price - self.exit_price) * self.shares 90 return gross - self.commission
Calculate profit in currency units (None if open).
92 @property 93 def profit_pct(self) -> float | None: 94 """Calculate profit as percentage (None if open).""" 95 if not self.is_closed or self.entry_price == 0: 96 return None 97 profit = self.profit 98 if profit is None: 99 return None 100 entry_value = self.entry_price * self.shares 101 return (profit / entry_value) * 100
Calculate profit as percentage (None if open).
103 @property 104 def duration(self) -> float | None: 105 """Trade duration in days (None if open).""" 106 if not self.is_closed: 107 return None 108 assert self.exit_time is not None 109 delta = self.exit_time - self.entry_time 110 return delta.total_seconds() / 86400 # Convert to days
Trade duration in days (None if open).
112 def to_dict(self) -> dict[str, Any]: 113 """Convert trade to dictionary.""" 114 return { 115 "entry_time": self.entry_time, 116 "entry_price": self.entry_price, 117 "exit_time": self.exit_time, 118 "exit_price": self.exit_price, 119 "side": self.side, 120 "shares": self.shares, 121 "commission": self.commission, 122 "profit": self.profit, 123 "profit_pct": self.profit_pct, 124 "duration": self.duration, 125 }
Convert trade to dictionary.
128@dataclass 129class BacktestResult: 130 """ 131 Comprehensive backtest results with performance metrics. 132 133 Follows TradingView/Mathieu2301 result format for familiarity. 134 135 Attributes: 136 symbol: Traded symbol. 137 period: Test period (e.g., "1y"). 138 interval: Data interval (e.g., "1d"). 139 strategy_name: Name of the strategy function. 140 initial_capital: Starting capital. 141 commission: Commission rate used. 142 trades: List of executed trades. 143 equity_curve: Daily equity values. 144 drawdown_curve: Daily drawdown values. 145 buy_hold_curve: Buy & hold comparison values. 146 """ 147 148 # Identification 149 symbol: str 150 period: str 151 interval: str 152 strategy_name: str 153 154 # Configuration 155 initial_capital: float 156 commission: float 157 158 # Results 159 trades: list[Trade] = field(default_factory=list) 160 equity_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float)) 161 drawdown_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float)) 162 buy_hold_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float)) 163 164 # === Performance Properties === 165 166 @property 167 def final_equity(self) -> float: 168 """Final portfolio value.""" 169 if self.equity_curve.empty: 170 return self.initial_capital 171 return float(self.equity_curve.iloc[-1]) 172 173 @property 174 def net_profit(self) -> float: 175 """Net profit in currency units.""" 176 return self.final_equity - self.initial_capital 177 178 @property 179 def net_profit_pct(self) -> float: 180 """Net profit as percentage.""" 181 if self.initial_capital == 0: 182 return 0.0 183 return (self.net_profit / self.initial_capital) * 100 184 185 @property 186 def total_trades(self) -> int: 187 """Total number of closed trades.""" 188 return len([t for t in self.trades if t.is_closed]) 189 190 @property 191 def winning_trades(self) -> int: 192 """Number of profitable trades.""" 193 return len([t for t in self.trades if t.is_closed and (t.profit or 0) > 0]) 194 195 @property 196 def losing_trades(self) -> int: 197 """Number of losing trades.""" 198 return len([t for t in self.trades if t.is_closed and (t.profit or 0) <= 0]) 199 200 @property 201 def win_rate(self) -> float: 202 """Percentage of winning trades.""" 203 if self.total_trades == 0: 204 return 0.0 205 return (self.winning_trades / self.total_trades) * 100 206 207 @property 208 def profit_factor(self) -> float: 209 """Ratio of gross profits to gross losses.""" 210 gross_profit = sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) > 0) 211 gross_loss = abs(sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) < 0)) 212 if gross_loss == 0: 213 return float("inf") if gross_profit > 0 else 0.0 214 return gross_profit / gross_loss 215 216 @property 217 def avg_trade(self) -> float: 218 """Average profit per trade.""" 219 closed = [t for t in self.trades if t.is_closed] 220 if not closed: 221 return 0.0 222 return sum(t.profit or 0 for t in closed) / len(closed) 223 224 @property 225 def avg_winning_trade(self) -> float: 226 """Average profit of winning trades.""" 227 winners = [t for t in self.trades if t.is_closed and (t.profit or 0) > 0] 228 if not winners: 229 return 0.0 230 return sum(t.profit or 0 for t in winners) / len(winners) 231 232 @property 233 def avg_losing_trade(self) -> float: 234 """Average loss of losing trades.""" 235 losers = [t for t in self.trades if t.is_closed and (t.profit or 0) < 0] 236 if not losers: 237 return 0.0 238 return sum(t.profit or 0 for t in losers) / len(losers) 239 240 @property 241 def max_consecutive_wins(self) -> int: 242 """Maximum consecutive winning trades.""" 243 return self._max_consecutive(lambda t: (t.profit or 0) > 0) 244 245 @property 246 def max_consecutive_losses(self) -> int: 247 """Maximum consecutive losing trades.""" 248 return self._max_consecutive(lambda t: (t.profit or 0) <= 0) 249 250 def _max_consecutive(self, condition: Callable[[Trade], bool]) -> int: 251 """Helper to find max consecutive trades matching condition.""" 252 closed = [t for t in self.trades if t.is_closed] 253 if not closed: 254 return 0 255 max_count = 0 256 current_count = 0 257 for trade in closed: 258 if condition(trade): 259 current_count += 1 260 max_count = max(max_count, current_count) 261 else: 262 current_count = 0 263 return max_count 264 265 @property 266 def sharpe_ratio(self) -> float: 267 """ 268 Sharpe ratio (risk-adjusted return). 269 270 Assumes 252 trading days and risk-free rate from current 10Y bond. 271 """ 272 if self.equity_curve.empty or len(self.equity_curve) < 2: 273 return float("nan") 274 275 returns = self.equity_curve.pct_change().dropna() 276 if returns.std() == 0: 277 return float("nan") 278 279 # Get risk-free rate 280 try: 281 from borsapy.bond import risk_free_rate 282 283 rf_annual = risk_free_rate() 284 except Exception: 285 rf_annual = 0.30 # Fallback 30% 286 287 rf_daily = rf_annual / 252 288 excess_returns = returns - rf_daily 289 return float(np.sqrt(252) * excess_returns.mean() / excess_returns.std()) 290 291 @property 292 def sortino_ratio(self) -> float: 293 """ 294 Sortino ratio (downside risk-adjusted return). 295 296 Uses downside deviation instead of standard deviation. 297 """ 298 if self.equity_curve.empty or len(self.equity_curve) < 2: 299 return float("nan") 300 301 returns = self.equity_curve.pct_change().dropna() 302 303 # Get risk-free rate 304 try: 305 from borsapy.bond import risk_free_rate 306 307 rf_annual = risk_free_rate() 308 except Exception: 309 rf_annual = 0.30 310 311 rf_daily = rf_annual / 252 312 excess_returns = returns - rf_daily 313 negative_returns = excess_returns[excess_returns < 0] 314 315 if len(negative_returns) == 0 or negative_returns.std() == 0: 316 return float("inf") if excess_returns.mean() > 0 else float("nan") 317 318 downside_std = negative_returns.std() 319 return float(np.sqrt(252) * excess_returns.mean() / downside_std) 320 321 @property 322 def max_drawdown(self) -> float: 323 """Maximum drawdown as percentage.""" 324 if self.drawdown_curve.empty: 325 return 0.0 326 return float(self.drawdown_curve.min()) * 100 327 328 @property 329 def max_drawdown_duration(self) -> int: 330 """Maximum drawdown duration in days.""" 331 if self.equity_curve.empty: 332 return 0 333 334 # Find periods where we're in drawdown 335 running_max = self.equity_curve.cummax() 336 in_drawdown = self.equity_curve < running_max 337 338 max_duration = 0 339 current_duration = 0 340 341 for is_dd in in_drawdown: 342 if is_dd: 343 current_duration += 1 344 max_duration = max(max_duration, current_duration) 345 else: 346 current_duration = 0 347 348 return max_duration 349 350 @property 351 def buy_hold_return(self) -> float: 352 """Buy & hold return as percentage.""" 353 if self.buy_hold_curve.empty: 354 return 0.0 355 first = self.buy_hold_curve.iloc[0] 356 last = self.buy_hold_curve.iloc[-1] 357 if first == 0: 358 return 0.0 359 return ((last - first) / first) * 100 360 361 @property 362 def vs_buy_hold(self) -> float: 363 """Strategy outperformance vs buy & hold (percentage points).""" 364 return self.net_profit_pct - self.buy_hold_return 365 366 @property 367 def calmar_ratio(self) -> float: 368 """Calmar ratio (annualized return / max drawdown).""" 369 if self.max_drawdown == 0: 370 return float("inf") if self.net_profit_pct > 0 else 0.0 371 # Annualize return (assuming 252 trading days) 372 trading_days = len(self.equity_curve) 373 if trading_days == 0: 374 return 0.0 375 annual_return = self.net_profit_pct * (252 / trading_days) 376 return annual_return / abs(self.max_drawdown) 377 378 # === Export Methods === 379 380 @property 381 def trades_df(self) -> pd.DataFrame: 382 """Get trades as DataFrame.""" 383 if not self.trades: 384 return pd.DataFrame( 385 columns=[ 386 "entry_time", 387 "entry_price", 388 "exit_time", 389 "exit_price", 390 "side", 391 "shares", 392 "commission", 393 "profit", 394 "profit_pct", 395 "duration", 396 ] 397 ) 398 return pd.DataFrame([t.to_dict() for t in self.trades]) 399 400 def to_dict(self) -> dict[str, Any]: 401 """ 402 Export results to dictionary. 403 404 Compatible with TradingView/Mathieu2301 format. 405 """ 406 return { 407 # Identification 408 "symbol": self.symbol, 409 "period": self.period, 410 "interval": self.interval, 411 "strategy_name": self.strategy_name, 412 # Configuration 413 "initial_capital": self.initial_capital, 414 "commission": self.commission, 415 # Summary 416 "net_profit": round(self.net_profit, 2), 417 "net_profit_pct": round(self.net_profit_pct, 2), 418 "final_equity": round(self.final_equity, 2), 419 # Trade Statistics 420 "total_trades": self.total_trades, 421 "winning_trades": self.winning_trades, 422 "losing_trades": self.losing_trades, 423 "win_rate": round(self.win_rate, 2), 424 "profit_factor": round(self.profit_factor, 2) if self.profit_factor != float("inf") else "inf", 425 "avg_trade": round(self.avg_trade, 2), 426 "avg_winning_trade": round(self.avg_winning_trade, 2), 427 "avg_losing_trade": round(self.avg_losing_trade, 2), 428 "max_consecutive_wins": self.max_consecutive_wins, 429 "max_consecutive_losses": self.max_consecutive_losses, 430 # Risk Metrics 431 "sharpe_ratio": round(self.sharpe_ratio, 2) if not np.isnan(self.sharpe_ratio) else None, 432 "sortino_ratio": round(self.sortino_ratio, 2) if not np.isnan(self.sortino_ratio) and self.sortino_ratio != float("inf") else None, 433 "calmar_ratio": round(self.calmar_ratio, 2) if self.calmar_ratio != float("inf") else None, 434 "max_drawdown": round(self.max_drawdown, 2), 435 "max_drawdown_duration": self.max_drawdown_duration, 436 # Comparison 437 "buy_hold_return": round(self.buy_hold_return, 2), 438 "vs_buy_hold": round(self.vs_buy_hold, 2), 439 } 440 441 def summary(self) -> str: 442 """ 443 Generate human-readable performance summary. 444 445 Returns: 446 Formatted summary string. 447 """ 448 d = self.to_dict() 449 450 lines = [ 451 "=" * 60, 452 f"BACKTEST RESULTS: {d['symbol']} ({d['strategy_name']})", 453 "=" * 60, 454 f"Period: {d['period']} | Interval: {d['interval']}", 455 f"Initial Capital: {d['initial_capital']:,.2f} TL", 456 f"Commission: {d['commission']*100:.2f}%", 457 "", 458 "--- PERFORMANCE ---", 459 f"Net Profit: {d['net_profit']:,.2f} TL ({d['net_profit_pct']:+.2f}%)", 460 f"Final Equity: {d['final_equity']:,.2f} TL", 461 f"Buy & Hold: {d['buy_hold_return']:+.2f}%", 462 f"vs B&H: {d['vs_buy_hold']:+.2f}%", 463 "", 464 "--- TRADE STATISTICS ---", 465 f"Total Trades: {d['total_trades']}", 466 f"Winning: {d['winning_trades']} | Losing: {d['losing_trades']}", 467 f"Win Rate: {d['win_rate']:.1f}%", 468 f"Profit Factor: {d['profit_factor']}", 469 f"Avg Trade: {d['avg_trade']:,.2f} TL", 470 f"Avg Winner: {d['avg_winning_trade']:,.2f} TL | Avg Loser: {d['avg_losing_trade']:,.2f} TL", 471 f"Max Consecutive Wins: {d['max_consecutive_wins']} | Losses: {d['max_consecutive_losses']}", 472 "", 473 "--- RISK METRICS ---", 474 f"Sharpe Ratio: {d['sharpe_ratio'] if d['sharpe_ratio'] else 'N/A'}", 475 f"Sortino Ratio: {d['sortino_ratio'] if d['sortino_ratio'] else 'N/A'}", 476 f"Calmar Ratio: {d['calmar_ratio'] if d['calmar_ratio'] else 'N/A'}", 477 f"Max Drawdown: {d['max_drawdown']:.2f}%", 478 f"Max DD Duration: {d['max_drawdown_duration']} days", 479 "=" * 60, 480 ] 481 482 return "\n".join(lines)
Comprehensive backtest results with performance metrics.
Follows TradingView/Mathieu2301 result format for familiarity.
Attributes: symbol: Traded symbol. period: Test period (e.g., "1y"). interval: Data interval (e.g., "1d"). strategy_name: Name of the strategy function. initial_capital: Starting capital. commission: Commission rate used. trades: List of executed trades. equity_curve: Daily equity values. drawdown_curve: Daily drawdown values. buy_hold_curve: Buy & hold comparison values.
166 @property 167 def final_equity(self) -> float: 168 """Final portfolio value.""" 169 if self.equity_curve.empty: 170 return self.initial_capital 171 return float(self.equity_curve.iloc[-1])
Final portfolio value.
173 @property 174 def net_profit(self) -> float: 175 """Net profit in currency units.""" 176 return self.final_equity - self.initial_capital
Net profit in currency units.
178 @property 179 def net_profit_pct(self) -> float: 180 """Net profit as percentage.""" 181 if self.initial_capital == 0: 182 return 0.0 183 return (self.net_profit / self.initial_capital) * 100
Net profit as percentage.
185 @property 186 def total_trades(self) -> int: 187 """Total number of closed trades.""" 188 return len([t for t in self.trades if t.is_closed])
Total number of closed trades.
190 @property 191 def winning_trades(self) -> int: 192 """Number of profitable trades.""" 193 return len([t for t in self.trades if t.is_closed and (t.profit or 0) > 0])
Number of profitable trades.
195 @property 196 def losing_trades(self) -> int: 197 """Number of losing trades.""" 198 return len([t for t in self.trades if t.is_closed and (t.profit or 0) <= 0])
Number of losing trades.
200 @property 201 def win_rate(self) -> float: 202 """Percentage of winning trades.""" 203 if self.total_trades == 0: 204 return 0.0 205 return (self.winning_trades / self.total_trades) * 100
Percentage of winning trades.
207 @property 208 def profit_factor(self) -> float: 209 """Ratio of gross profits to gross losses.""" 210 gross_profit = sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) > 0) 211 gross_loss = abs(sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) < 0)) 212 if gross_loss == 0: 213 return float("inf") if gross_profit > 0 else 0.0 214 return gross_profit / gross_loss
Ratio of gross profits to gross losses.
216 @property 217 def avg_trade(self) -> float: 218 """Average profit per trade.""" 219 closed = [t for t in self.trades if t.is_closed] 220 if not closed: 221 return 0.0 222 return sum(t.profit or 0 for t in closed) / len(closed)
Average profit per trade.
224 @property 225 def avg_winning_trade(self) -> float: 226 """Average profit of winning trades.""" 227 winners = [t for t in self.trades if t.is_closed and (t.profit or 0) > 0] 228 if not winners: 229 return 0.0 230 return sum(t.profit or 0 for t in winners) / len(winners)
Average profit of winning trades.
232 @property 233 def avg_losing_trade(self) -> float: 234 """Average loss of losing trades.""" 235 losers = [t for t in self.trades if t.is_closed and (t.profit or 0) < 0] 236 if not losers: 237 return 0.0 238 return sum(t.profit or 0 for t in losers) / len(losers)
Average loss of losing trades.
240 @property 241 def max_consecutive_wins(self) -> int: 242 """Maximum consecutive winning trades.""" 243 return self._max_consecutive(lambda t: (t.profit or 0) > 0)
Maximum consecutive winning trades.
245 @property 246 def max_consecutive_losses(self) -> int: 247 """Maximum consecutive losing trades.""" 248 return self._max_consecutive(lambda t: (t.profit or 0) <= 0)
Maximum consecutive losing trades.
265 @property 266 def sharpe_ratio(self) -> float: 267 """ 268 Sharpe ratio (risk-adjusted return). 269 270 Assumes 252 trading days and risk-free rate from current 10Y bond. 271 """ 272 if self.equity_curve.empty or len(self.equity_curve) < 2: 273 return float("nan") 274 275 returns = self.equity_curve.pct_change().dropna() 276 if returns.std() == 0: 277 return float("nan") 278 279 # Get risk-free rate 280 try: 281 from borsapy.bond import risk_free_rate 282 283 rf_annual = risk_free_rate() 284 except Exception: 285 rf_annual = 0.30 # Fallback 30% 286 287 rf_daily = rf_annual / 252 288 excess_returns = returns - rf_daily 289 return float(np.sqrt(252) * excess_returns.mean() / excess_returns.std())
Sharpe ratio (risk-adjusted return).
Assumes 252 trading days and risk-free rate from current 10Y bond.
291 @property 292 def sortino_ratio(self) -> float: 293 """ 294 Sortino ratio (downside risk-adjusted return). 295 296 Uses downside deviation instead of standard deviation. 297 """ 298 if self.equity_curve.empty or len(self.equity_curve) < 2: 299 return float("nan") 300 301 returns = self.equity_curve.pct_change().dropna() 302 303 # Get risk-free rate 304 try: 305 from borsapy.bond import risk_free_rate 306 307 rf_annual = risk_free_rate() 308 except Exception: 309 rf_annual = 0.30 310 311 rf_daily = rf_annual / 252 312 excess_returns = returns - rf_daily 313 negative_returns = excess_returns[excess_returns < 0] 314 315 if len(negative_returns) == 0 or negative_returns.std() == 0: 316 return float("inf") if excess_returns.mean() > 0 else float("nan") 317 318 downside_std = negative_returns.std() 319 return float(np.sqrt(252) * excess_returns.mean() / downside_std)
Sortino ratio (downside risk-adjusted return).
Uses downside deviation instead of standard deviation.
321 @property 322 def max_drawdown(self) -> float: 323 """Maximum drawdown as percentage.""" 324 if self.drawdown_curve.empty: 325 return 0.0 326 return float(self.drawdown_curve.min()) * 100
Maximum drawdown as percentage.
328 @property 329 def max_drawdown_duration(self) -> int: 330 """Maximum drawdown duration in days.""" 331 if self.equity_curve.empty: 332 return 0 333 334 # Find periods where we're in drawdown 335 running_max = self.equity_curve.cummax() 336 in_drawdown = self.equity_curve < running_max 337 338 max_duration = 0 339 current_duration = 0 340 341 for is_dd in in_drawdown: 342 if is_dd: 343 current_duration += 1 344 max_duration = max(max_duration, current_duration) 345 else: 346 current_duration = 0 347 348 return max_duration
Maximum drawdown duration in days.
350 @property 351 def buy_hold_return(self) -> float: 352 """Buy & hold return as percentage.""" 353 if self.buy_hold_curve.empty: 354 return 0.0 355 first = self.buy_hold_curve.iloc[0] 356 last = self.buy_hold_curve.iloc[-1] 357 if first == 0: 358 return 0.0 359 return ((last - first) / first) * 100
Buy & hold return as percentage.
361 @property 362 def vs_buy_hold(self) -> float: 363 """Strategy outperformance vs buy & hold (percentage points).""" 364 return self.net_profit_pct - self.buy_hold_return
Strategy outperformance vs buy & hold (percentage points).
366 @property 367 def calmar_ratio(self) -> float: 368 """Calmar ratio (annualized return / max drawdown).""" 369 if self.max_drawdown == 0: 370 return float("inf") if self.net_profit_pct > 0 else 0.0 371 # Annualize return (assuming 252 trading days) 372 trading_days = len(self.equity_curve) 373 if trading_days == 0: 374 return 0.0 375 annual_return = self.net_profit_pct * (252 / trading_days) 376 return annual_return / abs(self.max_drawdown)
Calmar ratio (annualized return / max drawdown).
380 @property 381 def trades_df(self) -> pd.DataFrame: 382 """Get trades as DataFrame.""" 383 if not self.trades: 384 return pd.DataFrame( 385 columns=[ 386 "entry_time", 387 "entry_price", 388 "exit_time", 389 "exit_price", 390 "side", 391 "shares", 392 "commission", 393 "profit", 394 "profit_pct", 395 "duration", 396 ] 397 ) 398 return pd.DataFrame([t.to_dict() for t in self.trades])
Get trades as DataFrame.
400 def to_dict(self) -> dict[str, Any]: 401 """ 402 Export results to dictionary. 403 404 Compatible with TradingView/Mathieu2301 format. 405 """ 406 return { 407 # Identification 408 "symbol": self.symbol, 409 "period": self.period, 410 "interval": self.interval, 411 "strategy_name": self.strategy_name, 412 # Configuration 413 "initial_capital": self.initial_capital, 414 "commission": self.commission, 415 # Summary 416 "net_profit": round(self.net_profit, 2), 417 "net_profit_pct": round(self.net_profit_pct, 2), 418 "final_equity": round(self.final_equity, 2), 419 # Trade Statistics 420 "total_trades": self.total_trades, 421 "winning_trades": self.winning_trades, 422 "losing_trades": self.losing_trades, 423 "win_rate": round(self.win_rate, 2), 424 "profit_factor": round(self.profit_factor, 2) if self.profit_factor != float("inf") else "inf", 425 "avg_trade": round(self.avg_trade, 2), 426 "avg_winning_trade": round(self.avg_winning_trade, 2), 427 "avg_losing_trade": round(self.avg_losing_trade, 2), 428 "max_consecutive_wins": self.max_consecutive_wins, 429 "max_consecutive_losses": self.max_consecutive_losses, 430 # Risk Metrics 431 "sharpe_ratio": round(self.sharpe_ratio, 2) if not np.isnan(self.sharpe_ratio) else None, 432 "sortino_ratio": round(self.sortino_ratio, 2) if not np.isnan(self.sortino_ratio) and self.sortino_ratio != float("inf") else None, 433 "calmar_ratio": round(self.calmar_ratio, 2) if self.calmar_ratio != float("inf") else None, 434 "max_drawdown": round(self.max_drawdown, 2), 435 "max_drawdown_duration": self.max_drawdown_duration, 436 # Comparison 437 "buy_hold_return": round(self.buy_hold_return, 2), 438 "vs_buy_hold": round(self.vs_buy_hold, 2), 439 }
Export results to dictionary.
Compatible with TradingView/Mathieu2301 format.
441 def summary(self) -> str: 442 """ 443 Generate human-readable performance summary. 444 445 Returns: 446 Formatted summary string. 447 """ 448 d = self.to_dict() 449 450 lines = [ 451 "=" * 60, 452 f"BACKTEST RESULTS: {d['symbol']} ({d['strategy_name']})", 453 "=" * 60, 454 f"Period: {d['period']} | Interval: {d['interval']}", 455 f"Initial Capital: {d['initial_capital']:,.2f} TL", 456 f"Commission: {d['commission']*100:.2f}%", 457 "", 458 "--- PERFORMANCE ---", 459 f"Net Profit: {d['net_profit']:,.2f} TL ({d['net_profit_pct']:+.2f}%)", 460 f"Final Equity: {d['final_equity']:,.2f} TL", 461 f"Buy & Hold: {d['buy_hold_return']:+.2f}%", 462 f"vs B&H: {d['vs_buy_hold']:+.2f}%", 463 "", 464 "--- TRADE STATISTICS ---", 465 f"Total Trades: {d['total_trades']}", 466 f"Winning: {d['winning_trades']} | Losing: {d['losing_trades']}", 467 f"Win Rate: {d['win_rate']:.1f}%", 468 f"Profit Factor: {d['profit_factor']}", 469 f"Avg Trade: {d['avg_trade']:,.2f} TL", 470 f"Avg Winner: {d['avg_winning_trade']:,.2f} TL | Avg Loser: {d['avg_losing_trade']:,.2f} TL", 471 f"Max Consecutive Wins: {d['max_consecutive_wins']} | Losses: {d['max_consecutive_losses']}", 472 "", 473 "--- RISK METRICS ---", 474 f"Sharpe Ratio: {d['sharpe_ratio'] if d['sharpe_ratio'] else 'N/A'}", 475 f"Sortino Ratio: {d['sortino_ratio'] if d['sortino_ratio'] else 'N/A'}", 476 f"Calmar Ratio: {d['calmar_ratio'] if d['calmar_ratio'] else 'N/A'}", 477 f"Max Drawdown: {d['max_drawdown']:.2f}%", 478 f"Max DD Duration: {d['max_drawdown_duration']} days", 479 "=" * 60, 480 ] 481 482 return "\n".join(lines)
Generate human-readable performance summary.
Returns: Formatted summary string.
485class Backtest: 486 """ 487 Backtest engine for evaluating trading strategies. 488 489 Runs a strategy function over historical data and calculates 490 comprehensive performance metrics. 491 492 Attributes: 493 symbol: Stock symbol to backtest. 494 strategy: Strategy function to evaluate. 495 period: Historical data period. 496 interval: Data interval (e.g., "1d", "1h"). 497 capital: Initial capital. 498 commission: Commission rate per trade (e.g., 0.001 = 0.1%). 499 indicators: List of indicators to calculate. 500 501 Examples: 502 >>> def my_strategy(candle, position, indicators): 503 ... if indicators['rsi'] < 30: 504 ... return 'BUY' 505 ... elif indicators['rsi'] > 70: 506 ... return 'SELL' 507 ... return 'HOLD' 508 509 >>> bt = Backtest("THYAO", my_strategy, period="1y") 510 >>> result = bt.run() 511 >>> print(result.sharpe_ratio) 512 """ 513 514 # Indicator period warmup 515 WARMUP_PERIOD = 50 516 517 def __init__( 518 self, 519 symbol: str, 520 strategy: StrategyFunc, 521 period: str = "1y", 522 interval: str = "1d", 523 capital: float = 100_000.0, 524 commission: float = 0.001, 525 indicators: list[str] | None = None, 526 slippage: float = 0.0, # Future use 527 ): 528 """ 529 Initialize Backtest. 530 531 Args: 532 symbol: Stock symbol (e.g., "THYAO"). 533 strategy: Strategy function with signature: 534 strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None 535 period: Historical data period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y). 536 interval: Data interval (1m, 5m, 15m, 30m, 1h, 4h, 1d). 537 capital: Initial capital in TL. 538 commission: Commission rate per trade (0.001 = 0.1%). 539 indicators: List of indicators to calculate. Options: 540 'rsi', 'rsi_7', 'sma_20', 'sma_50', 'sma_200', 541 'ema_12', 'ema_26', 'ema_50', 'macd', 'bollinger', 542 'atr', 'atr_20', 'stochastic', 'adx' 543 slippage: Slippage per trade (for future use). 544 """ 545 self.symbol = symbol.upper() 546 self.strategy = strategy 547 self.period = period 548 self.interval = interval 549 self.capital = capital 550 self.commission = commission 551 self.indicators = indicators or ["rsi", "sma_20", "ema_12", "macd"] 552 self.slippage = slippage 553 554 # Strategy name for reporting 555 self._strategy_name = getattr(strategy, "__name__", "custom_strategy") 556 557 # Data storage 558 self._df: pd.DataFrame | None = None 559 self._df_with_indicators: pd.DataFrame | None = None 560 561 def _load_data(self) -> pd.DataFrame: 562 """Load historical data from Ticker.""" 563 from borsapy.ticker import Ticker 564 565 ticker = Ticker(self.symbol) 566 df = ticker.history(period=self.period, interval=self.interval) 567 568 if df is None or df.empty: 569 raise ValueError(f"No historical data available for {self.symbol}") 570 571 return df 572 573 def _calculate_indicators(self, df: pd.DataFrame) -> pd.DataFrame: 574 """Add indicator columns to DataFrame.""" 575 from borsapy.technical import ( 576 calculate_adx, 577 calculate_atr, 578 calculate_bollinger_bands, 579 calculate_ema, 580 calculate_macd, 581 calculate_rsi, 582 calculate_sma, 583 calculate_stochastic, 584 ) 585 586 result = df.copy() 587 588 for ind in self.indicators: 589 ind_lower = ind.lower() 590 591 # RSI variants 592 if ind_lower == "rsi": 593 result["rsi"] = calculate_rsi(df, period=14) 594 elif ind_lower.startswith("rsi_"): 595 try: 596 period = int(ind_lower.split("_")[1]) 597 result[f"rsi_{period}"] = calculate_rsi(df, period=period) 598 except (IndexError, ValueError): 599 pass 600 601 # SMA variants 602 elif ind_lower.startswith("sma_"): 603 try: 604 period = int(ind_lower.split("_")[1]) 605 result[f"sma_{period}"] = calculate_sma(df, period=period) 606 except (IndexError, ValueError): 607 pass 608 609 # EMA variants 610 elif ind_lower.startswith("ema_"): 611 try: 612 period = int(ind_lower.split("_")[1]) 613 result[f"ema_{period}"] = calculate_ema(df, period=period) 614 except (IndexError, ValueError): 615 pass 616 617 # MACD 618 elif ind_lower == "macd": 619 macd_df = calculate_macd(df) 620 result["macd"] = macd_df["MACD"] 621 result["macd_signal"] = macd_df["Signal"] 622 result["macd_histogram"] = macd_df["Histogram"] 623 624 # Bollinger Bands 625 elif ind_lower in ("bollinger", "bb"): 626 bb_df = calculate_bollinger_bands(df) 627 result["bb_upper"] = bb_df["BB_Upper"] 628 result["bb_middle"] = bb_df["BB_Middle"] 629 result["bb_lower"] = bb_df["BB_Lower"] 630 631 # ATR variants 632 elif ind_lower == "atr": 633 result["atr"] = calculate_atr(df, period=14) 634 elif ind_lower.startswith("atr_"): 635 try: 636 period = int(ind_lower.split("_")[1]) 637 result[f"atr_{period}"] = calculate_atr(df, period=period) 638 except (IndexError, ValueError): 639 pass 640 641 # Stochastic 642 elif ind_lower in ("stochastic", "stoch"): 643 stoch_df = calculate_stochastic(df) 644 result["stoch_k"] = stoch_df["Stoch_K"] 645 result["stoch_d"] = stoch_df["Stoch_D"] 646 647 # ADX 648 elif ind_lower == "adx": 649 result["adx"] = calculate_adx(df, period=14) 650 651 return result 652 653 def _get_indicators_at(self, idx: int) -> dict[str, float]: 654 """Get indicator values at specific index.""" 655 if self._df_with_indicators is None: 656 return {} 657 658 row = self._df_with_indicators.iloc[idx] 659 indicators = {} 660 661 # Extract all non-OHLCV columns as indicators 662 exclude_cols = {"Open", "High", "Low", "Close", "Volume", "Adj Close"} 663 664 for col in self._df_with_indicators.columns: 665 if col not in exclude_cols: 666 val = row[col] 667 if pd.notna(val): 668 indicators[col] = float(val) 669 670 return indicators 671 672 def _build_candle(self, idx: int) -> dict[str, Any]: 673 """Build candle dict from DataFrame row.""" 674 if self._df is None: 675 return {} 676 677 row = self._df.iloc[idx] 678 timestamp = self._df.index[idx] 679 680 if isinstance(timestamp, pd.Timestamp): 681 timestamp = timestamp.to_pydatetime() 682 683 return { 684 "timestamp": timestamp, 685 "open": float(row["Open"]), 686 "high": float(row["High"]), 687 "low": float(row["Low"]), 688 "close": float(row["Close"]), 689 "volume": float(row.get("Volume", 0)) if "Volume" in row else 0, 690 "_index": idx, 691 } 692 693 def run(self) -> BacktestResult: 694 """ 695 Run the backtest. 696 697 Returns: 698 BacktestResult with all performance metrics. 699 700 Raises: 701 ValueError: If no data available for symbol. 702 """ 703 # Load data 704 self._df = self._load_data() 705 self._df_with_indicators = self._calculate_indicators(self._df) 706 707 # Initialize state 708 cash = self.capital 709 position: Position = None 710 shares = 0.0 711 trades: list[Trade] = [] 712 current_trade: Trade | None = None 713 714 # Track equity curve 715 equity_values = [] 716 dates = [] 717 718 # Buy & hold tracking 719 initial_price = self._df["Close"].iloc[self.WARMUP_PERIOD] 720 bh_shares = self.capital / initial_price 721 722 # Run simulation 723 for idx in range(self.WARMUP_PERIOD, len(self._df)): 724 candle = self._build_candle(idx) 725 indicators = self._get_indicators_at(idx) 726 price = candle["close"] 727 timestamp = candle["timestamp"] 728 729 # Get strategy signal 730 try: 731 signal = self.strategy(candle, position, indicators) 732 except Exception: 733 signal = "HOLD" 734 735 # Execute trades 736 if signal == "BUY" and position is None: 737 # Calculate shares to buy (use all available cash) 738 entry_commission = cash * self.commission 739 available = cash - entry_commission 740 shares = available / price 741 742 current_trade = Trade( 743 entry_time=timestamp, 744 entry_price=price, 745 side="long", 746 shares=shares, 747 commission=entry_commission, 748 ) 749 750 cash = 0.0 751 position = "long" 752 753 elif signal == "SELL" and position == "long" and current_trade is not None: 754 # Close position 755 exit_value = shares * price 756 exit_commission = exit_value * self.commission 757 758 current_trade.exit_time = timestamp 759 current_trade.exit_price = price 760 current_trade.commission += exit_commission 761 762 trades.append(current_trade) 763 764 cash = exit_value - exit_commission 765 shares = 0.0 766 position = None 767 current_trade = None 768 769 # Track equity 770 if position == "long": 771 equity = shares * price 772 else: 773 equity = cash 774 775 equity_values.append(equity) 776 dates.append(timestamp) 777 778 # Close any open position at end 779 if position == "long" and current_trade is not None: 780 final_price = self._df["Close"].iloc[-1] 781 exit_value = shares * final_price 782 exit_commission = exit_value * self.commission 783 784 current_trade.exit_time = self._df.index[-1] 785 if isinstance(current_trade.exit_time, pd.Timestamp): 786 current_trade.exit_time = current_trade.exit_time.to_pydatetime() 787 current_trade.exit_price = final_price 788 current_trade.commission += exit_commission 789 790 trades.append(current_trade) 791 792 # Build curves 793 equity_curve = pd.Series(equity_values, index=pd.DatetimeIndex(dates)) 794 795 # Calculate drawdown curve 796 running_max = equity_curve.cummax() 797 drawdown_curve = (equity_curve - running_max) / running_max 798 799 # Buy & hold curve 800 bh_values = self._df["Close"].iloc[self.WARMUP_PERIOD:] * bh_shares 801 buy_hold_curve = pd.Series(bh_values.values, index=pd.DatetimeIndex(dates)) 802 803 return BacktestResult( 804 symbol=self.symbol, 805 period=self.period, 806 interval=self.interval, 807 strategy_name=self._strategy_name, 808 initial_capital=self.capital, 809 commission=self.commission, 810 trades=trades, 811 equity_curve=equity_curve, 812 drawdown_curve=drawdown_curve, 813 buy_hold_curve=buy_hold_curve, 814 )
Backtest engine for evaluating trading strategies.
Runs a strategy function over historical data and calculates comprehensive performance metrics.
Attributes: symbol: Stock symbol to backtest. strategy: Strategy function to evaluate. period: Historical data period. interval: Data interval (e.g., "1d", "1h"). capital: Initial capital. commission: Commission rate per trade (e.g., 0.001 = 0.1%). indicators: List of indicators to calculate.
Examples:
def my_strategy(candle, position, indicators): ... if indicators['rsi'] < 30: ... return 'BUY' ... elif indicators['rsi'] > 70: ... return 'SELL' ... return 'HOLD'
>>> bt = Backtest("THYAO", my_strategy, period="1y") >>> result = bt.run() >>> print(result.sharpe_ratio)
517 def __init__( 518 self, 519 symbol: str, 520 strategy: StrategyFunc, 521 period: str = "1y", 522 interval: str = "1d", 523 capital: float = 100_000.0, 524 commission: float = 0.001, 525 indicators: list[str] | None = None, 526 slippage: float = 0.0, # Future use 527 ): 528 """ 529 Initialize Backtest. 530 531 Args: 532 symbol: Stock symbol (e.g., "THYAO"). 533 strategy: Strategy function with signature: 534 strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None 535 period: Historical data period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y). 536 interval: Data interval (1m, 5m, 15m, 30m, 1h, 4h, 1d). 537 capital: Initial capital in TL. 538 commission: Commission rate per trade (0.001 = 0.1%). 539 indicators: List of indicators to calculate. Options: 540 'rsi', 'rsi_7', 'sma_20', 'sma_50', 'sma_200', 541 'ema_12', 'ema_26', 'ema_50', 'macd', 'bollinger', 542 'atr', 'atr_20', 'stochastic', 'adx' 543 slippage: Slippage per trade (for future use). 544 """ 545 self.symbol = symbol.upper() 546 self.strategy = strategy 547 self.period = period 548 self.interval = interval 549 self.capital = capital 550 self.commission = commission 551 self.indicators = indicators or ["rsi", "sma_20", "ema_12", "macd"] 552 self.slippage = slippage 553 554 # Strategy name for reporting 555 self._strategy_name = getattr(strategy, "__name__", "custom_strategy") 556 557 # Data storage 558 self._df: pd.DataFrame | None = None 559 self._df_with_indicators: pd.DataFrame | None = None
Initialize Backtest.
Args: symbol: Stock symbol (e.g., "THYAO"). strategy: Strategy function with signature: strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None period: Historical data period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y). interval: Data interval (1m, 5m, 15m, 30m, 1h, 4h, 1d). capital: Initial capital in TL. commission: Commission rate per trade (0.001 = 0.1%). indicators: List of indicators to calculate. Options: 'rsi', 'rsi_7', 'sma_20', 'sma_50', 'sma_200', 'ema_12', 'ema_26', 'ema_50', 'macd', 'bollinger', 'atr', 'atr_20', 'stochastic', 'adx' slippage: Slippage per trade (for future use).
693 def run(self) -> BacktestResult: 694 """ 695 Run the backtest. 696 697 Returns: 698 BacktestResult with all performance metrics. 699 700 Raises: 701 ValueError: If no data available for symbol. 702 """ 703 # Load data 704 self._df = self._load_data() 705 self._df_with_indicators = self._calculate_indicators(self._df) 706 707 # Initialize state 708 cash = self.capital 709 position: Position = None 710 shares = 0.0 711 trades: list[Trade] = [] 712 current_trade: Trade | None = None 713 714 # Track equity curve 715 equity_values = [] 716 dates = [] 717 718 # Buy & hold tracking 719 initial_price = self._df["Close"].iloc[self.WARMUP_PERIOD] 720 bh_shares = self.capital / initial_price 721 722 # Run simulation 723 for idx in range(self.WARMUP_PERIOD, len(self._df)): 724 candle = self._build_candle(idx) 725 indicators = self._get_indicators_at(idx) 726 price = candle["close"] 727 timestamp = candle["timestamp"] 728 729 # Get strategy signal 730 try: 731 signal = self.strategy(candle, position, indicators) 732 except Exception: 733 signal = "HOLD" 734 735 # Execute trades 736 if signal == "BUY" and position is None: 737 # Calculate shares to buy (use all available cash) 738 entry_commission = cash * self.commission 739 available = cash - entry_commission 740 shares = available / price 741 742 current_trade = Trade( 743 entry_time=timestamp, 744 entry_price=price, 745 side="long", 746 shares=shares, 747 commission=entry_commission, 748 ) 749 750 cash = 0.0 751 position = "long" 752 753 elif signal == "SELL" and position == "long" and current_trade is not None: 754 # Close position 755 exit_value = shares * price 756 exit_commission = exit_value * self.commission 757 758 current_trade.exit_time = timestamp 759 current_trade.exit_price = price 760 current_trade.commission += exit_commission 761 762 trades.append(current_trade) 763 764 cash = exit_value - exit_commission 765 shares = 0.0 766 position = None 767 current_trade = None 768 769 # Track equity 770 if position == "long": 771 equity = shares * price 772 else: 773 equity = cash 774 775 equity_values.append(equity) 776 dates.append(timestamp) 777 778 # Close any open position at end 779 if position == "long" and current_trade is not None: 780 final_price = self._df["Close"].iloc[-1] 781 exit_value = shares * final_price 782 exit_commission = exit_value * self.commission 783 784 current_trade.exit_time = self._df.index[-1] 785 if isinstance(current_trade.exit_time, pd.Timestamp): 786 current_trade.exit_time = current_trade.exit_time.to_pydatetime() 787 current_trade.exit_price = final_price 788 current_trade.commission += exit_commission 789 790 trades.append(current_trade) 791 792 # Build curves 793 equity_curve = pd.Series(equity_values, index=pd.DatetimeIndex(dates)) 794 795 # Calculate drawdown curve 796 running_max = equity_curve.cummax() 797 drawdown_curve = (equity_curve - running_max) / running_max 798 799 # Buy & hold curve 800 bh_values = self._df["Close"].iloc[self.WARMUP_PERIOD:] * bh_shares 801 buy_hold_curve = pd.Series(bh_values.values, index=pd.DatetimeIndex(dates)) 802 803 return BacktestResult( 804 symbol=self.symbol, 805 period=self.period, 806 interval=self.interval, 807 strategy_name=self._strategy_name, 808 initial_capital=self.capital, 809 commission=self.commission, 810 trades=trades, 811 equity_curve=equity_curve, 812 drawdown_curve=drawdown_curve, 813 buy_hold_curve=buy_hold_curve, 814 )
Run the backtest.
Returns: BacktestResult with all performance metrics.
Raises: ValueError: If no data available for symbol.
817def backtest( 818 symbol: str, 819 strategy: StrategyFunc, 820 period: str = "1y", 821 interval: str = "1d", 822 capital: float = 100_000.0, 823 commission: float = 0.001, 824 indicators: list[str] | None = None, 825) -> BacktestResult: 826 """ 827 Run a backtest with a single function call. 828 829 Convenience function that creates a Backtest instance and runs it. 830 831 Args: 832 symbol: Stock symbol (e.g., "THYAO"). 833 strategy: Strategy function with signature: 834 strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None 835 period: Historical data period. 836 interval: Data interval. 837 capital: Initial capital. 838 commission: Commission rate. 839 indicators: List of indicators to calculate. 840 841 Returns: 842 BacktestResult with all performance metrics. 843 844 Examples: 845 >>> def rsi_strategy(candle, position, indicators): 846 ... if indicators.get('rsi', 50) < 30 and position is None: 847 ... return 'BUY' 848 ... elif indicators.get('rsi', 50) > 70 and position == 'long': 849 ... return 'SELL' 850 ... return 'HOLD' 851 852 >>> result = bp.backtest("THYAO", rsi_strategy, period="1y") 853 >>> print(f"Net Profit: {result.net_profit_pct:.2f}%") 854 >>> print(f"Sharpe: {result.sharpe_ratio:.2f}") 855 """ 856 bt = Backtest( 857 symbol=symbol, 858 strategy=strategy, 859 period=period, 860 interval=interval, 861 capital=capital, 862 commission=commission, 863 indicators=indicators, 864 ) 865 return bt.run()
Run a backtest with a single function call.
Convenience function that creates a Backtest instance and runs it.
Args: symbol: Stock symbol (e.g., "THYAO"). strategy: Strategy function with signature: strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None period: Historical data period. interval: Data interval. capital: Initial capital. commission: Commission rate. indicators: List of indicators to calculate.
Returns: BacktestResult with all performance metrics.
Examples:
def rsi_strategy(candle, position, indicators): ... if indicators.get('rsi', 50) < 30 and position is None: ... return 'BUY' ... elif indicators.get('rsi', 50) > 70 and position == 'long': ... return 'SELL' ... return 'HOLD'
>>> result = bp.backtest("THYAO", rsi_strategy, period="1y") >>> print(f"Net Profit: {result.net_profit_pct:.2f}%") >>> print(f"Sharpe: {result.sharpe_ratio:.2f}")