SSD Nodes Learn Hosting plans →
ガイド Matt Connor著者 Matt Connor

VPSで自ホスト型の株式調査エージェントを構築

VPS上に株式調査エージェントを構築します。市場データ、DuckDB、systemd timer、Claude APIを組み合わせ、米国市場の引け後に取得、保存、選別、要約する方法を解説します。

自ホスト型の株式調査エージェントとは

自ホスト型の株式調査エージェントは、自分で管理するサーバー上で動作する小さなプログラムです。スケジュールに従って市場データを取得し、ローカルデータベースに保存し、データを選別して、変化した内容を大規模言語モデル(LLM)に文章化させます。データを読み取り、フィルタリングするだけです。取引は行いません。また、このガイドは金融上の助言ではありません。

この構成をゼロから作る 2 人が異なるライブラリを選んでも、最終的には同じ 4 つの部分に行き着きます。価格とファンダメンタルズを供給するフィード、取得したすべての行を保持するローカルストア、タイマーでストアを更新するジョブ、残った行を文章に変換する LLM 層です。このガイドでは、Python、DuckDB、systemd timer、Claude API(application programming interface)を使ってこの構成を作ります。これは固定ループで動く限定的なエージェントです。そのため、まず一般的な構成を学びたい場合は、AI エージェントを学ぶための段階的な道筋で、ドメイン固有の問題に進む前に、ループ、ツール、メモリを順に扱えます。実行処理は別のジョブであり、障害モードも異なります。実行処理は、代わりに トレーディングボット用に構成した VPS で運用してください。

4 つの構成要素と、それぞれの役割

フィードは、外部と通信する唯一の構成要素です。ティッカーと日付範囲を指定してデータを要求し、行を返す方法を把握しています。後続の処理はすべてフィードではなくデータベースを読み取るため、フィードの停止による影響は、画面全体が壊れることではなく、新しいデータが 1 日分取得できなくなることに限定されます。

ストアが、この仕組み全体の目的です。記録していない日次終値は、通常、後から再取得できます。一方、日中の株価、修正前の予測値、または再表示前のファンダメンタルズの数値は、再取得できない場合があります。ストアを使うことで、データがその日に実際に示していた内容の記録を構築できます。

スケジューラーは、更新を実行するタイミングを決定します。サーバーでは systemd timer がこれに当たります。そのため、この構成ではコードよりも VPS が重要になります。

LLM 層は、SQL が生成した短いテキストブロックを読み取り、その内容の要約を書きます。データベースには接続せず、クエリも作成しません。モデルに SQL を生成させると、1 つの誤ったトークンが、流暢な文章の中に誤った数値として入り込み、比較対象がなくなります。SQL が数値を生成すれば、モデルが誤る可能性があるのは文章だけです。送信した行と照合して、文章の内容を確認できます。

ノートパソコンではなく VPS で実行する理由

理由はスケジューラーです。米国市場がニューヨーク時間の 16:00 に引ける場合、ベルリンでは 22:00、ジャカルタでは翌朝の 04:00 です。ノートパソコンは、どちらの時刻にもスリープしています。実行漏れによる損失は、遅れて通知する場合より大きくなります。日次バーは通常、後から再取得できますが、改訂されたデータは再取得できません。そのため、記録上の欠落は恒久的に残ります。同じ考え方は、スケジュールと保存状態を再起動後も維持する必要があるすべてのエージェントに当てはまります。自分の VPS で KiroCrew を常時稼働エージェントとして実行するのは、この要件を前提にしています。

2 つ目の理由は規模が小さいものの、無視できません。サーバーには 1 つの API key を 1 つのファイルに保存し、ログインシェルを持たない 1 つの system user が所有し、1 つのジョブだけが使用します。Web 閲覧にも使うノートパソコンで、同じ構成を実現するのははるかに困難です。key はコード内にもモデルの入力にも含めないでください。これは AI エージェントから API key を除外するで説明する内容です。

ディスク、RAM、トークンの容量を見積もる

ディスクは簡単です。日次バーは、ティッカーごと、取引日ごとに 1 行です。米国の 1 年間の取引日数は約 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"
  }
]

20 銘柄では、1 年後に 5,040 行になります。500 tickers の行は、10 年後に 1,260,000 行になります。各行は日付と少数の倍精度浮動小数点値で構成され、DuckDB は列を圧縮して保存します。そのため、容量はギガバイトではなく数十メガバイトです。この見積もりは、私の見積もりも含めて、そのまま信用しないでください。最初のバックフィル後に du -h /opt/research/data/market.duckdb を実行し、実際の値を使ってください。

RAM は、小規模な VPS で問題になります。DuckDB はデフォルトで、単一のクエリに対してマシンのメモリと全 CPU コアの大きな割合を使用します。これは分析用サーバーでは適切ですが、ほかの処理も実行する 2 GB のマシンでは適切ではありません。prices テーブル全体に対する集計を 1 回実行しただけで、カーネルによってプロセスが強制終了されることがあります。その場合、systemd は Main process exited, code=killed, status=9/KILL を報告し、journalctl -k にはメモリ不足による kill が記録されます。memory_limit と threads を明示的に設定すると、クエリは停止せず、処理速度が低下します。

トークンは推定せず、実測してください。すべての Messages API のレスポンスには、usage オブジェクトが含まれます。このオブジェクトには input_tokens と output_tokens があります。呼び出しごとに両方の値をテーブルへ書き込みます。1 週間後には実際の使用量が分かるため、確認した日にモデルに表示されている単価を掛けて料金を計算できます。計画時に考慮しやすい点が 2 つあります。すべての Claude モデルで、出力トークンの単価は入力トークンより高く設定されています。そのため、メモの上限を 200 語にするほうが、送信するデータを削減するより料金に大きく影響します。また、プロンプトキャッシュは 1 日 1 回のジョブには有効ではありません。キャッシュの有効期間はデフォルトで 5 分です。Anthropic が提供する最長の有効期間は 1 時間ですが、書き込み時の料金は、5 分キャッシュの 1.25 倍ではなく、基本入力料金の 2 倍です。いずれの場合も、翌日の実行時にはキャッシュしたブロックの有効期限が切れており、入力料金を全額支払うことになります。キャッシュが有効なのは、同じ大きなテキストブロックを使って 1 回の実行で多数の呼び出しを行う場合です。

インストールするコンポーネント

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と表示されます。仮想環境を作成せず、システムの Python に対して pip install を実行した場合、Ubuntu 24.04 は error: externally-managed-environment で処理を停止します。ディストリビューションが /usr/lib/python3 を管理しており、pip による書き込みを許可しないためです。venv は形式的な手順ではありません。pip が書き込める唯一のディレクトリです。

API key は、サービスユーザーが読み取れて、他のユーザーは読み取れないファイルに保存します。

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 も付けずに 1 行だけ記述します。systemd はこのファイルを shell に渡さず、自身で解析するためです。

ANTHROPIC_API_KEY=sk-ant-your-key-here

ストア: 2 つのテーブル

# /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 は、そのティッカーと日付に対応する既存の行を上書きします。そのため、バックフィルを 2 回実行しても、テーブル内の行が重複して増えることはありません。このキーがないと、クラッシュ後の再実行で各バーが黙って重複します。その後に計算する平均値もすべて誤ります。しかも、それを知らせるエラーメッセージはどこにも表示されません。

DuckDB では、ファイルを開いて書き込みできるプロセスは正確に 1 つだけです。2 つ目の writer は直ちに失敗し、続いてそのファイルを保持している PID が Could not set lock on file とともに表示されます。実際には、別のターミナルで開いたままの対話型 duckdb shell が原因であることが多いです。read only 接続にしても、この制約は回避できません。DuckDB の concurrency ドキュメントでは、2 つのモードが説明されています。1 つのプロセスが読み書きするモードと、複数のプロセスが読み取りだけを行うモードです。この 2 つを混在させることはできません。connect には read_only flag を指定できるため、複数の reader がファイルを共有できます。ただし、writer がファイルを開いていない間だけ機能します。複数のプロセスが本当に同時に書き込む必要がある場合は、別の engine を使うべきです。SQLite を WAL mode で使うと、1 つの writer が commit している間も reader が処理を続けられます。また、busy timeout を設定すると、他の writer は失敗せずに待機します。サーバー用途での DuckDB と SQLite の比較では両者を比較し、VPS で SQLite を本番運用する方法では 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()

手動で 1 回実行します。

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

初回実行では数年分のデータを補完するため、ティッカーごとに数千件の件数を示す行が 1 行ずつ出力されます。1 分後にもう一度実行すると、各行の件数は 1 または 2 になります。ジョブが、すでに保存されている最終日から処理を開始するためです。この 2 回目の実行が本当のテストです。件数がまだ数千件の場合、max(day) は何も返しておらず、insert が毎晩、全履歴を書き直しています。

ファイル内で最も重要なのは、df.empty のチェックです。Ticker.history() は、シンボルが誤っている場合や上場廃止になっている場合でも例外を発生させません。価格データが見つからず、シンボルが上場廃止になっている可能性があるという警告を出力し、空の DataFrame を返します。検査を行わないジョブは何も書き込まず、終了コード 0 で終了します。そのため systemd では正常な実行として表示されますが、テーブルはひそかに増加しなくなります。毎日同じ行を返す画面を数週間後に確認して、初めて問題に気付きます。

タイムスタンプの処理にも理由があります。フィードが返すインデックスには取引所のタイムゾーンが付いている場合があり、オフセットを削除することは UTC に変換することと同じではありません。東京の取引セッションに付いた現地時間の 0 時を UTC に変換すると、UTC では前日の暦日になります。そのため UTC 変換を行うと、日本の各バーが暗黙的に 1 日前へずれ、主キーが壊れます。tz_localize(None) は取引所固有のセッション日を保持します。日足データでは、この日付が必要です。

市場終了時に実行するタイマー

米国市場はニューヨーク時間の16:00に終了します。最後の約定が確定するまで数分かかるため、ジョブは16:20に実行します。時刻はUTCではなく、ニューヨーク時間で指定してください。ニューヨークは冬時間ではUTCマイナス5時間、夏時間ではUTCマイナス4時間です。そのため、UTCの固定時刻でタイマーを記述すると、年に2回、実行時刻が1時間ずれて、取引終了前に実行されます。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を受け付ける唯一のサービス種別です。ExecStartは指定された順序で実行され、いずれかが非0で終了すると停止します。これは必要な動作です。更新に失敗した後、古いデータを表示してはなりません。Persistent=trueは、カーネル更新で再起動するVPSで重要です。これがない状態で16:15に再起動すると、実行はそのまま失われます。これがあれば、マシンの復旧後すぐにジョブが実行されます。実行して終了するのではなく、常時稼働する必要があるエージェントには、反対のunit構成が必要です。長時間稼働するプロセスにRestart=の規則を適用し、タイマーは使用しません。VPS上でsystemdを使ってdshをヘッドレス実行する方法では、この構成を説明しています。

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 は存在する行を対象に平均を計算するため、ある ticker の3行目では3日分の平均を返し、それでも ma50 と呼びます。これを ma20 と比較すると、実際には発生していないクロスオーバーが、各 ticker の履歴の開始時点で発生したように扱われます。行番号でフィルタリングすると、ウィンドウが完全にそろっていない行を除外できます。

モデルが認識するものと、決して認識しないもの

# /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 はデフォルトで思考し、思考トークンは出力として課金され、max_tokens にも算入されます。思考トークンはテキストより前のブロックとして返されます。この行がないと、resp.content[0] が思考ブロックになる可能性があり、.text の参照に失敗します。Claude Sonnet 5 は現在、レガシーモデルです。後継の claude-sonnet-5-5 は disabled を拒否します。そこで使用する設定は thinking={'type': 'between_tools'} です。この設定により、回答前の思考が無効になります。

system prompt の単語数制限により、料金の高い部分の上限が設定されます。与えられた行だけを使うという指示は、信頼せずに検証する必要があります。ブロックから 1 列削除して再実行し、出力を確認します。その列の数値がまだ表示される場合、モデルが不足分を補っています。prompt の制約が不十分です。このテストは 2 分で実施でき、実際に確認するための唯一の確実な方法です。

モデルが API key、データベースのパス、クエリの実行結果を認識することはありません。モデルが受け取るのは行データで、返すのは文章です。この境界があるため、出力を検証できます。メモ内のすべての数値は、送信したブロックにも含まれているはずなので、行ごとに比較できます。Web から取得したページまで入力に含めると、この境界の形が変わります。見出しやフォーラムの投稿には、あなたに向けた事実ではなく、モデルに向けた指示が含まれる場合があるためです。これは、エージェントに SearXNG の検索バックエンドを提供することで生じる攻撃対象領域です。prompt の使い方について詳しくは、財務分析に Claude を使用するで、モデルが読み取るのに適した情報をさらに説明しています。

1 回の実行を最初から最後まで

New York 時間の 16:20 に、タイマーがサービスを起動します。refresh.py は最後に保存した日から各 ticker のデータをフィードに要求し、それぞれ 1 本または 2 本の新しい bar を書き込み、ticker ごとに 1 行を出力します。screen.py は同じファイルを開き、移動平均のクエリを実行して、数行の結果を受け取ります。その結果を数百 token のテキストブロックにします。1 回の API 呼び出しで短いメモに変換し、そのメモを journal に送り、token 数とヒット数を含む 1 行を runs に書き込みます。

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

正常なログには、ticker ごとに 1 行、続いてメモ、最後に research-refresh.service: Deactivated successfully が記録されます。サーバー上の Claude Code セッションでこのログを読みながら、別のセッションで画面上のクエリを実行している場合は、2 つのセッション間でテキストを渡せます。そのため、ターミナル間で行を手作業でコピーする必要がありません。1 週間後、データベースから実際のコストを読み出します。

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

これらの合計に、読み取った日のモデルが示す 100 万 token あたりの料金を掛けます。これにより、誰かの見積もりではなく、実際の金額を確認できます。公開料金は変わります。計算方法は変わりません。

バックテストが過学習する理由と、その過程を監視する方法

2 つのウィンドウ長の関数としてスクリーニングを作り直し、パラメーターの組み合わせを格子状に総当たりして、リターンの順に並べます。最良の組み合わせは非常に優れているように見えます。問題はその結果ではなく、そこに至る方法です。200 組のグリッドは 200 回の実験に相当し、その中で最も運がよかったものを選んでいます。

この過程は 10 分で確認できます。ストアを日付で半分に分割します。前半だけでグリッドを総当たりし、勝者を記録します。同じグリッドを後半でも実行します。2 つの勝者が大きく異なるなら、パラメーターはノイズに適合しています。調整に使った期間でしか勝てない組み合わせは、明日の結果を示しません。

生存者バイアスは、過学習より深刻です。過学習と異なり、パラメーター調整では修正できないためです。ティッカーの一覧が現在の指数構成銘柄である場合、そこには生き残った企業しか含まれません。2019 年に上場廃止になったティッカーをフィードに問い合わせても、空のフレームが返ります。その企業はストアにもテストにも一度も入りません。実行するすべてのバックテストで、失敗した銘柄はすでに除外されています。

修正再表示されたファンダメンタルズは、時系列を壊します。API が今日返す 2019 年の四半期の売上高は、2019 年に公表された数値と一致しない場合があります。現在のファンダメンタルズと 2019 年の価格を混在させるスクリーニングは、その時点では存在しなかった情報を使用しています。価格は通常、この点では安全です。ファンダメンタルズは通常、安全ではありません。

調整済み価格は後から変わります。 auto_adjust=True では、終値が配当と株式分割を反映して過去にさかのぼって調整されるため、同じクエリを翌月に実行すると、少し異なる履歴が返されます。実際に使用した行を保存することで、結果を再現できるようになります。これもローカルストアが必要な理由の 1 つです。

このバックテストは、手数料とスリッページも無視し、注文によって価格が動かないと仮定しています。これらは執行に関する事項であり、ここでは対象外です。詳細は VPS 上で取引ボットを実行する で説明します。

失敗パターンと表示される文字列

error: externally-managed-environment が pip の実行時に表示される場合は、仮想環境の外にいます。/opt/research/venv/bin/pip を絶対パスで呼び出してください。

Could not set lock on file の後に PID が表示される場合は、別のプロセスが DuckDB ファイルを開いたまま書き込みを保持しています。通常は、終了し忘れた対話シェルです。そのプロセスを終了してください。書き込み側が開いたままでは、2 つ目の接続を read_only=True で開いても解決しません。DuckDB は複数プロセスによる読み取り、または 1 プロセスによる書き込みのどちらか一方しか同時に許可しないためです。

systemctl status の Main process exited, code=killed, status=9/KILL が表示される場合は、メモリ不足により kernel がジョブを強制終了しています。journalctl -k | grep -i oom で確認し、store.py の memory_limit を下げてください。

正常終了したのに何も書き込まれない。 systemctl status は active (exited) を読み取りますが、テーブルの行数が増えていません。feed が空のフレームを返しています。この失敗は最も見つけにくいため、すべての ticker が空で返った場合にジョブが non zero で終了するようにしてください。

市場休日に timer が実行された。 systemd は取引所のカレンダーを認識しないため、Mon-Fri には休日が含まれます。実行自体は行われますが、feed に新しいデータはありません。この場合、ジョブはエラーではなく正常な状態として扱う必要があります。

API からの 429。 レート制限を超えています。Anthropic SDK は自動的にバックオフ付きで再試行し、Anthropic(max_retries=5) によって試行回数が増えます。それでも毎日失敗する場合は、ジョブが 1 回のバーストで要求しすぎています。

これは何ではないか

これはリサーチアシスタントです。モデルが提出書類を要約すると、その書類の読み取り結果が生成されます。本文に印字された数値を、確信を持って誤ることもあります。そのため、ノート内のすべての数値は、送信した行まで追跡できなければなりません。出力は、自分で読むべき項目の候補一覧として扱ってください。ここで提供するものは金融助言ではなく、シグナルでもありません。

バックテストは、アイデアを退けるには有用ですが、アイデアを確認する用途には不向きです。自分のデータで失敗する戦略は、実際に使えません。通過した戦略も、自分のデータを生き残っただけです。これは、1am に感じるほど強い主張ではありません。

執行は意図的に対象外としています。注文とブローカーの認証情報は、read only のリサーチ用ボックスとは異なるリスク特性を持ちます。両者を混在させると、取引キーを LLM のプロンプトと同じマシンに置くことになります。このパターンを、自分のサーバーで実行する価値がある他の構成と並べて確認したい場合は、自分でホストする価値がある AI エージェントで全体像を確認できます。

FAQ

有料の市場データフィードは必要ですか?

プロトタイプには必要ありません。無料の非公式フィードで、システムの構造を学ぶことはできます。ただし、そのフィードは、提供元が継続提供を保証していない Web サイトに依存しているため、停止します。通常は例外ではなく空のフレームとして停止するため、ジョブで行数を確認する必要があります。データが意思決定に使われ始めたら、ドキュメント化された API とサポート窓口を備えた有料フィードに移行してください。ストア層があると、この切り替えのコストを抑えられます。変更するのは取得関数だけで、スケジュール、スキーマ、画面はそのまま維持できます。

価格データは SQLite と DuckDB のどちらに保存すべきですか?

DuckDB はカラム型で、多数の行をスキャンして集計する処理に適しています。10 年分のバーから移動平均を計算する処理が、まさにこれに当たります。SQLite は行指向で、複数のプロセスから小さな読み書きを同時に行う用途に適しています。1 つの定期ジョブが数百行を追加した後、数百万行をスキャンする構成なら、DuckDB のほうが適しています。複数のプロセスが同時に書き込む必要がある場合は、WAL モードの SQLite を使うと、1 つのライターがコミットしている間もリーダーが処理できます。busy timeout を設定すれば、他のライターは即時に失敗せず、待機します。

LLM の呼び出し料金は月額でどの程度ですか?

すべてのレスポンスから usage.input_tokens と usage.output_tokens をテーブルに記録し、確認した日のモデルの 100 万トークンあたりの料金を使って、週次合計を月次に換算してください。次の四半期にも有効な金額は、この方法で計算した値だけです。短いスクリーニングを毎日実行する場合、呼び出し回数は少なくなります。制御しやすいのはメモの長さです。要約を 200 語に制限すると、行数を減らすよりも料金を抑えられます。すべての Claude model では、出力トークンの料金が入力トークンより高いためです。

ジョブは成功したのに、新しい行が書き込まれないのはなぜですか?

一般的な原因は 2 つあります。1 つ目は市場が休場していた場合です。systemd の Mon-Fri スケジュールには、取引所の休場日が含まれます。2 つ目は、フィードがすべてのティッカーについて空のフレームを返した場合です。クライアントライブラリは、これを例外ではなく警告として表示することが多いため、プロセスは 0 で終了し、systemd も実行成功として表示します。SELECT max(day) FROM prices を直近の実際の取引日と比較して、両者を区別してください。すべてのティッカーが空だった場合は、ジョブを非ゼロで終了させます。

エージェントに購入対象を決めさせることはできますか?

できません。そう試みるように構築すると、この種のプロジェクトは失敗します。モデルには市場へのアクセスがなく、保有状況や税務状況も把握できません。また、自身の数値を他の情報と照合する手段もありません。モデルが得意なのは、大量のテキストを読み、今日確認すべき少数の項目を示すことです。モデルが生成した内容は金融アドバイスではありません。判断と、その責任は、あなたにあります。