borsapy.backtest

Backtest Engine for trading strategy evaluation.

This module provides a framework for backtesting trading strategies on historical OHLCV data with comprehensive performance metrics.

Features:

  • Strategy function interface
  • Technical indicator integration
  • Performance metrics (Sharpe, Sortino, Profit Factor)
  • Trade tracking and analysis
  • Equity curve generation
  • Buy & Hold comparison

Examples:

import borsapy as bp

>>> def rsi_strategy(candle, position, indicators):
...     if indicators['rsi'] < 30 and position is None:
...         return 'BUY'
...     elif indicators['rsi'] > 70 and position == 'long':
...         return 'SELL'
...     return 'HOLD'

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

Represents a single trade in a backtest.

Attributes: entry_time: When the trade was opened. entry_price: Price at entry. exit_time: When the trade was closed (None if open). exit_price: Price at exit (None if open). side: Trade direction ('long' or 'short'). shares: Number of shares traded. commission: Total commission paid (entry + exit).

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
75    @property
76    def is_closed(self) -> bool:
77        """Check if trade is closed."""
78        return self.exit_time is not None and self.exit_price is not None

Check if trade is closed.

profit: float | None
80    @property
81    def profit(self) -> float | None:
82        """Calculate profit in currency units (None if open)."""
83        if not self.is_closed:
84            return None
85        assert self.exit_price is not None
86        if self.side == "long":
87            gross = (self.exit_price - self.entry_price) * self.shares
88        else:
89            gross = (self.entry_price - self.exit_price) * self.shares
90        return gross - self.commission

Calculate profit in currency units (None if open).

profit_pct: float | None
 92    @property
 93    def profit_pct(self) -> float | None:
 94        """Calculate profit as percentage (None if open)."""
 95        if not self.is_closed or self.entry_price == 0:
 96            return None
 97        profit = self.profit
 98        if profit is None:
 99            return None
100        entry_value = self.entry_price * self.shares
101        return (profit / entry_value) * 100

Calculate profit as percentage (None if open).

duration: float | None
103    @property
104    def duration(self) -> float | None:
105        """Trade duration in days (None if open)."""
106        if not self.is_closed:
107            return None
108        assert self.exit_time is not None
109        delta = self.exit_time - self.entry_time
110        return delta.total_seconds() / 86400  # Convert to days

Trade duration in days (None if open).

def to_dict(self) -> dict[str, typing.Any]:
112    def to_dict(self) -> dict[str, Any]:
113        """Convert trade to dictionary."""
114        return {
115            "entry_time": self.entry_time,
116            "entry_price": self.entry_price,
117            "exit_time": self.exit_time,
118            "exit_price": self.exit_price,
119            "side": self.side,
120            "shares": self.shares,
121            "commission": self.commission,
122            "profit": self.profit,
123            "profit_pct": self.profit_pct,
124            "duration": self.duration,
125        }

Convert trade to dictionary.

@dataclass
class BacktestResult:
128@dataclass
129class BacktestResult:
130    """
131    Comprehensive backtest results with performance metrics.
132
133    Follows TradingView/Mathieu2301 result format for familiarity.
134
135    Attributes:
136        symbol: Traded symbol.
137        period: Test period (e.g., "1y").
138        interval: Data interval (e.g., "1d").
139        strategy_name: Name of the strategy function.
140        initial_capital: Starting capital.
141        commission: Commission rate used.
142        trades: List of executed trades.
143        equity_curve: Daily equity values.
144        drawdown_curve: Daily drawdown values.
145        buy_hold_curve: Buy & hold comparison values.
146    """
147
148    # Identification
149    symbol: str
150    period: str
151    interval: str
152    strategy_name: str
153
154    # Configuration
155    initial_capital: float
156    commission: float
157
158    # Results
159    trades: list[Trade] = field(default_factory=list)
160    equity_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float))
161    drawdown_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float))
162    buy_hold_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float))
163
164    # === Performance Properties ===
165
166    @property
167    def final_equity(self) -> float:
168        """Final portfolio value."""
169        if self.equity_curve.empty:
170            return self.initial_capital
171        return float(self.equity_curve.iloc[-1])
172
173    @property
174    def net_profit(self) -> float:
175        """Net profit in currency units."""
176        return self.final_equity - self.initial_capital
177
178    @property
179    def net_profit_pct(self) -> float:
180        """Net profit as percentage."""
181        if self.initial_capital == 0:
182            return 0.0
183        return (self.net_profit / self.initial_capital) * 100
184
185    @property
186    def total_trades(self) -> int:
187        """Total number of closed trades."""
188        return len([t for t in self.trades if t.is_closed])
189
190    @property
191    def winning_trades(self) -> int:
192        """Number of profitable trades."""
193        return len([t for t in self.trades if t.is_closed and (t.profit or 0) > 0])
194
195    @property
196    def losing_trades(self) -> int:
197        """Number of losing trades."""
198        return len([t for t in self.trades if t.is_closed and (t.profit or 0) <= 0])
199
200    @property
201    def win_rate(self) -> float:
202        """Percentage of winning trades."""
203        if self.total_trades == 0:
204            return 0.0
205        return (self.winning_trades / self.total_trades) * 100
206
207    @property
208    def profit_factor(self) -> float:
209        """Ratio of gross profits to gross losses."""
210        gross_profit = sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) > 0)
211        gross_loss = abs(sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) < 0))
212        if gross_loss == 0:
213            return float("inf") if gross_profit > 0 else 0.0
214        return gross_profit / gross_loss
215
216    @property
217    def avg_trade(self) -> float:
218        """Average profit per trade."""
219        closed = [t for t in self.trades if t.is_closed]
220        if not closed:
221            return 0.0
222        return sum(t.profit or 0 for t in closed) / len(closed)
223
224    @property
225    def avg_winning_trade(self) -> float:
226        """Average profit of winning trades."""
227        winners = [t for t in self.trades if t.is_closed and (t.profit or 0) > 0]
228        if not winners:
229            return 0.0
230        return sum(t.profit or 0 for t in winners) / len(winners)
231
232    @property
233    def avg_losing_trade(self) -> float:
234        """Average loss of losing trades."""
235        losers = [t for t in self.trades if t.is_closed and (t.profit or 0) < 0]
236        if not losers:
237            return 0.0
238        return sum(t.profit or 0 for t in losers) / len(losers)
239
240    @property
241    def max_consecutive_wins(self) -> int:
242        """Maximum consecutive winning trades."""
243        return self._max_consecutive(lambda t: (t.profit or 0) > 0)
244
245    @property
246    def max_consecutive_losses(self) -> int:
247        """Maximum consecutive losing trades."""
248        return self._max_consecutive(lambda t: (t.profit or 0) <= 0)
249
250    def _max_consecutive(self, condition: Callable[[Trade], bool]) -> int:
251        """Helper to find max consecutive trades matching condition."""
252        closed = [t for t in self.trades if t.is_closed]
253        if not closed:
254            return 0
255        max_count = 0
256        current_count = 0
257        for trade in closed:
258            if condition(trade):
259                current_count += 1
260                max_count = max(max_count, current_count)
261            else:
262                current_count = 0
263        return max_count
264
265    @property
266    def sharpe_ratio(self) -> float:
267        """
268        Sharpe ratio (risk-adjusted return).
269
270        Assumes 252 trading days and risk-free rate from current 10Y bond.
271        """
272        if self.equity_curve.empty or len(self.equity_curve) < 2:
273            return float("nan")
274
275        returns = self.equity_curve.pct_change().dropna()
276        if returns.std() == 0:
277            return float("nan")
278
279        # Get risk-free rate
280        try:
281            from borsapy.bond import risk_free_rate
282
283            rf_annual = risk_free_rate()
284        except Exception:
285            rf_annual = 0.30  # Fallback 30%
286
287        rf_daily = rf_annual / 252
288        excess_returns = returns - rf_daily
289        return float(np.sqrt(252) * excess_returns.mean() / excess_returns.std())
290
291    @property
292    def sortino_ratio(self) -> float:
293        """
294        Sortino ratio (downside risk-adjusted return).
295
296        Uses downside deviation instead of standard deviation.
297        """
298        if self.equity_curve.empty or len(self.equity_curve) < 2:
299            return float("nan")
300
301        returns = self.equity_curve.pct_change().dropna()
302
303        # Get risk-free rate
304        try:
305            from borsapy.bond import risk_free_rate
306
307            rf_annual = risk_free_rate()
308        except Exception:
309            rf_annual = 0.30
310
311        rf_daily = rf_annual / 252
312        excess_returns = returns - rf_daily
313        negative_returns = excess_returns[excess_returns < 0]
314
315        if len(negative_returns) == 0 or negative_returns.std() == 0:
316            return float("inf") if excess_returns.mean() > 0 else float("nan")
317
318        downside_std = negative_returns.std()
319        return float(np.sqrt(252) * excess_returns.mean() / downside_std)
320
321    @property
322    def max_drawdown(self) -> float:
323        """Maximum drawdown as percentage."""
324        if self.drawdown_curve.empty:
325            return 0.0
326        return float(self.drawdown_curve.min()) * 100
327
328    @property
329    def max_drawdown_duration(self) -> int:
330        """Maximum drawdown duration in days."""
331        if self.equity_curve.empty:
332            return 0
333
334        # Find periods where we're in drawdown
335        running_max = self.equity_curve.cummax()
336        in_drawdown = self.equity_curve < running_max
337
338        max_duration = 0
339        current_duration = 0
340
341        for is_dd in in_drawdown:
342            if is_dd:
343                current_duration += 1
344                max_duration = max(max_duration, current_duration)
345            else:
346                current_duration = 0
347
348        return max_duration
349
350    @property
351    def buy_hold_return(self) -> float:
352        """Buy & hold return as percentage."""
353        if self.buy_hold_curve.empty:
354            return 0.0
355        first = self.buy_hold_curve.iloc[0]
356        last = self.buy_hold_curve.iloc[-1]
357        if first == 0:
358            return 0.0
359        return ((last - first) / first) * 100
360
361    @property
362    def vs_buy_hold(self) -> float:
363        """Strategy outperformance vs buy & hold (percentage points)."""
364        return self.net_profit_pct - self.buy_hold_return
365
366    @property
367    def calmar_ratio(self) -> float:
368        """Calmar ratio (annualized return / max drawdown)."""
369        if self.max_drawdown == 0:
370            return float("inf") if self.net_profit_pct > 0 else 0.0
371        # Annualize return (assuming 252 trading days)
372        trading_days = len(self.equity_curve)
373        if trading_days == 0:
374            return 0.0
375        annual_return = self.net_profit_pct * (252 / trading_days)
376        return annual_return / abs(self.max_drawdown)
377
378    # === Export Methods ===
379
380    @property
381    def trades_df(self) -> pd.DataFrame:
382        """Get trades as DataFrame."""
383        if not self.trades:
384            return pd.DataFrame(
385                columns=[
386                    "entry_time",
387                    "entry_price",
388                    "exit_time",
389                    "exit_price",
390                    "side",
391                    "shares",
392                    "commission",
393                    "profit",
394                    "profit_pct",
395                    "duration",
396                ]
397            )
398        return pd.DataFrame([t.to_dict() for t in self.trades])
399
400    def to_dict(self) -> dict[str, Any]:
401        """
402        Export results to dictionary.
403
404        Compatible with TradingView/Mathieu2301 format.
405        """
406        return {
407            # Identification
408            "symbol": self.symbol,
409            "period": self.period,
410            "interval": self.interval,
411            "strategy_name": self.strategy_name,
412            # Configuration
413            "initial_capital": self.initial_capital,
414            "commission": self.commission,
415            # Summary
416            "net_profit": round(self.net_profit, 2),
417            "net_profit_pct": round(self.net_profit_pct, 2),
418            "final_equity": round(self.final_equity, 2),
419            # Trade Statistics
420            "total_trades": self.total_trades,
421            "winning_trades": self.winning_trades,
422            "losing_trades": self.losing_trades,
423            "win_rate": round(self.win_rate, 2),
424            "profit_factor": round(self.profit_factor, 2) if self.profit_factor != float("inf") else "inf",
425            "avg_trade": round(self.avg_trade, 2),
426            "avg_winning_trade": round(self.avg_winning_trade, 2),
427            "avg_losing_trade": round(self.avg_losing_trade, 2),
428            "max_consecutive_wins": self.max_consecutive_wins,
429            "max_consecutive_losses": self.max_consecutive_losses,
430            # Risk Metrics
431            "sharpe_ratio": round(self.sharpe_ratio, 2) if not np.isnan(self.sharpe_ratio) else None,
432            "sortino_ratio": round(self.sortino_ratio, 2) if not np.isnan(self.sortino_ratio) and self.sortino_ratio != float("inf") else None,
433            "calmar_ratio": round(self.calmar_ratio, 2) if self.calmar_ratio != float("inf") else None,
434            "max_drawdown": round(self.max_drawdown, 2),
435            "max_drawdown_duration": self.max_drawdown_duration,
436            # Comparison
437            "buy_hold_return": round(self.buy_hold_return, 2),
438            "vs_buy_hold": round(self.vs_buy_hold, 2),
439        }
440
441    def summary(self) -> str:
442        """
443        Generate human-readable performance summary.
444
445        Returns:
446            Formatted summary string.
447        """
448        d = self.to_dict()
449
450        lines = [
451            "=" * 60,
452            f"BACKTEST RESULTS: {d['symbol']} ({d['strategy_name']})",
453            "=" * 60,
454            f"Period: {d['period']} | Interval: {d['interval']}",
455            f"Initial Capital: {d['initial_capital']:,.2f} TL",
456            f"Commission: {d['commission']*100:.2f}%",
457            "",
458            "--- PERFORMANCE ---",
459            f"Net Profit: {d['net_profit']:,.2f} TL ({d['net_profit_pct']:+.2f}%)",
460            f"Final Equity: {d['final_equity']:,.2f} TL",
461            f"Buy & Hold: {d['buy_hold_return']:+.2f}%",
462            f"vs B&H: {d['vs_buy_hold']:+.2f}%",
463            "",
464            "--- TRADE STATISTICS ---",
465            f"Total Trades: {d['total_trades']}",
466            f"Winning: {d['winning_trades']} | Losing: {d['losing_trades']}",
467            f"Win Rate: {d['win_rate']:.1f}%",
468            f"Profit Factor: {d['profit_factor']}",
469            f"Avg Trade: {d['avg_trade']:,.2f} TL",
470            f"Avg Winner: {d['avg_winning_trade']:,.2f} TL | Avg Loser: {d['avg_losing_trade']:,.2f} TL",
471            f"Max Consecutive Wins: {d['max_consecutive_wins']} | Losses: {d['max_consecutive_losses']}",
472            "",
473            "--- RISK METRICS ---",
474            f"Sharpe Ratio: {d['sharpe_ratio'] if d['sharpe_ratio'] else 'N/A'}",
475            f"Sortino Ratio: {d['sortino_ratio'] if d['sortino_ratio'] else 'N/A'}",
476            f"Calmar Ratio: {d['calmar_ratio'] if d['calmar_ratio'] else 'N/A'}",
477            f"Max Drawdown: {d['max_drawdown']:.2f}%",
478            f"Max DD Duration: {d['max_drawdown_duration']} days",
479            "=" * 60,
480        ]
481
482        return "\n".join(lines)

Comprehensive backtest results with performance metrics.

Follows TradingView/Mathieu2301 result format for familiarity.

Attributes: symbol: Traded symbol. period: Test period (e.g., "1y"). interval: Data interval (e.g., "1d"). strategy_name: Name of the strategy function. initial_capital: Starting capital. commission: Commission rate used. trades: List of executed trades. equity_curve: Daily equity values. drawdown_curve: Daily drawdown values. buy_hold_curve: Buy & hold comparison values.

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
166    @property
167    def final_equity(self) -> float:
168        """Final portfolio value."""
169        if self.equity_curve.empty:
170            return self.initial_capital
171        return float(self.equity_curve.iloc[-1])

Final portfolio value.

net_profit: float
173    @property
174    def net_profit(self) -> float:
175        """Net profit in currency units."""
176        return self.final_equity - self.initial_capital

Net profit in currency units.

net_profit_pct: float
178    @property
179    def net_profit_pct(self) -> float:
180        """Net profit as percentage."""
181        if self.initial_capital == 0:
182            return 0.0
183        return (self.net_profit / self.initial_capital) * 100

Net profit as percentage.

total_trades: int
185    @property
186    def total_trades(self) -> int:
187        """Total number of closed trades."""
188        return len([t for t in self.trades if t.is_closed])

Total number of closed trades.

winning_trades: int
190    @property
191    def winning_trades(self) -> int:
192        """Number of profitable trades."""
193        return len([t for t in self.trades if t.is_closed and (t.profit or 0) > 0])

Number of profitable trades.

losing_trades: int
195    @property
196    def losing_trades(self) -> int:
197        """Number of losing trades."""
198        return len([t for t in self.trades if t.is_closed and (t.profit or 0) <= 0])

Number of losing trades.

win_rate: float
200    @property
201    def win_rate(self) -> float:
202        """Percentage of winning trades."""
203        if self.total_trades == 0:
204            return 0.0
205        return (self.winning_trades / self.total_trades) * 100

Percentage of winning trades.

profit_factor: float
207    @property
208    def profit_factor(self) -> float:
209        """Ratio of gross profits to gross losses."""
210        gross_profit = sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) > 0)
211        gross_loss = abs(sum(t.profit or 0 for t in self.trades if t.is_closed and (t.profit or 0) < 0))
212        if gross_loss == 0:
213            return float("inf") if gross_profit > 0 else 0.0
214        return gross_profit / gross_loss

Ratio of gross profits to gross losses.

avg_trade: float
216    @property
217    def avg_trade(self) -> float:
218        """Average profit per trade."""
219        closed = [t for t in self.trades if t.is_closed]
220        if not closed:
221            return 0.0
222        return sum(t.profit or 0 for t in closed) / len(closed)

Average profit per trade.

avg_winning_trade: float
224    @property
225    def avg_winning_trade(self) -> float:
226        """Average profit of winning trades."""
227        winners = [t for t in self.trades if t.is_closed and (t.profit or 0) > 0]
228        if not winners:
229            return 0.0
230        return sum(t.profit or 0 for t in winners) / len(winners)

Average profit of winning trades.

avg_losing_trade: float
232    @property
233    def avg_losing_trade(self) -> float:
234        """Average loss of losing trades."""
235        losers = [t for t in self.trades if t.is_closed and (t.profit or 0) < 0]
236        if not losers:
237            return 0.0
238        return sum(t.profit or 0 for t in losers) / len(losers)

Average loss of losing trades.

max_consecutive_wins: int
240    @property
241    def max_consecutive_wins(self) -> int:
242        """Maximum consecutive winning trades."""
243        return self._max_consecutive(lambda t: (t.profit or 0) > 0)

Maximum consecutive winning trades.

max_consecutive_losses: int
245    @property
246    def max_consecutive_losses(self) -> int:
247        """Maximum consecutive losing trades."""
248        return self._max_consecutive(lambda t: (t.profit or 0) <= 0)

Maximum consecutive losing trades.

sharpe_ratio: float
265    @property
266    def sharpe_ratio(self) -> float:
267        """
268        Sharpe ratio (risk-adjusted return).
269
270        Assumes 252 trading days and risk-free rate from current 10Y bond.
271        """
272        if self.equity_curve.empty or len(self.equity_curve) < 2:
273            return float("nan")
274
275        returns = self.equity_curve.pct_change().dropna()
276        if returns.std() == 0:
277            return float("nan")
278
279        # Get risk-free rate
280        try:
281            from borsapy.bond import risk_free_rate
282
283            rf_annual = risk_free_rate()
284        except Exception:
285            rf_annual = 0.30  # Fallback 30%
286
287        rf_daily = rf_annual / 252
288        excess_returns = returns - rf_daily
289        return float(np.sqrt(252) * excess_returns.mean() / excess_returns.std())

Sharpe ratio (risk-adjusted return).

Assumes 252 trading days and risk-free rate from current 10Y bond.

sortino_ratio: float
291    @property
292    def sortino_ratio(self) -> float:
293        """
294        Sortino ratio (downside risk-adjusted return).
295
296        Uses downside deviation instead of standard deviation.
297        """
298        if self.equity_curve.empty or len(self.equity_curve) < 2:
299            return float("nan")
300
301        returns = self.equity_curve.pct_change().dropna()
302
303        # Get risk-free rate
304        try:
305            from borsapy.bond import risk_free_rate
306
307            rf_annual = risk_free_rate()
308        except Exception:
309            rf_annual = 0.30
310
311        rf_daily = rf_annual / 252
312        excess_returns = returns - rf_daily
313        negative_returns = excess_returns[excess_returns < 0]
314
315        if len(negative_returns) == 0 or negative_returns.std() == 0:
316            return float("inf") if excess_returns.mean() > 0 else float("nan")
317
318        downside_std = negative_returns.std()
319        return float(np.sqrt(252) * excess_returns.mean() / downside_std)

Sortino ratio (downside risk-adjusted return).

Uses downside deviation instead of standard deviation.

max_drawdown: float
321    @property
322    def max_drawdown(self) -> float:
323        """Maximum drawdown as percentage."""
324        if self.drawdown_curve.empty:
325            return 0.0
326        return float(self.drawdown_curve.min()) * 100

Maximum drawdown as percentage.

max_drawdown_duration: int
328    @property
329    def max_drawdown_duration(self) -> int:
330        """Maximum drawdown duration in days."""
331        if self.equity_curve.empty:
332            return 0
333
334        # Find periods where we're in drawdown
335        running_max = self.equity_curve.cummax()
336        in_drawdown = self.equity_curve < running_max
337
338        max_duration = 0
339        current_duration = 0
340
341        for is_dd in in_drawdown:
342            if is_dd:
343                current_duration += 1
344                max_duration = max(max_duration, current_duration)
345            else:
346                current_duration = 0
347
348        return max_duration

Maximum drawdown duration in days.

buy_hold_return: float
350    @property
351    def buy_hold_return(self) -> float:
352        """Buy & hold return as percentage."""
353        if self.buy_hold_curve.empty:
354            return 0.0
355        first = self.buy_hold_curve.iloc[0]
356        last = self.buy_hold_curve.iloc[-1]
357        if first == 0:
358            return 0.0
359        return ((last - first) / first) * 100

Buy & hold return as percentage.

vs_buy_hold: float
361    @property
362    def vs_buy_hold(self) -> float:
363        """Strategy outperformance vs buy & hold (percentage points)."""
364        return self.net_profit_pct - self.buy_hold_return

Strategy outperformance vs buy & hold (percentage points).

calmar_ratio: float
366    @property
367    def calmar_ratio(self) -> float:
368        """Calmar ratio (annualized return / max drawdown)."""
369        if self.max_drawdown == 0:
370            return float("inf") if self.net_profit_pct > 0 else 0.0
371        # Annualize return (assuming 252 trading days)
372        trading_days = len(self.equity_curve)
373        if trading_days == 0:
374            return 0.0
375        annual_return = self.net_profit_pct * (252 / trading_days)
376        return annual_return / abs(self.max_drawdown)

Calmar ratio (annualized return / max drawdown).

trades_df: pandas.core.frame.DataFrame
380    @property
381    def trades_df(self) -> pd.DataFrame:
382        """Get trades as DataFrame."""
383        if not self.trades:
384            return pd.DataFrame(
385                columns=[
386                    "entry_time",
387                    "entry_price",
388                    "exit_time",
389                    "exit_price",
390                    "side",
391                    "shares",
392                    "commission",
393                    "profit",
394                    "profit_pct",
395                    "duration",
396                ]
397            )
398        return pd.DataFrame([t.to_dict() for t in self.trades])

Get trades as DataFrame.

def to_dict(self) -> dict[str, typing.Any]:
400    def to_dict(self) -> dict[str, Any]:
401        """
402        Export results to dictionary.
403
404        Compatible with TradingView/Mathieu2301 format.
405        """
406        return {
407            # Identification
408            "symbol": self.symbol,
409            "period": self.period,
410            "interval": self.interval,
411            "strategy_name": self.strategy_name,
412            # Configuration
413            "initial_capital": self.initial_capital,
414            "commission": self.commission,
415            # Summary
416            "net_profit": round(self.net_profit, 2),
417            "net_profit_pct": round(self.net_profit_pct, 2),
418            "final_equity": round(self.final_equity, 2),
419            # Trade Statistics
420            "total_trades": self.total_trades,
421            "winning_trades": self.winning_trades,
422            "losing_trades": self.losing_trades,
423            "win_rate": round(self.win_rate, 2),
424            "profit_factor": round(self.profit_factor, 2) if self.profit_factor != float("inf") else "inf",
425            "avg_trade": round(self.avg_trade, 2),
426            "avg_winning_trade": round(self.avg_winning_trade, 2),
427            "avg_losing_trade": round(self.avg_losing_trade, 2),
428            "max_consecutive_wins": self.max_consecutive_wins,
429            "max_consecutive_losses": self.max_consecutive_losses,
430            # Risk Metrics
431            "sharpe_ratio": round(self.sharpe_ratio, 2) if not np.isnan(self.sharpe_ratio) else None,
432            "sortino_ratio": round(self.sortino_ratio, 2) if not np.isnan(self.sortino_ratio) and self.sortino_ratio != float("inf") else None,
433            "calmar_ratio": round(self.calmar_ratio, 2) if self.calmar_ratio != float("inf") else None,
434            "max_drawdown": round(self.max_drawdown, 2),
435            "max_drawdown_duration": self.max_drawdown_duration,
436            # Comparison
437            "buy_hold_return": round(self.buy_hold_return, 2),
438            "vs_buy_hold": round(self.vs_buy_hold, 2),
439        }

Export results to dictionary.

Compatible with TradingView/Mathieu2301 format.

def summary(self) -> str:
441    def summary(self) -> str:
442        """
443        Generate human-readable performance summary.
444
445        Returns:
446            Formatted summary string.
447        """
448        d = self.to_dict()
449
450        lines = [
451            "=" * 60,
452            f"BACKTEST RESULTS: {d['symbol']} ({d['strategy_name']})",
453            "=" * 60,
454            f"Period: {d['period']} | Interval: {d['interval']}",
455            f"Initial Capital: {d['initial_capital']:,.2f} TL",
456            f"Commission: {d['commission']*100:.2f}%",
457            "",
458            "--- PERFORMANCE ---",
459            f"Net Profit: {d['net_profit']:,.2f} TL ({d['net_profit_pct']:+.2f}%)",
460            f"Final Equity: {d['final_equity']:,.2f} TL",
461            f"Buy & Hold: {d['buy_hold_return']:+.2f}%",
462            f"vs B&H: {d['vs_buy_hold']:+.2f}%",
463            "",
464            "--- TRADE STATISTICS ---",
465            f"Total Trades: {d['total_trades']}",
466            f"Winning: {d['winning_trades']} | Losing: {d['losing_trades']}",
467            f"Win Rate: {d['win_rate']:.1f}%",
468            f"Profit Factor: {d['profit_factor']}",
469            f"Avg Trade: {d['avg_trade']:,.2f} TL",
470            f"Avg Winner: {d['avg_winning_trade']:,.2f} TL | Avg Loser: {d['avg_losing_trade']:,.2f} TL",
471            f"Max Consecutive Wins: {d['max_consecutive_wins']} | Losses: {d['max_consecutive_losses']}",
472            "",
473            "--- RISK METRICS ---",
474            f"Sharpe Ratio: {d['sharpe_ratio'] if d['sharpe_ratio'] else 'N/A'}",
475            f"Sortino Ratio: {d['sortino_ratio'] if d['sortino_ratio'] else 'N/A'}",
476            f"Calmar Ratio: {d['calmar_ratio'] if d['calmar_ratio'] else 'N/A'}",
477            f"Max Drawdown: {d['max_drawdown']:.2f}%",
478            f"Max DD Duration: {d['max_drawdown_duration']} days",
479            "=" * 60,
480        ]
481
482        return "\n".join(lines)

Generate human-readable performance summary.

Returns: Formatted summary string.

class Backtest:
485class Backtest:
486    """
487    Backtest engine for evaluating trading strategies.
488
489    Runs a strategy function over historical data and calculates
490    comprehensive performance metrics.
491
492    Attributes:
493        symbol: Stock symbol to backtest.
494        strategy: Strategy function to evaluate.
495        period: Historical data period.
496        interval: Data interval (e.g., "1d", "1h").
497        capital: Initial capital.
498        commission: Commission rate per trade (e.g., 0.001 = 0.1%).
499        indicators: List of indicators to calculate.
500
501    Examples:
502        >>> def my_strategy(candle, position, indicators):
503        ...     if indicators['rsi'] < 30:
504        ...         return 'BUY'
505        ...     elif indicators['rsi'] > 70:
506        ...         return 'SELL'
507        ...     return 'HOLD'
508
509        >>> bt = Backtest("THYAO", my_strategy, period="1y")
510        >>> result = bt.run()
511        >>> print(result.sharpe_ratio)
512    """
513
514    # Indicator period warmup
515    WARMUP_PERIOD = 50
516
517    def __init__(
518        self,
519        symbol: str,
520        strategy: StrategyFunc,
521        period: str = "1y",
522        interval: str = "1d",
523        capital: float = 100_000.0,
524        commission: float = 0.001,
525        indicators: list[str] | None = None,
526        slippage: float = 0.0,  # Future use
527    ):
528        """
529        Initialize Backtest.
530
531        Args:
532            symbol: Stock symbol (e.g., "THYAO").
533            strategy: Strategy function with signature:
534                      strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None
535            period: Historical data period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y).
536            interval: Data interval (1m, 5m, 15m, 30m, 1h, 4h, 1d).
537            capital: Initial capital in TL.
538            commission: Commission rate per trade (0.001 = 0.1%).
539            indicators: List of indicators to calculate. Options:
540                       'rsi', 'rsi_7', 'sma_20', 'sma_50', 'sma_200',
541                       'ema_12', 'ema_26', 'ema_50', 'macd', 'bollinger',
542                       'atr', 'atr_20', 'stochastic', 'adx'
543            slippage: Slippage per trade (for future use).
544        """
545        self.symbol = symbol.upper()
546        self.strategy = strategy
547        self.period = period
548        self.interval = interval
549        self.capital = capital
550        self.commission = commission
551        self.indicators = indicators or ["rsi", "sma_20", "ema_12", "macd"]
552        self.slippage = slippage
553
554        # Strategy name for reporting
555        self._strategy_name = getattr(strategy, "__name__", "custom_strategy")
556
557        # Data storage
558        self._df: pd.DataFrame | None = None
559        self._df_with_indicators: pd.DataFrame | None = None
560
561    def _load_data(self) -> pd.DataFrame:
562        """Load historical data from Ticker."""
563        from borsapy.ticker import Ticker
564
565        ticker = Ticker(self.symbol)
566        df = ticker.history(period=self.period, interval=self.interval)
567
568        if df is None or df.empty:
569            raise ValueError(f"No historical data available for {self.symbol}")
570
571        return df
572
573    def _calculate_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
574        """Add indicator columns to DataFrame."""
575        from borsapy.technical import (
576            calculate_adx,
577            calculate_atr,
578            calculate_bollinger_bands,
579            calculate_ema,
580            calculate_macd,
581            calculate_rsi,
582            calculate_sma,
583            calculate_stochastic,
584        )
585
586        result = df.copy()
587
588        for ind in self.indicators:
589            ind_lower = ind.lower()
590
591            # RSI variants
592            if ind_lower == "rsi":
593                result["rsi"] = calculate_rsi(df, period=14)
594            elif ind_lower.startswith("rsi_"):
595                try:
596                    period = int(ind_lower.split("_")[1])
597                    result[f"rsi_{period}"] = calculate_rsi(df, period=period)
598                except (IndexError, ValueError):
599                    pass
600
601            # SMA variants
602            elif ind_lower.startswith("sma_"):
603                try:
604                    period = int(ind_lower.split("_")[1])
605                    result[f"sma_{period}"] = calculate_sma(df, period=period)
606                except (IndexError, ValueError):
607                    pass
608
609            # EMA variants
610            elif ind_lower.startswith("ema_"):
611                try:
612                    period = int(ind_lower.split("_")[1])
613                    result[f"ema_{period}"] = calculate_ema(df, period=period)
614                except (IndexError, ValueError):
615                    pass
616
617            # MACD
618            elif ind_lower == "macd":
619                macd_df = calculate_macd(df)
620                result["macd"] = macd_df["MACD"]
621                result["macd_signal"] = macd_df["Signal"]
622                result["macd_histogram"] = macd_df["Histogram"]
623
624            # Bollinger Bands
625            elif ind_lower in ("bollinger", "bb"):
626                bb_df = calculate_bollinger_bands(df)
627                result["bb_upper"] = bb_df["BB_Upper"]
628                result["bb_middle"] = bb_df["BB_Middle"]
629                result["bb_lower"] = bb_df["BB_Lower"]
630
631            # ATR variants
632            elif ind_lower == "atr":
633                result["atr"] = calculate_atr(df, period=14)
634            elif ind_lower.startswith("atr_"):
635                try:
636                    period = int(ind_lower.split("_")[1])
637                    result[f"atr_{period}"] = calculate_atr(df, period=period)
638                except (IndexError, ValueError):
639                    pass
640
641            # Stochastic
642            elif ind_lower in ("stochastic", "stoch"):
643                stoch_df = calculate_stochastic(df)
644                result["stoch_k"] = stoch_df["Stoch_K"]
645                result["stoch_d"] = stoch_df["Stoch_D"]
646
647            # ADX
648            elif ind_lower == "adx":
649                result["adx"] = calculate_adx(df, period=14)
650
651        return result
652
653    def _get_indicators_at(self, idx: int) -> dict[str, float]:
654        """Get indicator values at specific index."""
655        if self._df_with_indicators is None:
656            return {}
657
658        row = self._df_with_indicators.iloc[idx]
659        indicators = {}
660
661        # Extract all non-OHLCV columns as indicators
662        exclude_cols = {"Open", "High", "Low", "Close", "Volume", "Adj Close"}
663
664        for col in self._df_with_indicators.columns:
665            if col not in exclude_cols:
666                val = row[col]
667                if pd.notna(val):
668                    indicators[col] = float(val)
669
670        return indicators
671
672    def _build_candle(self, idx: int) -> dict[str, Any]:
673        """Build candle dict from DataFrame row."""
674        if self._df is None:
675            return {}
676
677        row = self._df.iloc[idx]
678        timestamp = self._df.index[idx]
679
680        if isinstance(timestamp, pd.Timestamp):
681            timestamp = timestamp.to_pydatetime()
682
683        return {
684            "timestamp": timestamp,
685            "open": float(row["Open"]),
686            "high": float(row["High"]),
687            "low": float(row["Low"]),
688            "close": float(row["Close"]),
689            "volume": float(row.get("Volume", 0)) if "Volume" in row else 0,
690            "_index": idx,
691        }
692
693    def run(self) -> BacktestResult:
694        """
695        Run the backtest.
696
697        Returns:
698            BacktestResult with all performance metrics.
699
700        Raises:
701            ValueError: If no data available for symbol.
702        """
703        # Load data
704        self._df = self._load_data()
705        self._df_with_indicators = self._calculate_indicators(self._df)
706
707        # Initialize state
708        cash = self.capital
709        position: Position = None
710        shares = 0.0
711        trades: list[Trade] = []
712        current_trade: Trade | None = None
713
714        # Track equity curve
715        equity_values = []
716        dates = []
717
718        # Buy & hold tracking
719        initial_price = self._df["Close"].iloc[self.WARMUP_PERIOD]
720        bh_shares = self.capital / initial_price
721
722        # Run simulation
723        for idx in range(self.WARMUP_PERIOD, len(self._df)):
724            candle = self._build_candle(idx)
725            indicators = self._get_indicators_at(idx)
726            price = candle["close"]
727            timestamp = candle["timestamp"]
728
729            # Get strategy signal
730            try:
731                signal = self.strategy(candle, position, indicators)
732            except Exception:
733                signal = "HOLD"
734
735            # Execute trades
736            if signal == "BUY" and position is None:
737                # Calculate shares to buy (use all available cash)
738                entry_commission = cash * self.commission
739                available = cash - entry_commission
740                shares = available / price
741
742                current_trade = Trade(
743                    entry_time=timestamp,
744                    entry_price=price,
745                    side="long",
746                    shares=shares,
747                    commission=entry_commission,
748                )
749
750                cash = 0.0
751                position = "long"
752
753            elif signal == "SELL" and position == "long" and current_trade is not None:
754                # Close position
755                exit_value = shares * price
756                exit_commission = exit_value * self.commission
757
758                current_trade.exit_time = timestamp
759                current_trade.exit_price = price
760                current_trade.commission += exit_commission
761
762                trades.append(current_trade)
763
764                cash = exit_value - exit_commission
765                shares = 0.0
766                position = None
767                current_trade = None
768
769            # Track equity
770            if position == "long":
771                equity = shares * price
772            else:
773                equity = cash
774
775            equity_values.append(equity)
776            dates.append(timestamp)
777
778        # Close any open position at end
779        if position == "long" and current_trade is not None:
780            final_price = self._df["Close"].iloc[-1]
781            exit_value = shares * final_price
782            exit_commission = exit_value * self.commission
783
784            current_trade.exit_time = self._df.index[-1]
785            if isinstance(current_trade.exit_time, pd.Timestamp):
786                current_trade.exit_time = current_trade.exit_time.to_pydatetime()
787            current_trade.exit_price = final_price
788            current_trade.commission += exit_commission
789
790            trades.append(current_trade)
791
792        # Build curves
793        equity_curve = pd.Series(equity_values, index=pd.DatetimeIndex(dates))
794
795        # Calculate drawdown curve
796        running_max = equity_curve.cummax()
797        drawdown_curve = (equity_curve - running_max) / running_max
798
799        # Buy & hold curve
800        bh_values = self._df["Close"].iloc[self.WARMUP_PERIOD:] * bh_shares
801        buy_hold_curve = pd.Series(bh_values.values, index=pd.DatetimeIndex(dates))
802
803        return BacktestResult(
804            symbol=self.symbol,
805            period=self.period,
806            interval=self.interval,
807            strategy_name=self._strategy_name,
808            initial_capital=self.capital,
809            commission=self.commission,
810            trades=trades,
811            equity_curve=equity_curve,
812            drawdown_curve=drawdown_curve,
813            buy_hold_curve=buy_hold_curve,
814        )

Backtest engine for evaluating trading strategies.

Runs a strategy function over historical data and calculates comprehensive performance metrics.

Attributes: symbol: Stock symbol to backtest. strategy: Strategy function to evaluate. period: Historical data period. interval: Data interval (e.g., "1d", "1h"). capital: Initial capital. commission: Commission rate per trade (e.g., 0.001 = 0.1%). indicators: List of indicators to calculate.

Examples:

def my_strategy(candle, position, indicators): ... if indicators['rsi'] < 30: ... return 'BUY' ... elif indicators['rsi'] > 70: ... return 'SELL' ... return 'HOLD'

>>> bt = Backtest("THYAO", my_strategy, period="1y")
>>> result = bt.run()
>>> print(result.sharpe_ratio)
Backtest( symbol: str, strategy: Callable[[dict, typing.Optional[typing.Literal['long', 'short']], dict], typing.Optional[typing.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)
517    def __init__(
518        self,
519        symbol: str,
520        strategy: StrategyFunc,
521        period: str = "1y",
522        interval: str = "1d",
523        capital: float = 100_000.0,
524        commission: float = 0.001,
525        indicators: list[str] | None = None,
526        slippage: float = 0.0,  # Future use
527    ):
528        """
529        Initialize Backtest.
530
531        Args:
532            symbol: Stock symbol (e.g., "THYAO").
533            strategy: Strategy function with signature:
534                      strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None
535            period: Historical data period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y).
536            interval: Data interval (1m, 5m, 15m, 30m, 1h, 4h, 1d).
537            capital: Initial capital in TL.
538            commission: Commission rate per trade (0.001 = 0.1%).
539            indicators: List of indicators to calculate. Options:
540                       'rsi', 'rsi_7', 'sma_20', 'sma_50', 'sma_200',
541                       'ema_12', 'ema_26', 'ema_50', 'macd', 'bollinger',
542                       'atr', 'atr_20', 'stochastic', 'adx'
543            slippage: Slippage per trade (for future use).
544        """
545        self.symbol = symbol.upper()
546        self.strategy = strategy
547        self.period = period
548        self.interval = interval
549        self.capital = capital
550        self.commission = commission
551        self.indicators = indicators or ["rsi", "sma_20", "ema_12", "macd"]
552        self.slippage = slippage
553
554        # Strategy name for reporting
555        self._strategy_name = getattr(strategy, "__name__", "custom_strategy")
556
557        # Data storage
558        self._df: pd.DataFrame | None = None
559        self._df_with_indicators: pd.DataFrame | None = None

Initialize Backtest.

Args: symbol: Stock symbol (e.g., "THYAO"). strategy: Strategy function with signature: strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None period: Historical data period (1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y). interval: Data interval (1m, 5m, 15m, 30m, 1h, 4h, 1d). capital: Initial capital in TL. commission: Commission rate per trade (0.001 = 0.1%). indicators: List of indicators to calculate. Options: 'rsi', 'rsi_7', 'sma_20', 'sma_50', 'sma_200', 'ema_12', 'ema_26', 'ema_50', 'macd', 'bollinger', 'atr', 'atr_20', 'stochastic', 'adx' slippage: Slippage per trade (for future use).

WARMUP_PERIOD = 50
symbol
strategy
period
interval
capital
commission
indicators
slippage
def run(self) -> BacktestResult:
693    def run(self) -> BacktestResult:
694        """
695        Run the backtest.
696
697        Returns:
698            BacktestResult with all performance metrics.
699
700        Raises:
701            ValueError: If no data available for symbol.
702        """
703        # Load data
704        self._df = self._load_data()
705        self._df_with_indicators = self._calculate_indicators(self._df)
706
707        # Initialize state
708        cash = self.capital
709        position: Position = None
710        shares = 0.0
711        trades: list[Trade] = []
712        current_trade: Trade | None = None
713
714        # Track equity curve
715        equity_values = []
716        dates = []
717
718        # Buy & hold tracking
719        initial_price = self._df["Close"].iloc[self.WARMUP_PERIOD]
720        bh_shares = self.capital / initial_price
721
722        # Run simulation
723        for idx in range(self.WARMUP_PERIOD, len(self._df)):
724            candle = self._build_candle(idx)
725            indicators = self._get_indicators_at(idx)
726            price = candle["close"]
727            timestamp = candle["timestamp"]
728
729            # Get strategy signal
730            try:
731                signal = self.strategy(candle, position, indicators)
732            except Exception:
733                signal = "HOLD"
734
735            # Execute trades
736            if signal == "BUY" and position is None:
737                # Calculate shares to buy (use all available cash)
738                entry_commission = cash * self.commission
739                available = cash - entry_commission
740                shares = available / price
741
742                current_trade = Trade(
743                    entry_time=timestamp,
744                    entry_price=price,
745                    side="long",
746                    shares=shares,
747                    commission=entry_commission,
748                )
749
750                cash = 0.0
751                position = "long"
752
753            elif signal == "SELL" and position == "long" and current_trade is not None:
754                # Close position
755                exit_value = shares * price
756                exit_commission = exit_value * self.commission
757
758                current_trade.exit_time = timestamp
759                current_trade.exit_price = price
760                current_trade.commission += exit_commission
761
762                trades.append(current_trade)
763
764                cash = exit_value - exit_commission
765                shares = 0.0
766                position = None
767                current_trade = None
768
769            # Track equity
770            if position == "long":
771                equity = shares * price
772            else:
773                equity = cash
774
775            equity_values.append(equity)
776            dates.append(timestamp)
777
778        # Close any open position at end
779        if position == "long" and current_trade is not None:
780            final_price = self._df["Close"].iloc[-1]
781            exit_value = shares * final_price
782            exit_commission = exit_value * self.commission
783
784            current_trade.exit_time = self._df.index[-1]
785            if isinstance(current_trade.exit_time, pd.Timestamp):
786                current_trade.exit_time = current_trade.exit_time.to_pydatetime()
787            current_trade.exit_price = final_price
788            current_trade.commission += exit_commission
789
790            trades.append(current_trade)
791
792        # Build curves
793        equity_curve = pd.Series(equity_values, index=pd.DatetimeIndex(dates))
794
795        # Calculate drawdown curve
796        running_max = equity_curve.cummax()
797        drawdown_curve = (equity_curve - running_max) / running_max
798
799        # Buy & hold curve
800        bh_values = self._df["Close"].iloc[self.WARMUP_PERIOD:] * bh_shares
801        buy_hold_curve = pd.Series(bh_values.values, index=pd.DatetimeIndex(dates))
802
803        return BacktestResult(
804            symbol=self.symbol,
805            period=self.period,
806            interval=self.interval,
807            strategy_name=self._strategy_name,
808            initial_capital=self.capital,
809            commission=self.commission,
810            trades=trades,
811            equity_curve=equity_curve,
812            drawdown_curve=drawdown_curve,
813            buy_hold_curve=buy_hold_curve,
814        )

Run the backtest.

Returns: BacktestResult with all performance metrics.

Raises: ValueError: If no data available for symbol.

def backtest( symbol: str, strategy: Callable[[dict, typing.Optional[typing.Literal['long', 'short']], dict], typing.Optional[typing.Literal['BUY', 'SELL', 'HOLD']]], period: str = '1y', interval: str = '1d', capital: float = 100000.0, commission: float = 0.001, indicators: list[str] | None = None) -> BacktestResult:
817def backtest(
818    symbol: str,
819    strategy: StrategyFunc,
820    period: str = "1y",
821    interval: str = "1d",
822    capital: float = 100_000.0,
823    commission: float = 0.001,
824    indicators: list[str] | None = None,
825) -> BacktestResult:
826    """
827    Run a backtest with a single function call.
828
829    Convenience function that creates a Backtest instance and runs it.
830
831    Args:
832        symbol: Stock symbol (e.g., "THYAO").
833        strategy: Strategy function with signature:
834                  strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None
835        period: Historical data period.
836        interval: Data interval.
837        capital: Initial capital.
838        commission: Commission rate.
839        indicators: List of indicators to calculate.
840
841    Returns:
842        BacktestResult with all performance metrics.
843
844    Examples:
845        >>> def rsi_strategy(candle, position, indicators):
846        ...     if indicators.get('rsi', 50) < 30 and position is None:
847        ...         return 'BUY'
848        ...     elif indicators.get('rsi', 50) > 70 and position == 'long':
849        ...         return 'SELL'
850        ...     return 'HOLD'
851
852        >>> result = bp.backtest("THYAO", rsi_strategy, period="1y")
853        >>> print(f"Net Profit: {result.net_profit_pct:.2f}%")
854        >>> print(f"Sharpe: {result.sharpe_ratio:.2f}")
855    """
856    bt = Backtest(
857        symbol=symbol,
858        strategy=strategy,
859        period=period,
860        interval=interval,
861        capital=capital,
862        commission=commission,
863        indicators=indicators,
864    )
865    return bt.run()

Run a backtest with a single function call.

Convenience function that creates a Backtest instance and runs it.

Args: symbol: Stock symbol (e.g., "THYAO"). strategy: Strategy function with signature: strategy(candle, position, indicators) -> 'BUY'|'SELL'|'HOLD'|None period: Historical data period. interval: Data interval. capital: Initial capital. commission: Commission rate. indicators: List of indicators to calculate.

Returns: BacktestResult with all performance metrics.

Examples:

def rsi_strategy(candle, position, indicators): ... if indicators.get('rsi', 50) < 30 and position is None: ... return 'BUY' ... elif indicators.get('rsi', 50) > 70 and position == 'long': ... return 'SELL' ... return 'HOLD'

>>> result = bp.backtest("THYAO", rsi_strategy, period="1y")
>>> print(f"Net Profit: {result.net_profit_pct:.2f}%")
>>> print(f"Sharpe: {result.sharpe_ratio:.2f}")