# 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}")

View Source

      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: View Source

     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 View Source

    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 View Source

    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 View Source

     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 View Source

    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]: View Source

    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: View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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 View Source

    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]: View Source

    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: View Source

    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: View Source

    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) View Source

    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: View Source

    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: View Source

    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}")