如何在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。笔记本电脑在这两个时间通常都处于休眠状态。错过一次运行的代价不只是晚收到一条通知:每日行情柱通常可以稍后重新获取,但任何被修订的数据都无法恢复,因此记录中的缺口将永久存在。
第二个原因影响较小,但同样实际。服务器中只保存一个 API key,位于一个文件中,由一个没有登录 shell 的系统用户拥有,并仅供一个任务使用。在同时用于浏览网页的笔记本电脑上,很难实现同样的隔离。不要将 key 写入代码,也不要将其放入模型输入中。相关内容请参阅 避免让 AI 代理接触 API key。
计算资源:磁盘、RAM 和 token
磁盘最容易估算。每天每个 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 行十年后达到 1,260,000 行。每行包含一个日期和少量 double 值。DuckDB 会压缩存储列,因此实际占用是几十 MB,而不是 GB。不要完全相信这个估算,包括我的估算。完成首次回填后运行 du -h /opt/research/data/market.duckdb,使用您自己的数据。
RAM 是小型 VPS 的限制所在。默认情况下,DuckDB 会为单个查询占用机器的大量内存和全部 CPU 核心。在分析服务器上这通常合理,但在还运行其他服务的 2 GB 服务器上则不合适。此时,对整个 prices 表执行一次聚合可能导致内核终止该进程,systemd 报告 Main process exited, code=killed, status=9/KILL,而 journalctl -k 会显示内存不足导致的终止。显式设置 memory_limit 和 threads 后,查询会变慢,但不会被终止。
应测量 token,而不是估算。每个 Messages API 响应都包含一个 usage 对象,其中有 input_tokens 和 output_tokens。每次调用都将这两个值写入表中。一周后,您就能知道实际用量,再乘以检查当天模型列出的价格。两点足以用于规划。所有 Claude 模型的输出 token 单价都高于输入 token,因此将摘要限制为 200 个单词,比减少发送的数据更能降低费用。提示缓存对每天运行一次的任务没有帮助,因为缓存有效期按分钟计算:到下一次运行时,缓存块已经过期,您需要再次支付完整的输入价格。缓存适用于一次运行在同一个大型文本块上发起多次调用的场景。
安装组件
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 写入其中。虚拟环境不是可有可无的做法。它是 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 会覆盖该股票代码和日期已经存在的行,因此回填运行两次不会向表中重复写入数据。如果没有该主键,崩溃后重新运行一次就会悄悄复制每根 K 线,之后计算的所有平均值都会错误,而且不会有任何错误消息提示原因。
DuckDB 只允许一个进程以写入方式打开文件。第二个写入进程会立即失败,并显示 Could not set lock on file,后面跟着持有文件的 PID。实际中,持有文件的通常是您在另一个终端中留下的交互式 duckdb shell。读取进程可以通过 read_only=True,因此 connect 会使用该标志。如果确实有多个进程需要同时写入,应使用其他数据库引擎:启用 WAL 模式的 SQLite 允许读取进程在一个写入进程提交时继续工作,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 分钟后再次运行,每行应显示 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 小时编写的定时器每年会因夏令时切换两次而偏移 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 的服务类型,并按顺序运行这些服务;其中一个服务以非零状态退出后,后续服务将停止运行。这正是所需的行为:刷新失败后不得继续生成基于过期数据的屏幕。对于因内核更新而重启的 VPS,Persistent=true 很重要。如果没有它,服务器在 16:15 重启时,本次运行会直接丢失;有了它,机器恢复运行后任务会立即执行。
sudo systemctl daemon-reload
sudo systemctl enable --now research-refresh.timer
systemctl list-timers research-refresh.timerlist-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 会对现有的所有行求平均,因此某个 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 从每个代码上次存储的日期开始请求数据源,为每个代码写入一到两根新 K 线,并为每个代码输出一行日志。screen.py 打开同一个文件,执行移动平均查询,并返回少量数据行。这些数据行会组成一个包含几百个 token 的文本块。一次 API 调用将其转换为简短备注,备注写入日志,同时有一行记录写入 runs,其中包含 token 数量和命中次数。
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;将这些总数乘以读取当天模型列出的每百万 token 价格。这样得到的是真实金额,而不是他人的估算值。公开价格会变化,但计算方法不会。
为什么回测会过拟合,以及如何观察它发生
将筛选条件改写为两个窗口长度的函数,遍历所有参数组合,并按收益率排序。表现最好的组合看起来会非常出色。这正是问题所在,而不是结果本身。包含 200 个组合的网格就代表 200 次实验,而您只是保留了其中最幸运的结果。
您可以在十分钟内观察到这一点。按日期将数据集分成两半。只在前半段遍历参数网格,并记录胜出的组合。再在后半段遍历同一网格。如果两次胜出的组合相差很大,说明参数拟合了噪声;某个组合只在用于调参的那一半数据上胜出,并不能说明它在明天有效。
幸存者偏差比过拟合更严重,因为调参无法修复它。您的 ticker 列表包含的是今天的指数成分股,因此只包含存续至今的公司。向数据源请求 2019 年退市的 ticker 时,返回的却是空数据框。这意味着该公司从未进入您的数据存储,也从未进入测试集。您运行的每次回测都已经排除了失败的公司。
重述后的基本面数据会破坏时间线。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 文件,通常是您忘记关闭的交互式 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),但表的记录数没有增加。数据源返回了空数据帧。这种故障最容易被忽略,因此当所有 ticker 返回空结果时,应让任务以非零状态退出。
定时器在市场休市日触发。 systemd 不知道交易所日历,因此 Mon-Fri 包含休市日。任务会运行,但数据源没有新数据。任务应将此情况视为正常情况,而不是错误。
API 返回 429。 您超出了速率限制。Anthropic SDK 会自动使用退避策略重试,Anthropic(max_retries=5) 会增加尝试次数。如果每天仍然失败,说明任务在一次突发请求中请求了过多数据。
这不是什么
这是一个研究助手。模型总结申报文件时,输出的是对该文件的解读;它可能会自信地误读文本中的某个数字。因此,说明中的每个数值都必须能追溯到您发送的某一行。请将输出视为一份供您自行阅读的候选事项清单。本文不构成任何金融建议,也不提供任何交易信号。
回测适合用于否定想法,但不擅长确认想法。某个策略在您自己的数据上失败,说明它确实不可行。某个策略通过测试,只能说明它经受住了您这份数据的检验;这比凌晨1点时感觉到的结论要弱得多。
执行操作明确不在本文范围内。订单和经纪商凭据与只读研究服务器具有不同的风险特征。将二者混用,会使交易密钥与 LLM 提示词处于同一台机器上。如果您想了解这种模式与其他适合在自有服务器上运行的方案之间的关系,请参阅值得自行托管的 AI agent,其中会做更全面的介绍。
FAQ
我需要付费的市场数据源吗?
原型阶段不需要。免费的非官方数据源足以帮助您了解系统结构,但它可能会失效,因为它依赖一个对您不承担任何义务的网站。它通常不是抛出异常,而是返回空数据框,因此任务必须检查行数。一旦数据开始用于支持决策,就应切换到提供文档化 API 和支持邮箱的付费数据源。数据存储层可以降低切换成本:只需更换获取函数,调度、架构和界面都无需改变。
我应该将价格数据存储在 SQLite 还是 DuckDB 中?
DuckDB 使用列式存储,适合扫描大量行并计算聚合值,这正是计算十年 K 线移动平均所需的操作。SQLite 使用行式存储,更适合多个进程同时进行大量小规模读写。对于单个定时任务追加几百行数据,然后扫描数百万行的场景,DuckDB 更合适。如果必须让多个进程同时写入,SQLite 的 WAL 模式允许读取者在一个写入者提交事务时继续工作;设置 busy timeout 后,其他写入者会等待,而不是直接失败。
LLM 调用每月需要多少费用?
将每次响应中的 usage.input_tokens 和 usage.output_tokens 记录到表中,然后将每周总量乘以您检查当天模型列出的每百万 token 价格。只有这个数值在下个季度仍然可靠。每天对一组较短的筛选结果运行一次时,调用次数不会很多。您可以控制备注的长度:将摘要限制为 200 个词,比减少发送的数据行更节省费用,因为每个 Claude 模型的输出 token 定价都高于输入 token。
为什么我的任务执行成功,却没有写入新行?
通常有两个原因。市场当天休市,因为 systemd 的 Mon-Fri 调度包含交易所假日。或者数据源为每个股票代码都返回了空数据框,而客户端库通常只打印警告,不抛出异常,因此进程仍以退出码 0 结束,systemd 也仍会显示任务成功。比较 SELECT max(day) FROM prices 与最近一个实际交易日即可区分这两种情况;当所有股票代码都返回空结果时,应让任务以非零状态退出。
智能体可以决定买入什么吗?
不可以。让它尝试这样做,正是这类项目容易出问题的原因。模型无法访问市场,也不了解您的持仓或税务情况,无法将自己的计算结果与任何可靠数据进行核对。它擅长的是阅读大量文本,并告诉您今天哪些少数项目值得关注。它生成的内容不构成投资建议,决定权及由此产生的责任仍由您承担。