SSD Nodes Learn Hosting plans →
Anleitungen Matt ConnorVon Matt Connor

Aktienanalyse-Agent auf eigenem VPS einrichten

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

Was ein selbst gehosteter Agent zur Aktienanalyse ist

Ein selbst gehosteter Agent zur Aktienanalyse 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 und prüft sie und bittet ein Large Language Model (LLM) um eine Zusammenfassung der Änderungen. Der Agent liest und filtert. Er führt keine Trades aus. Dieser Leitfaden enthält keine Finanzberatung.

Zwei Personen, die dieses System von Grund auf entwickeln, wählen möglicherweise unterschiedliche Bibliotheken und landen trotzdem bei denselben vier Bestandteilen: einem Feed, der Preise und Fundamentaldaten liefert, einem lokalen Speicher, der jede jemals abgerufene Zeile aufbewahrt, einem Job, der den Speicher zeitgesteuert aktualisiert, und einer LLM-Schicht, die aus den verbleibenden Zeilen Sätze erzeugt. Dieser Leitfaden erstellt diese Struktur mit Python, DuckDB, einem systemd-Timer und der Claude API (application programming interface). Es handelt sich um einen spezialisierten Agenten mit einer festen Schleife. Wenn Sie zunächst die allgemeine Variante kennenlernen möchten, führt Sie der gestufte Lernpfad für KI-Agenten der Reihe nach durch Schleife, Tools und Memory, bevor daraus ein fachliches Problem wird. Die Ausführung ist ein separater Job mit eigenen Fehlerbildern und gehört stattdessen auf einen für Trading-Bots eingerichteten VPS.

Die vier Teile und ihre Aufgaben

Der Feed ist der einzige Teil, der mit der Außenwelt kommuniziert. Er weiß, wie Ticker und 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 dadurch einen Tag mit neuen Daten und führt nicht zu einer nicht funktionierenden Oberfläche.

Der Store ist der zentrale Zweck des gesamten Aufbaus. Ein Tages-Schlusskurs, den Sie nicht gespeichert haben, kann später meist erneut abgerufen werden. Ein Intraday-Kurs, eine Schätzung vor ihrer Überarbeitung oder eine Fundamentalkennzahl vor ihrer nachträglichen Änderung kann jedoch nicht erneut abgerufen werden. Der Store ermöglicht es Ihnen, festzuhalten, was die Daten an dem Tag tatsächlich ausgesagt haben.

Der Scheduler legt fest, 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 Ihr SQL erzeugt hat, und erstellt daraus eine Zusammenfassung. Sie verbindet sich nie mit der Datenbank und erstellt auch die Abfrage nicht. Wenn das Modell das SQL schreibt, wird aus einem falschen Token eine falsche Zahl in einem flüssig formulierten Satz, ohne Vergleichsmöglichkeit. Wenn SQL die Zahlen erzeugt, kann das Modell nur den Text falsch formulieren. Sie können den Text mit den übermittelten Zeilen abgleichen.

Warum es auf einem VPS statt auf einem Laptop ausgeführt werden sollte

Der Scheduler ist der Grund. Ein US-Börsenschluss 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 oder ist ausgeschaltet. Ein verpasster Lauf kostet mehr als eine verspätete Notiz: Tagesdaten können in der Regel später erneut abgerufen werden, aber Daten, die nachträglich geändert werden, nicht. Die Lücke in Ihrer Aufzeichnung bleibt daher dauerhaft bestehen. Dasselbe gilt für jeden Agenten, dessen Zeitplan und gespeicherter Zustand einen Reboot überstehen müssen. Genau darauf ist KiroCrew als dauerhaft laufenden Agenten auf Ihrem eigenen VPS auszuführen ausgelegt.

Der zweite Grund ist weniger bedeutend, aber dennoch relevant. Der Server speichert einen API-Schlüssel in einer Datei. Die Datei gehört einem Systembenutzer ohne Login-Shell und wird von genau einem 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 herauszuhalten.

Größenplanung: Festplatte, RAM und Tokens

Die Festplattenkapazität lässt sich einfach planen. Ein Tagesbalken entspricht einer Zeile pro Ticker und Handelstag. Ein US-Handelsjahr umfasst etwa 252 Tage.

DiagrammRows in the prices table by watchlist size, at 252 trading days a year
Die Daten hinter diesem Diagramm
[
  {
    "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 eine Handvoll Gleitkommazahlen. DuckDB speichert Spalten komprimiert. Daher handelt es sich eher um einige Dutzend Megabyte als 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 den ermittelten Wert.

Beim RAM zeigt sich die Begrenzung eines kleinen VPS. DuckDB verwendet standardmäßig einen großen Anteil des Arbeitsspeichers und alle CPU-Kerne des Systems für eine einzelne Abfrage. Das ist auf einem Analytics-Server sinnvoll, aber auf einem System mit 2 GB RAM problematisch, wenn dort weitere Dienste laufen. Eine Aggregation über die gesamte Preistabelle führt dann dazu, dass der Kernel den Prozess beendet. systemd meldet Main process exited, code=killed, status=9/KILL, während journalctl -k den Beenden des Prozesses wegen Speichermangels anzeigt. Setzen Sie memory_limit und threads explizit. Dann wird die Abfrage langsamer, statt dass sie abbricht.

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 am Tag der Prüfung ausweist. Zwei Punkte sind stabil genug für die Planung. Ausgabetokens sind bei jedem Claude-Modell teurer als Eingabetokens. Wenn Sie die Notiz auf 200 Wörter begrenzen, sinken die Kosten daher stärker, als wenn Sie die gesendeten Daten kürzen. Prompt-Caching hilft außerdem nicht bei einem Job, der einmal täglich ausgeführt wird. Der Cache bleibt standardmäßig 5 Minuten erhalten. Die längste von Anthropic angebotene Lebensdauer beträgt 1 Stunde. Das Schreiben in diesen Cache kostet das 2-Fache des Basistarifs für Eingaben, während ein 5-Minuten-Cache das 1,25-Fache kostet. In beiden Fällen ist der gecachte Block beim Lauf am nächsten Tag abgelaufen. Dann zahlen Sie wieder den vollständigen Preis für die Eingaben. Caching lohnt sich, wenn ein Lauf innerhalb eines kurzen Zeitraums viele Aufrufe mit demselben großen Textblock ausführt.

Installieren Sie die erforderlichen Komponenten

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 mit dem System-Python ausgeführt haben, wird Ubuntu 24.04 mit error: externally-managed-environment beendet, weil die Distribution /usr/lib/python3 verwaltet und nicht zulässt, dass pip dort schreibt. 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

Tragen Sie eine einzige Zeile ein, ohne Anführungszeichen und ohne export, weil systemd diese Datei selbst analysiert, 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. Wenn Sie das Backfill zweimal ausführen, werden die Daten daher nicht doppelt in die Tabelle geschrieben. Ohne diesen Schlüssel dupliziert jeder erneute Lauf nach einem Absturz unbemerkt jeden Bar. Jeder danach berechnete Durchschnitt ist dann falsch. Es gibt keine Fehlermeldung, die 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. 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. Eine schreibgeschützte Verbindung umgeht diese Einschränkung nicht. Die DuckDB-Dokumentation zur Nebenläufigkeit beschreibt zwei Modi: Ein Prozess liest und schreibt, oder mehrere Prozesse lesen ausschließlich. Eine Mischung ist nicht möglich. connect akzeptiert ein read_only-Flag, damit mehrere Leser die Datei gemeinsam verwenden können. Das funktioniert nur, solange kein Schreibprozess die Datei geöffnet hat. Wenn mehrere Prozesse tatsächlich gleichzeitig schreiben müssen, benötigen Sie dafür eine andere Engine: SQLite im WAL-Modus lässt Lesezugriffe zu, 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 im Produktivbetrieb ausführen beschreibt 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 enthält jede Zeile 1 oder 2 Datensätze, weil der Job beim 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 gibt es eine Warnung aus, dass das Symbol möglicherweise nicht mehr gelistet ist und keine Kursdaten gefunden wurden. Der genaue Wortlaut ändert sich zwischen den Bibliotheksversionen. Anschließend wird ein leeres DataFrame zurückgegeben. Ein Job ohne diese Prüfung schreibt nichts, 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 Verarbeitung der Zeitstempel hat einen bestimmten 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 in UTC. Eine Tokioter Sitzung mit dem lokalen Zeitstempel Mitternacht wird in UTC auf den vorherigen Kalendertag umgerechnet. Dadurch verschiebt eine UTC-Konvertierung jeden japanischen Tagesbalken unbemerkt um einen Tag zurück und verletzt den Primärschlüssel. tz_localize(None) behält das Sitzungsdatum der jeweiligen Börse bei. Genau das bezeichnet ein Tagesbalken.

Der Timer zum Börsenschluss

Der US-Markt schließt um 16:00 Uhr New Yorker Zeit. Die letzten Kurse benötigen noch einige Minuten zur Verarbeitung. Daher läuft der Job um 16:20 Uhr. Geben Sie diese Zeit in New Yorker Zeit und nicht in UTC an. New York liegt im Winter 5 Stunden und im Sommer 4 Stunden hinter UTC. Ein Timer mit einer festen UTC-Stunde verschiebt sich daher zweimal im Jahr um eine Stunde und wird vor dem Börsenschluss ausgelöst. systemd 235 und höher akzeptiert 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 beendet wird. Genau dieses Verhalten wird benötigt: Auf eine fehlgeschlagene Aktualisierung darf keine Anzeige veralteter Daten folgen. Persistent=true ist auf einem VPS wichtig, der für Kernel-Updates neu gestartet wird. Wenn der Neustart ohne diese Option um 16:15 Uhr erfolgt, geht der Lauf einfach verloren. Mit dieser Option läuft der Job, sobald der Rechner wieder verfügbar ist. Ein Agent, der dauerhaft laufen und nicht nur einmal gestartet und beendet werden soll, benötigt den umgekehrten Unit-Aufbau: einen langlebigen Prozess mit Restart=-Regeln und überhaupt keinen Timer. Das wird unter dsh ohne Terminal auf einem VPS mit systemd ausführen beschrieben.

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 am 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. In diesem Fall hatte enable nichts, womit es timers.target verknüpfen konnte.

Der Bildschirm: Zuerst SQL, zuletzt das Modell

SQL ist exakt und verursacht pro Ausführung keine Kosten. Das Modell ist es nicht. Daher 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 Durchschnitt über alle vorhandenen Zeilen. Daher liefert die dritte Zeile eines Tickers den Durchschnitt von drei Tagen und bezeichnet ihn weiterhin als ma50. Wenn Sie diesen Wert mit ma20 vergleichen, erzeugen Sie am Anfang der Historie jedes Tickers einen Crossover, der nie stattgefunden hat. Der Filter nach der 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,
    thinking={'type': 'disabled'},
    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 Zeile thinking ist bei diesem Modell wichtig. Claude Sonnet 5 denkt standardmäßig. Seine Thinking-Tokens werden als Output abgerechnet und auf max_tokens angerechnet. Sie werden als Block vor dem Text zurückgegeben. Ohne diese Zeile kann resp.content[0] der Thinking-Block sein, und der Lookup für .text schlägt fehl. Claude Sonnet 5 ist inzwischen ein Legacy-Modell. Sein Nachfolger claude-sonnet-5-5 lehnt disabled ab. Die entsprechende Einstellung ist thinking={'type': 'between_tools'}. Sie deaktiviert das Denken vor der Antwort.

Das Wortlimit im System-Prompt begrenzt den teuren Teil der Abrechnung. Die Anweisung, nur die bereitgestellten 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 Abfrage erneut aus und lesen Sie die Ausgabe. Wenn weiterhin ein Wert für diese Spalte erscheint, hat das Modell die Lücke ergänzt. Dann ist Ihr Prompt nicht präzise genug. Dieser Test dauert zwei Minuten. Er ist die einzige verlässliche Methode, das herauszufinden.

Das Modell sieht den API-Key nicht. Es sieht den Datenbankpfad nicht und führt keine Abfrage aus. Es empfängt Zeilen und gibt Prosa zurück. Diese Grenze 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. Sobald Sie diese Eingabe um aus dem Web abgerufene Seiten erweitern, verändert sich diese Grenze. Eine Überschrift oder ein Forenbeitrag kann Anweisungen enthalten, die sich an das Modell statt an Sie richten. Genau diese Angriffsfläche eröffnet einem Agenten ein SearXNG-Such-Backend. Weitere Informationen zur Erstellung von Prompts finden Sie unter Claude für die Finanzanalyse verwenden. Dort wird ausführlicher beschrieben, 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 dem zuletzt gespeicherten Tag ab, schreibt für jeden Ticker ein oder zwei neue Bars 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 zurück. Diese Zeilen werden zu einem Textblock mit einigen hundert Tokens. Ein API-Aufruf erstellt daraus eine kurze Notiz. Die Notiz wird in das Journal geschrieben. Eine Zeile mit den Token-Anzahlen und der Anzahl der Treffer wird in runs gespeichert.

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

Ein gesunder Log enthält eine Zeile pro Ticker, danach die Notiz und anschließend research-refresh.service: Deactivated successfully. Wenn Sie diesen Log in einer Claude-Code-Sitzung auf dem Server lesen, während eine zweite Sitzung die Bildschirmabfrage bearbeitet, können beide Text aneinander übergeben, anstatt dass Sie Zeilen zwischen Terminals kopieren. 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 die Werte abrufen. So erhalten Sie den tatsächlichen Betrag statt einer Schätzung. Veröffentliche Preise ändern sich. Die Berechnung bleibt unverändert.

Warum Backtests überfitten und wie Sie das beobachten

Formulieren Sie den Screen als Funktion der beiden Fensterlängen, durchlaufen Sie ein Raster aus Wertepaaren 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 entspricht 200 Experimenten, und Sie haben das Paar mit dem meisten Glück ausgewählt.

Sie können das innerhalb von zehn Minuten beobachten. Teilen Sie den Datenbestand 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. Liegen die beiden Gewinner weit auseinander, passen sich die Parameter an Rauschen an. Ein Paar, das nur in der Hälfte gewinnt, für die Sie es optimiert haben, sagt nichts über den nächsten Tag aus.

Survivorship ist schlimmer als Overfitting, weil sich dieser Fehler nicht durch Optimierung beheben lässt. Ihre Tickerliste enthält die heutigen Indexmitglieder und damit nur die Unternehmen, die überlebt haben. Fragen Sie den Feed nach einem Ticker, der 2019 dekotiert wurde, liefert er einen leeren Dataframe zurück. Dieses Unternehmen gelangt dadurch weder in Ihren Datenbestand noch in Ihren Test. Jeder Backtest, den Sie ausführen, hat die fehlgeschlagenen Unternehmen bereits ausgeschlossen.

Nachträglich korrigierte Fundamentaldaten zerstören die zeitliche Zuordnung. Die Umsatzkennzahl, die die API heute für ein Quartal aus 2019 zurückgibt, entspricht nicht immer der Kennzahl, die 2019 veröffentlicht wurde. Ein Screen, der heutige Fundamentaldaten mit Kursen aus 2019 kombiniert, verwendet Informationen, die damals noch nicht verfügbar waren. Kurse sind in diesem Zusammenhang normalerweise unproblematisch. Fundamentaldaten sind es meistens nicht.

Adjustierte Kurse verändern sich nachträglich. Bei auto_adjust=True werden Schlusskurse für Dividenden und Splits rückwirkend angepasst. Deshalb 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 ignoriert außerdem Provisionen und Slippage und geht davon aus, 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 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 hat die DuckDB-Datei zum Schreiben geöffnet. Meist handelt es sich um eine vergessene interaktive Shell. Schließen Sie sie. Eine zweite Verbindung mit read_only=True hilft nicht, solange dieser Schreibprozess noch läuft. DuckDB erlaubt entweder mehreren Prozessen das Lesen oder einem Prozess das Schreiben, aber nicht beides gleichzeitig.

Main process exited, code=killed, status=9/KILL in systemctl status. Der Kernel hat den Job 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 DataFrames zurückgegeben. Dieser Fehler bleibt am längsten unbemerkt. Lassen Sie den Job deshalb mit einem Fehlerstatus enden, wenn für alle Ticker leere Ergebnisse zurückkommen.

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 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 den Aufruf automatisch mit Backoff, und Anthropic(max_retries=5) erhöht die Anzahl der Versuche. Wenn der Aufruf trotzdem jeden Tag fehlschlägt, fordert der Job in einem einzelnen Burst zu viele Daten an.

Was dies nicht ist

Dies ist ein Forschungsassistent. Ein Modell, das einen Bericht zusammenfasst, liefert eine Interpretation dieses Berichts. Bei einer im Text genannten Zahl kann es dennoch sicher und selbstbewusst falsch liegen. 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 lesen sollten. Dies ist keine Finanzberatung, und keiner der Punkte 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 besteht, hat lediglich Ihre Daten überlebt. Das ist eine deutlich schwächere Aussage, als es sich um 1 Uhr morgens anfühlt.

Die Ausführung von Orders bleibt bewusst außerhalb des Umfangs. Orders und Broker-Zugangsdaten haben ein anderes Risikoprofil als eine Read-only-Research-Box. Wenn Sie beides kombinieren, liegen die 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, finden Sie unter den selbst gehosteten KI-Agenten, deren Betrieb sich lohnt einen umfassenderen Überblick.

FAQ

Benötige ich einen kostenpflichtigen Marktdaten-Feed?

Nicht für einen Prototyp. Ein kostenloser, nicht offizieller Feed reicht aus, um die Struktur des Systems kennenzulernen. Er wird ausfallen, weil er von einer Website abhängt, die Ihnen nichts schuldet. 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 Austausch kostengünstig: Nur die Abruffunktion ändert sich. Zeitplan, Schema und Anzeige 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 ist ein gleitender Durchschnitt über zehn Jahre mit Kursbalken. SQLite ist zeilenorientiert und besser für viele kleine Lese- und Schreibvorgänge durch mehrere Prozesse gleichzeitig geeignet. 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 arbeiten, während ein Writer committet. Ein Busy-Timeout lässt andere Writer warten, statt sofort mit einem Fehler abzubrechen.

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 Wochensummen mit dem Preis pro 1 Million Token, den Ihr Modell an dem Tag angibt, an dem Sie die Prüfung durchführen. Das ist die einzige Zahl, die auch im nächsten Quartal noch stimmt. Ein täglicher Lauf über eine kurze Auswahl umfasst nur wenige Aufrufe. Die Länge der Notiz können Sie steuern: Eine Begrenzung der Zusammenfassung auf 200 Wörter spart mehr als das Senden weniger Zeilen, weil Ausgabetoken bei jedem Claude-Modell teurer als Eingabetoken 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 hat für jeden Ticker einen leeren DataFrame zurückgegeben. Clientbibliotheken melden das häufig als ausgegebene Warnung und nicht als Exception. Daher beendet sich der Prozess weiterhin mit 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. Lassen Sie den Job mit einem Fehlerstatus ungleich 0 beenden, wenn für jeden Ticker ein leerer DataFrame zurückkommt.

Kann der Agent entscheiden, was ich kaufen soll?

Nein. Genau hier scheitern solche Projekte häufig, wenn sie den Agenten dazu bringen sollen. Das Modell hat keinen Marktzugriff, kennt weder Ihre Position noch Ihre steuerliche Situation und kann seine eigenen Zahlen nicht mit einer unabhängigen Quelle prüfen. Es eignet sich dafür, 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.