SSD Nodes Learn 🎉 VPS ab $4.99/Monat
Anleitungen Matt ConnorVon Matt Connor

Aktienanalyse-Agent auf einem VPS selbst hosten

Bauen Sie einen selbst gehosteten Aktienanalyse-Agenten mit Marktdatenfeed, DuckDB-Speicher, systemd-Timer zum Börsenschluss und LLM-Screening ohne Trade-Ausführung.

Was ein selbst gehosteter Aktienanalyse-Agent ist

Ein selbst gehosteter Aktienanalyse-Agent ist ein kleines Programm auf einem Server, den Sie selbst verwalten. Es ruft regelmäßig Marktdaten ab, speichert sie in einer lokalen Datenbank, filtert die Daten und bittet ein Large Language Model (LLM) um eine Zusammenfassung der Änderungen. Das Programm liest und filtert. Es führt keine Trades aus. Nichts in dieser Anleitung ist eine Finanzberatung.

Zwei Personen, die dieses System von Grund auf entwickeln, wählen möglicherweise unterschiedliche Bibliotheken und erhalten trotzdem dieselben vier Bestandteile: einen Datenfeed für Kurse und Fundamentaldaten, einen lokalen Speicher für jede bisher abgerufene Zeile, einen Job zur zeitgesteuerten Aktualisierung des Speichers und eine LLM-Schicht, die aus den verbleibenden Zeilen Sätze erstellt. Diese Anleitung setzt diese Struktur mit Python, DuckDB, einem systemd-Timer und der Claude API (application programming interface) um. 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 Komponenten und ihre Aufgaben

Der Feed ist die einzige Komponente, die mit der Außenwelt kommuniziert. Er weiß, wie ein Ticker und ein Datumsbereich abgefragt werden und wie die Zeilen zurückgegeben werden. Alle nachgelagerten Komponenten lesen aus Ihrer Datenbank statt aus dem Feed. Ein Ausfall des Feeds kostet Sie daher einen Tag mit neuen Daten und führt nicht zu einer fehlerhaften Anzeige.

Der Store ist der zentrale Zweck des gesamten Vorhabens. Einen Tages-Schlusskurs, den Sie nicht gespeichert haben, können Sie später meist erneut abrufen. Ein Intraday-Kurs, eine Schätzung vor ihrer Überarbeitung oder eine Fundamentalkennzahl vor einer nachträglichen Korrektur lässt sich dagegen nicht wiederherstellen. Mit dem Store dokumentieren Sie, was die Daten an dem jeweiligen Tag tatsächlich ausgesagt haben.

Der Scheduler legt fest, wann die Aktualisierung ausgeführt wird. Auf einem Server ist das ein systemd-Timer. Deshalb ist der VPS in diesem Zusammenhang wichtiger als der Code.

Die LLM-Schicht liest einen kurzen Textblock, den Ihr SQL erzeugt hat, und erstellt daraus eine Zusammenfassung. Sie verbindet sich nie mit der Datenbank und erstellt auch keine Abfrage. Wenn das Modell das SQL schreibt, wird aus einem falschen Token eine falsche Zahl in einem flüssig formulierten Satz, ohne dass ein Vergleichswert vorhanden ist. Wenn SQL die Zahlen erzeugt, kann das Modell nur den Text falsch formulieren. Sie können den Text anschließend mit den übermittelten Zeilen vergleichen.

Warum der Betrieb auf einem VPS besser ist als auf einem Laptop

Der Scheduler ist der entscheidende Grund. Der US-Markt schließt um 16:00 Uhr New Yorker Zeit, also um 22:00 Uhr in Berlin und um 04:00 Uhr am nächsten Morgen in Jakarta. Ein Laptop befindet sich zu beiden Zeitpunkten im Ruhezustand. Ein verpasster Lauf kostet mehr als eine verspätete Notiz: Tagesbalken können normalerweise später erneut abgerufen werden. Daten, die anschließend überarbeitet werden, können jedoch nicht wiederhergestellt werden. Die Lücke in Ihrer Aufzeichnung bleibt daher dauerhaft bestehen.

Der zweite Grund ist weniger wichtig, aber dennoch relevant. Der Server enthält einen API-Schlüssel in einer einzigen Datei. Diese Datei gehört einem einzigen Systembenutzer ohne Login-Shell und wird von einem einzigen Job verwendet. Auf dem Laptop, den Sie auch zum Surfen im Internet verwenden, lässt sich das deutlich schwerer umsetzen. Halten Sie den Schlüssel aus dem Code und aus der Eingabe des Modells heraus. Dieses Thema wird unter API-Schlüssel aus einem AI-Agenten heraushalten behandelt.

Dimensionierung: Festplatte, RAM und Tokens

Der Speicherbedarf auf der 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 Double-Werte. DuckDB speichert Spalten komprimiert. Daher handelt es sich um einige Dutzend Megabyte und nicht um Gigabyte. 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 Ihren eigenen Wert.

Der 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 auf einem Rechner mit 2 GB RAM problematisch, wenn dort noch andere Dienste laufen. Eine Aggregation über die gesamte Preistabelle führt dann dazu, dass der Kernel den Prozess beendet. systemd meldet dabei Main process exited, code=killed, status=9/KILL, während journalctl -k den Speichermangel und die Beendigung wegen Out-of-Memory anzeigt. Setzen Sie memory_limit und threads explizit. Dann wird die Abfrage langsamer, statt abgebrochen zu werden.

Tokens sollten gemessen und nicht geschätzt werden. Jede Antwort der Messages API enthält ein usage-Objekt 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. Multiplizieren Sie es mit dem Preis, den Ihr Modell am Tag der Prüfung angibt. Zwei Punkte sind stabil genug für die Planung. 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 die gesendeten Daten zu kürzen. Prompt-Caching hilft bei einem einmal täglich ausgeführten Job dagegen nicht. Die Cache-Lebensdauer wird in Minuten gemessen. 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 viele Aufrufe mit demselben großen Textblock ausführt.

Komponenten 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, bricht Ubuntu 24.04 mit error: externally-managed-environment ab. Die Distribution verwaltet /usr/lib/python3 selbst und verhindert, dass pip dort schreibt. Die venv ist keine Frage der Höflichkeit. Sie ist das einzige Verzeichnis, in das pip schreiben darf.

Der API-Schlüssel gehört in eine Datei, die der Dienstbenutzer lesen kann, auf die aber kein anderer Benutzer Zugriff hat:

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

Tragen Sie eine Zeile ohne Anführungszeichen und ohne export ein. systemd verarbeitet diese Datei selbst, 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 sicher wiederholt werden kann. INSERT OR REPLACE überschreibt eine bereits vorhandene Zeile für diesen Ticker und diesen Tag. Dadurch verdoppelt eine zweimal ausgeführte Rückbefüllung die Tabelle nicht. Ohne diesen Schlüssel würde eine erneute Ausführung nach einem Absturz jeden Balken unbemerkt duplizieren. Jeder danach berechnete Durchschnitt wäre falsch. Es gäbe keine Fehlermeldung, die darauf hinweist.

DuckDB erlaubt genau einem Prozess, die Datei zum Schreiben geöffnet zu halten. Ein zweiter Schreibprozess schlägt sofort mit Could not set lock on file fehl. Danach wird die PID des Prozesses angezeigt, 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. Lesezugriffe verwenden read_only=True. Deshalb setzt connect das Flag. Wenn mehrere Prozesse tatsächlich gleichzeitig schreiben müssen, ist dafür eine andere Engine zuständig: SQLite im WAL-Modus lässt Lesezugriffe zu, während ein Schreibprozess einen Commit ausführt. Ein Busy-Timeout sorgt dafür, dass andere Schreibprozesse warten, statt fehlzuschlagen. DuckDB und SQLite für eine Server-Workload vergleicht beide Systeme. SQLite produktiv auf einem VPS betreiben beschreibt die Einstellungen, mit denen WAL ordnungsgemäß 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. Führen Sie den Job eine Minute später erneut aus. 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, beendet sich mit Exit-Code 0 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 bestimmten Grund. Der vom Feed gelieferte Index kann die Zeitzone der Börse enthalten. Das Entfernen des Offsets ist nicht dasselbe wie eine Umwandlung in UTC. Eine Tokioter Sitzung mit einem Zeitstempel um Mitternacht in Ortszeit wird bei der Umwandlung in UTC auf den vorherigen Kalendertag verschoben. Dadurch verschiebt eine UTC-Umwandlung jeden japanischen Tagesbalken unbemerkt um einen Tag zurück und verletzt den Primärschlüssel. tz_localize(None) verwendet weiterhin das Sitzungsdatum der jeweiligen Börse. Dieses Datum entspricht der Bedeutung eines Tagesbalkens.

Der Timer zum Börsenschluss

Der US-Markt schließt um 16:00 Uhr New Yorker Zeit. Die letzten Kurse brauchen einige Minuten, bis sie feststehen. Deshalb läuft der Job um 16:20 Uhr. 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 pro Jahr 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 einen ExecStart akzeptiert. Er führt sie in der angegebenen Reihenfolge aus und stoppt, sobald einer mit einem Wert ungleich null beendet wird. Genau dieses Verhalten wird benötigt: Auf eine fehlgeschlagene Aktualisierung darf keine Bildschirmanzeige veralteter Daten folgen. Persistent=true ist auf einem VPS wichtig, der wegen Kernel-Updates neu gestartet wird. Ohne diese Einstellung geht ein Lauf bei einem Neustart um 16:15 Uhr einfach verloren. Mit dieser Einstellung läuft der Job, 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 anzeigen, die den nächsten Lauf an einem Wochentag enthält, umgerechnet in die lokale Zeit des Servers. Eine leere Liste bedeutet, dass der Timer nicht aktiviert ist oder dass in der Unit der Abschnitt [Install] fehlt. Daher hatte enable nichts, das in timers.target verknüpft werden konnte.

Die Abfrage: Zuerst SQL, zuletzt das Modell

SQL ist exakt und verursacht pro Ausführung keine Kosten. Das Modell ist weder exakt noch kostenlos. Deshalb schränkt die Abfrage die Datenmenge ein. 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 deshalb den Mittelwert von drei Tagen und wird weiterhin als ma50 bezeichnet. Wenn Sie diesen Wert mit ma20 vergleichen, erzeugen Sie am Beginn der Historie jedes Tickers einen Crossover, der nie stattgefunden hat. Der Filter anhand der Zeilennummer entfernt die Zeilen, für die das Fenster noch nicht vollständig ist.

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()

Das Wortlimit 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 einfach zu vertrauen: Löschen Sie eine Spalte aus dem Block, führen Sie die Anfrage erneut aus und lesen Sie die Ausgabe. Wenn weiterhin eine Zahl für diese Spalte erscheint, hat das Modell die Lücke ergänzt, und Ihr Prompt ist nicht präzise genug. Dieser Test dauert zwei Minuten. Er ist die einzige zuverlässige Möglichkeit, das festzustellen.

Das Modell sieht den API-Schlüssel nicht, sieht den Datenbankpfad nicht und führt keine Abfrage aus. Es erhält Zeilen und gibt Prosa zurück. Diese Abgrenzung macht die Ausgabe überprüfbar, weil jede Zahl in der Notiz auch in dem gesendeten Block enthalten sein sollte und Sie beide Zeile für Zeile vergleichen können. Weitere Informationen zum Prompting finden Sie unter Claude für Finanzanalysen verwenden. Dort wird ausführlicher erläutert, welche Inhalte das Modell gut lesen kann.

Ein Durchlauf von Anfang bis Ende

Um 16:20 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 einige Zeilen. Diese Zeilen werden zu einem Textblock mit einigen hundert Tokens. Ein API-Aufruf wandelt sie in eine kurze Notiz um. Die Notiz wird in das Journal geschrieben. Außerdem wird eine Zeile in runs eingefügt, die die Tokenanzahl und die Anzahl der Treffer enthält.

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

Ein fehlerfreies Log enthält eine Zeile pro Ticker, danach die Notiz und anschließend research-refresh.service: Deactivated successfully. Nach einer Woche können Sie Ihre tatsächlichen Kosten aus der Datenbank auslesen:

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 Betrag statt einer Schätzung. Veröffentlichte Preise ändern sich. Die Berechnung bleibt unverändert.

Warum Backtests überfitten und wie Sie das beobachten

Schreiben Sie den Screen als Funktion der beiden Fensterlängen um, durchlaufen Sie ein Raster aus Wertepaaren und sortieren Sie diese nach Rendite. Das beste Paar wird hervorragend aussehen. Genau darin liegt das Problem, nicht im Ergebnis. Ein Raster mit 200 Paaren entspricht 200 Experimenten, und Sie haben das Paar mit dem meisten Glück behalten.

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

Survivorship Bias ist schlimmer als Overfitting, weil sich dieses Problem nicht durch Tuning beheben lässt. Ihre Ticker-Liste enthält die heutigen Indexmitglieder und damit nur die Unternehmen, die überlebt haben. Fragen Sie den Feed nach einem Ticker, der 2019 delisted wurde, erhalten Sie einen leeren Dataframe. Das bedeutet, dass dieses Unternehmen nie in Ihren Datenbestand und nie in Ihren Test gelangt. Jeder Backtest, den Sie ausführen, hat die Fehlschläge bereits ausgeschlossen.

Nachträglich korrigierte Fundamentaldaten zerstören die zeitliche Abfolge. Der Umsatzwert, den die API heute für ein Quartal aus dem Jahr 2019 zurückgibt, ist nicht immer der Wert, der 2019 veröffentlicht wurde. Ein Screen, der die heutigen Fundamentaldaten mit Kursen aus dem Jahr 2019 kombiniert, verwendet Informationen, die damals noch nicht existierten. Kurse sind in diesem Punkt normalerweise unproblematisch. Fundamentaldaten sind es normalerweise nicht.

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

Der Backtest berücksichtigt außerdem keine Provisionen und keinen Slippage. Er geht zudem davon aus, dass Ihre Order den Kurs nicht bewegt. Diese Aspekte gehören zur Ausführung. Sie liegen hier außerhalb des Umfangs und werden unter Trading-Bots auf einem VPS ausführen behandelt.

Fehlerzustände und die angezeigten Meldungen

error: externally-managed-environment, wenn pip ausgeführt wird. 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 Auftrag wegen Speichermangels beendet. Prüfen Sie dies mit journalctl -k | grep -i oom und verringern Sie anschließend memory_limit in store.py.

Ein grüner 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 Auftrag daher mit einem Fehlerstatus beenden, wenn für jeden Ticker ein leeres Ergebnis zurückkommt.

Der Timer wurde an einem Börsenfeiertag ausgelöst. systemd kennt den Börsenkalender nicht, daher enthält Mon-Fri auch Feiertage. Der Lauf wird ausgeführt, aber der Feed enthält keine neuen Daten. Der Auftrag 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 automatisch mit Backoff, und Anthropic(max_retries=5) erhöht die Anzahl der Versuche. Wenn der Lauf trotzdem jeden Tag fehlschlägt, fordert der Auftrag in einem einzelnen Burst zu viele Daten an.

Was dies nicht ist

Dies ist kein Research-Assistent. Ein Modell, das einen Bericht zusammenfasst, erstellt eine Interpretation dieses Berichts. Es kann eine im Text angegebene Zahl selbstsicher 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 müssen. Dies ist keine Finanzberatung und keine der Angaben ist ein Handelssignal.

Backtests eignen sich dazu, Ideen zu verwerfen, sind aber nur eingeschränkt geeignet, sie zu bestätigen. Eine Strategie, die mit Ihren eigenen Daten scheitert, ist tatsächlich unbrauchbar. Eine Strategie, die den Test besteht, hat lediglich Ihre Daten überstanden. Das ist eine deutlich schwächere Aussage, als es sich um 1am anfühlt.

Die Ausführung von Orders bleibt bewusst außerhalb des Umfangs. Orders und Broker-Zugangsdaten haben ein anderes Risikoprofil als eine reine Research-Umgebung. Wenn Sie beides vermischen, liegen Trading-Schlüssel auf demselben Rechner wie ein LLM-Prompt. Wenn Sie sehen möchten, wie dieses Muster neben anderen sinnvollen Anwendungen auf Ihrem eigenen Server einzuordnen ist, bietet die selbst gehosteten AI-Agenten, deren Betrieb sich lohnt einen umfassenderen Überblick.

FAQ

Brauche ich einen kostenpflichtigen Marktdaten-Feed?

Für einen Prototyp nicht. Ein kostenloser, inoffizieller Feed reicht aus, um die Struktur des Systems zu verstehen. 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. Ihr Job muss deshalb 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 Wechsel kostengünstig: Nur die Fetch-Funktion ändert sich. Zeitplan, Schema und Oberfläche bleiben unverändert.

Sollte ich Preise 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 Kursdaten. 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 den Commit ausführt. Ein Busy-Timeout lässt andere Writer warten, statt sie sofort fehlschlagen zu lassen.

Wie viel kosten die LLM-Aufrufe pro Monat?

Schreiben Sie usage.input_tokens und usage.output_tokens jeder Antwort in eine Tabelle. Multiplizieren Sie anschließend Ihre Wochenwerte mit dem Preis pro einer Million Token, den Ihr Modell am Tag der Prüfung angibt. Das ist die einzige Kennzahl, die auch im nächsten Quartal noch stimmt. Ein täglicher Lauf über eine kurze Auswahl erzeugt nur wenige Aufrufe. Die Länge der Notiz können Sie direkt steuern: Eine Begrenzung der Zusammenfassung auf 200 Wörter spart mehr als eine Verringerung der Zeilenanzahl, weil Output-Token bei jedem Claude-Modell teurer als Input-Token sind.

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

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

Kann der Agent entscheiden, was ich kaufen soll?

Nein. Genau an dieser Stelle 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 anhand unabhängiger Daten prüfen. 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 damit verbundene Verantwortung bleiben bei Ihnen.