当前位置: 首页 > news >正文

我把那个迭代了 18 个版本的 SDK 整个掀翻重写了:stock-sdk v2 升级手记

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 行,翻开来满屏都是这种货色:

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 里把「我没副作用」这事儿明明白白声明出来,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),一行代码都不用写,直接在终端就能取数:

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 协议的最小子集手写了出来——本质上就是一套跑在「换行分隔的 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,也可能是RangeErrorHttpError,赶上超时那会儿,甚至给你扔个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 这一整套请求治理。

五、类型乱成一锅粥:raw 漏出来、NaN、还有缺 tz

v1 大概攒了 85 个返回类型,里头藏着好几类设计,我现在回头看都有点替自己臊得慌:

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

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走遍全场:normalizeSymbol

v1 里最让我膈应的一笔隐性债,是符号格式各写各的、谁也不管谁。同样一

http://www.gsyq.cn/news/1603235.html

相关文章:

  • Python QQ机器人开发实战:3步构建智能消息处理系统
  • Cursor免费试用限制深度解析:从设备指纹识别到一键重置的完整方案
  • Gmail账号自动生成器:Python脚本快速创建随机邮箱的完整教程
  • 数据库系统中的事务处理查询优化与备份恢复
  • 扩散模型中音素对齐的结构性矛盾
  • TypeScript 泛型详解:让类型安全更进一步
  • Gmail账号自动生成器:三步创建随机邮箱的完整指南
  • 终极指南:Unitree RL GYM机器人强化学习框架的完整实践手册
  • CRMEB电商系统安全审计实战:公开接口漏洞分析与加固方案
  • 禁令两周后,美国政府放宽限制,允许Anthropic向超百家机构提供Mythos 5模型
  • Datasheet 生成 KiCad Symbol
  • TSW1100高速ADC数据采集卡实战指南:从硬件连接到性能评估
  • OBS-ASIO插件终极指南:实现专业音频设备的低延迟录制与直播
  • 深入解析EASY-HWID-SPOOFER:内核级硬件信息修改技术实现
  • GD32F303串口驱动开发:从寄存器到中断与环形缓冲区的实战解析
  • 3分钟快速上手:用Barrier实现一套键鼠控制多台电脑的终极方案
  • PySpark实战:从数据清洗到模型部署的泰坦尼克号幸存者预测完整流程
  • STK与MATLAB联动实战:Walker星座建模与参数解析
  • OpCore-Simplify:黑苹果配置的终极简化指南,3步完成专业级EFI构建
  • C++ 命名空间(namespace)全方位实战教学(零基础入门到工程高阶)
  • 从零构建WordPress渗透测试靶场:实战演练与安全加固
  • 【单片机毕业设计】 基于 STM32 的红外感应智能定时药盒设计,基于单片机的语音播报用药提醒装置开发(012901)
  • 【论文阅读】Stable-RAG: Mitigating Retrieval-Permutation-Induced Hallucinations in Retrieval-Augmented Gen
  • 日本风情lr预设|日系清新旅行人像海边街拍Lightroom下载lr调色风格
  • Python+Selenium端到端自动化测试实战:从POM设计到CI/CD集成
  • ECCV 2026 | 从静态拟合到动态分配:AMG-Fuse 用模态贡献Mask破解恶劣天气下的融合难题
  • 永不消亡的“数字幽灵”:为什么都2026年了,这个30年前的漏洞依然无处不在?
  • 5分钟掌握MGit:Android平台最强大的Git客户端全解析
  • 我把整个代码库喂给 Claude Code,工具超 50 个就静默丢失,这个坑太阴了
  • 【云原生与DevOps】01-Docker从入门到实践:镜像、容器、网络三位一体