Własny agent do analizy giełdowej na VPS - poradnik
Dowiedz się, jak zbudować własnego agenta giełdowego na VPS. Przewodnik obejmuje konfigurację DuckDB, automatyzację zadań przez systemd oraz integrację z modelem LLM.
Czym jest samodzielnie hostowany agent do analizy akcji
Samodzielnie hostowany agent do analizy akcji to niewielki program uruchomiony na własnym serwerze, który w określonych odstępach czasu pobiera dane rynkowe, przechowuje je w lokalnej bazie danych, przeprowadza ich selekcję i zleca dużemu modelowi językowemu (LLM) przygotowanie podsumowania zmian. Program ten odczytuje i filtruje dane. Nie dokonuje transakcji, a żadna część niniejszego przewodnika nie stanowi porady inwestycyjnej.
Dwie osoby budujące takie rozwiązanie od podstaw mogą wybrać różne biblioteki, ale ostatecznie uzyskają te same cztery elementy: źródło danych dostarczające ceny i wskaźniki fundamentalne, lokalny magazyn przechowujący wszystkie pobrane rekordy, zadanie odświeżające magazyn zgodnie z harmonogramem oraz warstwę LLM, która przekształca wyselekcjonowane dane w tekst. Niniejszy przewodnik realizuje ten schemat przy użyciu Python, DuckDB, systemd timer oraz Claude API (interfejs programowania aplikacji). Jest to wyspecjalizowany agent o stałej pętli działania. Jeśli wolisz najpierw zapoznać się z wersją ogólną, etapowa ścieżka nauki o agentach AI omawia pętle, narzędzia i pamięć, zanim zagadnienia te zostaną przeniesione do konkretnej domeny. Realizacja transakcji to osobne zadanie o odmiennych trybach awarii, dlatego powinno być obsługiwane przez serwer VPS skonfigurowany pod boty handlowe.
Cztery elementy i ich funkcje
Feed to jedyny komponent komunikujący się ze światem zewnętrznym. Potrafi wysłać zapytanie o ticker oraz zakres dat, a następnie zwrócić wiersze danych. Wszystkie kolejne etapy przetwarzania odczytują dane z bazy, a nie bezpośrednio z feedu. Dzięki temu awaria źródła danych skutkuje jedynie brakiem danych z jednego dnia, zamiast awarią całego interfejsu.
Store stanowi główny cel całego procesu. Dzienną cenę zamknięcia, której nie udało się zapisać, zazwyczaj można pobrać ponownie w późniejszym terminie. Notowań śróddziennych, szacunków przed ich korektą czy danych fundamentalnych przed ich aktualizacją nie da się odzyskać. Store pozwala zbudować archiwum informacji w postaci, w jakiej były one dostępne w dniu publikacji.
Scheduler decyduje o momencie odświeżenia danych. Na serwerze rolę tę pełni systemd timer, dlatego w tym kontekście konfiguracja VPS jest ważniejsza niż sam kod.
Warstwa LLM odczytuje krótki blok tekstu wygenerowany przez SQL i tworzy jego podsumowanie. Model nigdy nie łączy się z bazą danych i nie tworzy zapytań. Jeśli model generuje SQL, jeden błędny token może skutkować błędną liczbą w poprawnie brzmiącym zdaniu, której nie ma do czego porównać. Jeśli to SQL dostarcza liczby, model może jedynie popełnić błąd w opisie tekstowym, który można zweryfikować, porównując go z przesłanymi wierszami danych.
Dlaczego warto uruchomić to na VPS zamiast na laptopie
Powodem jest harmonogram zadań. Zamknięcie sesji na rynku amerykańskim o 16:00 czasu nowojorskiego przypada na 22:00 w Berlinie i 04:00 rano następnego dnia w Dżakarcie. Laptop w obu tych przypadkach jest w trybie uśpienia. Pominięte uruchomienie kosztuje więcej niż tylko spóźniona notatka: dzienne słupki cenowe zazwyczaj można pobrać później, ale danych, które podlegają korekcie, nie da się odzyskać, więc luka w rejestrze jest trwała. To samo rozumowanie dotyczy każdego agenta, którego harmonogram i zapisany stan muszą przetrwać restart systemu, co stanowi podstawę uruchamiania KiroCrew jako zawsze aktywnego agenta na własnym VPS.
Drugi powód jest mniej istotny, ale wciąż realny. Serwer przechowuje jeden klucz API, w jednym pliku, należącym do jednego użytkownika systemowego bez powłoki logowania, używanego przez jedno zadanie. Znacznie trudniej jest to zorganizować na laptopie, którego używa się również do przeglądania sieci. Należy trzymać klucz z dala od kodu i danych wejściowych modelu, co jest przedmiotem przechowywania kluczy API poza zasięgiem agenta AI.
Dobór zasobów: dysk, RAM i tokeny
Dysk jest najmniej problematyczny. Jeden dzienny słupek to jeden wiersz na ticker w każdym dniu sesyjnym, a rok giełdowy w USA liczy około 252 dni.
Dane, na których opiera się ten wykres
[
{
"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"
}
]Dwadzieścia tickerów to 5,040 wierszy po roku. Linia 500 tickers osiąga 1,260,000 wierszy po dziesięciu latach. Każdy wiersz zawiera datę i kilka wartości typu double, a DuckDB przechowuje kolumny w formie skompresowanej, więc zajmują one dziesiątki megabajtów, a nie gigabajty. Nie należy polegać na tym szacunku, w tym na moim. Po pierwszym uzupełnieniu danych należy wykonać du -h /opt/research/data/market.duckdb i użyć własnych wyników.
RAM stanowi wyzwanie w przypadku małych serwerów VPS. DuckDB domyślnie zajmuje znaczną część pamięci maszyny i wszystkie rdzenie dla pojedynczego zapytania, co jest poprawne na serwerze analitycznym, ale nie na maszynie z 2 GB RAM, która obsługuje również inne procesy. Jedna agregacja całej tabeli cen kończy się zabiciem procesu przez jądro systemu, a systemd zgłasza Main process exited, code=killed, status=9/KILL, podczas gdy journalctl -k wskazuje na przerwanie z powodu braku pamięci (OOM kill). Należy jawnie ustawić memory_limit oraz threads, dzięki czemu zapytanie będzie wolniejsze, zamiast powodować awarię.
Zużycie tokenów należy mierzyć, a nie szacować. Każda odpowiedź Messages API zawiera obiekt usage z polami input_tokens oraz output_tokens. Należy zapisywać obie wartości do tabeli przy każdym wywołaniu, a po tygodniu znany będzie rzeczywisty wolumen, który należy pomnożyć przez cennik modelu obowiązujący w dniu sprawdzenia. Dwie kwestie są wystarczająco stabilne, aby planować w oparciu o nie budżet. Tokeny wyjściowe są wyceniane wyżej niż wejściowe w każdym modelu Claude, więc ograniczenie notatki do 200 słów wpłynie na rachunek bardziej niż przycinanie przesyłanych danych. Prompt caching nie sprawdza się w zadaniach uruchamianych raz dziennie. Pamięć podręczna domyślnie utrzymuje dane przez 5 minut, a najdłuższy czas oferowany przez Anthropic to 1 godzina, co kosztuje 2x ceny bazowej wejścia zamiast 1.25x dla cache 5-minutowego. Tak czy inaczej, do czasu uruchomienia zadania następnego dnia, zbuforowany blok wygaśnie i konieczne będzie ponowne opłacenie pełnej ceny za wejście. Caching opłaca się, gdy jedno uruchomienie wykonuje wiele wywołań w obrębie tego samego dużego bloku tekstu.
Instalacja komponentów
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/dataPrzed napisaniem jakiegokolwiek kodu należy sprawdzić instalację:
sudo -u research /opt/research/venv/bin/python -c 'import duckdb, yfinance, anthropic; print("ok")'Polecenie to wypisuje ok. Jeśli pominięto środowisko wirtualne i uruchomiono pip install w systemowym Python, Ubuntu 24.04 zablokuje operację komunikatem error: externally-managed-environment. Wynika to z faktu, że dystrybucja zarządza /usr/lib/python3 i nie zezwala narzędziu pip na zapis w tej lokalizacji. Środowisko venv nie jest kwestią dobrych praktyk, lecz jedynym katalogiem, w którym pip posiada uprawnienia do zapisu.
Klucz API należy umieścić w pliku, który może odczytać wyłącznie użytkownik usługi:
sudo install -d -m 755 /etc/research
sudo install -m 640 -o root -g research /dev/null /etc/research/env
sudoedit /etc/research/envW pliku należy umieścić jedną linię tekstu bez cudzysłowów i bez export, ponieważ systemd parsuje ten plik samodzielnie, zamiast przekazywać go do powłoki:
ANTHROPIC_API_KEY=sk-ant-your-key-hereMagazyn danych: dwie tabele
# /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 conKlucz główny w (ticker, day) sprawia, że odświeżanie można bezpiecznie powtarzać. INSERT OR REPLACE nadpisuje wiersz, który już istnieje dla danego tickera i dnia, więc dwukrotne uruchomienie uzupełniania danych nie powoduje podwojenia zawartości tabeli. Bez klucza, ponowne uruchomienie po awarii cicho duplikuje każdy słupek, a każda obliczona później średnia będzie błędna, bez żadnego komunikatu o błędzie, który mógłby o tym poinformować.
DuckDB pozwala dokładnie jednemu procesowi na otwarcie pliku w trybie zapisu. Drugi proces zapisujący kończy się natychmiast błędem Could not set lock on file, po którym następuje PID procesu blokującego plik, którym w praktyce jest interaktywna powłoka duckdb pozostawiona w innym terminalu. Połączenie tylko do odczytu nie rozwiązuje tego problemu. Dokumentacja współbieżności DuckDB opisuje dwa tryby: jeden proces, który czyta i zapisuje, lub kilka procesów, które tylko czytają; nigdy nie jest to mieszanka obu tych trybów. connect przyjmuje flagę read_only, dzięki czemu kilku czytelników może współdzielić plik, co działa tylko wtedy, gdy żaden proces zapisujący nie ma go otwartego. Jeśli kilka procesów rzeczywiście musi zapisywać w tym samym momencie, jest to zadanie dla innego silnika: SQLite w trybie WAL pozwala czytelnikom pracować, podczas gdy jeden proces zapisujący zatwierdza transakcję, a limit czasu zajętości (busy timeout) sprawia, że inni zapisujący czekają zamiast kończyć pracę błędem. DuckDB kontra SQLite w obciążeniu serwerowym porównuje oba rozwiązania, a uruchamianie SQLite w środowisku produkcyjnym na VPS omawia ustawienia, dzięki którym tryb WAL działa poprawnie.
Zadanie odświeżania
# /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()Uruchom je jednorazowo ręcznie:
sudo -u research /opt/research/venv/bin/python /opt/research/refresh.pyPierwsze uruchomienie uzupełnia dane z lat ubiegłych i wyświetla po jednej linii dla każdego symbolu giełdowego z liczbą wierszy rzędu tysięcy. Uruchom zadanie ponownie minutę później; każda linia powinna wskazywać 1 lub 2 wiersze, ponieważ zadanie rozpoczyna się od ostatniego dnia zapisanego w bazie. To drugie uruchomienie jest właściwym testem: jeśli liczby wciąż są rzędu tysięcy, max(day) nie zwraca żadnych danych, a operacja insert nadpisuje całą historię każdej nocy.
Sprawdzenie df.empty jest najważniejszą linią w pliku. Ticker.history() nie zgłasza błędu w przypadku błędnego lub wycofanego z obrotu symbolu. Wyświetla jedynie ostrzeżenie o możliwym wycofaniu symbolu z obrotu i braku danych cenowych (dokładne brzmienie komunikatu zmienia się wraz z wersjami biblioteki) oraz zwraca pusty obiekt DataFrame. Zadanie pozbawione tego sprawdzenia nie zapisuje niczego, kończy się kodem 0, a systemd wskazuje poprawne wykonanie, podczas gdy tabela przestaje rosnąć. Problem wykrywa się dopiero po tygodniach, obserwując ekran, na którym codziennie wyświetlają się te same wiersze.
Obsługa znaczników czasu również ma swoje uzasadnienie. Indeks zwracany przez źródło danych może zawierać strefę czasową giełdy, a usunięcie przesunięcia nie jest równoważne z konwersją do UTC. Sesja tokijska o północy czasu lokalnego po konwersji do UTC przesuwa się na poprzedni dzień kalendarzowy, więc konwersja do UTC cicho przesuwa każdy japoński słupek o jeden dzień wstecz i narusza klucz główny. tz_localize(None) zachowuje datę sesji właściwą dla danej giełdy, co jest kluczowe dla definicji słupka dziennego.
Timer wyzwalany przy zamknięciu rynku
Rynek amerykański zamyka się o 16:00 czasu nowojorskiego, a ostatnie transakcje wymagają kilku minut na rozliczenie, dlatego zadanie uruchamiane jest o 16:20. Należy zapisać to w czasie nowojorskim, a nie w UTC. Nowy Jork stosuje czas UTC minus 5 zimą i UTC minus 4 latem, więc timer zapisany jako stała godzina UTC przesuwa się o godzinę dwa razy w roku i zaczyna wyzwalać się przed zamknięciem rynku. systemd w wersji 235 i nowszych akceptuje strefę czasową bezpośrednio w OnCalendar, a Ubuntu 24.04 dostarcza wersję 255. Wersję swojego systemu można sprawdzić za pomocą 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.targetType=oneshot to jedyny typ usługi, który akceptuje więcej niż jeden ExecStart; uruchamia je w kolejności, zatrzymując się, jeśli którykolwiek zakończy się kodem innym niż zero. Jest to dokładnie takie zachowanie, jakiego oczekujesz: nieudane odświeżenie nie może zostać zastąpione wyświetleniem nieaktualnych danych. Persistent=true ma znaczenie na serwerze VPS, który restartuje się w celu aktualizacji jądra. Restart o 16:15 bez tej opcji powoduje utratę uruchomienia; z nią zadanie wykona się natychmiast po powrocie maszyny do pracy. Agent, który musi działać w sposób ciągły, zamiast uruchamiać się i kończyć pracę, wymaga przeciwnej struktury jednostki: długotrwałego procesu z regułami Restart= i bez timera, co zostało opisane w uruchamianie dsh bez interfejsu na VPS z systemd.
sudo systemctl daemon-reload
sudo systemctl enable --now research-refresh.timer
systemctl list-timers research-refresh.timerlist-timers powinno wyświetlić kolumnę NEXT zawierającą czas następnego uruchomienia w dniu roboczym, przeliczony na lokalny czas serwera. Pusta lista oznacza, że timer nie jest włączony lub jednostce brakuje sekcji [Install], przez co enable nie miało czego powiązać z timers.target.
Ekran: najpierw SQL, na końcu model
SQL jest precyzyjny i nie generuje kosztów przy każdym uruchomieniu. Model nie posiada żadnej z tych cech. Zapytanie zawęża zatem zakres danych, a do modelu trafiają wyłącznie wyselekcjonowane rekordy. Zapisz to jako /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;Filtr n > 50 nie jest elementem dekoracyjnym. Funkcja ROWS BETWEEN 49 PRECEDING AND CURRENT ROW wylicza średnią dla wszystkich dostępnych wierszy, więc trzeci wiersz danych dla danego symbolu giełdowego zwraca średnią z trzech dni, wciąż nazywając ją ma50. W zestawieniu z ma20 prowadzi to do wygenerowania sygnału przecięcia na początku historii każdego symbolu, który w rzeczywistości nie wystąpił. Filtrowanie po numerze wiersza usuwa rekordy, dla których okno czasowe nie zostało w pełni wypełnione.
Co widzi model, a czego nigdy nie widzi
# /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()Linia thinking ma w tym modelu kluczowe znaczenie. Claude Sonnet 5 domyślnie korzysta z procesu myślowego, jego tokeny myślowe są rozliczane jako wyjściowe i wliczane do max_tokens, a zwracane są jako blok poprzedzający tekst właściwy. Bez tej linii resp.content[0] może zostać zinterpretowane jako blok myślowy, co powoduje niepowodzenie wyszukiwania .text. Claude Sonnet 5 jest obecnie modelem starszej generacji. Jego następca, claude-sonnet-5-5, odrzuca disabled, a właściwym ustawieniem jest thinking={'type': 'between_tools'}, które wyłącza proces myślowy przed wygenerowaniem odpowiedzi.
Limit słów w system prompt ogranicza kosztowniejszą część rachunku. Instrukcję korzystania wyłącznie z dostarczonych wierszy należy zweryfikować, zamiast przyjmować ją na wiarę: należy usunąć jedną kolumnę z bloku, uruchomić proces ponownie i sprawdzić wynik. Jeśli wartość dla tej kolumny nadal się pojawia, oznacza to, że model uzupełnił brakujące dane, a prompt nie jest wystarczająco precyzyjny. Ten test zajmuje dwie minuty i jest jedynym rzetelnym sposobem weryfikacji.
Model nigdy nie widzi API key, nie widzi ścieżki do bazy danych i nigdy nie wykonuje zapytań. Otrzymuje wiersze danych i zwraca tekst. Ta granica sprawia, że wynik jest weryfikowalny, ponieważ każda liczba w notatce powinna również znajdować się w przesłanym bloku danych, co pozwala na porównanie ich wiersz po wierszu. W momencie rozszerzenia danych wejściowych o strony pobrane z sieci, granica ta zmienia swój charakter, ponieważ nagłówek lub post na forum może zawierać instrukcje skierowane do modelu, a nie fakty przeznaczone dla użytkownika. Jest to ryzyko, które niesie ze sobą udostępnienie agentowi backendu wyszukiwania SearXNG. Więcej informacji na temat tworzenia promptów w tym zakresie znajduje się w artykule wykorzystanie Claude do analizy finansowej, który szczegółowo omawia, w czytaniu jakich treści model sprawdza się najlepiej.
Jednorazowe uruchomienie, od początku do końca
O godzinie 16:20 czasu nowojorskiego timer uruchamia usługę. refresh.py pobiera z kanału dane dla każdego tickera, zaczynając od ostatniego zapisanego dnia, zapisuje jeden lub dwa nowe słupki dla każdego z nich i wypisuje jedną linię na ticker. screen.py otwiera ten sam plik, wykonuje zapytanie o średnią kroczącą i otrzymuje kilka wierszy. Wiersze te tworzą blok tekstu o długości kilkuset tokenów. Jedno wywołanie API zamienia je w krótką notatkę, notatka trafia do dziennika, a jeden wiersz ląduje w runs wraz z liczbą tokenów i liczbą trafień.
journalctl -u research-refresh.service -n 50 --no-pagerPoprawny dziennik zawiera jedną linię na ticker, następnie notatkę, a potem research-refresh.service: Deactivated successfully. Jeśli czytasz ten dziennik z poziomu sesji Claude Code na serwerze, podczas gdy druga sesja pracuje nad zapytaniem ekranowym, obie sesje mogą przekazywać sobie tekst zamiast kopiowania linii między terminalami. Po tygodniu odczytaj własne koszty z bazy danych:
SELECT count(*) AS runs,
sum(input_tokens) AS in_tokens,
sum(output_tokens) AS out_tokens
FROM runs;Pomnóż te sumy przez ceny za milion tokenów, które podaje Twój model w dniu odczytu. Pozwoli to uzyskać rzeczywistą kwotę zamiast czyichś szacunków. Publikowane ceny ulegają zmianie. Arytmetyka pozostaje niezmienna.
Dlaczego backtesty ulegają overfittingowi i jak to monitorować
Przedstaw wynik jako funkcję dwóch długości okien, przetestuj siatkę par i uszereguj je według stopy zwrotu. Najlepsza para będzie wyglądać doskonale. To jest problem, a nie wynik. Siatka 200 par to 200 eksperymentów, z których zachowano ten, który miał najwięcej szczęścia.
Można zaobserwować ten proces w dziesięć minut. Podziel dane historyczne na pół według daty. Przeszukaj siatkę tylko na pierwszej połowie i zapisz zwycięzcę. Przeszukaj tę samą siatkę na drugiej połowie. Jeśli zwycięzcy znacznie się różnią, parametry dopasowują się do szumu, a para, która wygrywa tylko na połowie, na której została dostrojona, nie daje żadnych informacji o przyszłości.
Survivorship bias jest gorszy niż overfitting, ponieważ dostrajanie go nie naprawi. Lista tickerów to dzisiejsi członkowie indeksu, więc zawiera tylko firmy, które przetrwały. Jeśli zapytasz o ticker, który został wycofany z obrotu w 2019 roku, otrzymasz pustą ramkę danych, co oznacza, że firma ta nigdy nie trafi do Twojego magazynu danych i nigdy nie weźmie udziału w teście. Każdy przeprowadzony backtest już wykluczył porażki.
Restated fundamentals naruszają oś czasu. Wartość przychodów zwracana dzisiaj przez API dla kwartału z 2019 roku nie zawsze jest wartością, która została opublikowana w 2019 roku. Strategia łącząca dzisiejsze dane fundamentalne z cenami z 2019 roku wykorzystuje informacje, które wtedy nie istniały. Ceny są zazwyczaj bezpieczne, dane fundamentalne zazwyczaj nie.
Adjusted prices zmieniają się w czasie. W przypadku auto_adjust=True ceny zamknięcia są korygowane wstecznie o dywidendy i splity, więc to samo zapytanie uruchomione w przyszłym miesiącu zwróci nieco inną historię. Przechowywanie wierszy, które faktycznie zostały użyte, zapewnia powtarzalność wyników i jest kolejnym powodem istnienia lokalnego magazynu danych.
Backtest ignoruje również prowizje i poślizgi cenowe (slippage) oraz zakłada, że zlecenie nie wpływa na cenę. Kwestie te należą do egzekucji zleceń, która wykracza poza zakres tego opracowania i jest omówiona w uruchamianiu botów transakcyjnych na VPS.
Tryby awarii i komunikaty błędów
error: externally-managed-environment podczas uruchamiania pip. Znajdujesz się poza środowiskiem wirtualnym. Wywołaj /opt/research/venv/bin/pip, podając pełną ścieżkę.
Could not set lock on file wraz z numerem PID. Inny proces utrzymuje otwarty plik DuckDB w trybie zapisu; zazwyczaj jest to zapomniana sesja interaktywna. Zamknij ją. Otwarcie drugiego połączenia za pomocą read_only=True nie rozwiąże problemu, dopóki proces zapisujący jest aktywny, ponieważ DuckDB pozwala na jednoczesny odczyt przez wiele procesów lub zapis przez jeden proces, lecz nie na oba te działania jednocześnie.
Main process exited, code=killed, status=9/KILL w systemctl status. Jądro systemu przerwało zadanie z powodu braku pamięci. Potwierdź to za pomocą journalctl -k | grep -i oom, a następnie zmniejsz memory_limit w store.py.
Poprawne wykonanie, które nie zapisuje danych. systemctl status odczytuje active (exited), a tabela nie zwiększyła swojego rozmiaru. Źródło danych zwróciło puste ramki. Ten błąd jest najtrudniejszy do wykrycia, dlatego należy skonfigurować zadanie tak, aby kończyło się kodem błędu, gdy każdy ticker zwraca puste dane.
Uruchomienie timera w dniu wolnym od handlu. systemd nie posiada informacji o kalendarzu giełdowym, więc Mon-Fri obejmuje również dni świąteczne. Zadanie zostaje uruchomione, źródło danych nie zawiera nowych informacji, a system powinien traktować to jako stan normalny, a nie jako błąd.
429 z API. Przekroczono limit zapytań. SDK Anthropic automatycznie ponawia próby z opóźnieniem, a Anthropic(max_retries=5) zwiększa licznik prób. Jeśli błąd powtarza się codziennie, zadanie wysyła zbyt wiele zapytań w jednym bloku.
Czym to nie jest
To narzędzie jest asystentem badawczym. Model podsumowujący dokumentację tworzy jej interpretację i może błędnie zinterpretować liczbę zawartą w tekście, dlatego każda wartość w notatce musi być możliwa do zweryfikowania w przesłanym wierszu danych. Wynik należy traktować jako listę zagadnień do samodzielnego sprawdzenia. Treść nie stanowi porady finansowej ani sygnału inwestycyjnego.
Backtesty są przydatne do odrzucania nietrafionych pomysłów, ale słabo sprawdzają się w ich potwierdzaniu. Strategia, która nie sprawdza się na własnych danych, jest definitywnie nieskuteczna. Strategia, która przechodzi testy, jedynie przetrwała próbę na posiadanych danych, co jest znacznie słabszym wnioskiem, niż może się wydawać w nocy.
Realizacja zleceń celowo wykracza poza zakres tego narzędzia. Zlecenia i dane uwierzytelniające brokera wiążą się z innym profilem ryzyka niż środowisko badawcze typu read-only, a ich łączenie naraża klucze transakcyjne na przechowywanie na tej samej maszynie, na której przetwarzane są prompty LLM. Jeśli chcesz sprawdzić, jak ten wzorzec wpisuje się w inne rozwiązania warte uruchomienia na własnym serwerze, agenty AI do samodzielnego hostowania warte uwagi stanowią szerszy przegląd.
FAQ
Czy potrzebuję płatnego źródła danych rynkowych?
Nie w przypadku prototypu. Darmowe, nieoficjalne źródło wystarczy do poznania struktury systemu, jednak będzie ulegać awariom, ponieważ opiera się na stronie internetowej, która nie gwarantuje dostępności. Zazwyczaj awarie objawiają się pustymi ramkami danych, a nie wyjątkami, dlatego zadaniem użytkownika jest weryfikacja liczby wierszy. Przejdź na płatne źródło z udokumentowanym API i adresem wsparcia technicznego, gdy dane zaczną wpływać na podejmowanie decyzji. Magazyn danych sprawia, że taka zmiana jest tania: zmienia się tylko funkcja pobierania, podczas gdy harmonogram, schemat i interfejs pozostają bez zmian.
Czy przechowywać ceny w SQLite czy DuckDB?
DuckDB jest bazą kolumnową, stworzoną do skanowania wielu wierszy w celu obliczenia agregatów, co jest dokładnie tym, czym jest średnia krocząca z dziesięciu lat notowań. SQLite jest zorientowany wierszowo i lepiej radzi sobie z wieloma małymi operacjami odczytu i zapisu z kilku procesów jednocześnie. Dla pojedynczego zadania zaplanowanego, które dopisuje kilkaset wierszy, a następnie skanuje miliony, lepszym wyborem jest DuckDB. Jeśli kilka procesów musi zapisywać dane w tym samym czasie, SQLite w trybie WAL pozwala czytelnikom pracować, podczas gdy jeden proces zapisujący zatwierdza transakcję, a parametr busy timeout sprawia, że inni piszący czekają, zamiast kończyć działanie błędem.
Ile kosztują wywołania LLM miesięcznie?
Rejestruj usage.input_tokens oraz usage.output_tokens z każdej odpowiedzi w tabeli, a następnie pomnóż tygodniowe sumy przez cenę za milion tokenów, którą model podaje w dniu sprawdzenia. Jest to jedyna wartość, która pozostanie aktualna w następnym kwartale. Jedno dzienne uruchomienie dla krótkiego zestawienia to niewielka liczba wywołań, a długość notatki jest elementem, który można kontrolować: ograniczenie podsumowania do 200 słów daje większe oszczędności niż wysyłanie mniejszej liczby wierszy, ponieważ tokeny wyjściowe są wyżej wyceniane niż wejściowe w każdym modelu Claude.
Dlaczego zadanie zakończyło się sukcesem, ale nie zapisało nowych wierszy?
Istnieją dwie typowe przyczyny. Rynek był zamknięty, ponieważ harmonogram systemd Mon-Fri uwzględnia święta giełdowe. Alternatywnie, źródło danych zwróciło pustą ramkę dla każdego symbolu, co biblioteki klienckie często zgłaszają jako ostrzeżenie w konsoli, a nie jako wyjątek, więc proces kończy się kodem 0, a systemd pokazuje poprawny przebieg. Rozróżnij te sytuacje, porównując SELECT max(day) FROM prices z ostatnim rzeczywistym dniem handlowym i skonfiguruj zadanie tak, aby kończyło się niezerowym kodem wyjścia, gdy dane dla wszystkich symboli są puste.
Czy agent może decydować o zakupach?
Nie, a próba budowy takiego mechanizmu jest główną przyczyną niepowodzeń tych projektów. Model nie ma dostępu do rynku, nie zna Twojej pozycji ani sytuacji podatkowej i nie ma możliwości weryfikacji własnych obliczeń. Model sprawdza się w czytaniu dużej ilości tekstu i wskazywaniu kilku pozycji, które wymagają uwagi danego dnia. Nic, co napisze model, nie stanowi porady finansowej, a decyzja oraz odpowiedzialność za nią pozostają po stronie użytkownika.