VPS 自架股票研究代理程式教學
使用 Python、DuckDB、systemd timer 與 Claude API,在 VPS 建立每日收盤後執行的股票研究代理程式,擷取資料、保存本機紀錄並由 LLM 篩選摘要。
什麼是自架的股票研究代理程式
自架的股票研究代理程式,是執行於您所擁有伺服器上的小型程式。它依排程擷取市場資料,將資料保存在本機資料庫中,先進行篩選,再要求大型語言模型(LLM)整理變更內容。它只負責讀取與篩選,不會執行交易。本指南中的任何內容都不構成財務建議。
兩位從頭建立這類系統的人,可能會選用不同的函式庫,但最後仍會得到相同的四個部分:提供價格與基本面的資料來源、保存所有已擷取資料列的本機儲存區、依計時器更新儲存區的工作,以及將保留下來的資料列轉換成句子的 LLM 層。本指南使用 Python、DuckDB、systemd timer 和 Claude API(應用程式介面)建立這個架構。交易執行屬於另一項工作,也有不同的失敗模式,因此應改放在為交易機器人設定的 VPS上。
四個部分及其功能
資料來源是唯一與外部世界通訊的部分。它知道如何要求取得股票代號與日期範圍,也知道如何回傳資料列。後續所有元件都讀取你的資料庫,而不是直接讀取資料來源。因此,資料來源中斷時,你只會少一天的新資料,不會導致畫面無法使用。
資料儲存層是整個流程的核心。未記錄的每日收盤價通常可以稍後重新取得。盤中報價、尚未修訂前的估計值,或尚未重編前的基本面數據,則無法保證重新取得。資料儲存層讓你建立一份紀錄,保存資料在當天實際呈現的內容。
排程器決定何時執行更新。在伺服器上,這通常是 systemd timer,因此在這個架構中,VPS 比程式碼更重要。
LLM 層讀取由 SQL 產生的短文字區塊,並撰寫摘要。它不會連線到資料庫,也不會建立查詢。如果由模型撰寫 SQL,一個錯誤的 token 就可能變成流暢句子中的錯誤數字,而且沒有可供比對的依據。如果由 SQL 產生數字,模型最多只會把文字寫錯,而你可以將文字與傳入的資料列互相比對。
為何要在 VPS 上執行,而不是在筆記型電腦上
原因在於排程。美國市場於紐約時間 16:00 收盤時,柏林時間是 22:00,雅加達時間則是隔天 04:00。筆記型電腦在這兩個時間通常都處於睡眠狀態。錯過一次執行所造成的影響不只是晚收到通知:每日 K 線通常可以稍後重新擷取,但已遭修訂的資料無法還原,因此紀錄中的缺口會永久存在。同樣的道理也適用於排程和儲存狀態都必須在重新開機後保留的 agent;將 KiroCrew 作為自有 VPS 上的常駐 agent 執行就是以此為前提。
第二個原因較次要,但確實存在。伺服器只儲存一組 API key,放在一個檔案中,由一個沒有 login shell 的系統使用者擁有,並僅供一個工作使用。要在你同時用來瀏覽網頁的筆記型電腦上做到同樣的隔離,困難得多。請將 key 排除在程式碼和模型輸入之外;避免 AI agent 接觸 API key的內容正是這個主題。
磁碟、RAM 與 tokens 的容量估算
磁碟是最容易估算的部分。每天一根 K 線代表每個 ticker 在每個交易日各有一列,而美國一年約有 252 個交易日。
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 個 ticker 在一年後會有 5,040 列。500 tickers 行在 10 年後會達到 1,260,000 列。每列包含日期與少量 double,而 DuckDB 會壓縮儲存欄位,因此用量是數十 MB,而不是數 GB。不要盲信這個估算,包括我的估算。完成第一次回補後執行 du -h /opt/research/data/market.duckdb,以實際數值為準。
RAM 才是小型 VPS 的限制所在。DuckDB 預設會為單一查詢使用主機的大部分記憶體與所有核心。這對分析伺服器是合理的,但對同時執行其他服務的 2 GB 主機並不適用。此時,對整個 prices 資料表執行一次彙總,可能會讓核心終止該程序;systemd 會回報 Main process exited, code=killed, status=9/KILL,而 journalctl -k 會顯示因記憶體不足而終止程序。明確設定 memory_limit 與 threads 後,查詢會變慢,而不是直接終止。
tokens 應透過實際資料計量,而不是估算。每個 Messages API 回應都包含 usage 物件,其中有 input_tokens 與 output_tokens。每次呼叫都將這兩項寫入資料表。持續一週後,即可得知實際用量,再乘以查詢當日模型列出的價格。以下兩點足以作為規劃依據。所有 Claude 模型的 output tokens 定價都高於 input tokens,因此將摘要限制在 200 個單字,對費用的影響大於刪減傳送的資料。其次,prompt caching 不適合每天執行一次的工作,因為快取有效期以分鐘計算:下一次執行時,快取區塊已經過期,必須再次支付完整的 input 價格。當一次執行會針對同一大段文字發出多次呼叫時,快取才有成本效益。
安裝所需元件
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,因為 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會覆寫該 ticker 與日期已存在的資料列,因此回填執行兩次不會讓資料表出現重複資料。沒有主鍵時,當機後重新執行會悄悄重複每根 K 線,之後計算的所有平均值都會錯誤,而且不會有任何錯誤訊息提示問題。
DuckDB 只允許一個程序開啟檔案並進行寫入。第二個寫入程序會立即失敗,並顯示 Could not set lock on file 及持有檔案的 PID;實務上,這通常是你在另一個終端機中忘記關閉的互動式 duckdb shell。讀取者可通過 read_only=True,因此 connect 會接受該旗標。如果確實有多個程序需要在同一時間寫入,就應改用其他資料庫引擎:SQLite 的 WAL 模式允許讀取者在單一寫入者提交期間繼續運作,而 busy timeout 會讓其他寫入者等待,而不是直接失敗。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()先手動執行一次:
sudo -u research /opt/research/venv/bin/python /opt/research/refresh.py第一次執行會回填數年的資料,並為每個 ticker 輸出一行,筆數通常達到數千筆。等待一分鐘後再次執行,每行應只顯示 1 或 2 筆資料,因為工作會從已儲存的最後一天開始。第二次執行才是真正的測試:如果筆數仍然是數千筆,表示 max(day) 沒有回傳資料,而插入作業每晚都在重寫整段歷史資料。
df.empty 檢查是檔案中最重要的一行。Ticker.history() 不會因為 symbol 錯誤或已下市而引發例外。它會輸出警告,指出該 symbol 可能已下市且找不到價格資料(確切文字會因 library 版本而異),並回傳空的 DataFrame。沒有這項檢查的工作不會寫入任何資料,會以狀態碼 0 結束,而 systemd 仍會顯示健康的綠色執行狀態,資料表卻會在不知不覺中停止成長。幾週後,你才會從每天都回傳相同資料列的畫面發現問題。
時間戳記的處理也有原因。feed 回傳的 index 可能包含交易所時區;移除時區偏移,並不等同於轉換成 UTC。東京交易時段若標記為當地時間午夜,轉換成 UTC 後會落在前一個曆日。因此,轉換成 UTC 會在不知不覺中讓每根日本 K 線往前移一天,並破壞 primary key。tz_localize(None) 會保留交易所自身的交易日期,這正是 daily bar 所代表的日期。
收盤時觸發的 timer
美國市場在 New York time 16:00 收盤,最後幾筆成交資料需要幾分鐘才能完成,因此工作會在 16:20 執行。請以 New York time 撰寫,不要使用 UTC。New York 在冬季為 UTC minus 5,夏季為 UTC minus 4。因此,使用固定 UTC 小時設定的 timer 每年會有 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.targetType=oneshot 是唯一接受多個 ExecStart 的服務類型,並會依序執行這些項目;其中一項以非零狀態結束時就會停止。這正是所需的行為:refresh 失敗後,不應繼續對過時資料執行 screen。Persistent=true 對於會因核心更新而重新開機的 VPS 很重要。若在 16:15 重新開機但未設定此項,該次執行就會遺失;設定後,機器恢復運作時便會立即執行工作。
sudo systemctl daemon-reload
sudo systemctl enable --now research-refresh.timer
systemctl list-timers research-refresh.timerlist-timers 應顯示 NEXT 欄位,其中包含下一次工作日執行時間,並轉換為伺服器自身的 local time。清單為空表示 timer 未啟用,或 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,
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()系統提示中的字數上限,限制了費用中最昂貴的部分。至於「只能使用提供的資料列」這項指示,必須驗證,不能直接信任:從資料區塊中刪除一個欄位後重新執行,再閱讀輸出。如果該欄位的數值仍然出現,表示模型自行補上了缺漏,提示內容就不夠嚴謹。這項測試只需 2 分鐘,也是確認實際情況唯一可靠的方法。
模型永遠看不到 API key、資料庫路徑,也不會執行查詢。它接收資料列並產生文字。這個界線讓輸出可以驗證,因為備註中的每個數字也都應該出現在你傳送的資料區塊中,你可以逐行比對。如需進一步了解提示設計,使用 Claude 進行財務分析會更深入說明模型擅長讀取哪些內容。
完整執行流程
在紐約時間 16:20,計時器會啟動服務。refresh.py 會從最後儲存的日期開始,向資料來源要求每個 ticker 的資料,為每個 ticker 寫入一或兩根新 bar,並逐一輸出一行。screen.py 會開啟同一個檔案,執行移動平均查詢,並取得數筆資料列。這些資料列會組成一段數百個 token 的文字。一次 API 呼叫會將其轉換成簡短備註,備註會寫入日誌,並將一筆包含 token 數量與命中次數的資料寫入 runs。
journalctl -u research-refresh.service -n 50 --no-pager正常的日誌會依序包含每個 ticker 的一行、備註,以及 research-refresh.service: Deactivated successfully。執行一週後,可從資料庫讀回實際成本:
SELECT count(*) AS runs,
sum(input_tokens) AS in_tokens,
sum(output_tokens) AS out_tokens
FROM runs;將這些總數乘以模型在查詢當日列出的每百萬個 token 價格。如此即可取得實際數值,而不是他人的估算。公開列出的價格會變動,但計算方式不變。
回測為何會過度擬合,以及如何觀察它發生
將篩選條件改寫為兩個視窗長度的函式,掃描所有參數組合,再依報酬率排序。最佳組合看起來會非常出色。問題就在這裡,而不是結果本身。包含 200 組參數的網格就是 200 次實驗,而你只是保留了其中運氣最好的一組。
你可以在 ten minutes 內觀察它發生。依日期將資料分成兩半。只在前半部掃描網格,並記下勝出的組合。接著在後半部掃描相同的網格。如果兩次勝出的組合差異很大,表示參數是在擬合雜訊;而只在調參所用的那半部資料中勝出的組合,無法說明明天會發生什麼。
存活者偏差比過度擬合更嚴重,因為調參無法修正它。你的 ticker 清單是今天的指數成分股,因此只包含存活至今的公司。向 feed 查詢 2019 年下市的 ticker 時,得到的會是空資料框。這表示該公司從未進入你的 store,也從未進入測試。你執行的每次回測,都已經排除了失敗者。
重述後的基本面資料會破壞時間軸。API 今天回傳的 2019 年某季營收數字,不一定是 2019 年當時公布的數字。將今天的基本面資料與 2019 年的價格混用,等於使用當時尚不存在的資訊。價格通常沒有這個問題,基本面資料通常有。
調整後的價格會在你使用期間改變。使用 auto_adjust=True 時,收盤價會因股利與分割而向歷史回溯調整,因此下個月執行相同查詢時,取得的歷史資料可能會略有不同。儲存實際使用的資料列,才能讓結果具備可重現性;這也是 local store 存在的另一個原因。
回測也忽略了手續費與滑價,並假設你的委託不會影響價格。這些內容屬於執行層面,超出本文範圍,請參閱 在 VPS 上執行 trading bots。
失敗模式與你會看到的訊息
error: externally-managed-environment 出現在 pip 執行時。你目前不在虛擬環境中。請使用完整路徑呼叫 /opt/research/venv/bin/pip。
Could not set lock on file,後面接著 PID。其他程序正以寫入模式開啟 DuckDB 檔案,通常是你忘記關閉的互動式 shell。請關閉該程序,或使用 read_only=True 開啟第二個連線。
Main process exited, code=killed, status=9/KILL 出現在 systemctl status 中。核心因記憶體不足而終止工作。請使用 journalctl -k | grep -i oom 確認,然後降低 store.py 中的 memory_limit。
執行成功但沒有寫入資料。 systemctl status 讀取 active (exited),但資料表沒有增加資料列。這表示資料來源回傳了空的 frame。這種失敗最不容易察覺,因此當所有 ticker 都回傳空資料時,應讓工作以非零狀態結束。
計時器在市場休市日觸發。 systemd 不知道交易所行事曆,因此 Mon-Fri 會包含休市日。工作仍會執行,但資料來源沒有新資料。工作應將此情況視為正常,而不是錯誤。
API 回傳 429。 你已超過速率限制。Anthropic SDK 會自行採用退避機制重試,而 Anthropic(max_retries=5) 會提高嘗試次數。如果每天仍然失敗,表示工作在單次突發請求中要求的資料過多。
這不是什麼
這是研究助理。模型會摘要申報文件,但可能會自信地誤讀文件中的數字。因此,筆記中的每個數值都必須能追溯至你傳送的資料列。請將輸出視為待自行閱讀事項的候選清單。本文不構成任何財務建議,也不代表任何訊號。
回測適合用來排除想法,不適合用來確認想法。策略若在你自己的資料上失敗,就確實已經失效。策略若通過,也只代表它通過了你的資料驗證;這項結論遠比凌晨 1 點時的直覺來得有限。
執行交易刻意不在本文範圍內。訂單與經紀商憑證的風險特性,和唯讀研究主機不同。將兩者混用,會把交易金鑰與 LLM 提示放在同一台機器上。若你想了解這種架構與其他適合在自有伺服器上執行的項目之間的定位,值得自行託管的 AI agents 提供更完整的介紹。
FAQ
我需要付費的市場資料串流嗎?
原型階段不需要。免費的非官方資料串流足以協助你了解系統的運作方式,但它一定可能中斷,因為它依賴一個對你沒有任何義務的網站。它通常會回傳空的資料框,而不是拋出例外,因此工作流程必須檢查資料列數。當資料開始用於支援決策後,請改用具備正式文件與支援聯絡方式的付費資料串流。資料儲存層能讓這項替換成本很低:只需修改擷取函式,排程、結構描述與畫面都能維持不變。
我應該將價格資料存放在 SQLite 還是 DuckDB?
DuckDB 採用欄式儲存,適合掃描大量資料列並計算彙總值,這正是計算 10 年 K 線移動平均所需的操作。SQLite 採用列式儲存,更適合多個程序同時進行大量小型讀寫。若只有單一排程工作,每次附加數百列資料,接著掃描數百萬列資料,DuckDB 會更適合。若必須讓多個程序同時寫入,SQLite 的 WAL 模式可讓讀取程序在單一寫入程序提交資料時繼續運作;設定 busy timeout 後,其他寫入程序會等待,而不是直接失敗。
LLM 呼叫每月的成本是多少?
將每次回應中的 usage.input_tokens 與 usage.output_tokens 記錄到資料表,然後以每週總量乘以你查詢當日模型列出的每百萬 token 價格。這是唯一能在下個季度仍然成立的數字。每天對少量項目執行一次篩選時,呼叫次數不多;你能控制的是備註長度:將摘要限制在 200 個單字,比減少傳送的資料列更能節省成本,因為每個 Claude 模型的輸出 token 定價都高於輸入 token。
為什麼我的工作成功執行,卻沒有寫入新資料列?
通常有兩個原因。市場當天休市,因為 systemd Mon-Fri 排程包含交易所假日。或者資料串流對每個 ticker 都回傳空的資料框;用戶端函式庫通常會將此情況顯示為警告,而不是拋出例外,因此程序仍會以 0 結束,systemd 也仍會顯示成功執行。請將 SELECT max(day) FROM prices 與最近一個實際交易日比較,以區分這兩種情況;當所有 ticker 都回傳空資料時,應讓工作以非零狀態結束。
agent 能決定要買什麼嗎?
不能,也不應建置成嘗試這麼做。模型無法存取市場資訊,也不了解你的持倉或稅務狀況,並且無法將自身的數值與任何資料交叉驗證。它適合大量閱讀文字,並告訴你今天有哪些少數項目值得注意。它產生的任何內容都不是投資建議;決策及其責任仍由你承擔。