APOLLOHELIX

Article · 9 min read

How to Backtest on Borsa Istanbul: Data, Costs and Traps

A practical guide to backtesting Borsa Istanbul stocks: pulling data with Python, adjusted prices, commission and slippage maths, limit-up/limit-down locks and survivorship bias.

On Borsa Istanbul (BIST) a backtest is logically the same as anywhere else: you write a rule, run it on historical data and measure the result. The differences start in data, costs and market rules. This guide walks through each step of building a sound BIST backtest, with examples. For the general framework, read the Backtesting and Paper Trading Guide first; here we focus on what is specific to BIST.

Contents

  • Why BIST behaves differently
  • Step 1: Get the data from the right source
  • Step 2: Understand adjusted prices
  • Step 3: Turn a simple strategy into code
  • Step 4: Model costs and slippage realistically
  • Step 5: How limit-up/limit-down locks break results
  • Step 6: Remove the biases
  • Step 7: Read the result correctly, then move to paper
  • Common-mistakes checklist

Why BIST behaves differently

For the most liquid stocks in the world the gap between a backtest and reality is usually small. On BIST three things widen it:

  • Daily price limits: a stock can only move inside a band during one session.
  • Liquidity distribution: the biggest index names are very liquid, but small and mid caps have wide bid-ask spreads.
  • Data quality: capital increases, splits and dividends create jumps in the price series. If they are not adjusted, the strategy "profits" from opportunities that never existed.

These three points explain most backtests that look great and fail live.

Step 1: Get the data from the right source

Daily data is the easiest place to start. Some free sources serve BIST stocks by adding the .IS suffix to the ticker (for example THYAO.IS, GARAN.IS). Free data is fine for quick experiments, but it can contain missing days, bad prints or late adjustments, so always verify the results.

This Python example downloads daily data for a BIST stock with yfinance and runs basic sanity checks:

import pandas as pd
import yfinance as yf

SYMBOL = "THYAO.IS"          # BIST tickers carry the ".IS" suffix
START, END = "2018-01-01", "2026-01-01"

df = yf.download(SYMBOL, start=START, end=END, auto_adjust=True, progress=False)

# Recent yfinance versions may return multi-level columns even for one ticker
if isinstance(df.columns, pd.MultiIndex):
    df.columns = df.columns.get_level_values(0)

df = df[["Open", "High", "Low", "Close", "Volume"]].dropna()

# Basic data health checks
assert df.index.is_monotonic_increasing, "Dates are not sorted"
assert (df["High"] >= df["Low"]).all(), "A row has High < Low"
assert (df["Close"] > 0).all(), "Zero or negative price found"

# A move above 12% in one day is suspicious: a split/capital increase may be unadjusted
ret = df["Close"].pct_change()
print("Suspicious days:\n", ret[ret.abs() > 0.12])
print(df.tail())

Here auto_adjust=True applies split and dividend adjustment. Still, inspect the suspicious days by hand: a daily move above 12% is either real news or an unadjusted capital increase. Intraday (minute) data is usually paid and comes from a broker or data vendor.

Strategy Engine · Backtest (concept) STRATEGY Symbol: SAMPLE.IS Commission: set Slippage: set Price limits: on Run Gross (blue) vs net after costs (yellow)
Image: ApolloHelix backtest screen. This is a concept illustration, not a real screenshot or real results. The product is in development.

Step 2: Understand adjusted prices

When a company does a bonus share issue, its price drops overnight in theory, yet the investor's wealth has not changed. In an unadjusted series that looks like "a 50% crash in one day". A trend-following strategy fires a sell signal on that fake crash; a mean-reversion strategy buys the "bargain". Both react to an event that never happened.

Practical rule: always use prices adjusted for splits, bonus issues and dividends; use raw prices only for execution details such as order size and tick-size calculations.

Step 3: Turn a simple strategy into code

The example below builds a 20/50-day moving-average crossover, for education only. The key detail: the signal is produced from today's close but the trade happens on the next day; otherwise you look into the future (look-ahead bias).

sma_fast = df["Close"].rolling(20).mean()
sma_slow = df["Close"].rolling(50).mean()

signal = (sma_fast > sma_slow).astype(int)       # 1: in the market, 0: in cash
position = signal.shift(1).fillna(0)              # signal is acted on the next day

daily_ret = df["Close"].pct_change().fillna(0)
gross = position * daily_ret

print("Gross total return:", (1 + gross).prod() - 1)

This is still a gross result with no costs. Next we bring it closer to reality.

Step 4: Model costs and slippage realistically

Costs are the most overlooked part of a backtest. Commission, exchange and clearing fees, taxes and the bid-ask spread can erase the profit of any frequently trading strategy. For more detail see Commissions and slippage in backtests.

A real scenario: one round trip with 100,000 TRY

Illustrative assumptions (replace with your broker's schedule; real rates vary by broker, channel and period):

  • Commission (effective rate including taxes): 0.10% per side
  • Slippage and spread impact: 0.10% per side
Item Calculation Amount
Buy commission 100,000 × 0.10% 100 TRY
Buy slippage 100,000 × 0.10% 100 TRY
Sell commission 100,000 × 0.10% 100 TRY
Sell slippage 100,000 × 0.10% 100 TRY
Total round-trip cost 400 TRY (0.40%)

0.40% looks small for one trade. But a strategy making 100 round trips a year pays roughly 40% of capital per year in friction. If your average gross gain per trade is 0.5%, only 0.1% is left after costs, and a bit more slippage turns the strategy into a loss. That is why a good backtest always shows two curves: gross and net.

You can add costs in code like this:

COST_PER_SIDE = 0.001 + 0.001      # commission + slippage (illustrative)

trades = position.diff().abs().fillna(0)          # a position change means a trade
net = gross - trades * COST_PER_SIDE

print("Net total return:", (1 + net).prod() - 1)
print("Number of trades:", int(trades.sum()))

Liquidity warning: in low-volume stocks slippage can be several times the assumption above. As a rule, your order size should be a small fraction of daily volume; otherwise your own order moves the price.

Step 5: How limit-up/limit-down locks break results

Stocks on BIST have a price band for each session (for equities generally around 10% a day; confirm current rules in the exchange announcements). A price stuck at the upper bound is limit-up, at the lower bound limit-down.

A backtest is misled in three ways:

  1. Phantom buys at limit-up: if a stock opens at limit-up and stays locked, your real buy order sits in the queue and mostly does not fill. The backtest says "bought at the open" and books a nice profit. Strategies chasing limit-up streaks give unrealistic results for exactly this reason.
  2. Phantom exits at limit-down: if your stop-loss lands on a locked limit-down you cannot sell, and the next day may look the same. The backtest exits at the stop price while the real loss can be far larger.
  3. Gap effect: when the lock releases, the price trades far from the previous close, so the assumed execution price of close-based signals collapses.

Suggested fix: in the backtest, mark a day as "not tradable" if the open is near the limit band relative to the previous close. For sells, postpone the exit to the next tradable day on limit-down days and compute the loss at that price.

prev_close = df["Close"].shift(1)
limit_up = df["Open"] >= prev_close * 1.095       # open near upper limit (approximate threshold)
limit_dn = df["Open"] <= prev_close * 0.905       # open near lower limit

tradable = ~(limit_up | limit_dn)                 # assume orders do not fill on locked days
position = position.where(tradable, position.shift(1)).fillna(0)

This is a crude approximation, but it protects your results from needless optimism. Other market rules matter too: short selling and futures (VİOP) have their own conditions, and settlement times and trading hours differ.

Step 6: Remove the biases

  • Survivorship bias: dropping stocks that were delisted, stopped trading or collapsed inflates results. Applying today's index list to 2018 is a classic mistake. Read What is survivorship bias? for details.
  • Look-ahead: producing the signal from today's close and trading at today's close.
  • Overfitting: tuning parameters to fit the past perfectly. Split data into training and test periods and try on a period you never touched.
  • Regime change: high inflation, currency shocks and rate decisions shift behaviour on BIST from period to period; a strategy that works in one period may fail in another.

Step 7: Read the result correctly, then move to paper

Do not look only at total return. Evaluate together:

  • Maximum drawdown (loss from the worst peak to trough) and whether you could live with it,
  • Number of trades (few trades means statistically weak results),
  • Net return (after costs),
  • Consistency across different periods.

A backtest is not proof; it is a screening tool that rejects bad ideas cheaply. A strategy that looks good must pass a paper trading stage that runs on live data with real order logic. How to do that is covered in the Backtesting and Paper Trading Guide. To limit losses, read the risk management guide. For the big picture of algorithmic trading, the Algorithmic Trading Guide is a good start.

Common-mistakes checklist

  • I used adjusted prices and checked suspicious days by hand.
  • Signal and trade are not on the same bar; the trade is on the next day.
  • I included commission, taxes and slippage and report gross and net separately.
  • I treated locked limit-up/limit-down days as not tradable.
  • I covered delisted stocks to avoid survivorship bias.
  • I separated training and test periods.
  • I will not trade real money before testing on live data in the paper stage.

This article is educational and not investment advice. Past results do not guarantee future performance; trading involves risk of loss.