首页 / 文章

切勿将两份资产负债表简单平均:基于受限大型语言模型的股票分析框架

一种从不对两份资产负债表进行平均处理的股票估值方案:按优先级合并雅虎、美国证券交易委员会及FMP的数据,设置货币转换机制,并让本地大型语言模型各司其职。

发布者:REACTAPP.TOP阅读约 10 分钟2204 词

来源: Never Average Two Balance Sheets

本案例研究介绍了一种股票分析工具,该工具将基于 Ollama 的本地智能体与模型无法重新计算的确定性估值引擎相连接。该工具旨在给出一个答案:企业的价值与其当前成本相比如何。它运行六种估值模型和五种诊断评分,然后根据五级标准给出最终判定,涵盖印度、欧洲和美国的上市企业。

最初的版本是一个包含 1,700 行代码的单一 Python 程序。虽然运行正常,但数月之后仍无法回答估值工具最关键的问题:去年三月对某家公司的估值结果是什么,此后又发生了哪些变化?由于没有历史记录、仅有单一数据来源,且缓存会在程序重启时消失,因此无法实现持续跟踪。本文的重点就是将这个程序拆分为五个独立服务,并分析每个模块的功能价值。

划分边界

这五种服务的分工极为明确。marketdata是唯一可以访问互联网的,valuation是唯一会生成数值的,而只有gateway会对外部流量开放。

影响范围而非规模

用于业余项目的微服务常常让人嗤之以鼻。但这里的考量是实际的:不同类型的故障会相互关联,每一种都会耗费时间。由于只有一人使用该系统,流量并非需要考虑的因素。其目标是控制损害范围——某个被重命名的Yahoo字段只会影响单个容器,该容器会记录警告信息;模型挂起也不会阻止分析结果的返回。要测试不同的折扣率,只需重新部署大约600行无需I/O操作的纯函数代码即可。

通用代码存储在共享库中:包括配置文件、ORM模型、会回退到进程内存的Redis缓存、具备重试功能的HTTP客户端,以及一个为所有服务配置相同/health/live和/health/ready接口的工厂类。五个服务都以完全一致的方式报告运行状态。

从开始到结束的单一请求

对GET /api/analyze的调用会首先到达网关,网关再将请求分发到其他服务并整合各服务的响应结果。网关本身不会进行任何计算。由于进行了三次上游调用,浏览器最终只需发起一次请求即可。

@app.get("/api/analyze")
def analyze(q: str, market: str = "US", refresh: bool = False):
    snap = _up("marketdata", "/snapshot",
               params={"q": q, "market": market, "refresh": refresh})
    rates = _up("marketdata", f"/rates/{market}")
    result = _up("valuation", "/analyze", method="POST", json={
        "symbol": snap["symbol"], "market": market,
        "snapshot": snap["data"], "rates": rates, "persist": True,
    })
    # Provenance travels with the numbers, so the UI can show who said what.
    result["sources"] = snap.get("sources", [])
    result["disagreements"] = snap.get("disagreements", [])
    return result

在那个流程的最后阶段,即数据与来源信息一同呈现的方式,最终成为整个重写过程中最有价值的成果。

三个数据来源,绝不简单平均

优先级高于共识

原有的单一系统仅依赖雅虎财经的数据。雅虎财经的基本面数据存在滞后与缺陷,尤其是在最重要的领域——印度和欧洲的中小型上市公司数据方面最为薄弱。

后来又加入了两种其他数据来源。SEC EDGAR的XBRL API能够获取没有API密钥的美国公司数据,且这些数据完全按照申报内容呈现,因此可用于解决数据冲突。Financial Modeling Prep则能提供全球范围的数据覆盖,在雅虎财经最薄弱的领域表现最为出色。

有了三个数据来源就意味着会有三种不同的结果,简单地将它们平均是个诱人的捷径,但应避免这样做。如果将两份资产负债表混在一起,得到的只会是一份从未被正式提交的文件。

优先级顺序是明确的:

# SEC is as-filed, so it outranks everything in the US.
PRIORITY_US    = ("sec", "fmp", "yahoo")
PRIORITY_OTHER = ("fmp", "yahoo")

优先级最高且确实包含该字段的来源会直接胜出。其他所有值都会连同相应的差异一起保留以供显示:

def _dispersion(values: list[float]) -> float | None:
    """Relative spread across sources. 0.0 means they agree exactly."""
    clean = [v for v in values if v is not None]
    if len(clean) < 2:
        return None
    scale = abs(statistics.median(clean))
    if scale < 1e-9:
        return None if max(map(abs, clean)) < 1e-9 else 1.0
    return (max(clean) - min(clean)) / scale

超过5%的差异会被标记为不一致,并显示在数值旁边。如果SEC报告的净收入为130,而雅虎则称其为100,那么隐含的115数值就会掩盖这种矛盾。更好的情况是同时显示两个数值,并明确模型使用了哪一个。

货币问题

更棘手的陷阱与货币有关。 Infosys在印度国家证券交易所上市,其股价以卢比计价,但其账目却是以美元记录的。伦敦市场的股票价格以便士显示,而市值则以英镑计算。如果允许以美元报告数据的机构为以卢比计价的股票填写相关数据,那么计算出的市盈率可能会偏离实际约80倍,却仍看起来合理。

因此,在考虑优先级之前会先进行一项检查:

for name, data in sources.items():
    rc = (data.get("reporting_currency")
          or data.get("currency") or base_currency or "").upper()
    allowed_statements[name] = (not base_currency) or rc == base_currency.upper()

报告货币与交易货币不同的数据源根本无权对报表字段进行投票。这一规则虽然直接但正确,因为放弃自己的观点总比混合两种货币的计算结果要好。

其后果之一是:一旦选出胜出者,所有倍数指标都必须重新计算。如果将EDGAR的净收入与雅虎的市盈率混用,页面会出现自相矛盾的情况,而这种矛盾可能数周都无人察觉。因此,市盈率、市净率、市销率、企业价值/息税折旧摊销前利润比率、净资产收益率以及各项利润率都会根据所选输入数据在本地重新计算。

停止硬编码无风险利率

每个现金流折现模型都基于两个假设

现金流折现法取决于增长率和折现率。在该工具中,折现相关参数最初是以字典形式存在的,其中包含各市场的无风险利率、股权风险溢价以及税率:

"US": {"rf": 0.042, "erp": 0.045, "tax": 0.21},

这类数据一旦输入后就不再修改,因此会永久过时。系统会为每个市场选取一个FRED系列:美国使用DGS10,印度使用INDIRLTLT01STM,欧洲则使用OECD系列IRLTLT01..M156N。计算结果会被缓存一天,同时记录其观测日期,并被限制在合理范围内:

def risk_free(market: str, fallback: float) -> dict:
    """{rate, source, observed_on, series}. Never raises."""
    series_id = SERIES.get(market)
    static = {"rate": fallback, "source": "static",
              "observed_on": None, "series": None}
    if not series_id or not enabled():
        return static
    ...

注意那个函数的形态。系统中的每次积分运算都会返回降级的结果而非上升值。没有 FRED 密钥则表示使用静态利率;没有 FMP 密钥则表示仅使用两个数据源而非三个;没有 Redis 则意味着使用进程内字典。当缺少某些可选功能时,系统会通过 GET /api/providers 报告其所采用的路径,而不会报错。

按观测日期存储利率的重要性比看上去要高。六个月后,当决策发生变化时,你可以判断是业务状况发生了变动,还是折扣率发生了变化。

持仓是计算得出的,而非存储的

投资组合服务仅保存交易记录,账本才是唯一的真实依据。每次请求时都会根据账本重新计算各持仓的数值:数量、成本基准、已实现和未实现的收益、股息、权重,以及每个持仓和整个账户的 XIRR 值。

这样每次处理请求都需要更多工作量,但显然是正确的做法。如果在3月错误地输入了交易数据,等到9月再修正,历史记录就会自行“修复”。而如果直接存储该状态,就只能永远修补那个衍生出的数字。

对于持续有资金存入的投资组合而言,XIRR才是唯一真实的收益指标,因为简单的百分比涨幅会将并非由你决定的时机也算作你的功劳。该计算工具会首先尝试牛顿法,当牛顿法失效时再转而使用二分法,而如果现金流确实无法确定利率,则会返回None。

无法推翻分析结果的分析师

两端均具有确定性

最后一个组件是由本地Ollama模型支持的LangGraph智能体。让它变得聪明从来不是目标,更棘手的设计问题是如何避免出现确定的错误,而三项限制措施解决了这一问题。

它从不直接操作任何工具。所有的数据获取与计算都在调用模型之前的gather阶段完成。模型无法请求数据,因此也不能循环、漫无目的地搜索或编造数据来源。

它读取的是数据表,而非JSON格式。该数据表包含约四十个带标签的数字,大小不到4KB:

VALUATION
  blended fair value: 1402.11 INR (confidence medium, model spread 0.43)
  per model: dcf=1610.22, epv=1104.50, graham=1288.31, gordon=1377.90
  margin of safety: -8.4% - Trading above fair value
  growth assumed: 11.0% (basis: revenue YoY 11.0%)   discount rate: 11.2%
SOURCE DISAGREEMENTS (treat these figures as uncertain):
  net_income: fmp=261.0B, yahoo=248.5B (spread 4.9%, using fmp)

拥有80亿参数的模型能够很好地处理那份简洁的表格,但却无法妥善处理40KB大小的数据集。第二点值得注意:对齐过程中发现的差异会被包含在提示词中,这样模型就能知道哪些数值是不确定的。

它最多只能获得一级的自由度。 引擎的判定才是最终依据:

def _clamp_to_engine(model_verdict, engine_verdict):
    gap = SCALE.index(model_verdict) - SCALE.index(engine_verdict)
    if abs(gap) <= 1:
        return model_verdict, None
    capped = SCALE[SCALE.index(engine_verdict) + (1 if gap > 0 else -1)]
    return capped, (f"model said '{model_verdict}', more than one rung from "
                    f"the engine's '{engine_verdict}' - capped at '{capped}'")

该模型可以提出异议,但无法推翻无法重新计算的定量模型,且每次尝试都会被记录下来,这些记录本身也是值得分析的信号。

第二轮处理中,模型会检查自己初稿中的不合理主张;如果批评意见会改变判定结果,则会进入循环。若循环次数用尽后批评意见依然成立,那么原有的判定结果将乘以0.7。

当无法联系到Ollama时,流程会直接跳转到finalise阶段,并以status: engine_only的状态返回引擎的判定结果。此时接口仍会返回200状态码。模型缺失时只会产生质量较低的回答,而不会导致服务中断。

清单文件所包含的内容

所有内容都存储在本地k3s集群的stockanalysis命名空间中。

探测程序刻意不会感知到模型的存在:

startupProbe:    { httpGet: { path: /health/live,  port: http },
                   failureThreshold: 30, periodSeconds: 5 }
readinessProbe:  { httpGet: { path: /health/ready, port: http }, periodSeconds: 15 }
livenessProbe:   { httpGet: { path: /health/live,  port: http }, periodSeconds: 30 }

如果使用活体检测来测试 Ollama,而该模型尚未被拉取,系统会将其视为崩溃状态,kubelet 会不断重启该 Pod。是否模型正常运行是通过 /api/agent/model 来主动查询的。

该代理程序刻意只设置一个副本。主机上的一个 Ollama 进程负责处理一个模型,因此额外的 Pod 只会在同一块 GPU 上排队等待,同时还会使内存需求翻倍。在此场景下进行自动扩缩容只会浪费资源。真正需要扩缩容的是 valuation 部分:它是无状态、纯计算性质的,没有 I/O 操作。

外部流量只能直接到达网关。其余服务则通过默认拒绝的入口策略隐藏在 ClusterIP 后面。代理程序通过 /api/agent/* 进行访问,没有公开接口,这使得该代理成为后续进行身份验证的理想位置。整个系统的总请求量约为 1.1Gi。

四件出问题的东西,均与架构无关

  • containerd与dockerd的差异。Rancher Desktop在两种运行时下都会安装docker CLI,因此打印出的帮助信息根本无法解决问题。使用containerd时,镜像必须导入到k8s.io命名空间中。如果镜像是在其他地方构建的,就会导致每个容器都陷入ImagePullBackOff状态,不断报错称从未尝试联系过注册表。
  • CoreDNS 无法解析该主机地址。用于 OLLAMA_BASE_URL 的 URL http://host.rancher-desktop.internal:11434 无法正常工作,因为该主机名被定义在 WSL 发行版的 hosts 文件中,而非集群 DNS 中。这种情况下每次分析都会返回 engine_only 且没有任何错误提示,服务虽在运行但功能不完整,属于最棘手的故障类型。解决方法是部署时解析 WSL 的默认网关地址。
  • 内存不足。每种服务都有两个副本时,请求总量高达 2.24Gi,这还不包括 k3s 自身的容器。将每个服务的副本数减少到一个后,请求量降至 1.05Gi。另一个隐患是:使用 maxSurge: 1, maxUnavailable: 0 参数进行滚动更新时,会导致单节点上的单副本部署出现死锁,因为新容器需要内存,而旧容器尚未释放这些内存。
  • PowerShell 5.1与编码问题。除非存在BOM,否则它会将.ps1文件视为ANSI格式。注释中的连字符会被转换成乱码,从而导致解析器出错。

这四项工作所耗的时间都比架构设计本身还要长。

已知的缺陷

  • 对账的容忍度为全局5%。但实际上应按字段不同而有所差异,因为收入方面5%的差异已经很令人担忧,而利息支出方面的5%差异则属于正常波动。
  • 代理程序的事实表是通过字符串格式化手动生成的。虽然结构上是正确的,但应该直接从估值服务发布的架构中生成,这样两者就不会出现偏差。
  • 不存在身份验证机制。对于仅限于本地使用的单用户工具来说这还可以接受,但一旦部署到其他位置就不再安全了。

背后的模式

每个组件都应逐步退化而非彻底失效,并且应说明其功能。三个数据源可以毫不费力地缩减为两个。FRED会变成静态表格,Redis会变成字典,Ollama则归于沉默,但引擎依然会给出答案。

系统从不会夸大自身所掌握的信息,对于一个其输出可能影响实际决策的工具而言,这一点比其他所有设计考量都更为重要。

相关阅读

  • GraphRAG与Vector RAG:为何向量搜索在大规模应用中会失败 — Vector RAG在数据块隔离、跨文档推理以及处理大规模数据方面存在困难。本文将介绍GraphRAG如何通过构建社区和摘要来解决这些问题,以及何时该选择哪种技术。