Mein 4.000 $ Momentum-Experiment bei Interactive Brokers: Backtest, Python und IB Gateway Schritt für Schritt

Momentum-Strategie bei Interactive Brokers: 10-Jahres-Backtest, Python-Code und IB Gateway Anleitung für mein 4.000 $ Echtgeld-Experiment.

Die kurze Antwort: Momentum (also das gezielte Kaufen der stärksten Gewinner-Aktien der letzten 6 Monate) ist die am besten belegte Anomalie der gesamten Finanzwissenschaft. Ich habe die bekanntesten Studien von 1937 bis heute an 200 großen US-Aktien aus dem S&P 500 und Nasdaq-100 über 10 Jahre (13.10.16 bis 13.10.26) in Python nachgerechnet. Unser schlankes Long-Only-System (6 Monate Rückblick, 5 ganze Aktien bis 650 $, Haltepuffer Top 8) schlug den S&P 500 nach Abzug echter Interactive-Brokers-Gebühren deutlich (+46,79% p.a. bei -21,79% maximalem Rückgang), während populäre Zusatzfilter wie die 200-Tage-Linie auf den S&P 500 fast 79.500 $ Gewinn vernichteten. In diesem Artikel zeige ich dir die Geschichte hinter dem Effekt, meine kompletten Backtest-Daten, meine beiden Python-Skripte und anhand meiner echten Screenshots (mit goldenen Pfeilen markiert), wie du das System wahlweise über die Trader Workstation (TWS) oder das arbeitsspeichersparende IB Gateway Schritt für Schritt selbst aufsetzt.

Wenn du meine Analysen hier auf USAktienHub schon länger liest, kennst du meine Grundordnung. Den Großteil meines Geldes investiere ich ruhig und langfristig über DEGIRO und Trade Republic: langfristige US-Qualitätsaktien und eine bereitliegende Cash-Reserve bei DEGIRO, dazu drei monatliche ETF-Sparpläne (550,00 € pro Monat) plus verzinstes Bargeld bei Trade Republic. Die belegten Broker-Werte mit Stichtag stehen in meinem Echtgeld-Portfolio-Cockpit. Für dieses Experiment zählt nur der davon getrennte IBKR-Baustein. An diesem Fundament rüttle ich nicht.

Trotzdem hat mich seit 2021, als ich angefangen habe, meine DCF-Bewertungsmodelle selbst in Python zu schreiben, eine Frage nicht mehr losgelassen: Kann man mit einem klar abgegrenzten Teilbetrag und eisernen mathematischen Regeln den S&P 500 schlagen, ohne jeden Tag vor irgendwelchen Kerzencharts zu sitzen?

Genau dafür nutze ich mein drittes Depot bei Interactive Brokers. Für dieses Experiment habe ich dort 4.161,00 $ echtes US-Dollar-Bargeld bereitgestellt. Mein Python-Skript arbeitet mit einem festen Strategie-Budget von genau 4.000,00 $; die übrigen 161,00 $ bleiben als Gebühren- und Schwankungspuffer außerhalb des Budgets in Reserve. Bevor ich die erste Order abgeschickt habe, bin ich durch fast 90 Jahre Börsenforschung gegangen und habe jede einzelne Regel selbst in Python überprüft. Mein eiserner Grundsatz dabei: In mein Live-Skript kommt nur, was einen echten Backtest hat.

Das Wichtigste in 60 Sekunden

  • Das Setup (100 % Long-Only): 200 liquide US-Aktien (S&P 500 & Nasdaq-100), 6 Monate Rückblick (126 Handelstage), 5 ganze Aktien (max. 650 $ Stückpreis) und Haltepuffer Top 8 ohne Kredithebel (0 € Margin).
  • 10-Jahres-Backtest (13.10.16 bis 13.10.26): Aus 4.000 $ wurden nach Abzug echter IBKR-Tiered-Gebühren (0,38 $ pro Order) und 0,05% Slippage 124.190 $ (+46,79% p.a., Sharpe Ratio 1,29, Max Drawdown -21,79%) gegenüber 13.875 $ (+14,91% p.a.) im S&P 500.
  • Was im Backtest durchfiel: Die berühmte SPY > SMA200 Markt-Ampel kostete -79.572 $ Ertrag (weil sie im Bärenmarkt 2022 den Wechsel in Energie-, Rüstungs- und Pharma-Gewinner blockierte). Ein enger -8% Stop-Loss produzierte 49% Fehlalarme.
  • TWS vs. IB Gateway: Zum schnellen Prüfen von Kontostand und USD-Bargeld (4.161 $) nutze ich die Trader Workstation (Port 7496). Für das aktive Autotrading nutze ich das schlanke IB Gateway 10.39 (Port 4001), weil es rund -85% Arbeitsspeicher (RAM) spart. Unser Python-Skript unterstützt automatisch beides!

1. Die Geschichte hinter Momentum: Warum selbst Nobelpreisträger diesen Effekt nicht wegdiskutieren konnten

Viele Privatanleger glauben, Momentum sei irgendeine kurzlebige Modeerscheinung aus Internetforen. In Wahrheit ist es der am längsten untersuchte und am härtesten geprüfte Effekt der gesamten Kapitalmarktforschung:

  • Die Pioniere vor dem Computerzeitalter (Cowles & Jones 1937, Levy 1967): Schon 1937 fiel Alfred Cowles und Herbert Jones auf, dass Aktien, die im Vorjahr gestiegen waren, auch im Folgejahr deutlich häufiger stiegen als fielen. Im Jahr 1967 veröffentlichte Robert A. Levy seine Arbeit zur „Relative Strength“ an 200 Aktien der New Yorker Börse und zeigte erstmals systematisch, dass das Kaufen der stärksten Aktien den Gesamtmarkt schlägt. Damals tat die akademische Welt das noch als Zufall ab, weil an den Universitäten die Effizienzmarkthypothese von Eugene Fama als unantastbares Gesetz galt.
  • Der akademische Paukenschlag von Jegadeesh & Titman (1993): Im März 1993 veröffentlichten Narasimhan Jegadeesh und Sheridan Titman im Journal of Finance ihre berühmte Studie „Returns to Buying Winners and Selling Losers: Implications for Stock Market Efficiency“. Sie untersuchten sämtliche US-Aktien von 1965 bis 1989 über verschiedene Rückblickzeiträume (3, 6, 9 und 12 Monate). Ihr Befund schlug ein wie eine Bombe: Wer einfach die Gewinner-Aktien der letzten 6 bis 12 Monate kaufte, erzielte Monat für Monat eine statistisch hochsignifikante Überrendite von rund +1% pro Monat gegenüber den Verlierern.
  • Das Eingeständnis von Fama & French und das Carhart-4-Faktor-Modell (1997): Vier Jahre später wies Mark Carhart (1997) nach, dass ein Großteil des Erfolgs bekannter US-Aktienfonds schlicht darauf beruhte, dass sie (bewusst oder unbewusst) Momentum-Gewinner im Depot hielten. Selbst Eugene Fama und Kenneth French, die Väter der Effizienzmarkthypothese, mussten zugeben, dass ihr eigenes 3-Faktor-Modell gegen Momentum machtlos war. Fama nannte Momentum später offen die „größte Blamage“ (premier anomaly) für seine Theorie.
  • Überall und zu über 80 % auf der Long-Seite (Asness et al. 2013, Israel & Moskowitz 2013): Im Jahr 2013 wiesen Clifford Asness, Tobias Moskowitz und Lasse Heje Pedersen in „Value and Momentum Everywhere“ nach, dass Momentum seit über einem Jahrhundert auf allen Kontinenten funktioniert. Und noch wichtiger für uns als Privatanleger: Israel & Moskowitz (2013) belegten, dass über 75% bis 85% der gesamten Momentum-Prämie nach Kosten allein von der Long-Seite (also dem Kaufen der Gewinner-Aktien) stammt. Man muss also keine Aktien leerverkaufen (shorten), um den Effekt zu ernten.

Warum verschwindet dieser Effekt nicht einfach, wenn ihn jeder kennt?
Als ehemaliger Einzelhändler (ich habe 15 Jahre lang vier eigene Einzelhandelsgeschäfte mit dem Schwerpunkt Schuhe geführt und diese 2018 geschlossen) ist mir die Ursache hinter Momentum sofort vertraut. Es ist reine menschliche Psychologie gepaart mit der Trägheit großer Institutionen:

Im Schuhgeschäft war es jedes Jahr dasselbe: Wenn sich ein bestimmter Winterschuh im Oktober dreimal so schnell verkaufte wie alle anderen Modelle, hast du für November genau diesen Bestseller nachbestellt. Kein vernünftiger Kaufmann räumt seinen besten Umsatzbringer aus dem Schaufenster, nur weil er „schon gut gelaufen ist“, um stattdessen die unverkäuflichen Ladenhüter aus dem Keller in die erste Reihe zu stellen. An der Börse machen viele Privatanleger aber genau diesen Fehler (Disposition Effect): Sie verkaufen ihre besten Gewinner viel zu früh nach +15% Gewinn und klammern sich jahrelang an ihre Verlierer. Gleichzeitig können große Pensionskassen und Fonds nach einem starken Quartalsbericht von Nvidia, Broadcom oder Eli Lilly nicht an einem einzigen Nachmittag für 5 Milliarden Dollar Aktien kaufen, ohne den Kurs selbst um +20% hochzujagen. Sie müssen ihre Käufe über Wochen und Monate strecken. Genau diese langsame Welle großer Adressen reiten wir mit unserem kleinen, wendigen 4.000-Dollar-Konto mit.

2. Unser 10-Jahres-Backtest (100 % Long-Only): Welche Regeln Geld bringen und welche durchfallen

Ich habe keine einzige Börsenregel aus Büchern ungeprüft übernommen, sondern ein festes Universum aus 200 hochliquiden US-Schwergewichten aus dem S&P 500 und Nasdaq-100 (in 7 Gruppen, die alle 11 GICS-Sektoren von Tech und Halbleitern über Banken, Pharma und Konsum bis zu Industrie und Energie abdecken) über volle 10 Jahre (13.10.16 bis 13.10.26) mit echten Tageskursen, echten Interactive-Brokers-Gebühren (0,38 $ im Tiered-Tarif), 0,05% Slippage und echten ganzen Aktien (Whole Shares) getestet. Unser System arbeitet dabei zu 100 % Long-Only (keine Leerverkäufe, kein Kredithebel, kein Nachschussrisiko).

Überraschung 1: Die „SPY 200-Tage-Ampel“ hat im Test 79.572 Dollar Gewinn vernichtet!

Schau dir an, was passiert, wenn wir jeden populären Filter einzeln zu unserem 4.000-Dollar-Startkapital zuschalten (6 Monate Rückblick, 5 ganze Aktien):

Getestete Regel
10/2017 bis 10/2026, nach 1 Jahr Vorlauf
Endwert aus 4.000 $Rendite p.a.SharpeMax DrawdownGebühr/JahrEntscheidung
Benchmark: S&P 500 (SPY Buy & Hold)13.875 $+14,91%0,76-23,93%0,00 $Vergleich
Benchmark: Nasdaq-100 (QQQ Buy & Hold)20.693 $+20,16%0,87-32,58%0,00 $Vergleich
A) 6M-Momentum ohne Puffer (stur Top 5)96.688 $+42,74%1,19-25,34%38,78 $Basis
Zu viele Wechsel
B) + Keep-Puffer Top 8124.190 $+46,79%1,29-21,79%29,02 $Unser Live-Setup
+27.502 $ mehr Ertrag, 25 % weniger Gebühren
C) + Einzelaktie > eigener SMA200121.781 $+46,47%1,28-21,79%29,20 $Gestrichen
Kein Vorteil beim Risiko (0,00 %), kostet 2.409 $
D) + SPY > SMA200 Markt-Ampel (Cash darunter)44.618 $+30,93%0,98-21,83%29,54 $Gestrichen
Vernichtet 79.572 $ Endwert
E) Ohne 650 $ Aktienpreis-Deckel128.229 $+47,32%1,28-28,86%28,70 $Gestrichen
Der 650 $ Deckel bleibt: Er senkt den Max Drawdown um 7,07 Prozentpunkte

Warum hat die beliebte SPY > SMA200 Markt-Ampel (Zeile D) so katastrophal versagt?
Im Zins-Bärenmarkt 2022 fiel der S&P 500 unter seine 200-Tage-Linie, weil die hochgewichteten Tech-Konzerne einbrachen. Die starre SPY-Ampel verkaufte erst nach den ersten Verlusten im Frühjahr 2022 alles, lag dann 8 Monate lang tatenlos im Bargeld und beendete 2022 mit -13,61%. Unser ungefiltertes System (Zeile B) machte 2022 dagegen etwas viel Schlaueres: Weil unser 200er-Universum alle 11 GICS-Sektoren abdeckt, warf das 6-Monats-Momentum die fallenden Tech-Aktien von selbst raus und rotierte vollautomatisch in die Krisengewinner aus Energie, Rüstung und Pharma (ExxonMobil, Chevron, ConocoPhillips, Lockheed Martin, Eli Lilly, Vertex). Ergebnis: Unser Long-Only-System beendete das Krisenjahr 2022 mit gerade einmal -0,25% nahezu unverändert!

Überraschung 2: Rückblickzeitraum (3M, 6M, 9M oder 12M), Aktienanzahl (N=5) und Stop-Loss-Check

Auch die drei weiteren Kernfragen habe ich sauber gegeneinander getestet:

  • Rückblickzeitraum (Lookback): 6 Monate (126 Handelstage ohne Skip-Monat) schlugen mit 124.190 $ (+46,79% p.a., Sharpe 1,29) sowohl das hektische 3-Monats-Fenster (56.630 $, +34,37% p.a.) als auch das träge 12-1M-Fenster (58.910 $, +34,96% p.a.) und das 6-1M-Fenster mit übersprungenem letzten Monat (67.502 $, +37,03% p.a.).
  • Aktienanzahl für 4.000 $ (Ganze Aktien): 5 ganze Aktien (800 $ pro Slot) plus unsere automatische Cash-Sweep-Restgeldverteilung sind der mathematische Sweet Spot. Damit nutzt das System das Budget in ganzen Aktien fast vollständig aus; wie viel nach einem echten Lauf übrig bleibt, hängt von Ausführungskursen und Gebühren ab. Bei 7 oder 8 Aktien (500 $ bis 571 $ pro Slot) passen dagegen viele US-Qualitätsaktien gar nicht mehr in einen Slot, und ohne Nachkauf-Schleife läge über 30% des Geldes ungenutzt herum.
  • Taggenauer Stop-Loss-Vergleich: Ein enger Stop-Loss bei -8% halbierte den Endwert auf 58.420 $, weil 69 von 141 ausgelösten Stopps (49%!) reine Fehlalarme im normalen Wochenrauschen waren. Ich handle mein 4.000-Dollar-Konto deshalb ohne engen Einzel-Stop-Loss und behalte meinen persönlichen Konto-Kill-Switch bei 3.100 $ (~3.000 €) manuell in meiner eigenen Hand. Wer mit größerem Geld einen Airbag gegen Einzelaktien-Abstürze wünscht, findet in unserem Skript zusätzlich das -18% Katastrophen-Stop-Level (+44,62% p.a. im Backtest).

3. Schritt-für-Schritt mit meinen echten Screenshots: TWS (Port 7496) vs. IB Gateway (Port 4001)

Damit du beim Einrichten nicht raten musst, habe ich jeden einzelnen Klick auf meinem eigenen PC fotografiert und die wichtigen Stellen mit goldenen Pfeilen und Nummern markiert.

Schritt 1: Dein Werkzeugkasten auf dem Desktop (TWS vs. IB Gateway: Du hast die Wahl!)

Auf meinem Desktop liegen für dieses Projekt genau drei Programme, die du auf dem Screenshot mit den goldenen Pfeilen ①, ② und ③ siehst:

Desktop Setup mit Trader Workstation Port 7496, Visual Studio Code und IB Gateway 10.39 Port 4001 mit goldenen Pfeilen
Meine 3 Werkzeuge auf dem Desktop: ① Trader Workstation (zum schnellen Konto-Check), ② Visual Studio Code (für Python) und ③ IB Gateway 10.39 (meine Wahl fürs aktive Autotrading, spart 85 % RAM).

Warum zeige ich dir hier sowohl die Trader Workstation (TWS) als auch das IB Gateway? Weil beide Programme von Interactive Brokers stammen, aber einen völlig unterschiedlichen Arbeitsspeicher-Verbrauch (RAM) haben:

MerkmalWeg A: Trader Workstation (TWS)Weg B: IB Gateway 10.39 (Meine Wahl fürs Autotrading)
Arbeitsspeicher (RAM)Hoch (ca. 1.800 MB bis 2.500 MB RAM), da Charts und News mitladenExtrem schlank (ca. 220 MB bis 300 MB RAM), spart rund -85% RAM!
Standard-Socket-PortPort 7496 (Live) / Port 7497 (Paper)Port 4001 (Live) / Port 4002 (Paper)
Wofür nutze ich es?Einmaliger Konto- & USD-Cash-Check (4.161 $), visuelle ÜbersichtAktives Autotrading per Python-Skript ohne Grafik-Ballast
Unterstützung im Python-SkriptAutomatisch erkannt (oder gezielt mit --tws)Standard-Priorität (automatisch erkannt oder mit --gateway)

Du kannst als Leser völlig frei entscheiden, welchen Weg du nimmst: Wenn du gerne eine große Oberfläche siehst, lässt du einfach die TWS (Port 7496) offen. Wenn du wie ich Arbeitsspeicher sparen willst, schließt du die TWS nach dem Konto-Check und startest fürs Trading nur das schlanke IB Gateway (Port 4001). Unser Python-Skript erkennt beim Start automatisch, welches von beiden läuft!

Schritt 2: Die 250-Dollar-Zinsfalle vermeiden (Prüfe dein echtes USD-Bargeld!)

Wenn du Euros zu Interactive Brokers überweist und auf einem Margin-Konto direkt US-Aktien kaufst, tauscht IBKR deine Euros nicht automatisch um, sondern leiht dir die US-Dollar auf Kredit (ca. 6,3% Zinsen p.a., also rund 250 $ pro Jahr!). Tausche deine Euros deshalb vor dem ersten Kauf einmalig über FXCONV in echte US-Dollar um. Auf meinem Screenshot aus dem Account-Fenster siehst du an den beiden goldenen Pfeilen, worauf du achten musst:

IBKR Account Fenster mit 4.161 USD echtem Bargeld und 0 EUR Margin Requirements mit goldenen Pfeilen
Goldpfeil ①: Unter Market Value (Real FX Balance) liegen 4.161 $ echtes US-Dollar-Bargeld bereit. Goldpfeil ②: Die Margin-Auslastung steht bei exakt 0 EUR.

Auch im normalen Portfolio-Tab der TWS siehst du vor dem ersten Trade sofort die beiden Bargeld-Zeilen (EUR CASH: 6 und USD CASH: 4,161):

IBKR TWS Portfolio Ansicht mit EUR CASH 6 und USD CASH 4.161 mit goldenem Pfeil
Vor dem Start im Portfolio-Tab: 4.161 $ echtes USD-Bargeld und 6 € Rest-Euro.

Schritt 3 (Weg A): Die Python-Schnittstelle (API) in der Trader Workstation (TWS, Port 7496) freischalten

Wenn du direkt über die große Trader Workstation (TWS) handeln möchtest, schaltest du die lokale Schnittstelle für unser Python-Skript mit diesen drei Klicks frei. Klicke oben in der TWS auf ① Edit und dann auf ② Global Configuration…:

TWS Menü Edit und Global Configuration öffnen mit goldenen Pfeilen
Weg A (TWS): Oben auf ① Edit und ② Global Configuration klicken.

Anschließend klappst du links im Konfigurationsbaum den Punkt ① + API auf und klickst darunter auf Settings:

TWS Configuration Baum API aufklappen mit goldenem Pfeil
Weg A (TWS): Links im Baum auf ① + API klicken, damit darunter Settings erscheint.

Jetzt setzt du im Fenster API -> Settings der TWS genau diese vier Punkte:

TWS API Settings mit den 4 wichtigen Einstellungen und goldenen Pfeilen
Weg A (TWS): ① Enable ActiveX and Socket Clients (Haken setzen!), ② Read-Only API (Haken rausnehmen!), ③ Socket port 7496 (Live) und ④ Apply & OK.

Schritt 4 (Weg B · Meine Wahl fürs Autotrading): Das schlanke IB Gateway (Port 4001) auf Deutsch einrichten und 85 % RAM sparen!

Sobald ich meinen Kontostand und mein USD-Bargeld (4.161 $) in der TWS geprüft habe, schließe ich die schwere TWS komplett und nutze für das eigentliche Autotrading nur noch das kleine IB Gateway. Es verbraucht im Hintergrund nur rund 250 MB statt 2.000 MB Arbeitsspeicher (RAM) und lässt sich oben rechts direkt auf Deutsch umstellen. Hier sind alle 4 Schritte mit meinen echten Screenshots:

1. Im IB Gateway auf „IB API“ und „Live-Trading“ stellen:
Wenn du das IB Gateway startest, wähle oben bei API-Typ rechts ① IB API (nicht FIX-CTCI!), darunter ② Live-Trading (oder Paper-Trading zum kostenlosen Testen) und klicke nach Eingabe deiner Zugangsdaten auf ③ Anmelden:

IB Gateway Login Maske mit goldenen Pfeilen auf IB API, Live-Trading und Anmelden
Weg B (IB Gateway Schritt 1): ① API-Typ auf „IB API“ stellen, ② „Live-Trading“ wählen und ③ auf „Anmelden“ klicken.

2. Zwei-Faktor-Bestätigung auf dem Smartphone freigeben:
Direkt nach dem Klick auf Anmelden schickt dir Interactive Brokers eine Push-Mitteilung an die IBKR Mobile App auf deinem Telefon (✓ Benachrichtigung gesendet). Ein kurzes Antippen auf dem Handy genügt, und das Gateway loggt sich ein:

IB Gateway Zwei-Faktor-Authentifizierung per Smartphone Push mit goldenem Pfeil
Weg B (IB Gateway Schritt 2): Push-Benachrichtigung auf dem Smartphone antippen.

3. Grüne Verbindungsbalken prüfen & „Konfigurieren -> Einstellungen“ öffnen:
Sobald im schlanken Hauptfenster rechts alle drei Balken grün leuchten (verbunden, ON: eufarm, ON: euhmds, ②), steht deine Verbindung zur Börse bei minimalem RAM-Verbrauch. Klicke jetzt oben im Menü auf ① Konfigurieren -> Einstellungen:

IB Gateway verbunden mit 3 grünen Balken und Menü Konfigurieren Einstellungen mit goldenen Pfeilen
Weg B (IB Gateway Schritt 3): Oben auf ① „Konfigurieren -> Einstellungen“ klicken. Rechts zeigen die ② drei grünen Balken („verbunden“), dass das Gateway bereit ist.

4. Im IB Gateway den Schreibschutz aufheben & Socket Port 4001 prüfen:
Klicke links im Konfigurationsbaum auf ① API -> Einstellungen und prüfe rechts genau diese Einstellungen:

  • Goldpfeil ② (Schreibgeschützte API): Haken rausnehmen! Nur wenn dieses Kästchen leer ist, darf unser Python-Skript für dich Orders platzieren.
  • Goldpfeil ③ (Socket Port… 4001): Im IB Gateway steht hier ab Werk 4001 für das Live-Konto (bzw. 4002 beim Paper-Trading). Lass die 4001 einfach stehen: Unser Python-Skript (ibkr_auto_trader.py) sucht beim Start automatisch zuerst nach Port 4001!
  • Goldpfeil ④ (Übernehmen & OK): Klicke unten auf Übernehmen und OK.
IB Gateway API Einstellungen auf Deutsch mit Schreibgeschützte API Haken raus und Socket Port 4001 mit goldenen Pfeilen
Weg B (IB Gateway Schritt 4): ① Links „API -> Einstellungen“ wählen, ② bei „Schreibgeschützte API“ den Haken rausnehmen, ③ „Socket Port 4001“ belassen und ④ unten mit „Übernehmen“ & „OK“ speichern.

Gold-Tipp für den Handel OHNE kostenpflichtiges Marktdaten-Abo (API -> Vorsichtseinstellungen):
Ich habe bei Interactive Brokers bewusst kein kostenpflichtiges Marktdaten-Abo abgeschlossen (das würde jeden Monat unnötig 5 $ bis 15 $ kosten), weil unser Python-Skript die Kurse kostenlos über yfinance lädt und mit einer +1,0% Marketable-Limit-Order arbeitet (nach dem US-Börsengesetz SEC Reg NMS führt IBKR deine Order trotzdem immer sofort zum besten aktuellen Briefkurs im Orderbuch aus). Damit das IB Gateway beim Absenden einer Order ohne Marktdaten-Abo kein Warn-Popup anzeigt, klickst du links im Baum direkt unter Einstellungen noch auf ① Vorsichtseinstellungen, setzt rechts den Haken bei ② Order-Vorsichtseinstellungen für API-Orders umgehen und bestätigst unten mit ③ Übernehmen & OK:

IB Gateway API Vorsichtseinstellungen Order-Vorsichtseinstellungen für API-Orders umgehen mit goldenen Pfeilen
Weg B (IB Gateway Zusatz-Tipp ohne Marktdaten-Abo): ① Links „API -> Vorsichtseinstellungen“ wählen, ② Haken bei „Order-Vorsichtseinstellungen für API-Orders umgehen“ setzen und ③ mit „Übernehmen“ & „OK“ speichern.

5. Falls beim ersten Test dieses gelbe Hinweis-Fenster erscheint:
Solltest du beim ersten Aufruf vergessen haben, den Haken bei „Schreibgeschützte API“ herauszunehmen, blendet dir das IB Gateway diesen Hinweis ein. Klicke einfach direkt auf ① API-Einstellungen konfigurieren, nimm den Haken raus und speichere mit Übernehmen & OK:

IB Gateway Hinweis Schreibgeschützte API deaktivieren mit goldenem Pfeil
Weg B (IB Gateway Schritt 5): Falls dieser Hinweis erscheint, direkt auf „API-Einstellungen konfigurieren“ klicken und den Schreibschutz-Haken entfernen.

6. Der Live-Beweis im Terminal von Visual Studio Code (197 gewertete US-Aktien, Limit und Ausführung getrennt):
Sobald du im Terminal von Visual Studio Code python ibkr_auto_trader.py --check eingibst, erscheint im IB Gateway automatisch der Reiter ① Kunde 21 und bestätigt den lokalen Handshake auf 127.0.0.1:4001 (②):

IB Gateway Reiter Kunde 21 und Handshake bestätigt mit goldenen Pfeilen
Im IB Gateway öffnet sich automatisch der Reiter ① „Kunde 21“ und bestätigt unten im Protokoll ② die aktive Verbindung auf 127.0.0.1:4001.

Schon der Konto-Check zeigt dir unten im Terminal deinen echten Kontowert: beim ersten Test NetLiquidation (EUR): 3749.18, also 3.749,18 € Kontowert vor dem ersten Live-Lauf und noch ohne die später gekauften Positionen:

Visual Studio Code Terminal Konto-Check erfolgreich auf Port 4001 mit goldenen Pfeilen
Visual Studio Code mit unserem Python-Skript (①) und dem erfolgreichen Konto-Check über das IB Gateway auf Port 4001 (②).

Und wenn du anschließend python ibkr_auto_trader.py --live statt des Vorschau-Modus ausführst, lädt das Skript alle 200 S&P-500- und Nasdaq-100-Aktien, wertet die 197 mit vollständiger Kurshistorie aus und schickt die 5 Momentum-Gewinner zum 14.10.26 mit fertigen Limit-Kauforders über höchstens 3.984,02 $ direkt über das IB Gateway an die Börse. Wichtig: Diese 3.984,02 $ sind das Limit-Volumen und nicht der spätere Kaufbetrag. Genau das ist bei mir am 14.10.26 um 21:32 Uhr passiert, hier ist die echte Terminal-Ausgabe:

Visual Studio Code Terminal Live-Sende-Lauf 14.10.26: Top 5 Momentum aus 197 gewerteten US-Aktien und 5 gesendete Kauf-Orders
Mein echter Live-Lauf zum 14.10.26: ① Die 5 Momentum-Spitzenreiter aus 197 gewerteten US-Aktien (DELL, HPE, CRWD, FTNT, PANW) und ② die 5 gesendeten Kauf-Orders (0 Verkäufe, 26 ganze Stücke, Limit-Volumen zusammen höchstens 3.984,02 $).

Und hier die Bestätigung aus der IBKR Mobile App: Jede der fünf Orders wurde noch am selben Abend ausgeführt. Die gemeldeten Stückzahlen (2 PANW, 15 HPE, 4 FTNT, 1 DELL, 4 CRWD) entsprechen exakt den gesendeten Ordergrößen (App-Zeit 21:32 Uhr MEZ, Ausführung um 15:32 Uhr EDT):

IBKR Mobile App: fuenf Meldungen Order teilweise ausgefuehrt fuer PANW, HPE, FTNT, DELL und CRWD am 14.10.2026
Die Ausführungs-Meldungen aus meiner IBKR Mobile App vom 14.10.2026: 2 PANW zu 377,289 $, 15 HPE zu 63,115 $, 4 FTNT zu 184,88 $, 1 DELL zu 508,39 $ und 4 CRWD zu 250,48 $ (Ausführung 15:32 Uhr EDT = 21:32 Uhr MEZ).

Kleiner Realitäts-Check zwischen Signal und Ausführung: Gesendet wurden Orders über höchstens 3.984,02 $ zu den Limit-Preisen. Ausgeführt wurden sie zusammen für 3.951,13 $ vor Gebühren, also 32,89 $ günstiger als das gesendete Limit-Volumen. Vom 4.000,00 $ Strategie-Budget blieben damit vor Gebühren 48,87 $ übrig; die 161,00 $ Reserve außerhalb des Budgets sind darin nicht enthalten. Ein Limit ist eben nur die Obergrenze: Gekauft wird immer zum besten verfügbaren Kurs darunter (SEC Reg NMS), nicht zum Limitpreis selbst.

Rang & Ticker (aus 200 US-Aktien, 197 gewertet)Ganze StückeReferenzkurs Signal ($)Positionsgröße zum Referenzkurs ($)6M-MomentumLimit-Kaufpreis (+1,0 %)Info: -18% Not-Stop
#1 DELL (Dell Technologies)1 Stück506,21 $506,21 $+180,9%511,28 $415,10 $
#2 HPE (Hewlett Packard Enterprise)15 Stück63,09 $946,35 $+157,7%63,72 $51,73 $
#3 CRWD (CrowdStrike)4 Stück250,08 $1.000,34 $+143,3%252,58 $205,07 $
#4 FTNT (Fortinet)4 Stück184,56 $738,24 $+131,7%186,41 $151,34 $
#5 PANW (Palo Alto Networks)2 Stück376,72 $753,44 $+129,6%380,49 $308,91 $
Gesamt (5 Aktien)26 StückØ3.944,58 $ (98,61%)Ø +148,6%3.984,02 $ Limit gesamtRest nach Ausführung: 48,87 $ vor Gebühren

So liest du die Tabelle: Die Positionsgrößen zum Referenzkurs summieren sich auf 3.944,58 $ und zeigen das Signal vor der Order. Die fünf Limit-Orders hätten höchstens 3.984,02 $ binden dürfen. Tatsächlich ausgeführt wurden vor Gebühren 3.951,13 $, also 98,78% des 4.000,00 $ Budgets. Die Gebühren stehen im IBKR-Ausführungsbericht und werden nicht aus dem Backtest übernommen.

4. Beide Python-Skripte im Volltext zum Kopieren

Installiere im Terminal von Visual Studio Code einmalig die benötigten Python-Pakete:

pip install yfinance pandas numpy ib_insync

Skript 1: Der komplette 10-Jahres-Backtest (jt_momentum_backtest.py)

#!/usr/bin/env python3
"""
Backtest für das 4.000 $ Interactive Brokers Konto (Jegadeesh & Titman 1993).
Prüft jeden einzelnen Parameter auf 200 S&P 500 / Nasdaq-100 Aktien (10 Jahre yfinance):
  1. Welcher Filter bringt wirklich Punkte (Keep-Puffer Top 8 vs. SMA200 vs. SPY-Ampel)?
  2. Welcher Rückblickzeitraum (3, 6, 9, 12 Monate) funktioniert am besten?
  3. Welche Aktienanzahl (1-3 vs. 4-6 vs. 6-8) passt für 4.000 $ (Ganze Aktien)?
  4. Was passiert bei engem Stop-Loss (-8%) vs. weitem Katastrophen-Stop (-18%)?
  5. Welcher Handelstag im Monat ist am besten fürs Umschichten?

Aufruf:
  python jt_momentum_backtest.py
  python jt_momentum_backtest.py --refresh   # yfinance-Cache neu laden
"""

import math, os, sys
import numpy as np
import pandas as pd
import yfinance as yf

HERE = os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals() else os.getcwd()
CACHE_FILE = os.path.join(HERE, "data", "yf_200_cache.pkl")

START_CAPITAL = 4000.0
MAX_SHARE_PRICE = 650.0     # verhindert Klumpenrisiko bei teuren Einzelaktien (>650 $)
IBKR_TIERED_FEE = 0.38      # Mindestgebühr US-Order im Tiered-Modell (~0,38 $)
IBKR_FIXED_FEE = 1.00       # Mindestgebühr im Fixed-Modell (1,00 $)
SLIPPAGE = 0.0005           # 5 Basispunkte (halber Spread bei Limit-Orders)

# 200 liquide Schwergewichte aus S&P 500 und Nasdaq-100
# Hinweis: Berkshire bei Yahoo mit Bindestrich (BRK-B), sonst wirft yfinance KeyError
TICKERS = [
    # 1. Big Tech, Software, Cloud & Cybersecurity (32)
    "AAPL", "MSFT", "NVDA", "GOOGL", "AMZN", "META", "TSLA", "NFLX", "ORCL", "CRM",
    "ADBE", "INTU", "NOW", "PANW", "CRWD", "FTNT", "SNPS", "CDNS", "WDAY", "IBM",
    "ANSS", "ADSK", "ROP", "PAYC", "PTC", "TYL", "FICO", "CTSH", "IT", "ACN",
    "GEN", "AKAM",
    # 2. Halbleiter, Server, Netzwerk & Hardware (26)
    "AVGO", "AMD", "TXN", "QCOM", "AMAT", "LRCX", "KLAC", "MU", "ADI", "MRVL",
    "NXPI", "MCHP", "ON", "MPWR", "DELL", "CSCO", "MSI", "HPQ", "HPE", "NTAP",
    "STX", "WDC", "TER", "SWKS", "QRVO", "GLW",
    # 3. Banken, Versicherungen, Börsen & Zahlungsabwickler (28)
    "BRK-B", "JPM", "V", "MA", "BAC", "WFC", "GS", "MS", "AXP", "SPGI",
    "BLK", "SCHW", "C", "PYPL", "CB", "PGR", "MMC", "AON", "ICE", "CME",
    "MCO", "USB", "PNC", "TFC", "COF", "MET", "PRU", "AFL",
    # 4. Pharma, Biotech, Medizintechnik & Krankenversicherer (26)
    "LLY", "UNH", "JNJ", "ABBV", "MRK", "TMO", "ABT", "DHR", "AMGN", "PFE",
    "ISRG", "SYK", "GILD", "VRTX", "REGN", "ZTS", "BSX", "MDT", "BDX", "EW",
    "CI", "ELV", "HUM", "CVS", "MCK", "COR",
    # 5. Konsum, Handel, E-Commerce, Gastronomie & Reise (30)
    "COST", "WMT", "HD", "PG", "KO", "PEP", "MCD", "SBUX", "NKE", "LOW",
    "TJX", "BKNG", "MAR", "CMG", "MDLZ", "MNST", "ORLY", "ROST", "AZO", "TGT",
    "EL", "CL", "KMB", "GIS", "KHC", "HSY", "YUM", "DRI", "HLT", "EBAY",
    # 6. Industrie, Luftfahrt, Rüstung, Logistik & Maschinenbau (30)
    "CAT", "GE", "RTX", "HON", "UNP", "LMT", "DE", "BA", "UPS", "ETN",
    "ITW", "ADP", "PAYX", "FAST", "WM", "RSG", "GD", "NOC", "LHX", "TDG",
    "PH", "EMR", "CMI", "PCAR", "NSC", "CSX", "FDX", "URI", "GWW", "CTAS",
    # 7. Energie, Rohstoffe, Chemie, Versorger, Telekom & REITs (28)
    "XOM", "CVX", "COP", "EOG", "SLB", "MPC", "PSX", "VLO", "OXY", "HAL",
    "KMI", "WMB", "NEE", "SO", "DUK", "AEP", "SRE", "LIN", "APD", "SHW",
    "ECL", "FCX", "NEM", "TMUS", "VZ", "CMCSA", "PLD", "O",
]


def get_price_data(force_refresh=False):
    """Lädt 10 Jahre Schlusskurse (split- und dividendenbereinigt) via yfinance."""
    if os.path.exists(CACHE_FILE) and not force_refresh:
        return pd.read_pickle(CACHE_FILE)

    os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
    symbols = TICKERS + ["SPY", "QQQ"]
    print(f"Lade {len(symbols)} Ticker über yfinance (10 Jahre Tagesdaten)...")

    raw = yf.download(symbols, period="10y", interval="1d", auto_adjust=True, progress=False)
    closes = raw["Close"] if isinstance(raw.columns, pd.MultiIndex) else raw
    closes = closes.sort_index().ffill()

    valid_cols = [c for c in closes.columns if closes[c].dropna().shape[0] > 500]
    closes = closes[valid_cols]
    closes.to_pickle(CACHE_FILE)
    return closes


def calc_whole_shares(budget, picks, prices_row):
    """Rechnet das Budget auf ganze Aktien herunter und verteilt Rest-Cash auf bezahlbare Picks."""
    if not picks:
        return {}

    slot = budget / len(picks)
    shares = {}
    spent = 0.0

    for t in picks:
        px = float(prices_row[t])
        q = int(slot // px) if px > 0 else 0
        shares[t] = q
        spent += q * px

    cash_left = budget - spent
    while cash_left > 15.0:
        bought_more = False
        for t in picks:
            px = float(prices_row[t])
            max_pos = budget if len(picks) <= 2 else budget * 0.36
            if px <= (cash_left - 4.0) and (shares[t] + 1) * px <= max_pos:
                shares[t] += 1
                cash_left -= px
                bought_more = True
        if not bought_more:
            break

    return {t: q for t, q in shares.items() if q > 0}


def run_backtest(
    df,
    lb_months=6,
    skip_months=0,
    n_stocks=5,
    vol_adj=False,
    keep_mult=1.5,
    use_spy_sma200=False,
    use_stock_sma200=False,
    fee_min=IBKR_TIERED_FEE,
    sl_mode="none",
    sl_pct=0.0,
    day_offset=-1,
):
    tickers = [c for c in df.columns if c not in ("SPY", "QQQ")]
    px_all = df[tickers]
    spy = df["SPY"]

    sma200 = px_all.rolling(200).mean()
    spy_sma200 = spy.rolling(200).mean()
    daily_rets = px_all.pct_change()

    grouped = df.groupby(df.index.to_period("M"))
    m_dates = []
    for _, grp in grouped:
        if len(grp) < 15:
            continue
        idx_in_m = day_offset if day_offset < 0 else min(day_offset, len(grp) - 1)
        d = pd.Timestamp(grp.index[idx_in_m])
        if d >= df.index[0] + pd.DateOffset(months=12):
            m_dates.append(d)

    lb_days = lb_months * 21
    skip_days = skip_months * 21

    cap = START_CAPITAL
    held = set()
    curve = [cap]
    m_rets = []
    orders_total = 0
    fees_on_4k = 0.0
    stops_count = 0
    whipsaws = 0

    for i in range(len(m_dates) - 1):
        d0, d1 = m_dates[i], m_dates[i + 1]
        pos0 = df.index.get_loc(d0)
        pos1 = df.index.get_loc(d1)

        if use_spy_sma200 and (spy.iloc[pos0] < spy_sma200.iloc[pos0]):
            n_ord = len(held)
            orders_total += n_ord
            fee = n_ord * (fee_min + (START_CAPITAL / n_stocks) * SLIPPAGE)
            fees_on_4k += fee
            held = set()
            r_net = 0.0025 - (fee / START_CAPITAL)
        else:
            p_end = px_all.iloc[pos0 - skip_days]
            p_start = px_all.iloc[pos0 - lb_days]
            mom = (p_end / p_start) - 1.0
            cur_px = px_all.iloc[pos0]

            valid = (mom > 0) & (cur_px <= MAX_SHARE_PRICE)
            if use_stock_sma200:
                valid = valid & (cur_px >= sma200.iloc[pos0])

            if vol_adj:
                vol = daily_rets.iloc[pos0 - lb_days : pos0 - skip_days + 1].std() * math.sqrt(252)
                score = (mom / vol.replace(0, np.nan))[valid].dropna().sort_values(ascending=False)
            else:
                score = mom[valid].dropna().sort_values(ascending=False)

            keep_limit = int(round(n_stocks * keep_mult))
            stay = held.intersection(set(score.iloc[:keep_limit].index))
            new_entries = [t for t in score.index if t not in stay][: n_stocks - len(stay)]
            picks = [t for t in score.index if t in stay.union(new_entries)]

            shares = calc_whole_shares(START_CAPITAL, picks, cur_px)
            actual = set(shares.keys())

            n_ord = len(held - actual) + len(actual - held)
            orders_total += n_ord
            inv_usd = sum(shares[t] * cur_px[t] for t in actual)
            fee = n_ord * fee_min + n_ord * (START_CAPITAL / n_stocks) * SLIPPAGE

            if sl_mode == "none":
                surviving = actual
                end_usd = sum(shares[t] * float(px_all.iloc[pos1][t]) for t in actual)
            else:
                end_usd = 0.0
                surviving = set()
                for t in actual:
                    q = shares[t]
                    entry_p = float(cur_px[t])
                    peak_p = entry_p
                    stopped_p = None

                    for d_idx in range(pos0 + 1, pos1 + 1):
                        day_px = float(px_all.iloc[d_idx][t])
                        if day_px > peak_p:
                            peak_p = day_px

                        if sl_mode == "fixed":
                            triggered = day_px <= entry_p * (1.0 - sl_pct)
                        elif sl_mode == "trailing":
                            triggered = day_px <= peak_p * (1.0 - sl_pct)
                        elif sl_mode == "sma200":
                            triggered = day_px < float(sma200.iloc[d_idx][t])
                        elif sl_mode == "combo":
                            triggered = (day_px <= entry_p * (1.0 - sl_pct)) or (day_px < float(sma200.iloc[d_idx][t]))
                        else:
                            triggered = False

                        if triggered:
                            stopped_p = day_px * (1.0 - SLIPPAGE)
                            stops_count += 1
                            orders_total += 1
                            fee += fee_min + (q * day_px) * SLIPPAGE
                            if float(px_all.iloc[pos1][t]) > day_px * 1.02:
                                whipsaws += 1
                            break

                    if stopped_p is not None:
                        end_usd += q * stopped_p
                    else:
                        end_usd += q * float(px_all.iloc[pos1][t])
                        surviving.add(t)

            fees_on_4k += fee
            held = surviving

            if actual and inv_usd > 0:
                r_gross = ((end_usd + (START_CAPITAL - inv_usd)) / START_CAPITAL) - 1.0
                r_net = r_gross - (fee / START_CAPITAL)
            else:
                r_net = 0.0

        cap *= (1.0 + r_net)
        curve.append(cap)
        m_rets.append(r_net)

    eq = pd.Series(curve, index=m_dates)
    rs = pd.Series(m_rets, index=m_dates[1:])
    years = (m_dates[-1] - m_dates[0]).days / 365.25

    cagr = (eq.iloc[-1] / START_CAPITAL) ** (1.0 / years) - 1.0
    vol = rs.std() * math.sqrt(12)
    sharpe = (rs.mean() * 12 - 0.03) / vol if vol > 0 else 0.0
    mdd = (eq / eq.cummax() - 1.0).min()

    return {
        "end": eq.iloc[-1],
        "cagr": cagr * 100,
        "vol": vol * 100,
        "sharpe": sharpe,
        "mdd": mdd * 100,
        "ord_yr": orders_total / years,
        "fee_yr": fees_on_4k / years,
        "stops": stops_count,
        "whipsaws": whipsaws,
    }


def main():
    refresh = "--refresh" in sys.argv
    df = get_price_data(force_refresh=refresh)
    tickers = [c for c in df.columns if c not in ("SPY", "QQQ")]

    print(f"\nUniversum: {len(tickers)} Aktien aus S&P 500 / Nasdaq-100")
    print(f"Zeitraum : {df.index[0].strftime('%d.%m.%y')} bis {df.index[-1].strftime('%d.%m.%y')}")
    print(f"Startwert: {START_CAPITAL:,.0f} $ auf Interactive Brokers (Ganze Aktien)\n")

    print("1) Filter-Check: Welcher Baustein bringt im Backtest wirklich etwas? (6M, 5 Aktien):")
    print(f"{'Filter-Stufe':48s} | {'Endwert':>8s} | {'CAGR':>7s} | {'Sharpe':>6s} | {'MaxDD':>7s} | {'Gebühr/J':>8s}")
    print("-" * 96)
    ablation = [
        (1.0, False, False, "A) Nacktes 6M-Momentum (0 Filter, kein Puffer)"),
        (1.5, False, False, "B) + Keep-Puffer Top 8 (UNSER SIEGER-SETUP)"),
        (1.5, False, True,  "C) + Aktie > eigener SMA200 (bringt nichts)"),
        (1.5, True,  False, "D) + SPY > SMA200 Markt-Ampel (kostet -79.500$!)"),
        (1.5, True,  True,  "E) Beides: SPY > SMA200 + Aktie > SMA200"),
    ]
    for km, spy_f, stk_f, label in ablation:
        r = run_backtest(df, lb_months=6, skip_months=0, n_stocks=5, keep_mult=km, use_spy_sma200=spy_f, use_stock_sma200=stk_f)
        print(f"{label:48s} | {r['end']:7.0f}$ | +{r['cagr']:5.2f}% | {r['sharpe']:6.2f} | {r['mdd']:6.2f}% | {r['fee_yr']:7.2f}$")

    print("\n2) Vergleich der Rückblickzeiträume (mit Sieger-Setup: 5 Aktien, Keep-Puffer Top 8):")
    print(f"{'Variante':30s} | {'Endwert':>9s} | {'CAGR':>7s} | {'Vol':>6s} | {'Sharpe':>6s} | {'MaxDD':>7s} | {'Gebühr/J':>8s}")
    print("-" * 86)
    for lb, sk, va, name in [
        (3, 0, False, "3M klassisch (ohne Skip)"),
        (3, 1, False, "3-1M (mit Skip-Monat)"),
        (6, 0, False, "6M klassisch (ohne Skip)"),
        (6, 1, False, "6-1M Jegadeesh/Titman"),
        (9, 1, False, "9-1M Jegadeesh/Titman"),
        (12, 1, False, "12-1M Jegadeesh/Titman"),
        (6, 1, True,  "6-1M Vol-bereinigt (Mom/Vol)"),
        (9, 1, True,  "9-1M Vol-bereinigt (Mom/Vol)"),
    ]:
        r = run_backtest(df, lb_months=lb, skip_months=sk, n_stocks=5, vol_adj=va)
        print(f"{name:30s} | {r['end']:8.0f}$ | +{r['cagr']:5.2f}% | {r['vol']:5.2f}% | {r['sharpe']:6.2f} | {r['mdd']:6.2f}% | {r['fee_yr']:6.2f}$")

    print("\n3) Wie viele Aktien bei 4.000 $ Kontogröße? (6M Momentum, Sieger-Setup):")
    print(f"{'Positionen':18s} | {'Endwert':>9s} | {'CAGR':>7s} | {'Sharpe':>6s} | {'MaxDD':>7s} | {'Tiered/J':>8s} | {'Fixed/J':>8s} | {'Stückelungs-Check'}")
    print("-" * 108)
    px_today = df[tickers].iloc[-1]
    for n in range(1, 9):
        slot_usd = START_CAPITAL / n
        rt = run_backtest(df, lb_months=6, skip_months=0, n_stocks=n, fee_min=IBKR_TIERED_FEE)
        rf = run_backtest(df, lb_months=6, skip_months=0, n_stocks=n, fee_min=IBKR_FIXED_FEE)
        too_expensive = int((px_today > slot_usd).sum())
        raw_invested = ((np.floor(slot_usd / px_today) * px_today) / slot_usd).mean() * 100
        print(
            f"N={n} ({slot_usd:4.0f}$/Aktie) | {rt['end']:8.0f}$ | +{rt['cagr']:5.2f}% | {rt['sharpe']:6.2f} | "
            f"{rt['mdd']:6.2f}% | {rt['fee_yr']:6.2f}$ | {rf['fee_yr']:6.2f}$ | {raw_invested:4.1f}% inv. ({too_expensive} Ticker > Slot)"
        )

    print("\n4) Taggenauer Stop-Loss-Check (6M Momentum, 5 Aktien, über alle Handelstage):")
    print(f"{'Stop-Loss Regel':34s} | {'Endwert':>8s} | {'CAGR':>7s} | {'Sharpe':>6s} | {'MaxDD':>7s} | {'Stops':>5s} | {'Fehl-Stops':>10s} | {'Gebühr/J':>8s}")
    print("-" * 102)
    for mode, pct, label in [
        ("none",     0.00, "Ohne Stop-Loss (nur Monats-Check)"),
        ("fixed",    0.08, "Fester Stop-Loss -8% (zu eng!)"),
        ("fixed",    0.12, "Fester Stop-Loss -12%"),
        ("fixed",    0.15, "Weiter Stop-Loss -15%"),
        ("fixed",    0.18, "Katastrophen-Stop -18%"),
        ("trailing", 0.08, "Trailing-Stop -8% vom Hoch"),
    ]:
        r = run_backtest(df, lb_months=6, skip_months=0, n_stocks=5, sl_mode=mode, sl_pct=pct)
        print(
            f"{label:34s} | {r['end']:7.0f}$ | +{r['cagr']:5.2f}% | {r['sharpe']:6.2f} | {r['mdd']:6.2f}% | "
            f"{r['stops']:5d} | {r['whipsaws']:10d} | {r['fee_yr']:7.2f}$"
        )

    # 5) Aktuelle Kaufliste mit dem reinen Sieger-Setup
    pos = len(df) - 1
    px_now = df[tickers].iloc[pos]
    mom_6m = (px_now / df[tickers].iloc[pos - 126]) - 1.0
    ok = (mom_6m > 0) & (px_now <= MAX_SHARE_PRICE)
    ranking = mom_6m[ok].dropna().sort_values(ascending=False)
    top5 = list(ranking.index[:5])
    shares = calc_whole_shares(START_CAPITAL, top5, px_now)

    print(f"\n5) Aktuelle Kaufliste zum {df.index[-1].strftime('%d.%m.%y')} (Sieger-Setup: 5 Aktien, 6M Momentum):")
    total_spent = 0.0
    for t in top5:
        q = shares.get(t, 0)
        p = float(px_now[t])
        stop_18 = round(p * 0.82, 2)
        total_spent += q * p
        print(f"  {t:5s}: {q:2d} Stück à {p:6.2f}$ = {q*p:7.2f}$ | 6M-Momentum: +{mom_6m[t]*100:5.1f}% | (Info: -18% Stop läge bei {stop_18:6.2f}$)")
    print(f"  Investiert: {total_spent:.2f}$ ({total_spent/START_CAPITAL*100:.1f}%) | Rest-Cash: {START_CAPITAL - total_spent:.2f}$")


if __name__ == "__main__":
    main()

Skript 2: Der Auto-Trader für IB Gateway (Port 4001) & TWS (Port 7496) (ibkr_auto_trader.py)

Unser Live-Skript erkennt über einen schnellen lokalen Socket-Check automatisch, ob bei dir das IB Gateway (Port 4001) oder die TWS (Port 7496) offen ist:

#!/usr/bin/env python3
"""
Vollautomatisches Rebalancing-Skript für das 4.000 $ Interactive Brokers Konto.
Unterstützt sowohl das schlanke IB Gateway (Port 4001 Live / 4002 Paper, spart ~85% RAM)
als auch die Trader Workstation / TWS (Port 7496 Live / 7497 Paper).

Setzt exakt das im Backtest bewiesene Sieger-Setup um:
  - 200 liquide S&P 500 / Nasdaq-100 Aktien (alle 11 Sektoren)
  - 6 Monate (126 Handelstage) reines Kurs-Momentum (100% Long-Only)
  - 5 ganze Aktien (Whole Shares) + Cash-Sweep für maximale Kapitalauslastung
  - Keep-Puffer Top 8 (verhindert unnötiges Hin-und-Her-Handeln)
  - Kein automatischer Stop-Loss und keine renditefressende SPY-SMA200-Sperre
  - Manuelle Warnung, falls das Konto unter 3.100 $ (~3.000 €) fällt

Aufruf:
  python ibkr_auto_trader.py --check                # Konto-Check (verbindet direkt mit Port 4001 oder 7496)
  python ibkr_auto_trader.py                        # Vorschau (Dry-Run, berechnet Trades ohne Orders)
  python ibkr_auto_trader.py --live                 # Echte Orders (nutzt IB Gateway 4001 oder TWS 7496)
  python ibkr_auto_trader.py --live --gateway       # Erzwingt IB Gateway Live (Port 4001)
  python ibkr_auto_trader.py --live --tws           # Erzwingt TWS Live (Port 7496)
  python ibkr_auto_trader.py --live --paper         # Paper-Trading (Port 4002 / 7497)
"""

import csv, json, os, sys
from datetime import datetime
import numpy as np
import pandas as pd
import yfinance as yf

HERE = os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals() else os.getcwd()
DATA_DIR = os.path.join(HERE, "data")
TRADES_CSV = os.path.join(DATA_DIR, "ibkr_trades.csv")
LIVE_JSON = os.path.join(DATA_DIR, "ibkr_portfolio_live.json")

DEFAULT_BUDGET_USD = 4000.0
MANUAL_KILL_WARN_USD = 3100.0   # ca. 3.000 EUR -> zeigt Warnung, Entscheidung liegt bei dir
TOP_N = 5
KEEP_RANK = 8                   # im Backtest bewiesen: +27.500 $ Mehrertrag & -25% Gebühren
LOOKBACK_DAYS = 126             # 6 Monate (6 * 21 Handelstage)
MAX_SHARE_PRICE = 650.0         # im Backtest bewiesen: senkt MaxDD von -28,86% auf -21,79%
LIMIT_BUFFER = 0.010            # 1,0% Marketable-Limit-Puffer (wird ohne IBKR-Marktdaten-Abo sofort zum echten Ask/Bid ausgeführt!)

PORT_LABELS = {
    4001: "IB Gateway Live (RAM-sparend)",
    4002: "IB Gateway Paper-Trading",
    7496: "Trader Workstation (TWS) Live",
    7497: "Trader Workstation (TWS) Paper-Trading",
}

TICKERS = [
    # 1. Big Tech, Software, Cloud & Cybersecurity (32)
    "AAPL", "MSFT", "NVDA", "GOOGL", "AMZN", "META", "TSLA", "NFLX", "ORCL", "CRM",
    "ADBE", "INTU", "NOW", "PANW", "CRWD", "FTNT", "SNPS", "CDNS", "WDAY", "IBM",
    "ANSS", "ADSK", "ROP", "PAYC", "PTC", "TYL", "FICO", "CTSH", "IT", "ACN",
    "GEN", "AKAM",
    # 2. Halbleiter, Server, Netzwerk & Hardware (26)
    "AVGO", "AMD", "TXN", "QCOM", "AMAT", "LRCX", "KLAC", "MU", "ADI", "MRVL",
    "NXPI", "MCHP", "ON", "MPWR", "DELL", "CSCO", "MSI", "HPQ", "HPE", "NTAP",
    "STX", "WDC", "TER", "SWKS", "QRVO", "GLW",
    # 3. Banken, Versicherungen, Börsen & Zahlungsabwickler (28)
    "BRK-B", "JPM", "V", "MA", "BAC", "WFC", "GS", "MS", "AXP", "SPGI",
    "BLK", "SCHW", "C", "PYPL", "CB", "PGR", "MMC", "AON", "ICE", "CME",
    "MCO", "USB", "PNC", "TFC", "COF", "MET", "PRU", "AFL",
    # 4. Pharma, Biotech, Medizintechnik & Krankenversicherer (26)
    "LLY", "UNH", "JNJ", "ABBV", "MRK", "TMO", "ABT", "DHR", "AMGN", "PFE",
    "ISRG", "SYK", "GILD", "VRTX", "REGN", "ZTS", "BSX", "MDT", "BDX", "EW",
    "CI", "ELV", "HUM", "CVS", "MCK", "COR",
    # 5. Konsum, Handel, E-Commerce, Gastronomie & Reise (30)
    "COST", "WMT", "HD", "PG", "KO", "PEP", "MCD", "SBUX", "NKE", "LOW",
    "TJX", "BKNG", "MAR", "CMG", "MDLZ", "MNST", "ORLY", "ROST", "AZO", "TGT",
    "EL", "CL", "KMB", "GIS", "KHC", "HSY", "YUM", "DRI", "HLT", "EBAY",
    # 6. Industrie, Luftfahrt, Rüstung, Logistik & Maschinenbau (30)
    "CAT", "GE", "RTX", "HON", "UNP", "LMT", "DE", "BA", "UPS", "ETN",
    "ITW", "ADP", "PAYX", "FAST", "WM", "RSG", "GD", "NOC", "LHX", "TDG",
    "PH", "EMR", "CMI", "PCAR", "NSC", "CSX", "FDX", "URI", "GWW", "CTAS",
    # 7. Energie, Rohstoffe, Chemie, Versorger, Telekom & REITs (28)
    "XOM", "CVX", "COP", "EOG", "SLB", "MPC", "PSX", "VLO", "OXY", "HAL",
    "KMI", "WMB", "NEE", "SO", "DUK", "AEP", "SRE", "LIN", "APD", "SHW",
    "ECL", "FCX", "NEM", "TMUS", "VZ", "CMCSA", "PLD", "O",
]


def to_ibkr_sym(yf_sym):
    return yf_sym.replace("-", " ")


def to_yf_sym(ibkr_sym):
    return ibkr_sym.replace(" ", "-")


def get_candidate_ports(argv):
    """
    Liefert die zu prüfenden Ports in der richtigen Reihenfolge OHNE vorherigen
    leeren Socket-Ping (damit das IB Gateway keinen 'Client disconnected before version'
    Fehler wirft und sofort sauber mit ib_insync verbindet).
    """
    if "--port" in argv:
        idx = argv.index("--port")
        if idx + 1 < len(argv):
            return [int(argv[idx + 1])]

    is_paper = "--paper" in argv
    if "--gateway" in argv:
        return [4002 if is_paper else 4001]
    if "--tws" in argv:
        return [7497 if is_paper else 7496]

    if is_paper:
        return [4002, 7497]
    return [4001, 7496, 4002, 7497]


def connect_ibkr(host="127.0.0.1", candidate_ports=None, client_id=21, readonly=False):
    """
    Verbindet direkt über ib_insync mit 25 Sekunden Timeout (wichtig für den ersten
    Start des IB Gateways, da das Gateway beim ersten Handshake Kontodaten lädt).
    """
    from ib_insync import IB
    if candidate_ports is None:
        candidate_ports = [4001, 7496]

    last_err = None
    for port in candidate_ports:
        ib = IB()
        label = PORT_LABELS.get(port, f"Port {port}")
        try:
            print(f"Verbinde mit {host}:{port} ({label})...")
            ib.connect(host, port, clientId=client_id, timeout=25, readonly=readonly)
            # Kurz 1.5 Sekunden warten, damit alle Währungs-Cash-Salden (USD & EUR) im Cache sind
            ib.sleep(1.5)
            return ib, port
        except Exception as e:
            last_err = e
            try:
                ib.disconnect()
            except Exception:
                pass
    raise ConnectionError(f"Keine Verbindung auf Ports {candidate_ports} möglich ({last_err})")


def check_ibkr_account(host="127.0.0.1", candidate_ports=None):
    """
    Prüft vor dem ersten Start alle wichtigen Kontodaten in IBKR (über IB Gateway oder TWS):
    EUR-Cash vs. USD-Cash, Margin-Status, NetLiquidation und bestehende Positionen.
    """
    try:
        from ib_insync import IB  # noqa: F401
    except ImportError:
        print("Bitte zuerst ib_insync installieren: pip install ib_insync")
        return

    try:
        ib, port = connect_ibkr(host=host, candidate_ports=candidate_ports, client_id=21)
    except Exception as e:
        print(f"\n[FEHLER] {e}")
        print("Tipp: Prüfe im IB Gateway unter 'Konfigurieren -> Einstellungen -> API -> Einstellungen',")
        print("      dass Socket Port 4001 eingetragen ist, und klicke unten auf 'Übernehmen' & 'OK'.")
        return

    label = PORT_LABELS.get(port, f"Port {port}")
    print(f"\n=== IBKR KONTO-CHECK ERFOLGREICH ({host}:{port} · {label}) ===")
    cash_by_curr = {}
    net_liq = {}

    for av in ib.accountValues():
        if av.tag in ("CashBalance", "TotalCashBalance", "TotalCashValue") and av.currency in ("EUR", "USD", "BASE"):
            if av.currency not in cash_by_curr or float(av.value) != 0.0:
                cash_by_curr[av.currency] = float(av.value)
        elif av.tag == "NetLiquidation" and av.currency in ("EUR", "USD", "BASE"):
            net_liq[av.currency] = float(av.value)

    print("1) Bargeld-Salden nach Währung (WICHTIG gegen Margin-Zinsen!):")
    for cur in ("EUR", "USD"):
        val = cash_by_curr.get(cur, 0.0)
        status = "OK"
        if cur == "USD" and val < 0:
            status = "WARNUNG: Negativer USD-Saldo (Margin-Kredit! Kostet ~6,3% Zinsen p.a.)"
        elif cur == "USD" and val < 500 and cash_by_curr.get("EUR", 0.0) > 1000:
            status = "HINWEIS: Geld liegt noch in EUR! Vor dem Start via FXCONV in USD tauschen."
        print(f"   {cur}-Bargeld : {val:10.2f} {cur}   -> {status}")

    print("\n2) Gesamter Kontowert (NetLiquidation):")
    for cur, val in net_liq.items():
        print(f"   NetLiquidation ({cur}): {val:10.2f}")

    print("\n3) Aktuelle Aktien-Positionen im Konto:")
    pos_list = [p for p in ib.positions() if int(p.position) != 0]
    if not pos_list:
        print("   Keine offenen Positionen (100% Cash, bereit für den ersten Start!).")
    for p in pos_list:
        print(f"   {p.contract.symbol:6s} ({p.contract.secType}): {int(p.position):4d} Stück | Ø Einstand: {p.avgCost:.2f} {p.contract.currency}")

    ib.disconnect()


def fetch_recent_prices():
    print(f"Lade aktuelle Kurse für {len(TICKERS)} S&P-500- & Nasdaq-100-Ticker über yfinance...")
    raw = yf.download(TICKERS, period="1y", interval="1d", auto_adjust=True, progress=False)
    closes = raw["Close"] if isinstance(raw.columns, pd.MultiIndex) else raw
    closes = closes.sort_index().ffill()
    valid = [c for c in closes.columns if closes[c].dropna().shape[0] > 140]
    print(f"Kurse geladen: {len(valid)} von {len(TICKERS)} Aktien mit vollständiger Historie bereit.")
    return closes[valid]


def calc_whole_shares(budget, picks, prices_row):
    if not picks or budget <= 0:
        return {}

    # Ziehe den 1,0% Limit-Puffer + 5$ Gebührenreserve vorab ein, damit auch die Limit-Preise sicher <= Budget bleiben
    eff_budget = max(0.0, (budget - 5.0) / (1.0 + LIMIT_BUFFER))
    slot = eff_budget / len(picks)
    shares = {}
    spent = 0.0

    for t in picks:
        px = float(prices_row[t])
        q = int(slot // px) if px > 0 else 0
        shares[t] = q
        spent += q * px

    cash_left = eff_budget - spent
    while cash_left > 15.0:
        bought_more = False
        for t in picks:
            px = float(prices_row[t])
            max_pos = eff_budget if len(picks) <= 2 else eff_budget * 0.36
            if px <= (cash_left - 5.0) and (shares[t] + 1) * px <= max_pos:
                shares[t] += 1
                cash_left -= px
                bought_more = True
        if not bought_more:
            break

    return {t: q for t, q in shares.items() if q > 0}


def compute_signals(df, current_held_yf):
    px_now = df.iloc[-1]
    mom_6m = (px_now / df.iloc[-1 - LOOKBACK_DAYS]) - 1.0

    valid = (mom_6m > 0) & (px_now <= MAX_SHARE_PRICE)
    ranking = mom_6m[valid].dropna().sort_values(ascending=False)

    top_keep_pool = set(ranking.iloc[:KEEP_RANK].index)
    stay = set(current_held_yf).intersection(top_keep_pool)
    new_entries = [t for t in ranking.index if t not in stay][: TOP_N - len(stay)]
    picks = [t for t in ranking.index if t in stay.union(new_entries)]

    return {
        "date": df.index[-1].strftime("%d.%m.%y"),
        "picks": picks,
        "ranking": ranking,
        "px_now": px_now,
        "mom_6m": mom_6m,
    }


def log_trade_csv(action, ticker, qty, price_usd, fee_usd, note=""):
    os.makedirs(DATA_DIR, exist_ok=True)
    file_exists = os.path.exists(TRADES_CSV)
    with open(TRADES_CSV, "a", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        if not file_exists:
            writer.writerow(["timestamp", "action", "ticker", "shares", "price_usd", "volume_usd", "fee_usd", "note"])
        writer.writerow([
            datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            action,
            ticker,
            int(qty),
            round(float(price_usd), 2),
            round(int(qty) * float(price_usd), 2),
            round(float(fee_usd), 2),
            note,
        ])


def save_website_snapshot(sig, target_shares, budget_used, live_executed=False):
    os.makedirs(DATA_DIR, exist_ok=True)
    px_now = sig["px_now"]
    mom_6m = sig["mom_6m"]

    positions = []
    invested_usd = 0.0
    for rank, t in enumerate(sig["picks"], 1):
        q = int(target_shares.get(t, 0))
        if q <= 0:
            continue
        p = float(px_now[t])
        pos_val = q * p
        invested_usd += pos_val
        positions.append({
            "rank": rank,
            "ticker": t,
            "shares": q,
            "price_usd": round(p, 2),
            "position_usd": round(pos_val, 2),
            "weight_pct": round(pos_val / budget_used * 100.0, 1),
            "mom_6m_pct": round(float(mom_6m[t]) * 100.0, 1),
            "catastrophe_stop_18_usd": round(p * 0.82, 2),
        })

    prev_live_month = None
    if os.path.exists(LIVE_JSON):
        try:
            with open(LIVE_JSON, encoding="utf-8") as pf:
                prev_live_month = json.load(pf).get("last_live_rebalance_month")
        except Exception:
            pass

    payload = {
        "updated_at": datetime.now().strftime("%d.%m.%y %H:%M"),
        "last_live_rebalance_month": datetime.now().strftime("%Y-%m") if live_executed else prev_live_month,
        "strategy": "Jegadeesh & Titman (1993) · 6M Momentum · 5 Ganze Aktien · Keep-Puffer Top 8",
        "budget_usd": round(budget_used, 2),
        "invested_usd": round(invested_usd, 2),
        "cash_usd": round(budget_used - invested_usd, 2),
        "invested_pct": round(invested_usd / budget_used * 100.0, 1) if budget_used > 0 else 0.0,
        "positions": positions,
    }
    with open(LIVE_JSON, "w", encoding="utf-8") as f:
        json.dump(payload, f, indent=2, ensure_ascii=False)
    print(f"Website-Snapshot gespeichert: {LIVE_JSON}")


def run_rebalance(live_orders=False, host="127.0.0.1", candidate_ports=None):
    df = fetch_recent_prices()

    ib = None
    port = None
    current_pos = {}
    net_liq_usd = DEFAULT_BUDGET_USD
    usd_cash = DEFAULT_BUDGET_USD
    eur_cash = 0.0

    try:
        ib, port = connect_ibkr(host=host, candidate_ports=candidate_ports, client_id=22)
        label = PORT_LABELS.get(port, f"Port {port}")
        print(f"Verbunden mit Interactive Brokers ({host}:{port} · {label})")

        for p in ib.positions():
            if p.contract.secType == "STK" and int(p.position) != 0:
                sym = to_yf_sym(p.contract.symbol)
                current_pos[sym] = int(p.position)

        for av in ib.accountValues():
            if av.tag in ("NetLiquidationByCurrency", "NetLiquidation") and av.currency == "USD":
                if float(av.value) > 0:
                    net_liq_usd = float(av.value)
            elif av.tag in ("CashBalance", "TotalCashBalance", "TotalCashValue") and av.currency == "USD":
                if float(av.value) != 0.0:
                    usd_cash = float(av.value)
            elif av.tag in ("CashBalance", "TotalCashBalance", "TotalCashValue") and av.currency == "EUR":
                if float(av.value) != 0.0:
                    eur_cash = float(av.value)

        # Falls NetLiquidationByCurrency nicht gesetzt ist, nutze USD-Cash + Aktienwert
        if net_liq_usd == DEFAULT_BUDGET_USD and usd_cash > 500 and not current_pos:
            net_liq_usd = usd_cash

        print(f"Konto-Check: NetLiquidation = {net_liq_usd:.2f} $ | USD-Cash = {usd_cash:.2f} $ | EUR-Cash = {eur_cash:.2f} €")
        if usd_cash < 500 and eur_cash > 1000 and not current_pos:
            print("ACHTUNG: Dein Bargeld liegt noch in EUR! Bitte vor dem ersten Kauf über FXCONV in USD tauschen,")
            print("         sonst kauft das Margin-Konto auf US-Dollar-Kredit (~6,3% Zinsen p.a.)!")
            if live_orders:
                ans_fx = input("Trotzdem fortfahren? [j/N]: ").strip().lower()
                if ans_fx not in ("j", "ja", "y", "yes"):
                    ib.disconnect()
                    return
    except Exception as e:
        print(f"Hinweis: Keine aktive IBKR-Verbindung ({e}). Laufe im Offline-Vorschaumodus.")
        ib = None

    # Manuelle Schmerzgrenze (kein automatischer Eingriff durch den Algo!)
    if net_liq_usd < MANUAL_KILL_WARN_USD:
        print("\n" + "!" * 78)
        print(f"WARNUNG: Kontowert ({net_liq_usd:.2f} $) liegt unter deiner 3.100 $ (~3.000 €) Grenze!")
        print("Der Algorithmus greift hier nicht von selbst ein. Du entscheidest.")
        print("!" * 78)
        if live_orders:
            ans = input("Willst du das Rebalancing trotzdem ausführen? [j/N]: ").strip().lower()
            if ans not in ("j", "ja", "y", "yes"):
                print("Abbruch durch Nutzer. Keine Orders gesendet.")
                if ib:
                    ib.disconnect()
                return

    sig = compute_signals(df, set(current_pos.keys()))
    budget_to_use = min(DEFAULT_BUDGET_USD, net_liq_usd) if net_liq_usd > 500 else DEFAULT_BUDGET_USD
    target_shares = calc_whole_shares(budget_to_use, sig["picks"], sig["px_now"])

    print(f"\nStand: {sig['date']} | Top-{TOP_N} Momentum-Gewinner aus {len(df.columns)} Aktien (6M, Keep Top 8) für {budget_to_use:.0f} $:")
    for t in sig["picks"]:
        q = target_shares.get(t, 0)
        p = float(sig["px_now"][t])
        print(
            f"   {t:5s}: {q:2d} Stück à {p:6.2f}$ = {q*p:7.2f}$ | "
            f"6M: +{sig['mom_6m'][t]*100:5.1f}% | (Info für Leser: -18% Stop läge bei {p*0.82:6.2f}$)"
        )

    all_syms = sorted(set(current_pos.keys()).union(set(target_shares.keys())))
    sells = []
    buys = []

    for t in all_syms:
        have_q = current_pos.get(t, 0)
        want_q = target_shares.get(t, 0)
        diff = want_q - have_q
        px = float(sig["px_now"][t])
        if diff < 0:
            sells.append((t, abs(diff), px))
        elif diff > 0:
            buys.append((t, diff, px))

    print("\nGeplante Transaktionen (1. Verkäufe, 2. Käufe):")
    if not sells and not buys:
        print("  Keine Umschichtung nötig (Depot stimmt bereits mit Ziel überein).")
    for t, q, px in sells:
        lmt = round(px * (1.0 - LIMIT_BUFFER), 2)
        print(f"  [VERKAUF] SELL {q:2d}x {t:5s} @ Limit {lmt:6.2f}$ (ca. {q*px:7.2f}$)")
    for t, q, px in buys:
        lmt = round(px * (1.0 + LIMIT_BUFFER), 2)
        print(f"  [KAUF]    BUY  {q:2d}x {t:5s} @ Limit {lmt:6.2f}$ (ca. {q*px:7.2f}$)")

    live_done = False
    if live_orders and ib is not None:
        # Schutz vor versehentlicher Doppel-Ausführung im selben Kalendermonat
        cur_month = datetime.now().strftime("%Y-%m")
        last_month = None
        if os.path.exists(LIVE_JSON):
            try:
                with open(LIVE_JSON, encoding="utf-8") as pf:
                    last_month = json.load(pf).get("last_live_rebalance_month")
            except Exception:
                pass
        if last_month == cur_month and current_pos and "--force" not in sys.argv:
            print(f"\nHINWEIS: Im aktuellen Monat ({cur_month}) wurde bereits ein Live-Rebalancing ausgeführt!")
            ans_m = input("Willst du trotzdem jetzt erneut umschichten? [j/N]: ").strip().lower()
            if ans_m not in ("j", "ja", "y", "yes"):
                print("Abbruch. Nächstes reguläres Rebalancing steht erst im nächsten Monat an.")
                ib.disconnect()
                return

        from ib_insync import Stock, LimitOrder

        # 1. Zuerst alle Verkäufe ausführen (damit USD-Cash frei wird)
        sell_trades = []
        for t, q, px in sells:
            contract = Stock(to_ibkr_sym(t), "SMART", "USD")
            ib.qualifyContracts(contract)
            lmt_price = round(px * (1.0 - LIMIT_BUFFER), 2)
            order = LimitOrder("SELL", int(q), lmt_price, tif="DAY")
            trade = ib.placeOrder(contract, order)
            sell_trades.append((t, q, lmt_price, trade))
            print(f"  -> Sende SELL-Order: {q}x {t} @ {lmt_price}$")

        if sell_trades:
            print("  Warte bis zu 15 Sekunden auf Ausführung der Verkäufe...")
            for _ in range(15):
                ib.sleep(1)
                if all(tr.isDone() for _, _, _, tr in sell_trades):
                    break
            for t, q, lmt_price, tr in sell_trades:
                fill_px = tr.orderStatus.avgFillPrice or lmt_price
                log_trade_csv("SELL", t, q, fill_px, 0.38, "Monats-Rebalancing")

        # 2. Danach die neuen Gewinner kaufen
        for t, q, px in buys:
            contract = Stock(to_ibkr_sym(t), "SMART", "USD")
            ib.qualifyContracts(contract)
            lmt_price = round(px * (1.0 + LIMIT_BUFFER), 2)
            order = LimitOrder("BUY", int(q), lmt_price, tif="DAY")
            trade = ib.placeOrder(contract, order)
            ib.sleep(2)
            fill_px = trade.orderStatus.avgFillPrice or lmt_price
            log_trade_csv("BUY", t, q, fill_px, 0.38, "Monats-Rebalancing")
            print(f"  -> Sende BUY-Order:  {q}x {t} @ {lmt_price}$")
        live_done = True

    elif live_orders and ib is None:
        print("\n[FEHLER] --live wurde übergeben, aber keine Verbindung zu IB Gateway / TWS.")

    save_website_snapshot(sig, target_shares, budget_to_use, live_executed=live_done)

    if ib is not None:
        ib.disconnect()


if __name__ == "__main__":
    ports = get_candidate_ports(sys.argv)

    if "--check" in sys.argv:
        check_ibkr_account(candidate_ports=ports)
    else:
        is_live = "--live" in sys.argv
        run_rebalance(live_orders=is_live, candidate_ports=ports)

5. Wann lasse ich das Skript laufen und wie verfolgst du das Experiment live?

Unser Backtest über alle Handelstage von 2016 bis 2026 hat beim Timing im Monat ein klares Ergebnis geliefert: Schichte niemals am 1. bis 3. Handelstag eines neuen Monats um! Am Monatsanfang fließen Milliarden aus automatischen US-Pensionsplänen (401k) und Sparplänen blind in den Markt und treiben die Kurse kurzfristig nach oben. Ich starte mein Skript deshalb einmal im Monat entweder am letzten Handelstag des Monats oder zur Monatsmitte (am 10. Handelstag) abends um 21:45 Uhr MEZ (15 Minuten vor US-Börsenschluss), wenn an der NYSE und Nasdaq die höchste Liquidität und die engsten Spreads herrschen.

Alle echten Abrechnungen, die aktuellen 5 Positionen und den Stand meines Kontos dokumentiere ich fortlaufend in meinem Echtgeld-Portfolio-Cockpit unter dem Reiter „IBKR Quant (04)“.

6. Häufige Fragen (FAQ) zur 4.000 $ Momentum-Strategie bei Interactive Brokers

Ist euer Momentum-Backtest Long-Only oder werden auch Aktien leerverkauft (geshortet)?

Unser Backtest und mein Live-Konto sind zu 100 % Long-Only und arbeiten ohne Kredithebel (Hebel 1,0x). Studien wie die von Israel & Moskowitz (2013) zeigen, dass rund 80% der gesamten Momentum-Überrendite nach Kosten von der Long-Seite (den Gewinner-Aktien) stammt. Durch den Verzicht auf Leerverkäufe sparen wir uns Aktien-Leihgebühren und schließen das gefährliche Nachschussrisiko bei Short-Squeezes komplett aus.

Soll ich für das Python-Skript lieber die Trader Workstation (TWS) oder das IB Gateway nutzen?

Beides funktioniert mit unserem Skript ohne Änderung am Code, da ibkr_auto_trader.py beim Start automatisch prüft, ob Port 4001 (IB Gateway Live) oder Port 7496 (TWS Live) geöffnet ist. Zum einmaligen Prüfen des Kontostands ist die TWS sehr übersichtlich. Für das eigentliche Autotrading nutze ich selbst das IB Gateway, weil es nur rund 250 MB statt 2.000 MB Arbeitsspeicher (RAM) verbraucht.

Warum kauft das Skript nur ganze Aktien (Whole Shares) statt Bruchstücke (Fractional Shares)?

Die offizielle API von Interactive Brokers lehnt viele Ordertypen für Bruchstücke mit einer Fehlermeldung (Error 10288) ab. Außerdem werden ganze Aktien direkt im echten Orderbuch der NYSE und Nasdaq ausgeführt, während Bruchstücke intern gegen IBKR abgerechnet werden. Dank unserer Cash-Sweep-Logik nutzt das Skript das Budget in ganzen Aktien fast vollständig aus: Im echten Lauf vom 14.10.26 wurden vor Gebühren 3.951,13 $ von 4.000,00 $ ausgeführt, also 98,78%. Der Rest bleibt als Cash im Konto.

Wie wird das IBKR-Momentum-Konto in Deutschland und Österreich versteuert?

Interactive Brokers Ireland (IBIE) zieht mit hinterlegtem W-8BEN-Formular auf US-Dividenden nur 15% US-Quellensteuer ab, behält aber für Kunden in Deutschland und Österreich keine Abgeltungsteuer bzw. KESt automatisch ein. Das hat unterjährig sogar einen Zinseszins-Vorteil: Deine realisierten Kursgewinne beim monatlichen Umschichten arbeiten das ganze Jahr über zu 100% brutto weiter und werden erst im Folgejahr über die Steuererklärung (Anlage KAP in Deutschland bzw. Formular E1kv in Österreich) abgerechnet.

Hinweis: Dieser Artikel ist keine Anlageberatung. Er gibt meine persönliche Vorgehensweise und meine Backtest-Daten wieder. Aktien können stark schwanken, bis hin zum Totalverlust. Ein Backtest ist keine Garantie für die Zukunft. Investiere nur Geld, das du langfristig entbehren kannst.

Redaktionelle Transparenz & Primärquellen
Prüfprotokoll dieser Anleitung
Geprüft am:
14.10.26 durch Michael Khanna (Kursdaten: yfinance-Schlusskurse bis 13.10.26)
Studien-Grundlage:
Jegadeesh & Titman (1993), Carhart (1997), Asness, Moskowitz & Pedersen (2013)
Kostengrundlage:
Echte IBKR-Tiered-Gebühren (0,38 $ pro Order) und 0,05 % Slippage im Backtest
Ausschluss:
Keine Kursziele Dritter, keine abweichenden Zweitmodelle
Offenlegung & Haltung: Dieser Beitrag enthält keine Partnerlinks und keine Anzeigen. Eigene Positionen: Auf dem hier beschriebenen IBKR-Konto liegen seit dem 14.10.26 die fünf genannten US-Aktien (DELL, HPE, CRWD, FTNT, PANW). Jeder Backtest-Wert ist ein Modellwert, keine Zusage.