把一个写了 18 个版本的 SDK 推倒重来:stock-sdk v2 架构升级记
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 | sdk.getFullQuotes(codes) |
问题不只是难找。import { StockSDK } 进来,构造函数里 new 了全部十几个 service,你哪怕只想取个行情,整包都被拉进来,tree-shaking 帮不上忙。
v2 全部收进命名空间:
1 | sdk.quotes.cn(['sh600519']) // 原 getFullQuotes |
按领域分组之后,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 | import { calcMACD } from 'stock-sdk/indicators'; // 不再从主包拉 |
一共开了 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.json 的 bin 指向 dist/cli.js),不写一行代码就能在终端取数:
1 | npx stock-sdk quote 600519 000858 00700 # 一条命令混查 A 股 + 港股,自动识别市场 |
它本质就是一层薄壳:解析 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/mcp,import { 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甚至有volume和volume2两个重复字段;命名还漂移,marketCap和totalMarketCap、mainNet和mainNetInflow并存。
v2 的数据契约做了三件事:
raw 全删。 要原始字段,去 provider 层的 getXxxRaw() 调试函数拿,不再混进正经数据对象。
NaN 改成 null,判空从 Number.isNaN(...) 变成清清爽爽的 === null:
1 | // v1 |
行情类型收敛成可辨识联合 Quote,用 switch 收窄:
1 | import type { Quote } from 'stock-sdk'; |
这里要诚实交代一个没做完的点:单位统一(手→股 ×100、万→元 ×10000 这种)我这一版暂时没落地。原因是正确的换算倍率必须拿真实数据逐源逐字段核对,纯靠 mock 单测自己证自己根本验不出对错,盲改风险太大。所以契约里 volume/amount 的单位注释写的是「目标口径」,实际运行值目前还是各源原始口径。这件事留到能跑真实数据集成测试时再统一校准。把丑话说在前头,免得有人按注释口径去做回测然后数值对不上。
错误:别再让我 catch DOMException 了
v1 的错误是「上游抛什么我透传什么」——你可能 catch 到裸的 TypeError、RangeError、HttpError,超时的时候甚至是个 DOMException:
1 | // v1:超时居然要这么判 |
v2 对外只抛 SdkError,全部带统一的 code:
1 | import { SdkError } from 'stock-sdk/errors'; |
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 | npm install stock-sdk |
还在 v1 的用户可以钉在 legacy 线:
1 | npm install stock-sdk@legacy |
觉得有用的话,顺手点个 Star ⭐ 支持一下~
Happy Coding!
