image

stock-sdk 一路发到 v1.10.1,前前后后 18 个版本。要给这 18 版做个总结,其实就五个字:加接口,再加接口。A 股、港股、美股,基金、期货、期权,龙虎榜、北向资金、大宗交易……需求来一个,门面类上就多挂一个 getXxx()

底层那套三层架构(provider 取数 → service 编排 → 门面薄委托)倒是一直扛住了,没塌过,我对这点是真有点得意。可等第 105 个 getXxx() 落地,我连自己写的代码都得开搜索框翻方法名了——敲个 sdk.get,补全列表哗啦弹出一百多条,从 getAllAShareQuotes 滚到 getZTPool 都停不下来。就那一下我反应过来:这病,再塞几个接口是治不好的,得刨地基。

v2 干的就是刨地基这件事。它压根不是什么 v1.11,而是一次整个推翻、重新长一遍的架构跃迁——在不碰新数据源、不上实时订阅的前提下,把符号模型、数据契约、API 表面、请求层、错误体系挨个重写了一轮。挑几个我感触最深的说说。

一、105 个平铺方法,我是怎么塞进命名空间的

v1 那个门面类 sdk.ts,1052 行,通篇长这样:

sdk.getFullQuotes(codes)
sdk.getETFOptionDailyKline('10004336')
sdk.getIndividualFundFlow(...)
sdk.getDragonTigerInstitution(...)
// ……一直到第 105 个

难找还只是表象。import { StockSDK } 一进门,构造函数里十几个 service 全给你 new 一遍——哪怕你就想读个行情,整包也得跟着进来,tree-shaking 在这儿一点忙都帮不上。

v2 把它们统统归进命名空间:

sdk.quotes.cn(['sh600519'])            // 原 getFullQuotes
sdk.kline.withIndicators(...)          // 原 getKlineWithIndicators
sdk.options.etf.dailyKline('10004336') // 原 getETFOptionDailyKline
sdk.board.industry.constituents(s)     // 原 getIndustryConstituents

照业务领域归完类,补全就变成了「sdk. 先挑领域,再 . 挑方法」的两段式,顺手得多。门面类也跟着从 1052 行掉到 349 行——留下的几乎全是把 service 方法往对应命名空间上一挂的薄委托。

这儿有处我故意留着的小心思:命名空间是懒构建 + 缓存的,为的是让 sdk.quotes === sdk.quotes 这个引用稳得住。一开始我想偷懒,直接 this._ns[key] ??= build() 一行了事,结果踩雷了——tsup 的 cjs + splitting + minify 这条流水线会把它跟注入的 helper 揉成一个废标识符 return_nullishCoalesce,于是 require 出来的产物,每个命名空间 getter 头回访问就甩你一个 ReferenceError。最离谱的是只跑 src 的单测压根照不出这毛病,是后来补的「构建产物冒烟测试」才把它揪出来。所以你看到代码里那段啰里啰嗦的 if (cached === undefined),真不是我不会写简写,是被它咬过一口。

二、捎带手把 SDK 接上了终端和 AI:CLI 与 MCP

CLI:行情直接在终端里查

stock-sdk 主包自带命令行,装完即用(package.jsonbin 指向 dist/cli.js),一行代码不写就能在终端取数:

npx stock-sdk quote 600519 000858 00700   # 一条命令混查 A 股 + 港股,自动识别市场
stock-sdk kline 600519 --period weekly --adjust hfq --limit 30
stock-sdk indicators 600519 --ma 5,10,20 --macd --kdj

说穿了它就一层薄壳:解析 argv → new StockSDK() → 调命名空间方法 → 把结果格式化吐出来。所以库会的它全会,数据口径逐字节对得上。入口我设计了两层:

  • 高频别名quote / kline / indicators / search / codes 这几样最常用的,压成单个 token,还塞了点 CLI 才有的贴心料(自动认市场、按代码分组并发、--limit 截断)。
  • 命名空间直达:库里 84 个命名空间方法,不可能挨个起别名,索性允许你顺着路径一段段点下去——sdk.board.industry.list() 对上 stock-sdk board industry list,严丝合缝,不用记新东西。

全局选项也都是终端该有的那几样:--format json/table/csv--pretty--timeout--quiet。顺带提一句,为了守住「零运行时依赖」,argv parser 是我手搓的极简实现,commander 没引,yargs 也没引。

MCP:让 AI 自己去取行情

MCP(Model Context Protocol)这一块是冲着 AI 工具去的。一条命令把 server 拉起来:

stock-sdk mcp

起来之后走 stdio 跟 Cursor / Claude Desktop / Codex / Gemini 这类客户端对话,不占任何网络端口,模型就能直接调实时行情、K 线、搜索这些只读能力。

照旧是为了零依赖,官方那个 @modelcontextprotocol/sdk 我没引,而是自己手写了 MCP 协议的最小子集——本质上就是 JSON-RPC 2.0,跑在换行分隔的 stdin/stdout 上。范围我钉得很死,只留行情场景真正会用到的那点:transport 只做 stdio,能力只做 tools,方法只接 initialize / tools/list / tools/call;至于 HTTP/SSE、OAuth、sampling、resources/prompts,一概不碰,什么时候要什么时候再说。MCP 单走 stock-sdk/mcp 这个入口,你 import { StockSDK } 的时候,它一个字节都进不了 bundle。

一份定义,喂饱四张嘴

要说我自己最得意的,其实不是 CLI 也不是 MCP,而是它俩不是另起炉灶手写的第二套、第三套映射。CLI 的命令、MCP 的工具,加上 SDK 的方法契约,全是从同一份 src/spec/methods.ts 长出来的——枚举、默认值、参数形态,共用一个事实源。这么一来,「文档写着支持这参数、CLI 却不认」「MCP 工具的 schema 跟 SDK 真实签名对不上」这类经典漂移就没了滋生的土壤。再往后连文档站的 Playground 也接上了这份 spec,等于一份定义同时喂 SDK、CLI、MCP、Playground 四张嘴,改一处,四处齐变。

三、用多少拿多少:subpath 拆包 + tree-shaking

回到第一章提过的那个痛点:import { StockSDK } 一把就 new 出十几个 service,你明明只想算个 MACD,取数层连同全部 provider 却都被卷进了 bundle。Node 端无所谓,可一旦搬进浏览器,这就是白白多出来的体积。

v2 把所有能独立的部分都切成了 subpath,纯算的东西可以单拎出来用:

import { calcMACD } from 'stock-sdk/indicators'; // 不再从主包拉
import { calcSignals } from 'stock-sdk/signals';
import { normalizeSymbol } from 'stock-sdk/symbols';

总共开了 indicators / symbols / signals / screener / cache / errors / mcp 这么几条子路径。它们有个共性——「只有逻辑,不发请求」:指标算法、信号判定、选股回测、符号解析,全都能脱开取数层自己跑。所以你要做个纯前端的指标计算页,import { calcMACD } from 'stock-sdk/indicators' 就完事了,请求层、provider、MCP 那些代码,一个字节都不会蹭进来。

不过「路切开了」不代表「树就摇得动」。得几样东西一块儿垫底,才不至于出现「subpath 也分了,结果还是全量打包」的尴尬:

  • "sideEffects": false:在 package.json 里把「无副作用」明明白白写出来,bundler(webpack / Rollup / esbuild / Vite)才敢把你没 import 的导出整段铲掉。这是 tree-shaking 能不能落地的命门——多少库就是漏了这一行,功亏一篑。
  • ESM + CJS 双产物:现代打包器从 ESM 入口进,拿到的是能静态分析、能摇树的那一份;老的 CommonJS 环境走 CJS,两边都不耽误。
  • 零运行时依赖dependencies 是空的。符号解析、指标、信号、回测、缓存,清一色手写纯逻辑,不沾 lodash,不沾 dayjs,连一个传递依赖都没有。换句话说,你 import 进来的体积里没有一克属于「别人的库」——装个 stock-sdk,不会顺带把半个 node_modules 拽进门。

落到体感上就是:库本身能耐不小(A 股 / 港股 / 美股 / 基金 / 期货 / 期权 + 指标 + 信号 + 选股回测 + CLI + MCP),但你只为「真正用上的那块」掏钱。光用指标的人,bundle 里就不该冒出行情请求的代码;只在 Node 里取数的人,也犯不着把浏览器那一套扛在身上。这种「家伙事齐全,却按需结账」的感觉,v1 那个单入口全家桶是给不了的。

四、类型乱成一锅粥:raw 泄漏、NaN、缺 tz

v1 大约有 85 个返回类型,里头窝着好几类我现在回看都臊得慌的设计:

  • 8 个类型挂着 raw: string[]——把上游返回的原始字段数组直接拍在数据对象上,当「逃生舱」使。实现细节就这么大喇喇漏给了用户。
  • 13 个类型拿 timestamp: NaN 来表示「时间没解出来」。于是判空你得写 Number.isNaN(q.timestamp),头回用的人没有不栽的。
  • 20 多个日期类型连 tz 都没有,跨市场一掺就时区错乱。
  • 单位也是一团:amount 一会儿万一会儿元,volume 一会儿手一会儿股,FullQuote 里甚至并排躺着 volumevolume2 俩重复字段;命名还四处漂,marketCaptotalMarketCapmainNetmainNetInflow 各活各的。

v2 的数据契约就办了三件事:

raw 一律删干净。想要原始字段,去 provider 层的 getXxxRaw() 调试函数里拿,别再往正经数据对象里掺。

NaN 改成 null,判空也从 Number.isNaN(...) 变成了干净利落的 === null

// v1
if (Number.isNaN(q.timestamp)) { /* 无效 */ }
// v2
if (q.timestamp === null) { /* 无效 */ }

行情类型则从「一人一个独立接口」收拢成一个靠 assetType 判别的可辨识联合 Quote,拿 switch 一收窄就行:

import type { Quote } from 'stock-sdk';

function render(q: Quote) {
  switch (q.assetType) {
    case 'stock':
      console.log(q.price, q.changePercent); // 这里被收窄成股票 quote
      break;
    case 'fund':
      console.log(q.nav, q.accNav);          // 收窄成基金 quote
      break;
  }
}

这儿得老实交一个没干完的活:单位归一(手→股 ×100、万→元 ×10000 这类)我这一版先没落地。道理在于,正确的换算倍率非得拿真实数据一源一源、一字段一字段去核对不可,光靠 mock 单测自己证自己,根本验不出对错,瞎改风险太大。所以契约里 volume/amount 的单位注释写的是「目标口径」,跑出来的实际值眼下还是各源的原始口径。这事儿留到能上真实数据集成测试那天再统一校准。丑话先撂这儿,省得有人照注释口径去跑回测、然后数对不上。

五、求你们别再让我 catch DOMException 了

v1 的错误走的是「上游抛啥我原样转啥」——你可能 catch 到光秃秃的 TypeErrorRangeErrorHttpError,赶上超时,接到手里的居然是个 DOMException

// v1:超时居然要这么判
catch (e) {
  if (e instanceof DOMException && e.name === 'AbortError') { /* 超时 */ }
}

v2 对外只抛 SdkError,而且个个带统一的 code

import { SdkError } from 'stock-sdk/errors';

try {
  await sdk.quotes.cnSimple(['sh000001']);
} catch (e) {
  if (e instanceof SdkError) {
    switch (e.code) {
      case 'TIMEOUT':    break; // 真超时
      case 'ABORTED':    break; // 外部 signal 主动取消,跟超时区分开
      case 'HTTP_ERROR': break; // 非 2xx
    }
  }
}

ABORTED(你自己点了取消)和 TIMEOUT(是真等到超时了)拆开,是我被坑透之后最惦记的一个区分——这俩在 v1 里全糊成一个 AbortError,你根本没法判断到底是用户主动撤了还是网络真的断了。请求层也顺手做成了可组合的:能塞自定义 fetch,能接外部 AbortSignal,再配上限流、重试、熔断、host fallback 这一整套请求治理。

六、一个 string 闯天下:normalizeSymbol

v1 里最磨人的那笔隐性债,是符号格式各写各的。同一只贵州茅台,换个方法就得换个写法:

  • 腾讯接口要 sh600519,简版行情还得再加个 s_ 前缀;
  • 东财 secid 要 1.600519
  • 港股有时候 00700、有时候 hk00700
  • 美股分钟 K 线干脆要 105.AAPL

而「把用户随手敲的代码」翻成「各家接口认的格式」这套活,散在 15 处往上,愣是没有一个统一入口。每接一个新数据源,就得在某个旮旯里把前缀判断再抄一遍。

v2 把这摊事收成了一个 normalizeSymbol():在用户这一头,string 是头等公民——你写 '600519''sh600519''00700',它照单全收;进了内部,再交给各 provider 的 adapter 把归一化结果翻成自家要的格式。要是只想要纯解析能力,还能单独 import { normalizeSymbol } from 'stock-sdk/symbols',不发请求。

捎带把一堆历史上的歧义也捋顺了:北向、港美股、北交所、期货品种这些最容易撞车的,现在都有了明确的消歧规则——打个比方,你给的市场提示跟代码本身打架时,它会当场抛错,而不是像 v1 那样闷头取一份错市场的数据,再扔给你 debug 半天。

说说「单轨硬切」这个决定

v2 是一层兼容都不留的。没有 compat 入口,v1 那些旧方法别名也一个不保——sdk.getFullQuotes() 说没就没,你只能改成 sdk.quotes.cn()

这决定我是纠结过的。留一层 alias,对老用户当然更体面。可 v2 翻动的是符号、契约、错误、API 表面——没一处幸免,要是每处都留条兼容后路,等于把我费劲想端掉的那锅粥又原样养了起来,刨地基的意义也就荡然无存了。所以最后还是硬切,改用一份够细的迁移指南来接住——里面备齐了 sdk.getXxx()sdk.<ns>.<method>() 的整张映射表。代价是老代码升级得动手改,可改完是真清爽。

与之配套,npm 上 latest 现在指向 v2;老用户想钉死在 v1 也行,npm i stock-sdk@legacy 照样能装到 1.10.1,v1 文档也归档进了独立子域,断不了粮。


v2 就这么从一个「方法多到得靠搜索框」的平铺 SDK,长成了命名空间分明、契约统一、错误可辨识、用多少裁多少、还能接 CLI 和 AI 的模样。落到代码上,src/ 动了 92 个文件、净增 5000 多行;门面类反倒瘦掉了三分之二。

要是你还在用 v1,迁移指南我尽量写细了;要是你刚来,直接从 sdk.quotes.cn() 上手就成。

相关链接

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐