SSD Nodes Learn 🎉 VPS $4.99/月起
指南 Matt Connor作者: Matt Connor · 更新于 2026-08-13

如何在VPS上搭建自托管股票研究代理

了解如何用Python、DuckDB、systemd timer和Claude API搭建股票研究代理:收市后自动抓取数据、保存本地并生成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。笔记本电脑在这两个时间点都处于睡眠状态。错过一次运行的代价不只是晚收到一条通知:日线数据通常可以稍后重新获取,但任何已被修订的数据都无法恢复,因此记录中的缺口会永久存在。同样的原因也适用于其他需要在重启后保留调度和状态的代理。这正是 在自己的 VPS 上将 KiroCrew 作为常驻代理运行 所针对的场景。

第二个原因影响较小,但同样实际。服务器上只保存一个 API key,存放在一个文件中,由一个没有登录 shell 的系统用户拥有,并由一个任务使用。在你还要用笔记本电脑浏览 Web 的情况下,要实现同样的隔离要困难得多。不要将 key 放入代码,也不要将其放入模型输入中。相关内容请参阅 将 API key 排除在 AI 代理之外

容量规划:磁盘、RAM 和令牌

磁盘容量最容易规划。每天每个交易标的一根日线对应一行,美国一年约有 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 个交易标的在一年后有 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_limitthreads 后,查询会变慢,但不会被终止。

令牌数量应通过测量获得,而不是估算。每个 Messages API 响应都包含一个 usage 对象,其中有 input_tokensoutput_tokens。每次调用都将这两个值写入表中。一周后,你就能知道实际用量,再乘以检查当天模型列出的价格。以下两点足以用于规划。所有 Claude 模型的输出令牌价格都高于输入令牌价格,因此将说明限制为 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 写入其中。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会将其覆盖。因此,回填操作运行两次也不会在表中产生重复数据。如果没有该主键,崩溃后重新运行一次就会静默复制每根 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

首次运行会补齐多年的数据,并为每个股票代码打印一行,数量通常达到数千。等待一分钟后再次运行,每行应只显示 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.target

Type=oneshot 是唯一接受多个 ExecStart 的服务类型,并按顺序运行这些服务;其中一个服务以非零状态退出后,后续服务将停止运行。这正是所需的行为:刷新失败后,不能继续生成基于过期数据的屏幕。对于因内核更新而重启的 VPS,Persistent=true 很重要。如果没有该设置,服务器在 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 会对现有的所有行求平均值,因此某个 ticker 的第三行会返回三天的平均值,但仍将其称为 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()

系统提示词中的字数限制会限制费用中较高的部分。仅使用给定行的指令需要验证,不能直接信任:从数据块中删除一列,重新运行,然后查看输出。如果该列的数值仍然出现,说明模型补全了缺失内容,提示词的约束还不够严格。此测试只需两分钟,是确认实际情况的唯一可靠方法。

模型永远看不到 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 次实验,而你保留了其中最幸运的组合。

你可以在十分钟内观察到这一点。按日期将数据分成两半。只在前半部分遍历网格,并记录胜出的组合。再在后半部分遍历相同的网格。如果两次胜出的组合相差很大,说明参数拟合的是噪声;而一个只在用于调参的那一半数据上胜出的组合,无法说明明天会怎样。

幸存者偏差比过拟合更严重,因为调参无法修复它。你的股票代码列表使用的是今天的指数成分,因此只包含存续至今的公司。向数据源请求一个在 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 文件,通常是您忘记关闭的交互式 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 代理,其中会进行更全面的介绍。

FAQ

我需要付费的市场数据源吗?

原型阶段不需要。免费的非官方数据源足以帮助你了解系统结构,但它一定可能中断,因为它依赖的是一个对你没有任何责任的网站。它通常会返回空数据帧,而不是抛出异常,因此任务必须检查行数。一旦数据开始用于支持决策,就应切换到提供文档化 API 和支持邮箱的付费数据源。数据存储层能让这次切换成本很低:只需修改获取函数,调度、模式和界面都可以保持不变。

我应该将价格数据存储在 SQLite 还是 DuckDB 中?

DuckDB 使用列式存储,适合扫描大量行并计算聚合值,这正是计算 10 年 K 线移动平均值时所需的操作。SQLite 使用行式存储,更适合多个进程同时执行大量小规模读写。对于单个定时任务追加几百行数据、随后扫描数百万行的场景,DuckDB 更合适。如果必须让多个进程同时写入,WAL 模式下的 SQLite 允许读进程在一个写进程提交事务时继续工作;设置 busy timeout 后,其他写进程会等待,而不是直接失败。

LLM 调用每月需要多少费用?

将每次响应中的 usage.input_tokensusage.output_tokens 写入表中,然后用每周总量乘以模型在查询当天列出的每百万 token 价格。只有这个数值能在下个季度仍然保持准确。每天运行一次、处理较短筛选结果时,调用次数通常很少。你能控制的是备注长度:将摘要限制为 200 个单词,比减少发送的行数更节省费用,因为每个 Claude 模型的输出 token 价格都高于输入 token。

为什么我的任务执行成功了,却没有写入新行?

通常有两个原因。市场当天休市,因为 systemd Mon-Fri 调度包含交易所假日。或者数据源为每个标的都返回了空数据帧;客户端库通常会将其报告为打印的警告,而不是异常,因此进程仍以退出码 0 结束,systemd 仍会显示成功运行。比较 SELECT max(day) FROM prices 与最近一个实际交易日即可区分这两种情况;当所有标的都返回空结果时,应让任务以非零状态退出。

代理可以决定买入什么吗?

不可以,也不应构建一个试图这样做的系统。模型无法访问市场,也不了解你的持仓或税务情况,并且无法将自己的计算结果与任何数据进行校验。它擅长的是读取大量文本,并告诉你今天有哪些少数项目值得关注。它生成的任何内容都不是投资建议;决策及其责任仍由你承担。