SSD Nodes Learn Hosting plans →
Посібники Matt ConnorВід Matt Connor

Агент для дослідження акцій на власному VPS

Зберіть self-hosted агента для акцій на VPS: ринкові дані, DuckDB, таймер systemd після закриття ринку та LLM-фільтр без виконання угод.

Який вигляд має self-hosted агент для дослідження акцій

Self-hosted агент для дослідження акцій — це невелика програма на сервері, яким ви керуєте. Вона за розкладом отримує ринкові дані, зберігає їх у локальній базі даних, фільтрує їх і просить велику мовну модель (LLM) описати зміни. Агент лише читає та фільтрує дані. Він не здійснює торгівлю, і цей посібник не є фінансовою консультацією.

Двоє людей, які створюють таку систему з нуля, можуть вибрати різні бібліотеки, але все одно отримають однакову структуру з чотирьох частин: джерело, що надає ціни та фундаментальні показники; локальне сховище, яке зберігає кожен отриманий рядок; завдання, що оновлює сховище за таймером; і рівень LLM, який перетворює рядки, що залишилися після фільтрації, на речення. У цьому посібнику така структура створюється за допомогою Python, DuckDB, таймера systemd і Claude API (application programming interface). Це вузькоспеціалізований агент із фіксованим циклом. Якщо ви спочатку хочете розібратися із загальною версією, поетапний шлях вивчення AI-агентів послідовно розглядає цикл, інструменти та пам’ять, перш ніж застосувати їх до конкретної предметної області. Виконання операцій — це окреме завдання з окремими режимами відмови. Для нього краще використовувати VPS, налаштований для торгових ботів.

Чотири частини та призначення кожної

Канал даних — єдина частина, яка взаємодіє із зовнішнім світом. Він уміє запитувати тикер і діапазон дат, а потім повертати рядки. Усі наступні компоненти читають дані з вашої бази даних, а не з каналу. Тому збій каналу коштує вам одного дня нових даних, а не призводить до непрацездатності всього інтерфейсу.

Сховище — основна причина всієї цієї роботи. Денне значення закриття, яке ви не записали, зазвичай можна отримати пізніше. Внутрішньоденне котирування, оцінку до її перегляду або показник фундаментальних даних до його ретроспективного перерахунку відновити неможливо. Сховище дає змогу створити запис про те, які саме дані були доступні на певний день.

Планувальник визначає час оновлення. На сервері це systemd timer, тому VPS у цій схемі важливіший за код.

Рівень LLM читає короткий текстовий блок, сформований вашим SQL, і створює його резюме. Він ніколи не підключається до бази даних і не формує запит. Якщо модель пише SQL, один неправильний токен перетворюється на неправильне число всередині переконливого речення, і його немає з чим порівняти. Якщо SQL формує числа, модель може помилитися лише в тексті, а ви можете перевірити цей текст за рядками, які їй передали.

Чому запускати його на VPS, а не на ноутбуці

Причина — планувальник. Закриття ринку США о 16:00 за нью-йоркським часом — це 22:00 у Берліні та 04:00 наступного ранку в Джакарті. У цей час ноутбук не працює або перебуває в режимі сну. Пропущений запуск коштує дорожче, ніж затримка сповіщення: денні бари зазвичай можна повторно отримати пізніше, але дані, які було змінено під час перегляду, відновити неможливо, тому прогалина в історії залишається назавжди. Це саме стосується будь-якого агента, розклад і збережений стан якого мають переживати перезавантаження. Саме на цьому ґрунтується запуск KiroCrew як агента, що постійно працює на власному VPS.

Друга причина менш важлива, але також реальна. На сервері зберігається один API key в одному файлі. Власником файлу є один системний користувач без оболонки входу. Ключ використовує одне завдання. На ноутбуці, який ви також використовуєте для перегляду вебсторінок, організувати це набагато складніше. Зберігайте ключ окремо від коду та не передавайте його до вхідних даних моделі. Цьому присвячено матеріал як не передавати API keys AI-агенту.

Розрахунок обсягу: диск, RAM і токени

Диск — найпростіша частина. Один денний бар — це один рядок на кожен тикер за кожен торговий день, а в торговому році у США приблизно 252 дні.

ГрафікRows in the prices table by watchlist size, at 252 trading days a year
Дані, на яких побудовано графік
[
  {
    "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"
  }
]

Для двадцяти тикерів після року буде 5,040 рядків. Рядок 500 tickers досягає 1,260,000 рядків після десяти років. Кожен рядок містить дату та кілька значень типу double, а DuckDB зберігає стовпці у стисненому вигляді, тому йдеться про десятки мегабайтів, а не гігабайти. Не покладайтеся на цю оцінку, зокрема й на мою. Виконайте du -h /opt/research/data/market.duckdb після першого початкового завантаження даних і використовуйте власне значення.

RAM — це обмеження, яке особливо помітне на невеликому VPS. За замовчуванням DuckDB використовує значну частину пам’яті та всі ядра машини для одного запиту. Це правильно для аналітичного сервера, але неправильно для системи з 2 GB RAM, на якій працюють інші служби. Тоді агрегація всієї таблиці цін може завершитися примусовим зупиненням процесу ядром, а systemd повідомить Main process exited, code=killed, status=9/KILL, тоді як journalctl -k покаже зупинення через нестачу пам’яті. Явно задайте memory_limit і threads, і запит працюватиме повільніше, але не завершуватиметься аварійно.

Кількість токенів потрібно вимірювати, а не оцінювати. Кожна відповідь Messages API містить об’єкт usage з полями input_tokens і output_tokens. Записуйте обидва значення в таблицю під час кожного виклику. Через тиждень ви знатимете фактичний обсяг, який можна помножити на ціну, вказану для вашої моделі на день перевірки. Для планування достатньо врахувати два стабільні фактори. Токени вихідних даних дорожчі за токени вхідних даних у кожній моделі Claude, тому обмеження нотатки 200 словами сильніше впливає на рахунок, ніж скорочення даних, які ви надсилаєте. Кешування prompt не допомагає для завдання, яке запускається раз на день. Кеш за замовчуванням зберігається 5 хвилин, а найдовший час зберігання, який пропонує Anthropic, становить 1 годину. У такому разі запис коштує 2x базової ціни вхідних даних замість 1.25x для кешу на 5 хвилин. У будь-якому разі до запуску наступного дня кешований блок уже буде недійсним, і вам знову доведеться сплатити повну ціну вхідних даних. Кешування вигідне, коли один запуск виконує багато викликів з одним великим блоком тексту.

Встановіть необхідні компоненти

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

Перевірте встановлення, перш ніж писати код:

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

Ця команда виводить ok. Якщо пропустити віртуальне середовище та виконати pip install для системного Python, Ubuntu 24.04 завершує виконання з помилкою error: externally-managed-environment, оскільки дистрибутив керує /usr/lib/python3 і не дозволяє pip записувати туди. venv потрібен не для зручності. Це єдиний каталог, до якого pip має дозвіл на запис.

Ключ API зберігайте у файлі, який може читати користувач сервісу і ніхто інший:

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

Додайте в нього один рядок без лапок і без export, оскільки systemd самостійно розбирає цей файл, а не передає його shell:

ANTHROPIC_API_KEY=sk-ant-your-key-here

Сховище: дві таблиці

# /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

Первинний ключ у (ticker, day) робить оновлення безпечним для повторного запуску. INSERT OR REPLACE перезаписує рядок, який уже існує для цього тикера та дня, тому повторне виконання backfill не дублює дані в таблиці. Без цього ключа повторний запуск після збою непомітно дублює кожен бар, і всі подальші середні значення будуть неправильними. При цьому ніде не з’явиться повідомлення про помилку.

DuckDB дозволяє лише одному процесу відкрити файл для запису. Другий процес запису одразу завершується з помилкою Could not set lock on file, після якої вказується PID процесу, що утримує файл. На практиці це зазвичай інтерактивна оболонка duckdb, яку ви залишили відкритою в іншому терміналі. Підключення лише для читання не усуває це обмеження. У документації DuckDB описано два режими: один процес читає та записує або кілька процесів лише читають; змішувати ці режими не можна. connect приймає прапорець read_only, щоб кілька процесів-читачів могли спільно використовувати файл. Це працює лише доки файл не відкритий жодним процесом запису. Якщо кільком процесам справді потрібно одночасно виконувати запис, слід використовувати інший рушій: SQLite у режимі WAL дає змогу читачам працювати, поки один процес виконує commit, а timeout очікування звільнення ресурсу змушує інші процеси запису чекати замість негайного завершення з помилкою. У матеріалі DuckDB порівняно з SQLite для серверного навантаження наведено порівняння цих двох рушіїв, а в матеріалі запуск SQLite у production на VPS описано налаштування, за яких WAL працює коректно.

Завдання оновлення

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

Запустіть його вручну один раз:

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

Перший запуск завантажує дані за попередні роки та виводить по одному рядку для кожного тикера з кількістю записів у тисячах. Запустіть його знову через хвилину. Тоді в кожному рядку буде 1 або 2 записи, оскільки завдання починає з останнього вже збереженого дня. Саме другий запуск є фактичною перевіркою. Якщо кількість записів знову обчислюється тисячами, max(day) нічого не повертає, а операція вставки щоночі переписує всю історію.

Перевірка df.empty є найважливішим рядком у файлі. Ticker.history() не генерує виняток для неправильного символу або символу, вилученого з лістингу. Вона виводить попередження про те, що символ, можливо, більше не лістингується, а даних про ціни не знайдено (точне формулювання залежить від версії бібліотеки), і повертає порожній DataFrame. Завдання без цієї перевірки нічого не записує, завершується з кодом 0, а systemd показує успішний запуск, хоча таблиця непомітно перестає зростати. Ви дізнаєтеся про це лише через кілька тижнів, коли екран почне щодня повертати той самий набір рядків.

Обробка часових міток також має важливе обґрунтування. Індекс, який повертає джерело даних, може містити часовий пояс біржі. Видалення зміщення часу не те саме, що конвертація в UTC. Сесія в Токіо з часовою міткою опівночі за місцевим часом після конвертації в UTC припадає на попередню календарну дату. Тому конвертація в UTC непомітно зміщує кожну японську свічку на один день назад і порушує первинний ключ. tz_localize(None) зберігає власну дату біржової сесії, саме це й означає денна свічка.

Таймер, який спрацьовує після закриття ринку

Ринок США закривається о 16:00 за нью-йоркським часом, а останні угоди стабілізуються протягом кількох хвилин, тому завдання запускається о 16:20. Записуйте цей час за нью-йоркським часом, а не в UTC. Узимку Нью-Йорк має часовий пояс UTC мінус 5, а влітку — UTC мінус 4. Тому таймер із фіксованою годиною в UTC двічі на рік зміщується на годину й починає спрацьовувати до закриття ринку. systemd 235 і новіші версії дають змогу безпосередньо вказати часовий пояс у OnCalendar, а Ubuntu 24.04 постачається з версією 255. Перевірте свою версію за допомогою 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 — єдиний тип сервісу, який приймає більше одного ExecStart, і він виконує їх послідовно, зупиняючись, якщо один із них завершується з ненульовим кодом. Саме така поведінка вам потрібна: після невдалого оновлення не можна показувати екран зі застарілими даними. Persistent=true важливий на VPS, який перезавантажується для встановлення оновлень ядра. Якщо перезавантажити сервер о 16:15 без нього, запуск буде втрачено. З ним завдання виконається одразу після відновлення роботи машини. Агент, який має постійно працювати, а не запускатися й завершуватися, потребує протилежної структури unit: довготривалого процесу з правилами Restart= і без таймера. Саме це описано в матеріалі як запустити dsh без графічного інтерфейсу на VPS за допомогою systemd.

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

list-timers має показати стовпець NEXT із часом наступного запуску в будній день, перетвореним на локальний час самого сервера. Порожній список означає, що таймер не увімкнено або в unit відсутній розділ [Install], тому enable не мав чого пов’язувати з timers.target.

Екран: спочатку SQL, модель — в останню чергу

SQL точний і не потребує витрат під час кожного запуску. Модель — ні. Тому запит звужує набір даних, і до моделі надходять лише рядки, що пройшли фільтрацію. Збережіть це як /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;

Фільтр n > 50 — не декоративний елемент. ROWS BETWEEN 49 PRECEDING AND CURRENT ROW обчислює середнє для всіх наявних рядків, тому третій рядок тикера повертає середнє за три дні, але все одно називає його ma50. Якщо порівняти це значення з ma20, на початку історії кожного тикера буде створено перетин, якого насправді не було. Фільтрація за номером рядка вилучає рядки, для яких вікно ще не було заповнене повністю.

Що бачить модель і чого вона ніколи не бачить

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

Рядок thinking важливий для цієї моделі. Claude Sonnet 5 за замовчуванням виконує міркування, його thinking tokens тарифікуються як output і враховуються в max_tokens, а повертаються окремим блоком перед текстом. Без цього рядка resp.content[0] може містити блок міркувань, і звернення до .text завершується помилкою. Claude Sonnet 5 тепер є застарілою моделлю. Її наступник, claude-sonnet-5-5, відхиляє disabled, а потрібний там параметр — thinking={'type': 'between_tools'}, який вимикає міркування перед формуванням відповіді.

Обмеження кількості слів у системному промпті обмежує найдорожчу частину рахунку. Інструкцію використовувати лише надані рядки потрібно перевіряти, а не приймати на віру: видаліть один стовпець із блоку, запустіть його ще раз і перегляньте результат. Якщо значення для цього стовпця все одно з’явилося, модель заповнила пропуск, а ваш промпт недостатньо точний. Ця перевірка займає дві хвилини, і це єдиний надійний спосіб з’ясувати правду.

Модель ніколи не бачить API key, не бачить шлях до бази даних і не виконує запитів. Вона отримує рядки та повертає текст. Завдяки цій межі результат можна перевірити: кожне число в примітці має також міститися в надісланому блоці, тому їх можна порівняти рядок за рядком. Щойно ви розширюєте вхідні дані сторінками, отриманими з вебу, ця межа змінюється, оскільки заголовок або допис на форумі може містити інструкції для моделі, а не факти для вас. Саме таку поверхню відкриває надання агенту бекенду пошуку SearXNG. Докладніше про промптинг див. у матеріалі використання Claude для фінансового аналізу, де детальніше пояснено, які дані модель добре опрацьовує.

Один запуск від початку до кінця

О 16:20 за нью-йоркським часом timer запускає сервіс. refresh.py запитує feed для кожного ticker, починаючи з останнього збереженого дня, записує по одному або дві нові bars для кожного ticker і виводить окремий рядок для кожного ticker. screen.py відкриває той самий файл, виконує запит для moving average і отримує кілька рядків. Ці рядки перетворюються на текстовий блок обсягом у кілька сотень токенів. Один API-виклик перетворює їх на коротку нотатку, нотатка потрапляє до журналу, а один рядок записується в runs із кількістю токенів і кількістю збігів.

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

У коректному журналі є один рядок для кожного ticker, потім нотатка, а потім research-refresh.service: Deactivated successfully. Якщо ви читаєте цей журнал із сеансу Claude Code на сервері, а другий сеанс працює із запитом до екрана, два сеанси можуть передавати текст один одному, тож вам не потрібно копіювати рядки між терміналами. Через тиждень отримайте власні витрати безпосередньо з бази даних:

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

Помножте ці підсумки на ціни за мільйон токенів, які ваша модель указує на день перевірки. Так ви отримаєте фактичну суму, а не чиюсь оцінку. Опубліковані ціни змінюються. Арифметика — ні.

Чому backtest надмірно підганяється під дані та як це побачити

Перепишіть screen як функцію двох довжин вікна, переберіть сітку пар і ранжуйте їх за дохідністю. Найкраща пара виглядатиме чудово. У цьому й полягає проблема, а не результат. Сітка з 200 пар — це 200 експериментів, і ви залишили найвдаліший випадок.

Побачити це можна за десять хвилин. Розділіть store навпіл за датою. Переберіть сітку лише на першій половині та запишіть переможця. Переберіть ту саму сітку на другій половині. Якщо два переможці суттєво відрізняються, параметри підганяються під шум, а пара, яка перемагає лише на половині, на якій ви її налаштували, нічого не говорить про завтрашній день.

Survivorship гірший за надмірне підганяння, оскільки налаштування його не виправить. Ваш список тикерів містить учасників індексу на сьогодні, тому до нього входять лише компанії, які вижили. Якщо запитати feed про тикер, який було виключено з лістингу у 2019, він поверне порожній frame. Це означає, що така компанія не потрапляє до вашого store і не бере участі у вашому тесті. Кожен запущений вами backtest уже виключив невдалі випадки.

Restated fundamentals порушують часову послідовність. Показник виручки, який API повертає сьогодні для кварталу 2019 року, не завжди є показником, опублікованим у 2019 році. Screen, що поєднує сьогоднішні фундаментальні показники з цінами 2019 року, використовує інформацію, якої тоді ще не існувало. Ціни зазвичай безпечні в цьому аспекті. Фундаментальні показники — зазвичай ні.

Adjusted prices змінюються з часом. З auto_adjust=True ціни закриття коригуються назад з урахуванням дивідендів і дроблень акцій, тому той самий запит, виконаний наступного місяця, поверне дещо іншу історію. Збереження фактичних рядків, які ви використовували, забезпечує відтворюваність результату. Це ще одна причина, чому local store взагалі потрібен.

Backtest також ігнорує комісії та прослизання і припускає, що ваш ордер не впливає на ціну. Це належить до виконання ордерів, яке тут не розглядається. Докладніше про нього див. у запуск trading bots на VPS.

Режими відмов і повідомлення, які ви побачите

error: externally-managed-environment під час запуску pip. Ви працюєте поза virtual environment. Викличте /opt/research/venv/bin/pip за повним шляхом.

Could not set lock on file, після якого вказано PID. Інший процес відкрив файл DuckDB для запису. Зазвичай це інтерактивна оболонка, яку ви забули закрити. Закрийте її. Відкриття другого підключення з read_only=True не допоможе, доки процес, що виконує запис, залишається активним, оскільки DuckDB дозволяє одночасне читання кількома процесами або запис одним процесом, але не обидві операції одночасно.

Main process exited, code=killed, status=9/KILL у systemctl status. Ядро завершило завдання через нестачу пам’яті. Перевірте це за допомогою journalctl -k | grep -i oom, а потім зменште memory_limit у store.py.

Успішний запуск, який нічого не записує. systemctl status читає active (exited), але таблиця не збільшилася. Джерело даних повернуло порожні фрейми. Таку помилку найважче виявити, тому зробіть так, щоб завдання завершувалося з ненульовим кодом, якщо для кожного тикера повернуто порожній результат.

Таймер спрацював у біржове свято. systemd не знає календаря біржі, тому Mon-Fri включає святкові дні. Запуск відбувається, але джерело даних не повертає нових даних. Завдання має обробляти це як штатну ситуацію, а не як помилку.

429 від API. Перевищено ліміт частоти запитів. Anthropic SDK самостійно повторює запити з backoff, а Anthropic(max_retries=5) збільшує кількість спроб. Якщо помилка виникає щодня, завдання надсилає забагато запитів одним пакетом.

Що це не таке

Це дослідницький асистент. Модель, яка підсумовує звітність, формує власну інтерпретацію цього документа. Вона може впевнено помилитися щодо числа, надрукованого в тексті. Тому кожне число в нотатці має вести до рядка, який ви надіслали. Розглядайте результат як список матеріалів для самостійного ознайомлення. Цей матеріал не є фінансовою порадою і не містить жодного сигналу.

Бектести корисні для відхилення ідей, але малопридатні для їх підтвердження. Стратегія, яка не працює на ваших даних, справді непридатна. Стратегія, яка пройшла перевірку, лише витримала тест на ваших даних. Це набагато слабше твердження, ніж може здаватися о 1:00 ночі.

Виконання операцій навмисно залишається поза межами цього матеріалу. Накази та облікові дані брокера мають інший профіль ризику, ніж read-only дослідницький сервер. Їхнє поєднання розміщує ключі для торгівлі на тому самому комп’ютері, що й prompt для LLM. Якщо ви хочете побачити, як цей підхід співвідноситься з іншими корисними інструментами для запуску на власному сервері, у ширшому огляді self-hosted AI-агентів, які варто запускати.

FAQ

Чи потрібен мені платний канал ринкових даних?

Не для прототипу. Безкоштовний неофіційний канал підходить для вивчення структури системи, але він перестане працювати, оскільки залежить від вебсайту, який вам нічого не винен. Зазвичай він повертає порожні фрейми, а не виняток, тому завдання має перевіряти кількість рядків. Коли дані почнуть впливати на рішення, перейдіть на платний канал із документованим API та адресою служби підтримки. Саме сховище робить таку заміну недорогою: змінюється лише функція отримання даних, а розклад, схема й інтерфейс залишаються без змін.

Зберігати ціни в SQLite чи DuckDB?

DuckDB використовує колонкове зберігання та призначений для сканування великої кількості рядків під час обчислення агрегатів. Це саме той випадок, що потрібен для ковзного середнього за десять років біржових свічок. SQLite використовує рядкове зберігання й краще підходить для численних невеликих операцій читання та запису з кількох процесів одночасно. Для одного запланованого завдання, яке додає кілька сотень рядків, а потім сканує мільйони, DuckDB підходить краще. Якщо кілька процесів мають записувати одночасно, SQLite у режимі WAL дає змогу читачам працювати, поки один записувач фіксує транзакцію, а тайм-аут очікування звільнення дає іншим записувачам змогу зачекати замість негайного завершення з помилкою.

Скільки коштують виклики LLM на місяць?

Записуйте usage.input_tokens і usage.output_tokens з кожної відповіді в таблицю, а потім помножте тижневі підсумки на ціну за мільйон токенів, яку ваша модель вказує на день перевірки. Це єдина сума, яка залишатиметься актуальною наступного кварталу. Один щоденний запуск для короткого списку перевірки створює небагато викликів. Довжина нотатки — параметр, який ви контролюєте: обмеження підсумку 200 словами заощаджує більше, ніж зменшення кількості рядків, оскільки в кожній моделі Claude вихідні токени коштують дорожче за вхідні.

Чому моє завдання завершилося успішно, але не записало нових рядків?

Є дві звичайні причини. Ринок був закритий, оскільки розклад systemd Mon-Fri враховує біржові свята. Або канал даних повернув порожній фрейм для кожного тикера. Клієнтські бібліотеки часто повідомляють про це попередженням у виводі, а не винятком, тому процес завершується з кодом 0, а systemd показує успішний запуск. Розрізнити ці випадки можна, порівнявши SELECT max(day) FROM prices з останнім фактичним торговим днем. Якщо дані для всіх тикерів порожні, завдання має завершуватися з ненульовим кодом.

Чи може агент вирішувати, що купувати?

Ні. Саме спроба створити його для цього призводить до помилок у таких проєктах. Модель не має доступу до ринку, не бачить ваших позицій або податкової ситуації та не може перевірити власні розрахунки за зовнішніми даними. Вона добре читає великі обсяги тексту й повідомляє, які кілька елементів сьогодні заслуговують на вашу увагу. Усе, що вона створює, не є фінансовою порадою. Рішення та відповідальність за нього залишаються за вами.

Чи була ця стаття корисною?