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()
@dataclass
class Trade:
 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).

Trade( entry_time: datetime.datetime, entry_price: float, exit_time: datetime.datetime | None = None, exit_price: float | None = None, side: Literal['long', 'short'] = 'long', shares: float = 0.0, commission: float = 0.0)
entry_time: datetime.datetime
entry_price: float
exit_time: datetime.datetime | None = None
exit_price: float | None = None
side: Literal['long', 'short'] = 'long'
shares: float = 0.0
commission: float = 0.0
is_closed: bool
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.

profit: float | None
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).

profit_pct: float | None
 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).

duration: float | None
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).

def to_dict(self) -> dict[str, typing.Any]:
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.

@dataclass
class BacktestResult:
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.

BacktestResult( symbol: str, period: str, interval: str, strategy_name: str, initial_capital: float, commission: float, trades: list[Trade] = <factory>, equity_curve: pandas.core.series.Series = <factory>, drawdown_curve: pandas.core.series.Series = <factory>, buy_hold_curve: pandas.core.series.Series = <factory>)
symbol: str
period: str
interval: str
strategy_name: str
initial_capital: float
commission: float
trades: list[Trade]
equity_curve: pandas.core.series.Series
drawdown_curve: pandas.core.series.Series
buy_hold_curve: pandas.core.series.Series
final_equity: float
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.

net_profit: float
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.

net_profit_pct: float
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.

total_trades: int
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.

winning_trades: int
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.

losing_trades: int
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.

win_rate: float
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.

profit_factor: float
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.

avg_trade: float
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.

avg_winning_trade: float
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.

avg_losing_trade: float
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.

max_consecutive_wins: int
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.

max_consecutive_losses: int
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.

sharpe_ratio: float
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.

sortino_ratio: float
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.

max_drawdown: float
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.

max_drawdown_duration: int
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.

buy_hold_return: float
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.

vs_buy_hold: float
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).

calmar_ratio: float
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).

trades_df: pandas.core.frame.DataFrame
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.

def to_dict(self) -> dict[str, typing.Any]:
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.

def summary(self) -> str:
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.

class Backtest:
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)
Backtest( symbol: str, strategy: Callable[[dict, Optional[Literal['long', 'short']], dict], Optional[Literal['BUY', 'SELL', 'HOLD']]], period: str = '1y', interval: str = '1d', capital: float = 100000.0, commission: float = 0.001, indicators: list[str] | None = None, slippage: float = 0.0)
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).

WARMUP_PERIOD = 50
symbol
strategy
period
interval
capital
commission
indicators
slippage
def run(self) -> BacktestResult:
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.

def backtest( symbol: str, strategy: Callable[[dict, Optional[Literal['long', 'short']], dict], Optional[Literal['BUY', 'SELL', 'HOLD']]], period: str = '1y', interval: str = '1d', capital: float = 100000.0, commission: float = 0.001, indicators: list[str] | None = None) -> BacktestResult:
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}")