Zum Inhalt springen
t4ri > Algotrading > Backtest der Leap Strategie

Backtest der Leap Strategie

magnifying glass on top of document

In diesem Beitrag soll die Grundidee der Leap-Strategie mit einem Backtest (Rückvergleich) verifiziert werden. Grundsätzlich lässt sich der folgende Code mit einer kostenpflichtigen Polygon API-KEY deutlich vereinfachen. Es soll aber hier ein Backtracking Möglichkeit gezeigt werden, die keine Zusatzkosten erzeugt. Insofern werden Underlying Kurswerte von Yahoo-Finance verwendet und Optionspreise werden nach der Formel von Black-Scholes berechnet.

Der Code simuliert gemäß der Strategie alle Verkäufe und Glattstellungen innerhalb eines Jahres.

import numpy as np
import pandas as pd
import time
import os
import json
import ssl
import bisect
import logging
import yfinance as yf
import xml.etree.ElementTree as ET
from scipy.stats import norm
from datetime import datetime, timedelta, date
import urllib.request
from urllib.error import HTTPError

# Parameter
ticker = "SPY"
start_delta = -0.05
take_profit_rate = 0.8
stop_loss_rate = 2.0

# yahoo finance logging deaktivieren
logger = logging.getLogger('yfinance')
logger.disabled = True
logger.propagate = False


# Simulationszeitraum: 
end_date = date.today()
current_date = date.today() - timedelta(days=365)

# lade Cached Data
def load_cached_data():
    try:
        with open("cached_data.json", "r") as file:
            return json.load(file)
    except FileNotFoundError:
        return {}

# Speichere Cached Data
def save_cached_data(sdata):
    with open("cached_data.json", "w") as file:
        json.dump(sdata, file, indent=4)

        
# Bestimmung des Basiswerts
def get_underlying_price(ticker, sdate, cache):
    strdate = sdate.strftime("%Y-%m-%d")
    if strdate in cache.get("prices", {}):
        return cache["prices"][strdate]
    # evtl. Feiertag, prüfen ob n. Tag bekannt
    sorted_keys = sorted(cache.get("prices", {}).keys())
    # Index des ersten Elements finden, das >= sdate ist
    index = bisect.bisect_left(sorted_keys, strdate)
    # Falls index innerhalb der Liste liegt, existiert ein späteres Datum
    if index < len(sorted_keys) and index > 0:
        return None
    
    print("lade historischen Preis...")
    try:
        print("lade Marktdaten",ticker)
        data = yf.download(ticker, start=sdate.strftime("%Y-01-01"), end=sdate.strftime("%Y-12-31"))
        if data.empty:
            raise ValueError(f"Keine Daten für {ticker} verfügbar.")
        dict_data = {date.strftime("%Y-%m-%d"): close for date, close in data['Open'].items()}
    except Exception as e:
        raise ValueError(f"Fehler beim Abrufen der Daten für {ticker}: {e}")
    cache.setdefault("prices", {}).update(dict_data)
    save_cached_data(cache)
    if strdate in dict_data:
        return dict_data[strdate]
    else:
        return None

# Bestimmung des risikolosen Zins der New York Fed.
def get_risk_free_rate(sdate, cache):
    strdate = sdate.strftime("%Y-%m-%d")
    if strdate in cache.get("rates", {}):
        return cache["rates"].get(strdate)
    # evtl. Feiertag, prüfen ob n. Tag bekannt
    sorted_keys = sorted(cache.get("rates", {}).keys())
    # Index des ersten Elements finden, das >= sdate ist
    index = bisect.bisect_left(sorted_keys, strdate)
    # Falls index innerhalb der Liste liegt, existiert ein früheres Datum
    if index < len(sorted_keys) and index > 0:
        return cache["rates"].get(sorted_keys[index-1])
    print("lade Zinsdaten...")
    url = 'https://markets.newyorkfed.org/read?startDt=' + sdate.strftime("%Y-01-01") + '&endDt=' + sdate.strftime("%Y-12-31") + '&eventCodes=520&productCode=50&sort=postDt:-1.eventCode:1&format=xml'
    context = ssl._create_unverified_context()
    open_page = None
    try:
        open_page = urllib.request.urlopen(url,context=context)
        response = open_page.read() 
    except HTTPError as e:
        raise ValueError('Error code: %d' % e.code)          
    if open_page == None:
        raise ValueError("Fehler: Risikofreier Zinssatz nicht ladbar")
    root = ET.fromstring(response)
    d = {}          
    for rate in root.iter('rate'):
        for data in rate:
            tagname = data.tag.lower()
            if tagname == 'effectivedate':
                ldate = data.text
            elif tagname == 'percentrate':
                d[ldate] = float(data.text)/100.0
    if len(d)>0:
        cache.setdefault("rates", {}).update(d)
        save_cached_data(cache)
        if strdate in d:
            return d[strdate]
        else:
            return None
    return None

# Options Delta gem. Black Scholes
def black_scholes_delta(S, K, T, r, sigma, option_type="put"):
    d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
    if option_type == "call":
        return norm.cdf(d1)
    else:
        return -norm.cdf(-d1)

# Options Preis gem. Black Scholes
def black_scholes_price(S, K, T, r, sigma, option_type="put"):
    d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
    d2 = d1 - sigma * np.sqrt(T)
    
    if option_type == "call":
        price = S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
    else:
        price = K * np.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1)
    
    return price
        
# Bestimmung eines passenden Verfallstags (dritter Freitag des Monats)
def get_third_friday(sdate):
    """Findet den dritten Freitag des Monats für ein gegebenes Datum."""
    first_day = sdate.replace(day=1)
    first_friday = first_day + timedelta(days=(4 - first_day.weekday() + 7) % 7)  # Erster Freitag des Monats
    third_friday = first_friday + timedelta(weeks=2)  # Dritter Freitag
    return third_friday

# Bestimmung eines passenden Verfallstags (letzter Handeltag im Quartal)
def last_trading_day_of_quarter(dt):
    quarter = (dt.month - 1) // 3 + 1
    # Bestimme das letzte Monatsdatum des Quartals
    last_month_of_quarter = quarter * 3
    last_day_of_quarter = datetime(dt.year, last_month_of_quarter, 1) + timedelta(days=31)
    last_day_of_quarter = last_day_of_quarter.replace(day=1) - timedelta(days=1)

    # Falls der letzte Tag ein Wochenende ist, finde den letzten Handelstag (Freitag)
    while last_day_of_quarter.weekday() > 4:  # 5 = Samstag, 6 = Sonntag
        last_day_of_quarter -= timedelta(days=1)

    return last_day_of_quarter.date()    

# Bestimmung der historischen Volatilität
def historical_volatility(ticker, sdate, cache, tage = 500):
    get_underlying_price(ticker, sdate - timedelta(days=tage), cache)
    # Berechnet die historische Volatilität unter Verwendung der Preisquelle.
    data = []  
    strdate = sdate.strftime("%Y-%m-%d")
    sorted_keys = sorted(cache.get("rates", {}).keys())
    index = bisect.bisect_left(sorted_keys, strdate)
    for i in range(max(0,index-tage),index):
        data.append(cache["rates"].get(sorted_keys[i]))
    log_returns = np.log(np.array(data[1:]) / np.array(data[:-1]))
    hv = np.std(log_returns, ddof=1) * np.sqrt(252) + 0.1 # Annualisierte Volatilität mit 10% Offset
    if hv < 0.01:
        raise ValueError(f"historical volatility ungeeignet {hv}.")
    return hv

# Suchen eines Strike Preis für vorgegebenes Delta    
def find_strike_for_target_delta(ticker, expiration_date, current_date, cache, target_delta):
    underlying_price = get_underlying_price(ticker, current_date, cache)
    if underlying_price == None:
        return (None, None, None)
    strike_price = int(underlying_price / 5) * 5
    r = get_risk_free_rate(current_date, cache)
    T = (expiration_date - current_date).days / 365.0
    iv = historical_volatility(ticker, current_date, cache)
    best_strike = strike_price
    best_delta = None
    best_market_price = None
    min_delta_diff = float("inf")
    # Such nache geeignetem Delta mittels Schleife über Strike Preis
    for adjustment in range(-150, 150, 5):  
        test_strike = strike_price + adjustment
        market_price = black_scholes_price(underlying_price, test_strike, T, r, iv)
        if market_price < 0.01:
            continue
        delta = black_scholes_delta(underlying_price, test_strike, T, r, iv)
        delta_diff = abs(delta - target_delta)
        if delta_diff < min_delta_diff:
            min_delta_diff = delta_diff
            best_strike = test_strike
            best_delta = delta
            best_market_price = market_price
    return (best_strike, best_delta, best_market_price)


# Hauptfunktion
cache = load_cached_data()
 
saldo = 0


# Zeitraum durchlaufen
while current_date <= end_date:
    # Q2 und Q3 überspringen
    if current_date.month > 3 and current_date.month < 10:
        current_date = date(current_date.year,10,1)
    # Kauf an einem Montag
    if current_date.weekday() == 0:  
        expiration_date = None
        # suche nach einem 3. Freitag in 250-350 Tagen
        for days in range(10, 50, 10):
            potential_date = current_date + timedelta(days=300+days)
            third_friday = last_trading_day_of_quarter(potential_date)
            if 250 <= (third_friday - current_date).days <= 350:
                expiration_date = third_friday
                break
            potential_date = current_date + timedelta(days=300-days)
            third_friday = last_trading_day_of_quarter(potential_date)
            if 250 <= (third_friday - current_date).days <= 350:
                expiration_date = third_friday
                break
        if expiration_date is None:
            raise ValueError('Kein geeignetes Datum gefunden.')
        # suche nach einer Option mit gesuchtem delta 
        (strike_price, delta, market_price) = find_strike_for_target_delta(ticker, expiration_date, current_date, cache, start_delta)
        if market_price == None:
            current_date += timedelta(days=1)
            continue
        underlying_price = get_underlying_price(ticker, current_date, cache)
        last_market_price = market_price
        take_profit = market_price * take_profit_rate
        stop_loss = market_price * (stop_loss_rate + 1)
        saldo += market_price * 100
        option_ticker = f"O:{ticker}{expiration_date.strftime('%y%m%d')}P00{strike_price * 1000}"        
        print(f"{current_date.strftime('%y-%m-%d')} Verkauf {option_ticker}, delta {delta:.2f}, Preis {market_price:.2f}, Saldo {saldo:.2f}")
        # der Options-Preis mit Black Scholes berechnen
        T = (expiration_date - current_date).days / 365.0
        r = get_risk_free_rate(current_date, cache)
        iv = historical_volatility(ticker, current_date, cache)
     
        while market_price > take_profit and market_price < stop_loss and current_date < end_date:
            current_date += timedelta(days=1)
            # Wochenende überspringen
            if current_date.weekday() > 5:
                current_date += timedelta(days=2)
            new_underlying_price = get_underlying_price(ticker, current_date, cache)                    
            if new_underlying_price != None:
                underlying_price = new_underlying_price
            newr = get_risk_free_rate(current_date, cache)
            if newr is not None:
                r = newr
            T = (expiration_date - current_date).days / 365.0
            iv = historical_volatility(ticker, current_date, cache)
            market_price = black_scholes_price(underlying_price, strike_price, T, r, iv)
   
        saldo -= market_price * 100.0
        if current_date < end_date:
            print(f"{current_date.strftime('%y-%m-%d')} Glattstellung {option_ticker}, Preis {market_price:.2f}, Saldo {saldo:.2f}")
    current_date += timedelta(days=1)
print("Backtesting abgeschlossen.")

Im Zeitraum Februar 2024 – Februar 2025 ergibt sich der folgende Ablauf:


24-02-26 Verkauf O:SPY241231P00435000, delta -0.05, Preis 1.44, Saldo 144.08
24-03-07 Glattstellung O:SPY241231P00435000, Preis 1.03, Saldo 41.33
24-03-11 Verkauf O:SPY241231P00440000, delta -0.05, Preis 1.36, Saldo 177.27
24-03-13 Glattstellung O:SPY241231P00440000, Preis 1.02, Saldo 75.24
24-03-18 Verkauf O:SPY241231P00445000, delta -0.05, Preis 1.36, Saldo 211.06
24-03-21 Glattstellung O:SPY241231P00445000, Preis 0.90, Saldo 121.29
24-03-25 Verkauf O:SPY241231P00450000, delta -0.05, Preis 1.25, Saldo 246.59
24-05-07 Glattstellung O:SPY241231P00450000, Preis 0.99, Saldo 147.28
24-10-07 Verkauf O:SPY250630P00435000, delta -0.05, Preis 2.48, Saldo 395.34
24-10-15 Glattstellung O:SPY250630P00435000, Preis 1.72, Saldo 223.50
24-10-21 Verkauf O:SPY250930P00435000, delta -0.05, Preis 3.01, Saldo 524.47
24-11-07 Glattstellung O:SPY250930P00435000, Preis 2.26, Saldo 298.73
24-11-11 Verkauf O:SPY250930P00445000, delta -0.05, Preis 3.11, Saldo 609.31
24-12-03 Glattstellung O:SPY250930P00445000, Preis 2.38, Saldo 371.51

Üblicherweise verfallen Optionen auf den SPY an jedem Handelstag, da sogenannte Zero-Days-to-Expiration-Optionen (0DTE) verfügbar sind, die von Montag bis Freitag auslaufen. Neben diesen kurzfristigen Optionen gibt es auch wöchentliche Verfallstermine, die jeweils freitags enden, sofern sie nicht mit einem übergeordneten Verfall zusammenfallen. Die klassischen monatlichen Optionen sind an den dritten Freitag jedes Monats gekoppelt, eine Praxis, die sich im gesamten Markt etabliert hat.

Für längerfristige Strategien insbesondere für die gesuchten 300 Tage bieten sich quartalsweise Verfallstermine an. Diese fallen auf den letzten Handelstag der Quartalsmonate März, Juni, September und Dezember. Die Funktion last_trading_day_of_quarter() bestimmt das entsprechende Datum im Quartal.

Die Volatilität ist eine wesentliche Variable der Black-Scholes-Formel zur Berechnung des Marktpreises. Die eigentlich hier benötigte Implizite Volatilität erhält man nur von kommerziellen Datenanbietern. Um dennoch eine Berechnung zu ermöglichen wird in der Funktion historical_volatility() die historische Volatilität berechnet. Die historische Volatilität ist eine Näherung an die Implizite Volatilität. Die folgende Grafik vergleicht implizite Volatilität (dunkel grün, orange SMA20) und historische Volatilität (violet) für SPY Optionsdaten aus 2024:

Fazit: Als grundlegende Trendaussage ist der oben beschriebene Backtracking Algorithmus geeignet und er bestätigt im betrachteten Zeitraum einen positiven Ergebnisverlauf. Als Unschärfen verbleiben die Übertragbarkeit der kurzen historischen Betrachtung auf die Zukunft und die verwendeten Näherungsberechnungen zum Optionspreis.