SSD Nodes Learn 🎉 VPS от $4.99/мес
Руководства Matt ConnorАвтор: Matt Connor · Обновлено 2026-08-13

Как создать self-hosted агент для анализа акций на VPS

Создайте систему для автоматического анализа рынка на VPS. Руководство включает настройку потока данных, хранилище DuckDB, запуск через systemd timer и интеграцию с LLM.

Что такое self-hosted агент для анализа акций

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

Два разработчика, создающие такую систему с нуля, могут выбрать разные библиотеки, но в итоге придут к одним и тем же четырем компонентам: лента данных с ценами и фундаментальными показателями, локальное хранилище для всех полученных записей, задача для обновления хранилища по таймеру и уровень LLM, который преобразует отобранные записи в текстовые отчеты. В этом руководстве такая архитектура строится на базе Python, DuckDB, systemd timer и Claude API (application programming interface). Исполнение сделок — это отдельная задача с другими сценариями сбоев, и её следует размещать на VPS, настроенном для торговых ботов.

Четыре компонента и их назначение

Feed — это единственный компонент, взаимодействующий с внешним миром. Он умеет запрашивать тикер и диапазон дат, а также возвращать строки данных. Все последующие компоненты считывают данные из вашей базы данных, а не из feed, поэтому сбой в работе feed приведет лишь к потере данных за один день, а не к неработоспособности всего интерфейса.

Store — это главная цель всей системы. Дневную цену закрытия, которую вы не успели записать, обычно можно получить позже. Внутридневную котировку, оценку до её пересмотра или фундаментальный показатель до внесения правок — нельзя. Store позволяет сформировать архив того, что именно сообщали данные в конкретный день.

Scheduler определяет время обновления. На сервере это реализуется через systemd timer, поэтому VPS здесь важнее, чем сам код.

LLM layer считывает короткий текстовый блок, сформированный вашим SQL-запросом, и пишет его резюме. Этот слой никогда не подключается к базе данных и не составляет запросы. Если модель сама пишет SQL, один неверный токен превращается в неверное число внутри связного предложения, которое не с чем сравнить. Если числа извлекаются с помощью SQL, модель может ошибиться только в тексте, а вы сможете проверить этот текст, сверив его со строками, которые вы передали.

Почему стоит запускать это на VPS, а не на ноутбуке

Причина — планировщик задач. Закрытие торгов на рынке США в 16:00 по нью-йоркскому времени соответствует 22:00 в Берлине и 04:00 следующего дня в Джакарте. В это время ноутбук обычно находится в спящем режиме. Пропущенный запуск обходится дороже, чем простое уведомление об опоздании: дневные бары обычно можно догрузить позже, но данные, которые подвергаются пересмотру, восстановить нельзя, поэтому пробел в истории останется навсегда. Та же логика применима к любому агенту, чье расписание и сохраненное состояние должны пережить перезагрузку; именно на этом строится запуск KiroCrew в качестве постоянно работающего агента на собственном VPS.

Вторая причина менее значима, но все еще актуальна. На сервере хранится один API key, в одном файле, принадлежащем одному системному пользователю без доступа к оболочке входа, который используется одной задачей. Это гораздо сложнее организовать на ноутбуке, с которого вы также просматриваете веб-страницы. Храните ключ отдельно от кода и входных данных модели, что является темой безопасного хранения API keys для AI-агента.

Оценка ресурсов: диск, оперативная память и токены

С диском всё просто. Одна ежедневная запись — это одна строка на тикер в торговый день, а в торговом году в США примерно 252 дня.

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"
  }
]

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

Оперативная память — это то, на чём спотыкается небольшой VPS. По умолчанию DuckDB занимает значительную часть памяти машины и все её ядра для одного запроса, что оправдано на аналитическом сервере, но недопустимо на машине с 2 ГБ RAM, где работают и другие процессы. Одна агрегация по всей таблице цен приводит к тому, что ядро завершает процесс, systemd сообщает Main process exited, code=killed, status=9/KILL, а journalctl -k показывает, что процесс был убит из-за нехватки памяти (OOM kill). Установите memory_limit и threads явно, и запрос станет медленнее, вместо того чтобы приводить к краху.

Токены нужно измерять, а не оценивать. Каждый ответ Messages API содержит объект usage с полями input_tokens и output_tokens. Записывайте оба значения в таблицу при каждом вызове, и через неделю вы будете знать свой реальный объём, который нужно умножить на стоимость, указанную для вашей модели на день проверки. Две вещи достаточно стабильны, чтобы планировать бюджет. Выходные токены стоят дороже входных в любой модели Claude, поэтому ограничение заметки 200 словами сильнее влияет на счёт, чем сокращение объёма отправляемых данных. Кэширование промптов (prompt caching) не помогает при запуске задачи раз в день, так как время жизни кэша измеряется минутами: к следующему запуску кэшированный блок истечёт, и вы снова заплатите полную стоимость за входные токены. Кэширование выгодно, когда за один запуск выполняется множество вызовов с одним и тем же большим блоком текста.

Установка компонентов

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 перезаписывает существующую строку для данного тикера и дня, поэтому повторный запуск заполнения данных не приводит к дублированию записей в таблице. Без ключа повторный запуск после сбоя незаметно дублирует каждый бар, и все последующие вычисления средних значений будут неверными, при этом система не выдаст никаких сообщений об ошибках.

DuckDB позволяет только одному процессу открывать файл для записи. Второй процесс записи немедленно завершается с ошибкой Could not set lock on file, после которой указывается PID процесса, удерживающего файл; на практике это часто оказывается интерактивная оболочка duckdb, оставленная открытой в другом терминале. Процессы чтения передают read_only=True, поэтому connect использует этот флаг. Если нескольким процессам действительно необходимо записывать данные одновременно, это задача для другого движка: SQLite в режиме WAL позволяет процессам чтения работать, пока один процесс записи фиксирует транзакцию, а параметр busy 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) ничего не возвращает, и операция insert каждую ночь перезаписывает всю вашу историю.

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

Обработка временных меток также имеет свои причины. Индекс, который возвращает поток данных, может содержать часовой пояс биржи, и отбрасывание смещения — это не то же самое, что преобразование в UTC. Токийская сессия, отмеченная полночью по местному времени, при преобразовании в UTC переносится на предыдущий календарный день, поэтому конвертация в UTC незаметно сдвигает каждый японский бар на один день назад и нарушает первичный ключ. tz_localize(None) сохраняет дату сессии самой биржи, что и является определением дневного бара.

Таймер, срабатывающий при закрытии рынка

Американский рынок закрывается в 16:00 по нью-йоркскому времени, а для фиксации последних сделок требуется несколько минут, поэтому задача запускается в 16:20. Указывайте это время по Нью-Йорку, а не по UTC. Нью-Йорк находится в часовом поясе UTC-5 зимой и UTC-4 летом, поэтому таймер, привязанный к фиксированному часу UTC, будет дважды в год смещаться на час и срабатывать до закрытия рынка. Начиная с версии systemd 252, в 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 приведет к пропуску запуска, а с ним задача выполнится сразу после восстановления работы системы.

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

list-timers должен отображать столбец NEXT с указанием следующего запуска в будний день, пересчитанного в локальное время сервера. Пустой список означает, что таймер не включен или в юните отсутствует секция [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,
    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()

Ограничение на количество слов в системном промпте позволяет сократить расходы на обработку запросов. Инструкцию использовать только предоставленные строки необходимо проверять, а не принимать на веру: удалите один столбец из блока, запустите процесс снова и изучите результат. Если значение для этого столбца всё равно присутствует, значит, модель заполнила пробел самостоятельно, и ваш промпт недостаточно строг. Этот тест занимает две минуты и является единственным честным способом проверки.

Модель никогда не видит API key, не видит путь к базе данных и не выполняет запросы. Она получает строки и возвращает текст. Эта граница делает результат проверяемым, так как каждое число в заметке должно присутствовать в отправленном вами блоке, и вы можете сравнить их строка за строкой. Подробнее о составлении промптов для таких задач можно узнать в использовании Claude для финансового анализа, где подробно описано, с какими данными модель работает лучше всего.

Полный цикл выполнения

В 16:20 по нью-йоркскому времени таймер запускает сервис. refresh.py запрашивает данные для каждого тикера, начиная с последнего сохраненного дня, записывает по одному или два новых бара и выводит одну строку для каждого тикера. screen.py открывает тот же файл, выполняет запрос скользящего среднего и получает несколько строк. Эти строки превращаются в текстовый блок объемом в несколько сотен токенов. Один API-вызов преобразует их в краткую заметку, заметка отправляется в журнал, а одна строка с количеством токенов и числом совпадений сохраняется в runs.

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

Корректный лог содержит по одной строке на каждый тикер, затем заметку, а после — research-refresh.service: Deactivated successfully. Через неделю получите данные о своих расходах из базы данных:

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

Умножьте полученные итоги на стоимость миллиона токенов, указанную для вашей модели на день проверки. Это даст вам реальную цифру, а не чьи-то оценочные данные. Опубликованные цены меняются. Арифметика — нет.

Почему бэктесты переобучаются и как это отследить

Представьте скрининг как функцию от двух длин окон, просканируйте сетку пар и отранжируйте их по доходности. Лучшая пара будет выглядеть превосходно. Это проблема, а не результат. Сетка из 200 пар — это 200 экспериментов, и вы просто выбрали самый удачный.

Вы можете увидеть, как это происходит, за десять минут. Разделите хранилище данных пополам по дате. Просканируйте сетку только на первой половине и запишите победителя. Просканируйте ту же сетку на второй половине. Если победители сильно различаются, параметры подстраиваются под шум, а пара, которая выигрывает только на той половине, где вы её настраивали, ничего не говорит о завтрашнем дне.

Ошибка выжившего опаснее переобучения, так как её нельзя исправить настройкой параметров. Ваш список тикеров состоит из текущих участников индекса, поэтому он включает только те компании, которые выжили. Если запросить тикер компании, которая была исключена из листинга в 2019 году, вы получите пустой набор данных. Это значит, что компания никогда не попадет в ваше хранилище и не будет участвовать в тесте. Каждый ваш бэктест уже исключил все провальные варианты.

Пересмотренные фундаментальные показатели нарушают хронологию. Значение выручки, которое API возвращает сегодня за квартал 2019 года, не всегда совпадает с цифрой, опубликованной в 2019 году. Скрининг, смешивающий текущие фундаментальные показатели с ценами 2019 года, использует информацию, которой тогда не существовало. С ценами обычно проблем нет, а с фундаментальными данными — почти всегда.

Скорректированные цены меняются со временем. При использовании auto_adjust=True цены закрытия корректируются назад с учетом дивидендов и сплитов, поэтому тот же запрос, выполненный в следующем месяце, вернет немного другую историю. Сохранение строк, которые вы использовали на самом деле, делает результат воспроизводимым — это еще одна причина, по которой существует локальное хранилище.

Бэктест также игнорирует комиссии и проскальзывание, а также предполагает, что ваш ордер не влияет на цену. Эти факторы относятся к исполнению, которое выходит за рамки данной темы и рассматривается в запуске торговых ботов на VPS.

Типичные сбои и сообщения об ошибках

error: externally-managed-environment при запуске pip. Вы находитесь вне виртуального окружения. Вызывайте /opt/research/venv/bin/pip по полному пути.

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

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. Вы превысили лимит запросов. SDK Anthropic самостоятельно выполняет повторные попытки с увеличением интервала, а Anthropic(max_retries=5) увеличивает счетчик попыток. Если ошибка повторяется ежедневно, значит, задание запрашивает слишком много данных за один раз.

Чем это не является

Это помощник для исследований. Модель, составляющая резюме документа, выдает его интерпретацию и может ошибаться в цифрах, приведенных в тексте. Именно поэтому каждое число в заметке должно быть подтверждено строкой из исходных данных, которые вы предоставили. Рассматривайте результат как краткий список материалов для самостоятельного изучения. Это не является финансовой консультацией или торговым сигналом.

Бэктесты полезны для отсеивания идей, но слабы для их подтверждения. Стратегия, которая не проходит проверку на ваших данных, заведомо неработоспособна. Стратегия, которая прошла проверку, лишь успешно прошла через ваши данные — это гораздо менее значимое утверждение, чем кажется в час ночи.

Исполнение сделок намеренно вынесено за рамки данного руководства. Ордера и учетные данные брокера несут иные риски, чем исследовательская среда с доступом только для чтения. Их совмещение означает хранение торговых ключей на той же машине, где обрабатываются запросы к LLM. Если вы хотите узнать, какое место этот шаблон занимает среди других инструментов, которые стоит запускать на собственном сервере, ознакомьтесь с обзором полезных self-hosted AI-агентов.

FAQ

Нужна ли мне платная лента рыночных данных?

Для прототипа — нет. Бесплатная неофициальная лента подходит для изучения структуры системы, но она будет ломаться, так как зависит от веб-сайта, который вам ничем не обязан. Обычно она ломается, возвращая пустые кадры, а не исключения, поэтому ваша задача — проверять количество строк. Переходите на платную ленту с документированным API и адресом техподдержки, как только данные начнут влиять на принятие решений. Хранилище делает такую замену дешевой: меняется только функция получения данных, а расписание, схема и экран остаются прежними.

Где лучше хранить цены: в SQLite или DuckDB?

DuckDB — это колоночная база данных, созданная для сканирования большого количества строк с целью вычисления агрегатов, что идеально подходит для скользящего среднего по десятилетним барам. SQLite ориентирована на строки и лучше справляется с множеством мелких операций чтения и записи из нескольких процессов одновременно. Для одиночного запланированного задания, которое добавляет несколько сотен строк и затем сканирует миллионы, DuckDB подходит лучше. Если несколько процессов должны писать данные одновременно, SQLite в режиме WAL позволяет читателям работать, пока один писатель фиксирует транзакцию, а параметр busy timeout заставляет других писателей ждать, вместо того чтобы сразу выдавать ошибку.

Сколько стоят вызовы LLM в месяц?

Записывайте usage.input_tokens и usage.output_tokens из каждого ответа в таблицу, а затем умножайте еженедельные итоги на цену за миллион токенов, указанную для вашей модели на день проверки. Это единственная цифра, которая останется актуальной в следующем квартале. Один ежедневный запуск по короткому списку — это небольшое количество вызовов, и объем заметки — это то, что вы контролируете: ограничение резюме 200 словами экономит больше, чем отправка меньшего количества строк, так как выходные токены во всех моделях Claude стоят дороже входных.

Почему задание выполнилось успешно, но не записало новые строки?

Есть две распространенные причины. Рынок был закрыт, так как расписание systemd Mon-Fri учитывает праздничные дни биржи. Либо лента вернула пустой кадр для каждого тикера, что клиентские библиотеки часто сообщают как предупреждение в консоли, а не как исключение, поэтому процесс завершается с кодом 0, а systemd показывает успешный запуск. Различайте эти ситуации, сравнивая SELECT max(day) FROM prices с последним реальным торговым днем, и настройте задание так, чтобы оно завершалось с ненулевым кодом, если данные по всем тикерам пришли пустыми.

Может ли агент решать, что покупать?

Нет, и попытки реализовать это — главная ошибка в подобных проектах. У модели нет доступа к рынку, нет данных о вашей позиции или налоговой ситуации, и нет способа проверить свои расчеты. В чем она хороша, так это в чтении больших объемов текста и подсказках, на какие несколько позиций стоит обратить внимание сегодня. Все, что она пишет, не является финансовой консультацией, а решение, как и ответственность за него, остается за вами.