SSD Nodes Learn 🎉 VPS ab $5.50/Monat
Anleitungen Matt ConnorVon Matt Connor · Aktualisiert 2026-08-13

Aktienrecherche-Agent selbst hosten auf einem VPS

Bauen Sie einen Aktienrecherche-Agenten mit Marktdaten-Feed, DuckDB, systemd-Timer zum Börsenschluss und LLM-Screening. Er liest Daten, führt aber keine Trades aus.

Was ein selbst gehosteter Agent für Aktienrecherche ist

Ein selbst gehosteter Agent für Aktienrecherche ist ein kleines Programm auf einem Server, den Sie selbst verwalten. Es ruft nach einem Zeitplan Marktdaten ab, speichert sie in einer lokalen Datenbank, filtert sie und bittet ein Large Language Model (LLM) um eine Zusammenfassung der Änderungen. Der Agent liest und filtert. Er führt keine Trades aus. Nichts in dieser Anleitung ist eine Finanzberatung.

Zwei Personen, die dieses System von Grund auf entwickeln, wählen unterschiedliche Bibliotheken und kommen dennoch bei denselben vier Komponenten an: einem Feed für Kurse und Fundamentaldaten, einem lokalen Speicher für jede bisher abgerufene Zeile, einem Job zur zeitgesteuerten Aktualisierung des Speichers und einer LLM-Schicht, die aus den verbleibenden Zeilen Sätze formuliert. Diese Anleitung erstellt diese Struktur mit Python, DuckDB, einem systemd-Timer und der Claude API (application programming interface). Die Ausführung von Trades ist ein separater Job mit eigenen Fehlerursachen. Sie gehört stattdessen auf einen für Trading-Bots eingerichteten VPS.

Die vier Teile und die Aufgabe jedes Teils

Der Feed ist der einzige Teil, der mit der Außenwelt kommuniziert. Er weiß, wie ein Ticker und ein Datumsbereich abgefragt werden und wie er Zeilen zurückgibt. Alle nachgelagerten Komponenten lesen aus Ihrer Datenbank statt aus dem Feed. Ein Ausfall des Feeds kostet Sie dadurch einen Tag mit neuen Daten und nicht eine nicht funktionierende Oberfläche.

Der Store ist der eigentliche Zweck des gesamten Aufwands. Einen Tages-Schlusskurs, den Sie nicht gespeichert haben, können Sie später normalerweise erneut abrufen. Ein Intraday-Kurs, eine Schätzung vor ihrer Überarbeitung oder eine Fundamentalkennzahl vor ihrer Neufeststellung kann dagegen möglicherweise nicht erneut abgerufen werden. Mit dem Store halten Sie fest, was die Daten an dem Tag tatsächlich ausgesagt haben, an dem sie es ausgesagt haben.

Der Scheduler entscheidet, wann die Aktualisierung erfolgt. Auf einem Server ist das ein systemd-Timer. Deshalb ist der VPS hier wichtiger als der Code.

Die LLM-Schicht liest einen kurzen Textblock, den Ihre SQL-Abfrage erzeugt hat, und schreibt eine Zusammenfassung daraus. Sie verbindet sich nie mit der Datenbank und erstellt nie die Abfrage. Wenn das Modell das SQL schreibt, wird aus einem einzigen falschen Token eine falsche Zahl in einem flüssig formulierten Satz, ohne Vergleichsmöglichkeit. Wenn SQL die Zahlen erzeugt, kann das Modell nur die Formulierung falsch wiedergeben. Sie können die Formulierung dann mit den übermittelten Zeilen abgleichen.

Warum das auf einem VPS und nicht auf einem Laptop ausgeführt werden sollte

Der Scheduler ist der entscheidende Grund. Der Börsenschluss in den USA um 16:00 Uhr New Yorker Zeit entspricht 22:00 Uhr in Berlin und 04:00 Uhr am nächsten Morgen in Jakarta. Ein Laptop befindet sich zu beiden Zeitpunkten im Energiesparmodus. Ein verpasster Lauf kostet mehr als nur eine verspätete Notiz: Tagesbalken können in der Regel später erneut abgerufen werden, aber bereits überarbeitete Daten nicht. Die Lücke in Ihrer Aufzeichnung bleibt daher dauerhaft bestehen. Die gleiche Überlegung gilt für jeden Agenten, dessen Zeitplan und gespeicherter Zustand einen Reboot überstehen müssen. Genau dafür ist der Betrieb von KiroCrew als ständig aktiver Agent auf Ihrem eigenen VPS ausgelegt.

Der zweite Grund ist weniger wichtig, aber dennoch relevant. Der Server speichert einen API-Schlüssel in einer einzigen Datei. Diese Datei gehört einem Systembenutzer ohne Login-Shell und wird von einem einzigen Job verwendet. Das lässt sich auf dem Laptop, mit dem Sie auch im Web surfen, deutlich schwieriger umsetzen. Halten Sie den Schlüssel aus dem Code und aus der Eingabe des Modells heraus. Darum geht es in API-Schlüssel aus einem AI-Agenten heraushalten.

Dimensionierung: Festplatte, RAM und Tokens

Die Festplatte ist der einfache Teil. Ein Tagesbalken entspricht einer Zeile pro Ticker und Handelstag. Ein US-Handelsjahr umfasst etwa 252 Tage.

ChartRows in the prices table by watchlist size, at 252 trading days a year
The data behind this chart
[
  {
    "label": "20 tickers",
    "rows_after_1y": "5,040",
    "rows_after_10y": "50,400"
  },
  {
    "label": "100 tickers",
    "rows_after_1y": "25,200",
    "rows_after_10y": "252,000"
  },
  {
    "label": "500 tickers",
    "rows_after_1y": "126,000",
    "rows_after_10y": "1,260,000"
  }
]

Zwanzig Ticker ergeben nach einem Jahr 5,040 Zeilen. Die Zeile 500 tickers erreicht nach zehn Jahren 1,260,000 Zeilen. Jede Zeile enthält ein Datum und einige Gleitkommazahlen. DuckDB speichert Spalten komprimiert. Daher handelt es sich um einige Dutzend Megabyte und nicht um Gigabytes. Verlassen Sie sich nicht auf diese Schätzung, auch nicht auf meine. Führen Sie nach dem ersten Backfill du -h /opt/research/data/market.duckdb aus und verwenden Sie den ermittelten Wert.

RAM ist der Punkt, an dem ein kleiner VPS an seine Grenzen kommt. DuckDB verwendet standardmäßig einen großen Teil des Arbeitsspeichers und alle CPU-Kerne des Rechners für eine einzelne Abfrage. Das ist auf einem Analytics-Server sinnvoll, aber nicht auf einem Rechner mit 2 GB RAM, der zusätzlich andere Dienste ausführt. Eine Aggregation über die gesamte Preistabelle kann dann dazu führen, dass der Kernel den Prozess beendet. systemd meldet in diesem Fall Main process exited, code=killed, status=9/KILL, während journalctl -k den Beendigungsgrund Out of Memory anzeigt. Setzen Sie memory_limit und threads explizit. Dann wird die Abfrage langsamer, statt beendet zu werden.

Tokens sollten gemessen und nicht geschätzt werden. Jede Antwort der Messages API enthält ein Objekt usage mit input_tokens und output_tokens. Schreiben Sie beide Werte bei jedem Aufruf in eine Tabelle. Nach einer Woche kennen Sie Ihr tatsächliches Volumen. Dieses multiplizieren Sie mit dem Preis, den Ihr Modell an dem Tag Ihrer Prüfung angibt. Zwei Punkte sind für die Planung stabil genug. Output-Tokens sind bei jedem Claude-Modell teurer als Input-Tokens. Eine Begrenzung der Notiz auf 200 Wörter senkt die Kosten daher stärker, als wenn Sie die gesendeten Daten kürzen. Prompt-Caching hilft bei einem einmal täglich ausgeführten Job dagegen nicht, weil die Cache-Gültigkeit in Minuten gemessen wird. Beim nächsten Lauf ist der gecachte Block abgelaufen, und Sie zahlen erneut den vollständigen Preis für die Eingabe. Caching lohnt sich, wenn ein Lauf innerhalb kurzer Zeit viele Aufrufe mit demselben großen Textblock ausführt.

Bestandteile installieren

sudo apt update
sudo apt install -y python3-venv
sudo useradd --system --create-home --home-dir /opt/research --shell /usr/sbin/nologin research
sudo -u research python3 -m venv /opt/research/venv
sudo -u research /opt/research/venv/bin/pip install duckdb pandas yfinance anthropic
sudo install -d -o research -g research -m 750 /opt/research/data

Prüfen Sie die Installation, bevor Sie Code schreiben:

sudo -u research /opt/research/venv/bin/python -c 'import duckdb, yfinance, anthropic; print("ok")'

Dieser Befehl gibt ok aus. Wenn Sie die virtuelle Umgebung übersprungen und pip install gegen das System-Python ausgeführt haben, stoppt Ubuntu 24.04 den Vorgang mit error: externally-managed-environment, weil die Distribution /usr/lib/python3 verwaltet und pip dort keine Dateien schreiben darf. Die virtuelle Umgebung ist keine reine Formalität. Sie ist das einzige Verzeichnis, in dem pip Änderungen vornehmen darf.

Der API-Schlüssel gehört in eine Datei, die der Dienstbenutzer lesen kann und auf die sonst niemand zugreifen darf:

sudo install -d -m 755 /etc/research
sudo install -m 640 -o root -g research /dev/null /etc/research/env
sudoedit /etc/research/env

Schreiben Sie eine Zeile hinein, ohne Anführungszeichen und ohne export, weil systemd diese Datei selbst verarbeitet, statt sie an eine Shell zu übergeben:

ANTHROPIC_API_KEY=sk-ant-your-key-here

Der Speicher: zwei Tabellen

# /opt/research/store.py
import duckdb

DB = '/opt/research/data/market.duckdb'

SCHEMA = [
    """
    CREATE TABLE IF NOT EXISTS prices (
      ticker VARCHAR,
      day    DATE,
      open   DOUBLE,
      high   DOUBLE,
      low    DOUBLE,
      close  DOUBLE,
      volume BIGINT,
      PRIMARY KEY (ticker, day)
    )
    """,
    """
    CREATE TABLE IF NOT EXISTS runs (
      started_at    TIMESTAMPTZ,
      model         VARCHAR,
      input_tokens  BIGINT,
      output_tokens BIGINT,
      hits          BIGINT
    )
    """,
]

def connect(read_only=False):
    con = duckdb.connect(DB, read_only=read_only)
    con.execute("SET memory_limit='512MB'")
    con.execute('SET threads=2')
    if not read_only:
        for statement in SCHEMA:
            con.execute(statement)
    return con

Der Primärschlüssel auf (ticker, day) sorgt dafür, dass die Aktualisierung beliebig oft sicher wiederholt werden kann. INSERT OR REPLACE überschreibt eine bereits vorhandene Zeile für diesen Ticker und diesen Tag. Dadurch verdoppelt ein zweimaliges Ausführen des Backfills die Tabelle nicht. Ohne diesen Schlüssel würde ein erneuter Lauf nach einem Absturz jeden Balken unbemerkt duplizieren. Jeder danach berechnete Durchschnitt wäre falsch, ohne dass irgendwo eine Fehlermeldung darauf hinweist.

DuckDB erlaubt nur einem Prozess, die Datei zum Schreiben geöffnet zu halten. Ein zweiter Schreibprozess schlägt sofort mit Could not set lock on file fehl, gefolgt von der PID des Prozesses, der die Datei geöffnet hält. In der Praxis ist das meist die interaktive duckdb-Shell, die Sie in einem anderen Terminal geöffnet gelassen haben. Lesende Prozesse passieren read_only=True. Deshalb verwendet connect das Flag. Wenn mehrere Prozesse tatsächlich gleichzeitig schreiben müssen, ist eine andere Engine erforderlich: SQLite im WAL-Modus erlaubt Lesezugriffe, während ein Schreibprozess einen Commit ausführt. Ein Busy-Timeout lässt andere Schreibprozesse warten, statt sie fehlschlagen zu lassen. DuckDB und SQLite für eine Server-Workload vergleicht beide Systeme. SQLite auf einem VPS produktiv betreiben behandelt die Einstellungen, mit denen WAL zuverlässig funktioniert.

Der Aktualisierungsjob

# /opt/research/refresh.py
import sys
import pandas as pd
import yfinance as yf
from store import connect

TICKERS = ['AAPL', 'MSFT', 'KO', 'SAP', 'TSM']
FIRST_DAY = '2016-01-01'
COLS = ['ticker', 'day', 'open', 'high', 'low', 'close', 'volume']

def fetch(ticker, start):
    df = yf.Ticker(ticker).history(start=start, auto_adjust=True)
    if df.empty:
        return None
    df = df.reset_index()
    stamps = pd.to_datetime(df['Date'])
    if stamps.dt.tz is not None:
        stamps = stamps.dt.tz_localize(None)
    df['day'] = stamps.dt.date
    df['ticker'] = ticker
    df = df.rename(columns={'Open': 'open', 'High': 'high', 'Low': 'low',
                            'Close': 'close', 'Volume': 'volume'})
    return df[COLS]

def main():
    con = connect()
    empty = 0
    for ticker in TICKERS:
        last = con.execute('SELECT max(day) FROM prices WHERE ticker = ?',
                           [ticker]).fetchone()[0]
        rows_df = fetch(ticker, str(last) if last else FIRST_DAY)
        if rows_df is None:
            print(ticker + ': feed returned no rows', file=sys.stderr)
            empty += 1
            continue
        con.register('rows_df', rows_df)
        con.execute('INSERT OR REPLACE INTO prices '
                    'SELECT ticker, day, open, high, low, close, volume FROM rows_df')
        print(ticker + ': ' + str(len(rows_df)) + ' rows')
    con.close()
    if empty == len(TICKERS):
        print('every ticker returned nothing: the feed is broken', file=sys.stderr)
        sys.exit(1)

main()

Führen Sie ihn einmal manuell aus:

sudo -u research /opt/research/venv/bin/python /opt/research/refresh.py

Beim ersten Lauf werden mehrere Jahre nachgeladen. Für jeden Ticker wird eine Zeile mit einer Anzahl im Tausenderbereich ausgegeben. Starten Sie den Job eine Minute später erneut. Dann zeigt jede Zeile 1 oder 2 Datensätze, weil der Job am letzten bereits gespeicherten Tag beginnt. Dieser zweite Lauf ist der eigentliche Test: Wenn die Anzahlen weiterhin im Tausenderbereich liegen, liefert max(day) keine Daten zurück und der Insert schreibt jede Nacht Ihre gesamte Historie neu.

Die Prüfung mit df.empty ist die wichtigste Zeile in der Datei. Ticker.history() löst bei einem falschen oder nicht mehr gelisteten Symbol keinen Fehler aus. Stattdessen wird eine Warnung ausgegeben, dass das Symbol möglicherweise nicht mehr gelistet ist und keine Kursdaten gefunden wurden. Der genaue Wortlaut unterscheidet sich je nach Bibliotheksversion. Anschließend wird ein leeres DataFrame zurückgegeben. Ein Job ohne diese Prüfung schreibt keine Daten, wird mit Exit-Code 0 beendet und systemd zeigt einen erfolgreichen Lauf an, während die Tabelle unbemerkt nicht mehr wächst. Das fällt erst Wochen später auf, wenn eine Abfrage jeden Tag dieselben Datensätze zurückgibt.

Auch die Behandlung des Zeitstempels hat einen Grund. Der von der Datenquelle zurückgegebene Index kann die Zeitzone der Börse enthalten. Das Entfernen des Offsets ist nicht dasselbe wie eine Konvertierung nach UTC. Ein mit Mitternacht lokaler Zeit versehener Handelstag in Tokio wird in UTC auf den vorherigen Kalendertag verschoben. Eine UTC-Konvertierung verschiebt dadurch jeden japanischen Balken unbemerkt um einen Tag nach hinten und verletzt den Primärschlüssel. tz_localize(None) behält das eigene Sitzungsdatum der Börse bei. Dieses Datum entspricht der Bedeutung eines Tagesbalkens.

Der Timer zur Börsenschlusszeit

Der US-Markt schließt um 16:00 Uhr New Yorker Zeit. Da die letzten Kursfeststellungen einige Minuten zur Abrechnung benötigen, wird der Job um 16:20 Uhr ausgeführt. Geben Sie diese Zeit in New Yorker Zeit an, nicht in UTC. New York liegt im Winter bei UTC minus 5 und im Sommer bei UTC minus 4. Ein Timer mit einer festen UTC-Stunde verschiebt sich daher zweimal jährlich um eine Stunde und wird vor dem Börsenschluss ausgelöst. systemd 252 und höher akzeptieren in OnCalendar direkt eine Zeitzone. Ubuntu 24.04 wird mit Version 255 ausgeliefert. Prüfen Sie Ihre Version mit systemctl --version.

# /etc/systemd/system/research-refresh.service
[Unit]
Description=Refresh market data and run the daily screen
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=research
Group=research
WorkingDirectory=/opt/research
EnvironmentFile=/etc/research/env
ExecStart=/opt/research/venv/bin/python /opt/research/refresh.py
ExecStart=/opt/research/venv/bin/python /opt/research/screen.py
# /etc/systemd/system/research-refresh.timer
[Unit]
Description=Run the refresh after the US market close

[Timer]
OnCalendar=Mon-Fri 16:20 America/New_York
Persistent=true
RandomizedDelaySec=180

[Install]
WantedBy=timers.target

Type=oneshot ist der einzige Servicetyp, der mehr als ein ExecStart akzeptiert. Er führt sie der Reihe nach aus und bricht ab, sobald eines mit einem Fehlercode ungleich null beendet wird. Genau dieses Verhalten wird benötigt: Auf eine fehlgeschlagene Aktualisierung darf keine Bildschirmausgabe mit veralteten Daten folgen. Persistent=true ist auf einem VPS wichtig, der wegen Kernel-Aktualisierungen neu gestartet wird. Führen Sie den Neustart um 16:15 Uhr ohne diese Option durch, geht die Ausführung einfach verloren. Mit der Option wird der Job ausgeführt, sobald der Rechner wieder verfügbar ist.

sudo systemctl daemon-reload
sudo systemctl enable --now research-refresh.timer
systemctl list-timers research-refresh.timer

list-timers sollte eine NEXT-Spalte mit dem nächsten Lauf an einem Wochentag anzeigen, umgerechnet in die lokale Zeit des Servers. Eine leere Liste bedeutet, dass der Timer nicht aktiviert ist oder dass der Unit der Abschnitt [Install] fehlt. Daher hatte enable nichts, worauf es in timers.target verweisen konnte.

Der Bildschirm: SQL zuerst, das Modell zuletzt

SQL arbeitet exakt und verursacht pro Ausführung keine zusätzlichen Kosten. Das Modell ist dagegen nicht kostenlos. Deshalb schränkt die Abfrage die Datenmenge ein, und nur die verbleibenden Datensätze werden an das Modell gesendet. Speichern Sie dies als /opt/research/screen.sql.

WITH ma AS (
  SELECT ticker, day, close,
         avg(close) OVER w20 AS ma20,
         avg(close) OVER w50 AS ma50,
         row_number() OVER (PARTITION BY ticker ORDER BY day) AS n
  FROM prices
  WINDOW
    w20 AS (PARTITION BY ticker ORDER BY day ROWS BETWEEN 19 PRECEDING AND CURRENT ROW),
    w50 AS (PARTITION BY ticker ORDER BY day ROWS BETWEEN 49 PRECEDING AND CURRENT ROW)
)
SELECT ticker, day, close, round(ma20, 2) AS ma20, round(ma50, 2) AS ma50
FROM ma
WHERE n > 50 AND ma20 > ma50
ORDER BY day DESC, ticker
LIMIT 20;

Der Filter n > 50 ist keine Dekoration. ROWS BETWEEN 49 PRECEDING AND CURRENT ROW bildet den Mittelwert über alle vorhandenen Zeilen. Die dritte Zeile eines Tickers liefert daher den Mittelwert von drei Tagen und wird weiterhin als ma50 bezeichnet. Wenn Sie diesen Wert mit ma20 vergleichen, erzeugen Sie am Anfang der Historie jedes Tickers einen Crossover, der nie stattgefunden hat. Der Filter auf die Zeilennummer entfernt die Zeilen, für die das Fenster noch nicht vollständig war.

Was das Modell sieht und was es niemals sieht

# /opt/research/screen.py
from anthropic import Anthropic
from store import connect

SYSTEM = (
    'You are a research assistant. Use only the rows in the message. '
    'If a number is not in the rows, say that it is not available. '
    'Do not give investment advice, price targets or buy and sell calls. '
    'Write at most 200 words.'
)

con = connect()
sql = open('/opt/research/screen.sql').read()
rows = con.execute(sql).fetchall()
block = '\n'.join(' / '.join(str(v) for v in row) for row in rows)

client = Anthropic(max_retries=5)   # reads ANTHROPIC_API_KEY from the environment
resp = client.messages.create(
    model='claude-sonnet-5',
    max_tokens=600,
    system=SYSTEM,
    messages=[{'role': 'user', 'content': 'Screen hits, ticker / day / close / ma20 / ma50:\n' + block}],
)

print(resp.content[0].text)
con.execute('INSERT INTO runs VALUES (now(), ?, ?, ?, ?)',
            ['claude-sonnet-5', resp.usage.input_tokens,
             resp.usage.output_tokens, len(rows)])
con.close()

Die Wortbegrenzung im System-Prompt begrenzt die teure Hälfte der Abrechnung. Die Anweisung, nur die angegebenen Zeilen zu verwenden, müssen Sie überprüfen, statt ihr zu vertrauen: Löschen Sie eine Spalte aus dem Block, führen Sie den Vorgang erneut aus und prüfen Sie die Ausgabe. Wenn weiterhin ein Wert für diese Spalte erscheint, hat das Modell die Lücke selbst ausgefüllt. Ihr Prompt ist dann nicht eng genug gefasst. Dieser Test dauert zwei Minuten. Er ist die einzige verlässliche Methode, das festzustellen.

Das Modell sieht niemals den API-Schlüssel, den Datenbankpfad oder eine Abfrage. Es empfängt Zeilen und gibt Prosa zurück. Diese Abgrenzung macht die Ausgabe überprüfbar, weil jede Zahl in der Notiz auch in dem von Ihnen gesendeten Block enthalten sein sollte. Sie können die Angaben Zeile für Zeile vergleichen. Weitere Informationen zur Formulierung von Prompts finden Sie unter Claude für Finanzanalysen verwenden. Dort wird ausführlicher erläutert, welche Inhalte das Modell gut auswerten kann.

Ein Durchlauf von Anfang bis Ende

Um 16:20 Uhr New Yorker Zeit startet der Timer den Dienst. refresh.py fragt den Feed für jeden Ticker ab, beginnend mit dem zuletzt gespeicherten Tag, schreibt für jeden Ticker ein oder zwei neue Kursbalken und gibt eine Zeile pro Ticker aus. screen.py öffnet dieselbe Datei, führt die Abfrage für den gleitenden Durchschnitt aus und erhält eine Handvoll Zeilen. Diese Zeilen werden zu einem Textblock mit einigen hundert Tokens. Ein API-Aufruf wandelt ihn in eine kurze Notiz um. Die Notiz wird in das Journal geschrieben. Eine Zeile mit den Token-Anzahlen und der Anzahl der Treffer landet in runs.

journalctl -u research-refresh.service -n 50 --no-pager

Ein vollständiges Log enthält eine Zeile pro Ticker, danach die Notiz und anschließend research-refresh.service: Deactivated successfully. Lesen Sie nach einer Woche Ihre tatsächlichen Kosten aus der Datenbank aus:

SELECT count(*) AS runs,
       sum(input_tokens)  AS in_tokens,
       sum(output_tokens) AS out_tokens
FROM runs;

Multiplizieren Sie diese Summen mit den Preisen pro 1 Million Tokens, die Ihr Modell an dem Tag angibt, an dem Sie sie ablesen. So erhalten Sie den tatsächlichen Wert statt einer Schätzung. Veröffentliche Preise ändern sich. Die Berechnung bleibt gleich.

Warum Backtests überangepasst werden und wie Sie das beobachten

Schreiben Sie den Screen als Funktion der beiden Fensterlängen neu, durchlaufen Sie ein Raster aus Parameterpaaren und sortieren Sie die Paare nach Rendite. Das beste Paar wird hervorragend aussehen. Genau das ist das Problem, nicht das Ergebnis. Ein Raster mit 200 Paaren umfasst 200 Experimente, und Sie haben das Paar mit dem meisten Glück behalten.

Sie können das innerhalb von zehn Minuten beobachten. Teilen Sie den Store nach Datum in zwei Hälften. Durchlaufen Sie das Raster nur für die erste Hälfte und notieren Sie den Gewinner. Durchlaufen Sie dasselbe Raster für die zweite Hälfte. Wenn die beiden Gewinner weit auseinanderliegen, passen sich die Parameter an das Rauschen an. Ein Paar, das nur in der Hälfte gewinnt, auf die Sie es abgestimmt haben, sagt nichts über den nächsten Tag aus.

Survivorship Bias ist schlimmer als Overfitting, weil sich dieser nicht durch Tuning beheben lässt. Ihre Tickerliste enthält die heutigen Indexmitglieder und damit nur die Unternehmen, die überlebt haben. Wenn Sie den Feed nach einem Ticker fragen, der 2019 delisted wurde, liefert er einen leeren DataFrame zurück. Das bedeutet, dass dieses Unternehmen nie in Ihren Store und nie in Ihren Test gelangt. Jeder Backtest, den Sie ausführen, hat die Verlierer bereits ausgeschlossen.

Nachträglich korrigierte Fundamentaldaten durchbrechen die zeitliche Abfolge. Die Umsatzangabe, die die API heute für ein Quartal aus dem Jahr 2019 zurückgibt, entspricht nicht immer der Angabe, die 2019 veröffentlicht wurde. Ein Screen, der heutige Fundamentaldaten mit Kursen aus dem Jahr 2019 kombiniert, verwendet Informationen, die damals nicht verfügbar waren. Kurse sind in diesem Zusammenhang normalerweise unproblematisch. Fundamentaldaten sind es normalerweise nicht.

Adjustierte Kurse verändern sich nachträglich. Bei auto_adjust=True werden die Schlusskurse rückwirkend um Dividenden und Splits bereinigt. Deshalb liefert dieselbe Abfrage im nächsten Monat eine geringfügig andere Historie. Wenn Sie die tatsächlich verwendeten Zeilen speichern, wird ein Ergebnis reproduzierbar. Das ist ein weiterer Grund, warum der lokale Store überhaupt existiert.

Der Backtest ignoriert außerdem Provisionen und Slippage und nimmt an, dass Ihre Order den Kurs nicht bewegt. Diese Aspekte gehören zur Ausführung. Sie sind hier nicht Gegenstand und werden unter Trading-Bots auf einem VPS ausführen behandelt.

Fehlerbilder und die dabei angezeigten Meldungen

error: externally-managed-environment beim Ausführen von pip. Sie befinden sich außerhalb der virtuellen Umgebung. Rufen Sie /opt/research/venv/bin/pip über den vollständigen Pfad auf.

Could not set lock on file, gefolgt von einer PID. Ein anderer Prozess hält die DuckDB-Datei zum Schreiben geöffnet. Meist handelt es sich um eine vergessene interaktive Shell. Schließen Sie sie, oder öffnen Sie die zweite Verbindung mit read_only=True.

Main process exited, code=killed, status=9/KILL in systemctl status. Der Kernel hat den Prozess wegen Speichermangels beendet. Prüfen Sie dies mit journalctl -k | grep -i oom und verringern Sie anschließend memory_limit in store.py.

Ein erfolgreicher Lauf, der nichts schreibt. systemctl status liest active (exited), aber die Tabelle ist nicht gewachsen. Der Feed hat leere Frames zurückgegeben. Dieser Fehler bleibt am längsten unbemerkt. Lassen Sie den Job daher mit einem Fehlerstatus beenden, wenn alle Ticker leer zurückkommen.

Der Timer wurde an einem Börsenfeiertag ausgelöst. systemd kennt den Börsenkalender nicht, daher umfasst Mon-Fri auch Feiertage. Der Lauf wird ausgeführt, der Feed enthält keine neuen Daten, und der Job sollte dies als normalen Zustand und nicht als Fehler behandeln.

429 von der API. Sie haben ein Rate-Limit überschritten. Das Anthropic SDK wiederholt Anfragen selbstständig mit Backoff, und Anthropic(max_retries=5) erhöht die Anzahl der Versuche. Wenn der Lauf weiterhin jeden Tag fehlschlägt, fordert der Job in einem einzelnen Burst zu viele Daten an.

Was dies nicht ist

Dies ist ein Rechercheassistent. Ein Modell, das einen Geschäftsbericht zusammenfasst, erzeugt eine eigene Interpretation dieses Berichts. Dabei kann es eine im Text genannte Zahl selbstbewusst falsch wiedergeben. Deshalb muss jede Zahl in der Notiz auf eine von Ihnen übermittelte Zeile zurückgeführt werden können. Betrachten Sie die Ausgabe als Auswahlliste von Punkten, die Sie selbst prüfen sollten. Dies ist keine Finanzberatung, und die Ausgabe ist kein Signal.

Backtests eignen sich dazu, Ideen zu verwerfen, bestätigen sie aber nur eingeschränkt. Eine Strategie, die mit Ihren eigenen Daten scheitert, ist tatsächlich nicht tragfähig. Eine Strategie, die besteht, hat lediglich Ihre Daten überlebt. Das ist deutlich weniger belastbar, als es sich um 1am anfühlt.

Die Ausführung bleibt bewusst außerhalb des Umfangs. Orders und Broker-Zugangsdaten haben ein anderes Risikoprofil als eine read-only Research-Box. Wenn Sie beides vermischen, liegen Trading-Schlüssel auf demselben Rechner wie ein LLM-Prompt. Wenn Sie sehen möchten, wie dieses Muster neben den anderen Anwendungen einzuordnen ist, die Sie auf Ihrem eigenen Server betreiben können, ist die Übersicht zu den empfehlenswerten Self-Hosted-AI-Agents der weiterführende Überblick.

FAQ

Benötige ich einen kostenpflichtigen Marktdaten-Feed?

Nicht für einen Prototyp. Ein kostenloser, inoffizieller Feed reicht aus, um die Struktur des Systems kennenzulernen. Er wird ausfallen, weil er von einer Website abhängt, die Ihnen gegenüber zu nichts verpflichtet ist. Meist äußert sich das als leere DataFrames und nicht als Exception. Deshalb muss Ihr Job die Anzahl der Zeilen prüfen. Wechseln Sie zu einem kostenpflichtigen Feed mit dokumentierter API und Support-Adresse, sobald die Daten in eine Entscheidung einfließen. Der Store macht diesen Austausch kostengünstig: Nur die Fetch-Funktion ändert sich. Zeitplan, Schema und Anzeige bleiben unverändert.

Sollte ich Kurse in SQLite oder DuckDB speichern?

DuckDB ist spaltenorientiert und für das Scannen vieler Zeilen zur Berechnung eines Aggregats ausgelegt. Genau das benötigt ein gleitender Durchschnitt über zehn Jahre mit Kursbalken. SQLite ist zeilenorientiert und eignet sich besser für viele kleine Lese- und Schreibvorgänge aus mehreren Prozessen gleichzeitig. Für einen einzelnen geplanten Job, der einige hundert Zeilen anhängt und anschließend Millionen Zeilen scannt, ist DuckDB die bessere Wahl. Wenn mehrere Prozesse gleichzeitig schreiben müssen, können Leser in SQLite im WAL-Modus weiterarbeiten, während ein Writer committet. Ein Busy-Timeout lässt andere Writer warten, statt sie sofort fehlschlagen zu lassen.

Wie viel kosten die LLM-Aufrufe pro Monat?

Protokollieren Sie usage.input_tokens und usage.output_tokens aus jeder Antwort in einer Tabelle. Multiplizieren Sie anschließend die Wochenwerte mit dem Preis pro Million Token, den Ihr Modell an dem Tag angibt, an dem Sie die Berechnung durchführen. Nur dieser Wert bleibt im nächsten Quartal verlässlich. Ein täglicher Lauf über einen kurzen Screen erzeugt nur wenige Aufrufe. Die Länge der Notiz können Sie selbst steuern: Wenn Sie die Zusammenfassung auf 200 Wörter begrenzen, sparen Sie mehr, als wenn Sie weniger Zeilen senden, weil Output-Token bei jedem Claude-Modell teurer sind als Input-Token.

Warum war mein Job erfolgreich, obwohl er keine neuen Zeilen geschrieben hat?

Dafür gibt es zwei häufige Ursachen. Der Markt war geschlossen, weil ein systemd-Mon-Fri-Zeitplan Börsenfeiertage berücksichtigt. Oder der Feed hat für jeden Ticker einen leeren DataFrame zurückgegeben. Client-Bibliotheken melden das häufig als ausgegebene Warnung und nicht als Exception. Der Prozess beendet sich dann trotzdem mit Exit-Code 0, und systemd zeigt weiterhin einen erfolgreichen Lauf an. Unterscheiden Sie die Ursachen, indem Sie SELECT max(day) FROM prices mit dem letzten tatsächlichen Handelstag vergleichen. Der Job sollte mit einem Exit-Code ungleich 0 beendet werden, wenn alle Ticker leer zurückgegeben werden.

Kann der Agent entscheiden, was ich kaufen soll?

Nein. Genau daran scheitern solche Projekte häufig, wenn sie entsprechend entwickelt werden. Das Modell hat keinen Marktzugriff, kennt weder Ihre Position noch Ihre steuerliche Situation und kann seine eigenen Zahlen nicht mit einer unabhängigen Quelle abgleichen. Seine Stärke liegt darin, große Textmengen zu lesen und Ihnen mitzuteilen, welche wenigen Punkte heute Ihre Aufmerksamkeit verdienen. Nichts, was es schreibt, ist eine Finanzberatung. Die Entscheidung und die Verantwortung dafür bleiben bei Ihnen.