SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor

如何自建股票研究代理:VPS 自动化分析指南

本指南介绍如何利用 Python、DuckDB 和 systemd 定时器在 VPS 上构建自托管股票研究代理。通过将市场数据持久化存储并结合 LLM 自动生成分析报告,解决本地设备休眠导致的数据缺失问题,确保每日收盘后的数据完整性与分析准确性。

什么是自托管股票研究代理

自托管股票研究代理是运行在你自有服务器上的小型程序。它按计划拉取市场数据,将其存入本地数据库,进行筛选,并调用大语言模型(LLM)撰写变动报告。它负责读取和过滤。它不进行交易,本指南中的任何内容均不构成财务建议。

从零开始构建这套系统的两个人,可能会选择不同的库,但最终仍会得到相同的四个部分:提供价格和基本面数据的数据源、保存所有已获取数据行的本地存储、按计划刷新存储的任务,以及将保留下来的数据行转换为句子的 LLM 层。本指南使用 Python、DuckDB、systemd timer 和 Claude API(application programming interface)构建这一架构。这是一个采用固定循环的专用代理。如果您想先了解通用版本,可以先阅读 学习 AI 代理的分阶段路径,按顺序了解循环、工具和记忆,然后再将其应用到具体领域。执行是一个独立任务,也有独立的故障模式,因此应将其部署在 为交易机器人配置的 VPS 上。

四个组成部分及其功能

数据源 (The feed) 是唯一与外部世界交互的组件。它负责请求特定代码和日期范围的数据,并将返回的行数据交付给后续流程。下游的所有组件均读取数据库而非直接读取数据源,因此数据源中断只会导致丢失一天的更新数据,而不会导致界面崩溃。

存储 (The store) 是整个系统的核心。未记录的每日收盘价通常可以事后补录,但盘中报价、修订前的预估值或重述前的基本面数据一旦错过便无法找回。存储的作用在于构建一份记录,确保数据在发布当日的原始状态得以留存。

调度器 (The scheduler) 决定刷新操作的执行时间。在服务器上,这通常由 systemd timer 实现,这也是为什么在此场景下 VPS 的配置比代码本身更为关键。

大模型层 (The LLM layer) 读取由 SQL 生成的简短文本块,并据此撰写摘要。它从不直接连接数据库,也不负责构建查询语句。如果由模型直接编写 SQL,一旦出现一个错误的 token,就会在流畅的句子中产生错误的数值,且没有任何参考依据。若由 SQL 生成数值,模型仅可能在文字表述上出错,而你可以通过对比发送给模型的行数据来核实这些文字。

为何选择在 VPS 而非笔记本电脑上运行

调度程序是主要原因。美股市场于纽约时间 16:00 收盘,此时柏林为 22:00,雅加达则为次日凌晨 04:00。笔记本电脑在这两个时间点通常处于休眠状态。错过运行任务的代价远高于收到延迟通知:日线数据通常可以补录,但任何涉及修订的数据一旦错过便无法找回,导致记录出现永久性缺失。对于任何需要维持调度和持久化状态以应对重启的代理程序,逻辑同样适用,这也是 将 KiroCrew 作为常驻代理在个人 VPS 上运行 的核心设计目标。

第二个原因是安全性,虽然影响较小但同样现实。服务器仅持有一个 API 密钥,该密钥存放在单个文件中,由一个无登录 shell 的系统用户所有,并仅供单一任务使用。在日常用于浏览网页的笔记本电脑上,实现这种隔离配置要困难得多。请务必将密钥从代码和模型输入中分离,相关内容请参考 如何防止 API 密钥泄露给 AI 代理。

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

磁盘是最容易规划的部分。每天每个交易品种对应一行数据,每年美国交易日约为 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"
  }
]

二十个交易品种一年后有 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 后,查询会变慢,而不是直接终止。

应测量令牌用量,不要估算。每个 Messages API 响应都包含一个 usage 对象,其中有 input_tokens 和 output_tokens。每次调用都将这两个值写入表中。一周后即可知道实际用量,再乘以检查当天模型列出的价格。以下两点足以用于规划。所有 Claude 模型的输出令牌价格都高于输入令牌价格,因此将摘要限制为 200 个词,比减少发送的数据更能降低费用。此外,提示缓存对每天运行一次的任务没有帮助。缓存默认保留 5 分钟,Anthropic 提供的最长保留时间是 1 小时;写入缓存的费用是基础输入价格的 2x,而 5 分钟缓存是 1.25x。无论采用哪种方式,到第二天运行时缓存块都已过期,必须再次支付完整的输入价格。缓存适用于一次运行期间基于同一大段文本发起多次调用的场景。

安装组件

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。只读连接也无法绕过这一限制。DuckDB 的并发文档描述了两种模式:一个进程负责读取和写入,或多个进程仅读取;两种模式不能混用。connect接受一个 read_only 标志,使多个读取进程可以共享该文件,但前提是没有写入进程打开它。如果确实需要让多个进程同时写入,就应使用另一种数据库引擎: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 转换会静默地将所有日本市场的 K 线数据提前一天,从而破坏主键。tz_localize(None) 保留了交易所自身的会话日期,这正是日线数据的定义。

在收盘时触发的计时器

美国市场按纽约时间 16:00 收盘,最后成交记录还需要几分钟才能完成结算,因此任务在 16:20 运行。请使用纽约时间设置,不要使用 UTC。纽约时间冬季比 UTC 晚 5 小时,夏季比 UTC 晚 4 小时。因此,使用固定 UTC 小时编写的计时器每年会有两次偏移 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 的服务类型,并按顺序运行这些服务;其中一个服务以非零状态退出后,后续服务将停止运行。这正是所需的行为:刷新失败后,不能继续显示过期数据。对于会因内核更新而重启的 VPS,Persistent=true 很重要。如果没有该设置,机器在 16:15 重启时,本次运行会直接丢失;有了该设置,任务会在机器恢复后立即运行。需要持续运行而不是触发后退出的代理,应使用相反的单元结构,即配合 Restart= 规则的长期运行进程,并且完全不使用计时器。使用 systemd 在 VPS 上无头运行 dsh介绍了这种配置。

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 会对现有行进行平均,因此代码段的第三行会计算三天的平均值,但仍将其称为 ma50。若将其与 ma20 进行比较,则会在每个代码段历史记录的开头虚构出一个从未发生的交叉点。通过行号进行过滤,可以剔除窗口未填满的行。

模型能看到什么,以及始终看不到什么

# /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 默认会进行思考,其思考 token 按输出计费,并计入 max_tokens;这些内容会以一个代码块的形式出现在正文之前。如果没有这一行,resp.content[0] 可能就是思考代码块,.text 查找就会失败。Claude Sonnet 5 现在已是旧版模型。其后继模型 claude-sonnet-5-5 会拒绝 disabled;应在该模型中使用 thinking={'type': 'between_tools'},以便在生成答案前关闭思考。

系统提示词中的字数限制会限制费用较高的计费部分。必须验证“仅使用给定行”这一指令,不能直接相信:从数据块中删除一列,再运行一次并读取输出。如果输出中仍出现该列的数值,说明模型补全了缺失内容,提示词约束还不够严格。这个测试只需两分钟,也是唯一可靠的验证方法。

模型看不到 API 密钥,看不到数据库路径,也不会运行查询。它接收数据行并返回文字。正因为存在这条边界,输出才可检查:说明中的每个数字都应出现在发送的数据块中,您可以逐行比较。只要将输入范围扩大到包含从 Web 获取的页面,这条边界就会发生变化,因为标题或论坛帖子可能包含面向模型的指令,而不是面向您的事实;这正是 为代理提供 SearXNG 搜索后端 所暴露的风险面。关于提示词方面的更多内容,使用 Claude 进行金融分析 进一步介绍了模型擅长读取的内容。

单次运行,端到端

纽约时间 16:20,定时器启动该服务。refresh.py 从上次存储的日期开始,向数据源请求每个股票代码的行情,每个代码写入一到两条新 K 线,并为每个代码打印一行日志。screen.py 打开同一个文件,运行移动平均线查询,并返回几行数据。这些行组成一个包含几百个 token 的文本块。一次 API 调用将其转换为简短说明,该说明被存入日志,同时一行记录连同 token 计数和命中次数存入 runs。

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

正常的日志应包含每个代码一行记录,随后是说明,最后是 research-refresh.service: Deactivated successfully。如果您在服务器上的 Claude Code 会话中读取该日志,同时另一个会话在处理屏幕查询,两者可以直接传递文本,无需在终端之间手动复制行。一周后,从数据库中读取您自己的成本:

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 打开第二个连接也没有帮助,因为 DuckDB 允许多个进程读取,或允许一个进程写入,但不能同时进行这两种操作。

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 是列式存储,专为扫描大量行以进行聚合计算而设计,这正是计算十年 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 与上一个实际交易日来区分这两种情况,并在所有代码返回均为空时使作业以非零状态码退出。

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

不能。试图让它具备决策能力是此类项目失败的根源。模型无法访问市场,不了解你的持仓或税务状况,也无法核实自身计算结果的准确性。它擅长的是阅读大量文本,并告诉你今天有哪些项目值得关注。它所写的内容均不构成财务建议,决策及其责任始终由你承担。