我把那个迭代了 18 个版本的 SDK 整个掀翻重写了:stock-sdk v2 升级手记
stock-sdk这个库从最早那版一路发到v1.10.1前前后后一共 18 个版本。要我用一句话总结这 18 个版本到底在忙活什么那就是一个劲儿地往里塞数据接口。A 股行情、港股、美股、基金、期货、期权、龙虎榜、北向资金、大宗交易……来一个新需求我就在门面类上再钉一个getXxx()上去。三层架构——provider 负责取数、service 负责编排、门面只做薄薄一层委托——这套骨架倒是一直立得住没塌过这点我心里还是有点得意的。可问题是当我敲到第 105 个getXxx()的时候连我自己写代码都得开编辑器的搜索框去捞方法名了。手一打sdk.getautocomplete 哗啦弹出一百多个候选从getAllAShareQuotes能一路滚到getZTPool。就那一下我突然就清醒了这毛病不是再补几个接口能糊弄过去的得刨地基。v2 干的就是这件「刨地基」的事。它压根不是什么 v1.11 的小步迭代而是整个架构层面的一次推倒重写。前提我先划清楚这一版不接任何新数据源也不碰实时订阅在这个边界之内把符号模型、数据契约、API 表面、请求层、错误体系——全部从头捋了一遍。下面我挑几个自己印象最深的点跟你慢慢聊。一、105 个平铺方法我是怎么把它们塞进命名空间的v1 那个门面类sdk.ts足足 1052 行翻开来满屏都是这种货色sdk.getFullQuotes(codes) sdk.getETFOptionDailyKline(10004336) sdk.getIndividualFundFlow(...) sdk.getDragonTigerInstitution(...) // ……就这么一路排到第 105 个难找还只是表面。你import { StockSDK }一进来构造函数里咔咔new出十几个 service哪怕你就想拿个行情整坨东西也全被拽进来了tree-shaking 在这种结构面前完全使不上劲。到了 v2这些全归拢进命名空间了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纯算的东西可以单拎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 里把「我没副作用」这事儿明明白白声明出来bundlerwebpack / 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 接上了命令行和 AICLI 与 MCPCLI在终端里直接查行情stock-sdk主包本身就揣着命令行装完即用package.json里的bin指向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把行情直接喂给 AIMCPModel Context Protocol这一块是冲着 AI 工具去的。起 server 就一条命令stock-sdk mcp服务起来之后它走stdio跟 Cursor / Claude Desktop / Codex / Gemini 这些客户端对话一个网络端口都不开模型这边就能直接调用实时行情、K 线、搜索这类只读能力了。同样是为了零依赖这条铁律官方那个modelcontextprotocol/sdk我没引而是自己把 MCP 协议的最小子集手写了出来——本质上就是一套跑在「换行分隔的 stdin/stdout」之上的 JSON-RPC 2.0。范围我卡得死死的只覆盖行情这个场景里真正会用到的那点东西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 四张嘴都喂了改一处四处跟着变。四、错误体系求别再让我去 catch DOMException 了v1 处理错误的逻辑是「上游抛啥我原样透传啥」——所以你接到手的可能是个光秃秃的TypeError也可能是RangeError、HttpError赶上超时那会儿甚至给你扔个DOMException过来// v1判个超时居然得写成这样 catch (e) { if (e instanceof DOMException e.name AbortError) { /* 超时 */ } }v2 对外只认一种错只抛SdkError而且全都带着统一的codeimport { 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 这一整套请求治理。五、类型乱成一锅粥raw 漏出来、NaN、还有缺 tzv1 大概攒了 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// 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那几行单位注释写的是「目标口径」可实际跑出来的值眼下还是各家源的原始口径。这事儿我留到能跑真实数据集成测试那天再统一校准。丑话我先撂这儿免得真有人照着注释口径去做回测然后对着对不上的数字干瞪眼。六、一个string走遍全场normalizeSymbolv1 里最让我膈应的一笔隐性债是符号格式各写各的、谁也不管谁。同样一