stock-sdk 从第一个版本到 v1.10.1,一共发了 18 个版本——三层架构一直没塌,但门面类上堆了 105 个 getXxx(),autocomplete 一弹就是一百多个候选。

v2 不是 v1.11,是一次推倒重来的架构跃迁:命名空间 API、subpath 按需引入、统一数据契约、可辨识错误,外加 CLI 和 MCP。下面挑几个我自己最有感触的点聊聊。

stock-sdk 从第一个版本到 v1.10.1,一共发了 18 个版本。这 18 个版本干的事基本可以概括成一句话:不停地加数据接口。A 股行情、港股、美股、基金、期货、期权、龙虎榜、北向资金、大宗交易……每次有新需求就在门面类上再挂一个 getXxx()

三层架构(provider 取数 → service 编排 → 门面薄委托)一直没塌,这点我还挺自豪的。但写到第 105 个 getXxx() 的时候,我自己敲代码都开始靠编辑器的搜索框找方法名了——sdk.get 一按,autocomplete 弹出来一百多个候选,从 getAllAShareQuotes 一路滚到 getZTPool。那一刻我意识到:这不是再加几个接口能救的,得动地基了。

v2 就是这次「动地基」。它不是 v1.11,是一次推倒重来的架构跃迁——在不接新数据源、不做实时订阅的前提下,把符号模型、数据契约、API 表面、请求层、错误体系全部重做了一遍。


105 个扁平方法,是怎么变成命名空间的

v1 的门面类 sdk.ts 有 1052 行,全是这样的东西:

1
2
3
4
5
sdk.getFullQuotes(codes)
sdk.getETFOptionDailyKline('10004336')
sdk.getIndividualFundFlow(...)
sdk.getDragonTigerInstitution(...)
// ……一直到第 105 个

问题不只是难找。import { StockSDK } 进来,构造函数里 new 了全部十几个 service,你哪怕只想取个行情,整包都被拉进来,tree-shaking 帮不上忙。

v2 全部收进命名空间:

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

按领域分组之后,autocomplete 终于变成了「先 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),不是我不会写简写,是被坑过。


按需引入:subpath 拆包与 tree-shaking

上一节说到 v1 的痛:import { StockSDK } 一下子把十几个 service 全 new 出来,你只想算个 MACD,结果整个取数层、所有 provider 都被打进了 bundle。在 Node 端无所谓,但放到浏览器里,这就是实打实的体积浪费。

v2 把能独立的部分都拆成了 subpath,纯计算的东西可以单独拿:

1
2
3
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 单入口全家桶给不了的。


顺手把 SDK 接上了命令行和 AI:CLI 与 MCP

CLI:终端里直接查行情

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

1
2
3
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:

1
stock-sdk mcp

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

同样为了零依赖,我没有引官方的 @modelcontextprotocol/sdk,而是手写了 MCP 协议的最小子集——本质就是 JSON-RPC 2.0 跑在换行分隔的 stdin/stdout 上。范围严格钉死在行情场景真正用得到的部分:只做 stdio transport、只做 tools,处理 initialize / tools/list / tools/call 这几个方法;HTTP/SSE、OAuth、sampling、resources/prompts 这些一概不做,需要时再说。MCP 走独立入口 stock-sdk/mcpimport { StockSDK } 时它一个字节都不会进你的 bundle。

一份定义,喂四个端

我自己最满意的其实不是 CLI 或 MCP 本身,而是它们不是手写的第二套、第三套映射。CLI 的命令、MCP 的工具,连同 SDK 的方法契约,全部从同一份 src/spec/methods.ts 派生——枚举、默认值、参数形态同一个事实源。这样就不会出现「文档说支持这个参数,CLI 却不认」「MCP 工具的 schema 和 SDK 实际签名对不上」这类经典漂移。后来连文档站的 Playground 也接到了这份 spec,等于一份定义同时喂了 SDK、CLI、MCP、Playground 四个端,改一处四处生效。


类型一锅粥: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

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

行情类型收敛成可辨识联合 Quote,用 switch 收窄:

1
2
3
4
5
6
7
8
9
10
11
12
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

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

v2 对外只抛 SdkError,全部带统一的 code

1
2
3
4
5
6
7
8
9
10
11
12
13
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() 开始就行。


🔗 链接汇总

安装 v2:

1
2
3
4
5
npm install stock-sdk
# 或者
yarn add stock-sdk
# 或者
pnpm add stock-sdk

还在 v1 的用户可以钉在 legacy 线:

1
npm install stock-sdk@legacy

觉得有用的话,顺手点个 Star ⭐ 支持一下~

Happy Coding!