SSD Nodes Learn 🎉 VPS $5.50/月〜
ガイド Matt Connor著者 Matt Connor ・更新日 2026-08-13

VPSで自作する株式調査エージェントの構築方法

VPS上で株式調査エージェントを自作する方法を解説します。市場データ、DuckDB、systemd timer、Claude APIを使い、引け後に更新とLLMによるスクリーニングを自動化します。

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

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

これをゼロから構築する2人の開発者が異なるライブラリを選んでも、最終的には同じ4つの部分に分かれます。価格とファンダメンタルズを提供するフィード、取得したすべての行を保持するローカルストア、タイマーでストアを更新するジョブ、残った行を文章に変換するLLMレイヤーです。このガイドでは、Python、DuckDB、systemd timer、Claude API(アプリケーションプログラミングインターフェース)を使って、この構成を構築します。売買の実行は別のジョブとして扱い、異なる障害要因があるため、代わりにトレーディングボット用にセットアップしたVPSで運用します。

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

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

ストアが、この仕組み全体の中心です。記録しなかった日次終値は、通常、後から再取得できます。しかし、日中の価格、修正前の推定値、または再表示前のファンダメンタルズの数値は再取得できません。ストアによって、そのデータが示された日に実際に何を示していたかを記録できます。

スケジューラーは、更新を実行する時刻を決めます。サーバーでは systemd timer を使用します。そのため、この構成ではコードよりも VPS が重要になります。

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

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

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

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

ディスク、RAM、トークンのサイジング

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

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

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

トークン数は推測せず、実測してください。すべての Messages API レスポンスには、usage オブジェクトが含まれます。このオブジェクトには input_tokensoutput_tokens があります。毎回の呼び出しで両方をテーブルに書き込み、1 週間後に実際の使用量を確認します。その値に、確認時点でモデルに設定されている単価を掛ければ、コストを算出できます。計画の基準にしやすい点が 2 つあります。すべての Claude モデルで、出力トークンの単価は入力トークンより高く設定されています。そのため、ノートを 200 語に制限するほうが、送信するデータを削減するよりも請求額に大きく影響します。また、1 日 1 回のジョブではプロンプトキャッシュの効果はありません。キャッシュの有効期間は分単位で、次回の実行までにキャッシュされたブロックが期限切れになり、入力料金を全額支払うことになるためです。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 つ目の書き込みプロセスは、Could not set lock on file と、それを保持している PID を返して直ちに失敗します。実際には、別のターミナルで開いたままの対話型 duckdb shell が原因であることが多いです。読み取りは read_only=True を通過するため、connect ではこの flag を指定します。複数のプロセスが同時に書き込む必要がある場合は、別の engine を使用します。SQLite を WAL mode で実行すると、1 つの writer が commit している間も readers が処理を続けられます。また、busy timeout を設定すると、他の writers は失敗せずに待機します。サーバー用途における 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) は何も返しておらず、毎晩の実行で履歴全体を再挿入しています。

ファイル内で最も重要なのは 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 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] セクションがないため、enabletimers.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,
    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()

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

モデルが API key、データベースのパス、クエリの実行結果を見ることはありません。モデルが受け取るのは行データで、返すのは文章です。この境界があるため、出力を検証できます。メモ内の各数値は、送信したブロック内にも存在するはずです。そのため、行ごとに比較できます。prompt の使い方について詳しくは、財務分析に Claude を使う方法で、モデルが読み取るのを得意とする情報についてさらに説明しています。

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

ニューヨーク時間の 16:20 に、タイマーがサービスを起動します。refresh.py は保存済みの最終日から各ティッカーのデータをフィードに問い合わせ、各ティッカーについて 1 本または 2 本の新しいバーを書き込み、ティッカーごとに 1 行を出力します。screen.py は同じファイルを開いて移動平均のクエリを実行し、数行の結果を受け取ります。その結果を数百トークン程度のテキストブロックにします。1 回の API 呼び出しで短いメモに変換し、そのメモをジャーナルに書き込み、トークン数とヒット数を含む 1 行を runs に格納します。

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

正常なログには、ティッカーごとの 1 行、続いてメモ、最後に research-refresh.service: Deactivated successfully が記録されます。1 週間後に、データベースから実際のコストを確認します。

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

その合計に、確認した日にモデルが示している 100 万トークンあたりの料金を掛けます。これにより、誰かの推定値ではなく、実際の金額が得られます。公開料金は変わります。計算方法は変わりません。

バックテストが過学習する理由と、その発生を確認する方法

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 で開いてください。

Main process exited, code=killed, status=9/KILLsystemctl status に表示されます。カーネルがメモリ不足のためジョブを強制終了しました。journalctl -k | grep -i oom で確認し、store.pymemory_limit を下げてください。

成功しているのに何も書き込まれない。 systemctl statusactive (exited) を読み込み、テーブルの行数は増えていません。フィードが空のフレームを返しました。この障害は発見までに最も時間がかかるため、すべての ticker が空で返った場合はジョブを non zero で終了させてください。

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

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

これは何ではないか

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

バックテストは、アイデアを退けるには有用ですが、正しさを確認する用途には不向きです。自分のデータで失敗する戦略は、実際に使えません。通過した戦略も、自分のデータに対してのみ成立したにすぎません。これは、1am の時点で感じるほど大きな主張ではありません。

執行は意図的に対象外としています。注文と broker credentials には、read only のリサーチボックスとは異なるリスクがあります。両者を混在させると、取引キーを LLM prompt と同じマシンに置くことになります。このパターンを、自分のサーバーで実行する価値のある他の構成と並べた位置付けを確認したい場合は、実行する価値のある self-hosted AI agents が全体像を説明します。

FAQ

有料の market data feed は必要ですか?

プロトタイプには必要ありません。無料の非公式 feed でシステムの構成を学ぶことはできます。ただし、その feed は、何の義務も負わない Web サイトに依存しているため、いずれ動かなくなります。通常は例外ではなく空の frame として失敗するため、ジョブで行数を確認する必要があります。データが意思決定に使われ始めたら、ドキュメント化された API とサポート窓口がある有料 feed に移行してください。store によって、この切り替えを低コストで行えます。変更するのは fetch 関数だけで、schedule、schema、screen はそのまま維持できます。

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

DuckDB は columnar 形式で、多数の行をスキャンして集計する処理に適しています。10 年分の bar に対する移動平均は、まさにこの処理です。SQLite は row-oriented で、複数のプロセスから同時に行われる小規模な読み書きに適しています。1 つの scheduled job が数百行を追加した後、数百万行をスキャンする構成なら、DuckDB の方が適しています。複数のプロセスが同時に書き込む必要がある場合は、WAL mode の SQLite を使うと、1 つの writer が commit している間も reader が処理できます。busy timeout を設定すれば、他の writer は即座に失敗せず、待機できます。

LLM の呼び出しには月額でいくらかかりますか?

すべての response から usage.input_tokensusage.output_tokens を table に記録し、確認した日に model が提示している 1 million token あたりの料金を週次合計に掛けてください。次の四半期にも正確なまま残るのは、この数値だけです。短い screen を 1 日 1 回実行する場合、呼び出し回数は少数です。制御しやすいのは note の長さです。summary を 200 words に制限すると、行数を減らすより大きな節約になります。すべての Claude model では、output token の料金が input token より高いためです。

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

一般的な原因は 2 つあります。1 つ目は市場が休場していた場合です。systemd の Mon-Fri schedule には取引所の休日が含まれます。2 つ目は、feed がすべての ticker に対して空の frame を返した場合です。client library はこれを例外ではなく、表示される warning として報告することが多いため、プロセスは 0 で終了し、systemd でも実行成功として表示されます。SELECT max(day) FROM prices と直近の実際の取引日を比較すれば、原因を区別できます。また、すべての ticker が空だった場合は、ジョブを non zero で終了させてください。

agent に購入対象を決めさせることはできますか?

できません。そうしようとする設計が、この種のプロジェクトを失敗させます。model には市場へのアクセスがなく、保有状況や税務状況も把握できません。また、自分の数値を他の情報と照合する方法もありません。model が得意なのは、大量のテキストを読み、今日注意を向けるべき少数の項目を示すことです。model が出力する内容は financial advice ではありません。判断と、その判断に対する責任は、あなたにあります。