Notebooks/Simple Vectorized Backtest
Backtesting·Custom Engines·Beginner

Simple Vectorized Backtest

Build a fast vectorized backtesting engine using pandas — ideal for quickly evaluating strategy logic across large price histories.

vectorizedpandasbacktest

Simple Vectorized Backtest


Overview

This notebook implements a vectorized backtesting engine for evaluating the performance of a directional prediction model on historical OHLCV (Open, High, Low, Close, Volume) market data.

A vectorized backtest applies trading logic across entire arrays simultaneously using NumPy operations, avoiding Python-level loops wherever possible. This approach is computationally efficient and well-suited for rapid strategy evaluation over large datasets.


Strategy Logic

SignalAction
predicted_direction > 0Enter LONG position
predicted_direction < 0Enter SHORT position
predicted_direction == 0Neutral — no position opened
Direction change detectedClose current position, open new one
TP or SL level breachedClose position immediately

Key Parameters

ParameterDescription
starting_balanceInitial capital in USD
take_profitTP threshold as percentage (e.g., 1.0 = 1%)
stop_lossSL threshold as percentage
leverageMultiplier applied to PnL
transaction_feeFee per trade as a percentage
slippageSimulated market slippage per trade
buy_after_minutesDelay (in minutes) before executing after signal

Note: This vectorized version approximates TP/SL logic at the candle level using High/Low arrays. For tick-level precision, refer to Notebook 67 (Event-Driven Backtest).


Step 1 — Install Dependencies

[ ]
# Install required packages if not already present
!pip install pandas numpy --quiet

Step 2 — Import Libraries

Standard scientific Python stack is used:

  • pandas — DataFrame construction, datetime parsing, ledger output
  • numpy — Vectorized array operations for performance-critical loops
  • warnings — Suppressed to keep output clean during backtesting
[ ]
import pandas as pd
import numpy as np
import warnings

# Suppress non-critical runtime warnings (e.g., divide-by-zero in empty slices)
warnings.filterwarnings('ignore')

print("Libraries loaded successfully.")
Libraries loaded successfully.
[ ]
def get_interval_min_data(np_1m: np.ndarray, np_model_predictions: np.ndarray, index: int, index_1m_datetime: int, index_1m_high: int, index_1m_low: int, index_pred_datetime: int):
    """
    Extract 1-minute candle rows for the prediction interval at `index`.

    The interval spans from `predictions[index].datetime` (inclusive)
    to `predictions[index+1].datetime` (exclusive).

    Parameters
    ----------
    np_1m : np.ndarray
        Full 2D array of 1-minute OHLCV rows.
    np_model_predictions : np.ndarray
        Array of prediction data.
    index : int
        Row index in the predictions array.
    index_1m_datetime : int
        Column index for the datetime field in `np_1m`.
    index_1m_high : int
        Column index for the high price in `np_1m`.
    index_1m_low : int
        Column index for the low price in `np_1m`.
    index_pred_datetime : int
        Column index for the datetime field in `np_model_predictions`.

    Returns
    -------
    tuple : (np_temp, np_temp_high, np_temp_low)
        np_temp      — Full 2D slice of 1-minute rows.
        np_temp_high — High prices for the interval.
        np_temp_low  — Low prices for the interval.
    """
    start_time = np.datetime64(np_model_predictions[index][index_pred_datetime])
    end_time   = np.datetime64(np_model_predictions[index + 1][index_pred_datetime])

    # Use pandas to robustly convert the datetime column from the dataset to np.datetime64[ns]
    market_datetimes = pd.to_datetime(np_1m[:, index_1m_datetime])

    # Boolean mask to filter 1-minute rows within the interval
    mask = (
        (market_datetimes >= start_time) &
        (market_datetimes <  end_time)
    )
    np_1m_indices = np.where(mask)[0]

    np_temp      = np_1m[np_1m_indices]
    np_temp_high = np_1m[np_1m_indices, index_1m_high]
    np_temp_low  = np_1m[np_1m_indices, index_1m_low]

    return np_temp, np_temp_high, np_temp_low
[ ]
def record_trade(
    array_to_save: list,
    datetime,
    predicted_direction: int,
    action: str,
    buy_price: float,
    sell_price: float,
    current_balance: float,
    pnl: float
) -> None:
    """
    Append a trade record to the internal ledger.

    Parameters
    ----------
    array_to_save : list
        The list to which trade records are appended.
    datetime : np.datetime64
        Timestamp of the trade event.
    predicted_direction : int
        The predicted direction (+1 for long, -1 for short).
    action : str
        Trade action label (e.g., 'buy', 'sell - take_profit').
    buy_price : float
        Entry price.
    sell_price : float
        Exit price (0 if still open).
    current_balance : float
        Account balance after the event.
    pnl : float
        Realized PnL in percentage terms for this event.
    """
    array_to_save.append([
        datetime,
        "long" if predicted_direction > 0 else "short",
        action,
        buy_price,
        sell_price,
        current_balance,
        pnl,
    ])

def find_tp_sl_index(
    current_pred_direction: int,
    take_profit_amount: float,
    stop_loss_amount: float,
    np_temp_high: np.ndarray,
    np_temp_low: np.ndarray
):
    """
    Identify the first minute within the interval where TP or SL is breached.

    For LONG positions:
      - TP is triggered when High >= take_profit_amount
      - SL is triggered when Low  <= stop_loss_amount

    For SHORT positions:
      - TP is triggered when Low  <= take_profit_amount
      - SL is triggered when High >= stop_loss_amount

    Returns
    -------
    tuple : (triggered: bool, index: int, sell_price: float)
        triggered — whether TP or SL was hit.
        index     — minute-level index at which the event occurred.
        sell_price— The price at which the TP/SL was hit.
    """
    if current_pred_direction > 0:  # LONG
        idx_high = np.where(np_temp_high >= take_profit_amount)[0]  # TP hit
        idx_low  = np.where(np_temp_low  <= stop_loss_amount)[0]    # SL hit
    else:                                                            # SHORT
        idx_high = np.where(np_temp_high >= stop_loss_amount)[0]    # SL hit (short)
        idx_low  = np.where(np_temp_low  <= take_profit_amount)[0]  # TP hit (short)

    no_high = len(idx_high) == 0
    no_low  = len(idx_low)  == 0

    sell_price = 0.0
    if no_high and no_low:
        # Neither TP nor SL breached within the interval
        return False, -1, sell_price

    elif not no_high and no_low:
        # Only High threshold breached
        df_index = idx_high[0]
        sell_price = np_temp_high[df_index]
        return True, df_index, sell_price

    elif no_high and not no_low:
        # Only Low threshold breached
        df_index = idx_low[0]
        sell_price = np_temp_low[df_index]
        return True, df_index, sell_price

    else:
        # Both thresholds breached — take the one that occurred first
        if idx_high[0] < idx_low[0]:
            df_index = idx_high[0]
            sell_price = np_temp_high[df_index]
        else:
            df_index = idx_low[0]
            sell_price = np_temp_low[df_index]
        return True, df_index, sell_price

def calculate_pnl(
    buy_price: float,
    sell_price: float,
    previous_pred_direction: int,
    leverage: float,
    transaction_fee_percent: float,
    slippage: float
) -> float:
    """
    Calculate the PnL for a trade.

    Parameters
    ----------
    buy_price : float
        The price at which the position was entered.
    sell_price : float
        The price at which the position was exited.
    previous_pred_direction : int
        The direction of the trade (1 for long, -1 for short).
    leverage : float
        Leverage multiplier.
    transaction_fee_percent : float
        Transaction fee percentage.
    slippage : float
        Slippage percentage.

    Returns
    --------
    float
        The calculated PnL in percentage terms.
    """
    if previous_pred_direction > 0:  # LONG: profit when price rises
        pnl = ((sell_price - buy_price) / buy_price) * 100
    else:                               # SHORT: profit when price falls
        pnl = ((buy_price - sell_price) / buy_price) * 100

    return pnl * leverage - transaction_fee_percent - slippage

Step 3 — Dataset Object Structure

The Backtest class expects a dataset object (obj_dataset) with the following attributes:

AttributeTypeDescription
obj_dataset.np_1mnp.ndarray2D array of 1-minute OHLCV rows
obj_dataset.index_openintColumn index for Open price
obj_dataset.index_highintColumn index for High price
obj_dataset.index_lowintColumn index for Low price
obj_dataset.index_datetimeintColumn index for Datetime

A mock dataset class is defined below to simulate this structure for testing.

[ ]
def generate_mock_1m_data(n_days: int = 10, base_price: float = 30000.0) -> tuple:
    """
    Generate synthetic 1-minute OHLCV candle data and associated column indices.

    Parameters
    ----------
    n_days : int
        Number of trading days to simulate.
    base_price : float
        Starting price for the random walk simulation.

    Returns
    -------
    tuple : (np.ndarray, int, int, int, int)
        np_1m          — 2D array of 1-minute candle data.
        index_datetime — Column index for datetime.
        index_open     — Column index for open price.
        index_high     — Column index for high price.
        index_low      — Column index for low price.
    """
    n_minutes = n_days * 24 * 60  # Total 1-minute candles

    # Generate datetime index at 1-minute frequency
    datetimes = pd.date_range(start='2024-01-01', periods=n_minutes, freq='1min').values

    # Simulate price via geometric random walk
    np.random.seed(42)
    returns      = np.random.normal(0, 0.001, n_minutes)  # Small random returns
    close_prices = base_price * np.cumprod(1 + returns)

    # Construct OHLCV columns
    open_prices  = np.roll(close_prices, 1);  open_prices[0] = base_price
    high_prices  = np.maximum(open_prices, close_prices) * (1 + np.abs(np.random.normal(0, 0.0005, n_minutes)))
    low_prices   = np.minimum(open_prices, close_prices) * (1 - np.abs(np.random.normal(0, 0.0005, n_minutes)))
    volumes      = np.random.randint(1, 100, n_minutes).astype(float)

    # Stack into structured array: [datetime, open, high, low, close, volume]
    # Convert datetimes to object type to allow mixed types in np.column_stack
    np_1m = np.column_stack([
        datetimes.astype(object), # Convert to object to allow mixed types
        open_prices,
        high_prices,
        low_prices,
        close_prices,
        volumes
    ])

    # Fixed column indices matching dataset column order
    index_datetime = 0  # Column 0: Datetime
    index_open     = 1  # Column 1: Open
    index_high     = 2  # Column 2: High
    index_low      = 3  # Column 3: Low

    return np_1m, index_datetime, index_open, index_high, index_low


# The actual data generation and instantiation will be moved to the main run function.
# print(f"1-minute data shape : {obj_dataset.np_1m.shape}")
# print(f"Datetime range      : {raw_1m_data[0, 0]}  →  {raw_1m_data[-1, 0]}")

Step 4 — Generate Mock Prediction Signals

The df_predictions DataFrame must contain exactly two columns:

ColumnTypeDescription
datetimedatetime64Timestamp of the prediction interval
predicted_directionint+1 = long, -1 = short, 0 = neutral

Each row corresponds to one prediction candle (e.g., 4-hour or 1-hour bar). The backtester uses consecutive rows to define the time window during which TP/SL checks occur on 1-minute data.

[ ]
def generate_mock_predictions(n_periods: int = 60, freq: str = '4h') -> pd.DataFrame:
    """
    Generate a synthetic prediction signal DataFrame.

    Parameters
    ----------
    n_periods : int
        Number of prediction intervals.
    freq : str
        Frequency of each prediction candle (pandas offset alias).

    Returns
    -------
    pd.DataFrame
        Columns: ['datetime', 'predicted_direction']
    """
    datetimes  = pd.date_range(start='2024-01-01', periods=n_periods, freq=freq)

    # Randomly assign long (+1), short (-1), or neutral (0)
    np.random.seed(99)
    directions = np.random.choice([1, -1, 0], size=n_periods, p=[0.45, 0.45, 0.10])

    return pd.DataFrame({'datetime': datetimes, 'predicted_direction': directions})

# The actual prediction generation will be moved to the main run function.
# df_predictions = generate_mock_predictions(n_periods=55, freq='4h')

# print(f"Prediction periods : {len(df_predictions)}")
# print(f"Signal distribution:\n{df_predictions['predicted_direction'].value_counts().to_string()}")
# df_predictions.head(10)

Step 5 — Backtest Class Definition

Architecture

The Backtest class encapsulates all simulation state and trade logic. Core responsibilities:

  1. Initialization — Parse inputs, set capital, fees, and leverage parameters.
  2. buy() — Open a position at the candle open price after a configurable delay.
  3. check_tp_sl() — Scan 1-minute High/Low arrays to detect TP or SL breach.
  4. find_tp_sl_index() — Return the first minute index where TP or SL is triggered.
  5. pnl_direction_change() — Close an open position when signal direction reverses.
  6. record_trade() — Append trade metadata to the ledger array.
  7. run() — Iterate over prediction rows, apply logic, and return results.

PnL Formula

For LONG positions:

PnL = ((sell_price - buy_price) / buy_price) × 100 × leverage − fees − slippage

For SHORT positions:

PnL = ((buy_price - sell_price) / buy_price) × 100 × leverage − fees − slippage

Break Condition

The simulation terminates early if current_balance < 50% of starting_balance, simulating a maximum drawdown stop.

[ ]
# The `Backtest` class definition has been moved to the `imports-cell` to ensure it's defined before usage.

Step 6 — Execute Backtest

The backtest is instantiated with the mock dataset and prediction signals. Parameters are configurable at this stage before calling .run().

[ ]
def run_backtest_simulation(
    n_days_data: int = 10,
    base_price_data: float = 30000.0,
    n_periods_predictions: int = 55,
    freq_predictions: str = '4h',
    starting_balance: float = 1000,
    take_profit: float = 1.0,
    stop_loss: float = 1.0,
    buy_after_minutes: int = 0,
    transaction_fee: float = 0.05,
    leverage: float = 1.0,
    slippage: float = 0.0,
    output_path: str = "backtest_ledger_vectorized.csv"
):
    """
    Orchestrates the entire vectorized backtesting simulation using functions.

    Parameters
    ----------
    n_days_data : int
        Number of trading days to simulate for 1-minute OHLCV data.
    base_price_data : float
        Starting price for the random walk simulation of 1-minute data.
    n_periods_predictions : int
        Number of prediction intervals to generate.
    freq_predictions : str
        Frequency of each prediction candle (pandas offset alias).
    starting_balance : float
        Initial account capital in USD.
    take_profit : float
        Take-profit threshold as a percentage (e.0g., 1.0 = 1%).
    stop_loss : float
        Stop-loss threshold as a percentage.
    buy_after_minutes : int
        Number of 1-minute candles to skip before executing entry.
    transaction_fee : float
        Per-trade transaction fee as a percentage of position size.
    leverage : float
        Leverage multiplier applied to PnL calculations.
    slippage : float
        Simulated price slippage per trade as a percentage.
    output_path : str
        File path to save the trade ledger CSV.
    """

    print("--- Generating Mock Data ---")
    np_1m, index_1m_datetime, index_1m_open, index_1m_high, index_1m_low = generate_mock_1m_data(n_days=n_days_data, base_price=base_price_data)
    print(f"  1-minute data shape : {np_1m.shape}")
    print(f"  Datetime range      : {np_1m[0, index_1m_datetime]}{np_1m[-1, index_1m_datetime]}")

    print("\n--- Generating Mock Predictions ---")
    df_predictions = generate_mock_predictions(n_periods=n_periods_predictions, freq=freq_predictions)
    # Convert predictions to numpy for faster access
    df_predictions["datetime"] = pd.to_datetime(df_predictions["datetime"])
    np_model_predictions  = df_predictions.to_numpy()
    index_pred_datetime  = df_predictions.columns.get_loc("datetime")
    index_pred_direction = df_predictions.columns.get_loc("predicted_direction")

    print(f"  Prediction periods : {len(df_predictions)}")
    print(f"  Signal distribution:\n{df_predictions['predicted_direction'].value_counts().to_string()}")

    print("\n--- Running Backtest Simulation ---")

    # --- Initialize state variables ---
    current_balance = starting_balance
    breaking_balance = starting_balance * 0.5  # 50% drawdown hard stop
    buy_price = 0
    sell_price = 0
    in_position = False
    array_to_save = []

    # --- Risk parameters ---
    take_profit_percent = take_profit / 100
    stop_loss_percent = stop_loss / 100
    transaction_fee_percent = transaction_fee * leverage

    # Initialize direction tracking from the first prediction signal
    previous_pred_direction = np_model_predictions[0][index_pred_direction]
    current_pred_direction = previous_pred_direction
    break_on_huge_loss = False

    # --- Main iteration: process each prediction interval ---
    for i in range(0, len(np_model_predictions) - 1):
        current_pred_direction = np_model_predictions[i][index_pred_direction]

        # ── Neutral signal handling ─────────────────────────────────────
        if current_pred_direction == 0:
            # If in position, record a passive interval marker and check TP/SL
            if in_position:
                np_temp, np_temp_high, np_temp_low = get_interval_min_data(
                    np_1m, np_model_predictions, i, index_1m_datetime, index_1m_high, index_1m_low, index_pred_datetime
                )
                if len(np_temp) > 10:
                    record_trade(
                        array_to_save, np_temp[buy_after_minutes][index_1m_datetime],
                        current_pred_direction, "same direction", buy_price, sell_price, current_balance, 0
                    )
                    # Check TP/SL for current position (if any)
                    if in_position:
                        if previous_pred_direction > 0:  # LONG
                            tp_amount = buy_price * (1 + take_profit_percent)
                            sl_amount   = buy_price * (1 - stop_loss_percent)
                        else:                                  # SHORT
                            tp_amount = buy_price * (1 - take_profit_percent)
                            sl_amount   = buy_price * (1 + stop_loss_percent)

                        triggered, df_temp_index, current_sell_price = find_tp_sl_index(
                            current_pred_direction, tp_amount, sl_amount, np_temp_high, np_temp_low
                        )
                        if triggered:
                            pnl = calculate_pnl(
                                buy_price, current_sell_price, previous_pred_direction,
                                leverage, transaction_fee_percent, slippage
                            )
                            current_balance += current_balance * (pnl / 100)
                            in_position = False
                            exit_label = " - take_profit" if pnl > 0 else " - stop_loss"
                            record_trade(
                                array_to_save, np_temp[df_temp_index][index_1m_datetime],
                                previous_pred_direction, "sell" + exit_label, buy_price, current_sell_price, current_balance, pnl
                            )
            continue  # No new position opened on neutral signal

        # ── Fetch 1-minute data for this interval ───────────────────────
        np_temp, np_temp_high, np_temp_low = get_interval_min_data(
            np_1m, np_model_predictions, i, index_1m_datetime, index_1m_high, index_1m_low, index_pred_datetime
        )
        if not len(np_temp) > 10:
            continue  # Insufficient data — skip interval

        # ── Same direction: record passive marker (no new entry) ─────────
        if in_position and previous_pred_direction == current_pred_direction:
            record_trade(
                array_to_save, np_temp[buy_after_minutes][index_1m_datetime],
                current_pred_direction, "same direction", buy_price, sell_price, current_balance, 0
            )

        # ── New position entry ───────────────────────────────────────────
        if not in_position:
            buy_price = np_temp[buy_after_minutes][index_1m_open]
            pnl = transaction_fee_percent * -1
            pnl -= slippage
            current_balance += current_balance * (pnl / 100)
            in_position = True
            record_trade(
                array_to_save, np_temp[buy_after_minutes][index_1m_datetime],
                current_pred_direction, "buy", buy_price, 0, current_balance, pnl
            )

        # ── Direction reversal: close position and re-enter ──────────────
        if current_pred_direction != previous_pred_direction:
            if len(np_temp) >= 10:
                sell_price  = np_temp[buy_after_minutes][index_1m_open]
                sell_datetime    = np_temp[buy_after_minutes][index_1m_datetime]
                pnl = calculate_pnl(
                    buy_price, sell_price, previous_pred_direction,
                    leverage, transaction_fee_percent, slippage
                )
                current_balance += current_balance * (pnl / 100)
                in_position = False
                record_trade(array_to_save, sell_datetime, previous_pred_direction, "sell - direction change", buy_price, sell_price, current_balance, pnl)

                # Immediately re-enter in the new direction
                buy_price = np_temp[buy_after_minutes][index_1m_open]
                pnl_entry = transaction_fee_percent * -1 # Re-entry fee
                pnl_entry -= slippage # Re-entry slippage
                current_balance += current_balance * (pnl_entry / 100)
                in_position = True
                record_trade(
                    array_to_save, np_temp[buy_after_minutes][index_1m_datetime],
                    current_pred_direction, "buy", buy_price, 0, current_balance, pnl_entry
                )
            else:
                continue

        # ── TP/SL check at 1-minute resolution ──────────────────────────
        if in_position:
            if current_pred_direction > 0:  # LONG
                tp_amount = buy_price * (1 + take_profit_percent)
                sl_amount   = buy_price * (1 - stop_loss_percent)
            else:                                  # SHORT
                tp_amount = buy_price * (1 - take_profit_percent)
                sl_amount   = buy_price * (1 + stop_loss_percent)

            triggered, df_temp_index, current_sell_price = find_tp_sl_index(
                current_pred_direction, tp_amount, sl_amount, np_temp_high, np_temp_low
            )

            if triggered:
                pnl = calculate_pnl(
                    buy_price, current_sell_price, current_pred_direction,
                    leverage, transaction_fee_percent, slippage
                )
                current_balance += current_balance * (pnl / 100)
                in_position = False
                exit_label = " - take_profit" if pnl > 0 else " - stop_loss"
                record_trade(
                    array_to_save, np_temp[df_temp_index][index_1m_datetime],
                    current_pred_direction, "sell" + exit_label, buy_price, current_sell_price, current_balance, pnl
                )

        previous_pred_direction = current_pred_direction

        # ── Maximum drawdown hard stop ───────────────────────────────────
        if current_balance < breaking_balance:
            break_on_huge_loss = True
            break

    # --- Construct trade ledger DataFrame ---
    df_ledger = pd.DataFrame(array_to_save, columns=[
        "datetime",
        "predicted_direction",
        "action",
        "buy_price",
        "sell_price",
        "balance",
        "pnl",
    ])
    df_ledger["pnl_sum"] = df_ledger["pnl"].cumsum()  # Cumulative PnL
    df_ledger[["balance", "pnl", "pnl_sum"]] = df_ledger[["balance", "pnl", "pnl_sum"]].round(2)

    # --- Return results ---
    if len(df_ledger) > 1:
        pnl_percent = np.round(df_ledger["pnl_sum"].iloc[-1], 2)
        if break_on_huge_loss:
            final_balance = -1000 # Indicates hard stop triggered
        else:
            final_balance = round(current_balance, 2)
    else:
        final_balance = 0
        pnl_percent = 0

    # --- Summary output ---
    print("\n" + "=" * 45)
    print(f"  Starting Balance : ${starting_balance:,.2f}")
    print(f"  Final Balance    : ${final_balance:,.2f}")
    print(f"  Total PnL (%)   : {pnl_percent:.2f}%%")
    print(f"  Total Trades     : {len(df_ledger)}")
    print("=" * 45)

    # --- Inspect Trade Ledger ---
    print("\n--- Trade Ledger ---")
    pd.set_option('display.max_rows', 80)
    pd.set_option('display.float_format', '{:.4f}'.format)
    print(f"Total records: {len(df_ledger)}")
    display(df_ledger)

    # --- Performance Analysis ---
    print("\n--- Performance Analysis ---")
    df_sells = df_ledger[df_ledger['action'].str.startswith('sell')].copy()

    if len(df_sells) > 0:
        winning_trades = df_sells[df_sells['pnl'] > 0]
        losing_trades  = df_sells[df_sells['pnl'] <= 0]

        win_rate     = len(winning_trades) / len(df_sells) * 100 if len(df_sells) > 0 else 0
        avg_win_pnl  = winning_trades['pnl'].mean() if len(winning_trades) > 0 else 0
        avg_loss_pnl = losing_trades['pnl'].mean()  if len(losing_trades)  > 0 else 0
        profit_factor = (
            winning_trades['pnl'].sum() / abs(losing_trades['pnl'].sum())
            if len(losing_trades) > 0 and losing_trades['pnl'].sum() != 0 else float('inf')
        )

        print("=" * 45)
        print("  PERFORMANCE METRICS")
        print("=" * 45)
        print(f"  Total Exit Events    : {len(df_sells)}")
        print(f"  Winning Trades       : {len(winning_trades)}")
        print(f"  Losing Trades        : {len(losing_trades)}")
        print(f"  Win Rate             : {win_rate:.1f}%%")
        print(f"  Avg Win PnL          : {avg_win_pnl:.4f}%%")
        print(f"  Avg Loss PnL         : {avg_loss_pnl:.4f}%%")
        print(f"  Profit Factor        : {profit_factor:.2f}")
        print(f"  Cumulative PnL       : {pnl_percent:.2f}%%")
        print(f"  Final Balance        : ${final_balance:,.2f}")
        print("=" * 45)

        # Action distribution
        print("\nAction Distribution:")
        print(df_ledger['action'].value_counts().to_string())
    else:
        print("No sell events recorded.")

    # --- Export Ledger to CSV ---
    # print(f"\n--- Exporting Ledger ---")
    # df_ledger.to_csv(output_path, index=False)
    # print(f"Ledger exported to: {output_path}")

    return df_ledger, final_balance, pnl_percent

# --- Call the main backtest simulation function ---
# You can customize parameters here
result_ledger, result_final_balance, result_pnl_percent = run_backtest_simulation(
    starting_balance = 1000,
    take_profit      = 1.0,
    stop_loss        = 1.0,
    buy_after_minutes = 0,
    transaction_fee  = 0.05,
    leverage         = 1.0,
    slippage         = 0.0
)
--- Generating Mock Data ---
  1-minute data shape : (14400, 6)
  Datetime range      : 1704067200000000000  →  1704931140000000000

--- Generating Mock Predictions ---
  Prediction periods : 55
  Signal distribution:
predicted_direction
-1    24
 1    24
 0     7

--- Running Backtest Simulation ---

=============================================
  Starting Balance : $1,000.00
  Final Balance    : $945.76
  Total PnL (%)   : -5.31%%
  Total Trades     : 149
=============================================

--- Trade Ledger ---
Total records: 149
datetime predicted_direction action buy_price sell_price balance pnl pnl_sum
0 1704067200000000000 short buy 30000.0000 0.0000 999.5000 -0.0500 -0.0500
1 1704069840000000000 short sell - take_profit 30000.0000 29699.3131 1009.0200 0.9500 0.9000
2 1704081600000000000 short buy 29979.3317 0.0000 1008.5100 -0.0500 0.8500
3 1704089880000000000 short sell - stop_loss 29979.3317 30308.9425 996.9200 -1.1500 -0.3000
4 1704096000000000000 short buy 30233.3861 0.0000 996.4200 -0.0500 -0.3500
... ... ... ... ... ... ... ... ...
144 1704801600000000000 short sell - direction change 28321.1973 28321.1973 949.2400 -0.0500 -4.9600
145 1704801600000000000 long buy 28321.1973 0.0000 948.7600 -0.0500 -5.0100
146 1704805860000000000 long sell - take_profit 28321.1973 28606.1013 957.8300 0.9600 -4.0500
147 1704816000000000000 long buy 28286.4097 0.0000 957.3500 -0.0500 -4.1000
148 1704820740000000000 long sell - stop_loss 28286.4097 27957.9499 945.7600 -1.2100 -5.3100

149 rows × 8 columns


--- Performance Analysis ---
=============================================
  PERFORMANCE METRICS
=============================================
  Total Exit Events    : 74
  Winning Trades       : 26
  Losing Trades        : 48
  Win Rate             : 35.1%%
  Avg Win PnL          : 0.9423%%
  Avg Loss PnL         : -0.5437%%
  Profit Factor        : 0.94
  Cumulative PnL       : -5.31%%
  Final Balance        : $945.76
=============================================

Action Distribution:
action
buy                        74
sell - direction change    29
sell - take_profit         23
sell - stop_loss           22
same direction              1