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