作 者:老余捞鱼
原创不易,转载请标明出处及原作者。

文中示例仅用于技术讨论,不构成任何操作建议。
量化策略开发应以学习和技术交流为目的。
本号不荐股、不卖课、不承诺收益。
市场有风险,请合法合规投资。
本文约 4900 字 | 预计阅读 8-12 分钟,后有代码下载,建议先收藏。
· · ·
工具用久了,脑子里会冒出一个念头。我每天盯的数据到底躺在谁的服务器上,有没有可能整套搬到自己的机器里头。
这篇文章讲的就是这件事。TradingView 有可以本地部署的图表库,用 Vue 起一个页面,就能在自己机器上跑起一整套 K 线图表,接上自己的数据源。
数据不归它管。K 线从哪儿来,它压根不关心。它管的是图表。画线、缩放、十字光标、多周期切换、指标叠加,这些活它全包了,你一行都不用写。

二、两层东西,两个门槛
先把两层拆开讲,这样你往下一路看,就知道每一步卡在哪儿。
整套部署其实是两层东西叠在一起。上面一层是资源和页面,入口脚本、bundles、样式、语言包、类型声明,再加一个提供容器的页面。下面一层是数据,历史 K 线怎么取、实时行情怎么推。
上面那一层的门槛不在技术,在 TradingView 授权。官方图表库不公开分发,想拿到完整那套资源,得先过授权流程(订阅用户就能用)。这一步过不去,后面全都免谈,所以它是整件事里最硬的一道门槛,得最先确认。

下面那一层才是大多数人真正花时间的地方。实时行情怎么落到正确的那张图上,输入是一包消息,输出是一次回调,中间靠一张订阅表维持关系。这段是纯 JavaScript,跟图表库没关系,换个项目原样搬走就能用。
所以我把这套方案整理成了下面这份清单。四步每步配一句验收标准,数据接口细到每个字段,订阅层给完整实现。最后再把四个最容易翻车的地方逐个标出来。你照着往下走,哪一步对不上,回头看那句验收标准,就知道卡在哪儿。
三、四步装起来
整套部署拆成四步,每一步我都配了一句验收标准。这几句话我单独拎出来讲,因为那就是你判断自己做没做对的尺子。
第一步 初始化前端项目
页面出容器,状态交给 Store 管。
容器得有明确的宽高,还得等页面挂载完,再去初始化图表。默认标的也得提前备好,不然图表创建出来解析不到交易对。
这一步的关键是挂载。页面能跑、状态能读、容器已经出现在页面上,这三样齐了才算过。
① 状态放在 Store 里,页面只负责提供容器
// store/chart.js 存默认标的、周期、主题
import { defineStore } from "pinia";
export const useChartStore = defineStore("chart", {
state: () => ({
symbol: "BTCUSDT",
resolution: "1m",
}),
});② 挂载完成之后再创建图表实例
<template>
<div ref="container" class="chart-container"></div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, ref } from "vue";
import { useChartStore } from "@/store/chart";
const container = ref(null);
const store = useChartStore();
let widget = null;
onMounted(() => {
// 容器必须已经有明确的宽高,图表才知道往哪儿画
widget = new TradingView.widget({
container: container.value,
symbol: store.symbol,
interval: store.resolution,
library_path: "/charting_library/",
datafeed: new CustomDataFeed(),
locale: "zh",
});
});
onBeforeUnmount(() => {
// 页面卸载时销毁实例,否则订阅会留在表里
widget = null;
});
</script>
<style>
.chart-container {
width: 100%;
height: 520px;
}
</style>第二步 加载图表库
拿到官方资源之后,整个目录原样保存,入口脚本、bundles、样式、语言包、类型声明,一样都别动。里面的 bundles 是图表库自己管的,别重命名,也别拆开重新编译。
页面要先把入口脚本加载起来,脚本跑成功之后,window 上才会挂出那个全局对象。到这一步,你的业务代码才能创建图表实例。
③ 先加载入口脚本,再创建实例
<script src="/charting_library/standalone.js"></script>
<script>
// 先加载入口脚本(standalone.js),跑完 window 上才有 TradingView 这个全局对象
// 顺序反了会报 TradingView is not defined
const widget = new TradingView.widget({
container: document.getElementById("chart"),
library_path: "/charting_library/",
datafeed: new CustomDataFeed(),
});
</script>配置里的 library_path 指向整套资源的访问目录。图表后面还要从这个目录去取内部脚本和样式,所以入口文件能打开,不代表所有资源都到位了。
这一步的关键是完整。资源全都能加载上,控制台里既没有 TradingView is not defined,也没有 404。
第三步 对接数据源
写一个 CustomDataFeed,塞进图表的 datafeed 参数里。
这里有个方向要留神,是图表主动来调你的接口,不是你往图表里硬塞数据。方向搞反了,后面会全乱。

这三个方法各有各的分工。onReady 声明你支持哪些周期,resolveSymbol 交代交易时段、时区,还有价格精度,getBars 按标的、周期,还有起止时间取历史数据。
数据回来之后统一转成六个字段,时间、开盘、最高、最低、收盘、成交量。时间用毫秒时间戳,不是秒。数据按时间升序排好,再用 onHistoryCallback 交回去。这六个字段的形状,历史数据和实时行情必须一模一样,不然图表会把两边当成两拨东西。
④ 三个方法各管一段
class CustomDataFeed {
// 声明支持哪些周期
onReady(callback) {
setTimeout(() => {
callback({ supported_resolutions: ["1", "5", "15", "60", "1D"] });
}, 0);
}
// 交代交易时段、时区、价格精度
resolveSymbol(symbolName, onResolve, onError) {
onResolve({
name: symbolName,
session: "24x7",
timezone: "Asia/Shanghai",
minmov: 1,
pricescale: 100,
has_intraday: true,
});
}
// 按标的、周期、起止时间取历史 K 线
async getBars(symbolInfo, resolution, periodParams, onHistoryCallback, onError) {
// 图表给过来的 from / to 是秒,先转毫秒,两套单位各管一段,别混着用
const fromMs = periodParams.from * 1000;
const toMs = periodParams.to * 1000;
const rows = await fetchKline(symbolInfo.name, resolution, fromMs, toMs);
const bars = rows.map((r) => ({
time: Number(r.time), // 毫秒时间戳,不是秒
open: Number(r.open),
high: Number(r.high),
low: Number(r.low),
close: Number(r.close),
volume: Number(r.volume),
}));
onHistoryCallback(bars, { noData: bars.length === 0 });
}
}这一步的关键是格式。首屏能看到历史 K 线,切标的、切周期、往左滚动的时候还能继续取到数据,就算过了。
第四步 补上实时行情
历史显示出来之后,图表会自己来调 subscribeBars。这个时候,你把标的、周期、订阅者编号,以及一个回调函数交给 WebSocket 那层。
收到实时消息,转成跟历史一致的格式。时间跟最后一根 K 线相同,就更新这一根。时间进入新周期,就追加一根新的。
同一条连接可以承载多个标的和周期,所以要靠订阅表按频道记清楚谁在听。切标的或周期的时候把旧订阅删掉,页面卸载的时候销毁图表实例。真要上线,断线重连、心跳、异常消息、订阅恢复这些活也都躲不掉。
⑤ 图表把实时订阅交出来,剩下的由订阅层接管
// 图表把实时订阅交出来,剩下的由订阅层接管
subscribeBars(symbolInfo, resolution, onRealtimeCallback, subscriberUID, onResetCacheNeededCallback) {
socket.subscribeOnStream(
symbolInfo,
resolution,
onRealtimeCallback,
subscriberUID,
onResetCacheNeededCallback,
null
);
}
unsubscribeBars(subscriberUID) {
socket.unsubscribeFromStream(subscriberUID);
}这一步的关键是订阅。历史和实时能接上,切频道的时候旧订阅被清干净,当前这根一直在动,就对了。
四步走完,一套能看能动的本地 K 线就成形了。
以上是流程部分,照着做就能跑起来。往下是动手部分,讲那张订阅表到底怎么工作、代码怎么写,还有四个最容易翻车的地方。不关心代码的话,第五节和第七节的结论可以直接看。
四、一张订阅表,管着三件事
先说 PubSub 这个词。
全名是发布订阅。你可以想成广播电台,发消息的人只管往某个频道丢,听的人只管守着自己关心的那个频道,两边不用认识彼此。
落到 K 线这个场景里,标的加周期拼起来就是频道名。一分钟周期的 BTC 对 USDT,频道名就是 btcusdt@kline_1m。WebSocket 收到行情的时候是发的人,图表给过来的回调是听的人,订阅表记的就是谁在听哪个频道。
加这张表,为的是把两边解耦。WebSocket 不需要知道页面上有几张图,图表也不需要懂底层的消息协议。
| 角色 | 对应实现 |
|---|---|
| 频道 | 标的加周期拼成的字符串 |
| 订阅表 | 一个 Map |
| 听众 | 图表给的回调函数 |
| 消息 | WebSocket 收到的那一包 |
那为什么非要在中间加这一层。
直接在 WebSocket 的消息回调里写图表更新逻辑,只有一张图的时候也跑得起来。图一多就乱了,回调和连接的关系缠成一团,取消订阅的时候你都不知道该关哪一个。
中间加一层表之后,好处是三件。
连接更少。同一个频道只建一次远端订阅,本地再把同一条行情分发给所有需要的图表。
切换更稳。每个听众带一个订阅者编号,取消的时候按编号精确删除,不会误伤。
职责更清楚。WebSocket 管收发,订阅表管关系,图表管渲染。
表本身很简单,两个类型定义就装下了。
⑥ 一张表,两个类型
// 一个听众,id 是图表给的订阅者编号,用来精确删除
type Handler = {
id: string;
callback: (bar: any) => void;
};
// 一个频道,handlers 里装着所有在听它的听众
type Subscription = {
lastBar: any;
handlers: Handler[];
};先看订阅。这段代码一共十几行,要紧的就在中间那个判断上。
⑦ 频道已存在时,本地加人,远端不加指令
subscribeOnStream(symbolInfo, resolution, callback, subscriberUID, onResetCache, lastBar) {
const topic = this.getTopic(symbolInfo.name, resolution);
const current = this.channelToSubscription.get(topic);
// 频道已经有人订过,本地加个听众就行,不用再通知服务端
if (current) {
current.handlers.push({ id: subscriberUID, callback });
return;
}
// 新频道,建记录,然后才发订阅指令
this.channelToSubscription.set(topic, {
lastBar,
handlers: [{ id: subscriberUID, callback }],
});
this.emit("SUBSCRIBE", [topic]);
}注意那个提前返回。频道有人订过的时候,代码直接 return,远端一条指令都不发。
「两个图表共用一条连接」就是在这儿发生的。第二张图订阅的时候走进上面那个分支,本地听众从 1 个变成 2 个,远端完全不知道多了个人。

再看消息进来之后是怎么分发下去的。
⑧ 还原频道名,再分发给所有听众
publish(rawData) {
const message = JSON.parse(rawData);
// 关键一步,这里必须跟订阅端用同一套频道名规则
const topic = this.getTopic(message.symbol, message.resolution);
const item = this.channelToSubscription.get(topic);
if (!item) return;
const bar = {
time: Number(message.time),
open: Number(message.open),
high: Number(message.high),
low: Number(message.low),
close: Number(message.close),
volume: Number(message.volume),
};
item.lastBar = bar;
// 同一个频道的所有听众,各收到一次
item.handlers.forEach((handler) => handler.callback(bar));
}这里有两行是关键。上面那行 getTopic 是第一个坑的入口,下面那个 forEach 就是分发动作本身,一条行情进来,两个听众各收到一次。
最后是取消订阅。这一段的时机比动作本身重要。
⑨ 本地删人和远端退订,是两件事
unsubscribeFromStream(subscriberUID) {
for (const [topic, item] of this.channelToSubscription) {
item.handlers = item.handlers.filter((h) => h.id !== subscriberUID);
// 频道空了,才通知服务端退订
if (!item.handlers.length) {
this.emit("UNSUBSCRIBE", [topic]);
this.channelToSubscription.delete(topic);
}
}
}
getTopic(symbol, resolution) {
// 全局唯一的一套频道名规则,订阅和接收都走这里
return symbol.toLowerCase() + "@kline_" + resolution;
}
emit(method, topics) {
if (this.socket.readyState !== WebSocket.OPEN) return;
this.socket.send(JSON.stringify({ method, topics }));
}this.socket.send(JSON.stringify({ method, topics }));}取消订阅的动作是删本地列表,通知服务端是另一件事,得等列表空了才做。
这段逻辑跑起来只有三种状态。两张图表都开着的时候,本地 2 个听众,远端订阅指令 1 条。关掉第一张,本地减到 1 个,远端一条指令都不发。关掉第二张,本地归零,远端才收到那 1 条退订,频道从表里删掉。

差一拍就差在这儿。远端订阅的生死,看的是本地听众数量,不是图表数量。
五、四个最容易翻车的地方
前面四步走顺了,图也出来了,这时候最容易出岔子的反而是细节。下面四个,前两个在订阅层,后两个在数据和资源上。它们的共同点是都不太像 bug,更像玄学。
第一个坑,频道名少做一次归一化
频道名是拼出来的。订阅的时候走 getTopic,里面有一句 toLowerCase,会把标的转成小写,拼出来是 btcusdt@kline_1m。
问题出在消息端。要是那一段代码图省事,直接拿消息里的 symbol 拼频道名,没走统一的那个函数,拼出来就是 BTCUSDT@kline_1m。
两个字符串长得几乎一模一样,就差个大小写。
顺着这条路径往下走一遍就清楚了。订阅表里查出来是空,代码走到 if (!item) return 那一行,直接返回。
不抛异常。不打日志。控制台干干净净。
把两种写法并排看,同一根行情推进来,走归一化的那种图表更新 1 根 K 线,不走归一化的更新 0 根。而你坐在屏幕前看到的画面是,图表加载了,历史 K 线也在,就是不动。
排查这类问题最费时间的地方就在这儿。你不缺报错,你缺的是一条线索。历史能显示,说明接口是通的。图上不动,说明实时那一段断了。可代码一句话都不会告诉你。
后来我给自己立了一条硬规矩。频道名只准从 getTopic 这一个函数里出来,谁都不许自己拼。
第二个坑,切换周期时订阅者编号复用
订阅者编号是图表给的,每次订阅都带一个。取消订阅的时候,代码是按编号在全表里过滤的。
设想一个动作,你把图表从一分钟切到五分钟。图表那边通常会先退订旧的,再订阅新的。
要是实现里这两步的顺序被弄反了,先订阅新的再退订旧的,同时新订阅又复用了同一个编号,麻烦就来了。
退订那一行是按编号过滤的,它不区分频道。刚建立起来的五分钟订阅,编号跟正在退订的一分钟一模一样,于是被一起带走。
顺着这条路径走一遍,退订指令会发出 2 条,订阅表里一个频道都不剩。切完周期之后,五分钟这根 K 线再也收不到行情,回调触发 0 次。
这个坑比第一个更隐蔽。第一个坑是消息丢了但表还在,第二个坑是表被清干净了,你去翻表,什么都看不出来。

第三个坑,进来是秒,出去是毫秒
图表问你要历史数据的时候,给过来的是秒。你交回去的每根 K 线,时间字段得是毫秒。这两套单位各管一段,谁也不会替你转。
转漏了的后果不太直观。数据其实取回来了,只是取错了区间,图上表现为缺一段,或者整段往左挪了一格。控制台照样一条报错都没有。
第三步那段 getBars 里,我把转换写在最前面了,periodParams 的 from 和 to 各乘 1000 再往后端传。这个转换只做一次就够,做完别再动。
第四个坑,资源路径差一个斜杠,容器高度算出来是 0
这两个都在第二步,属于部署期的坑,一次配错,后面每一步都怪怪的。
library_path 指向整套资源的访问目录,末尾那个斜杠不能省。图表后面还要从这个目录去取内部的脚本和样式,路径拼接的时候少了斜杠,就变成往上一级目录找,入口文件照样能打开,内部资源全是 404。
容器高度那一条更常见。容器写的是百分比高度,父级没给高度,算出来就是 0。图表初始化不报错,画出来一片空白。
两个都好查,控制台和元素面板一眼能看到。真正费时间的是它们的表现太像数据问题了,人会一直往错误的方向找。
四个坑摆在一起,我心里那句结论也就清楚了。这套东西稳不稳,一半靠机制设计,一半靠命名、编号、单位、路径这些小事上的规矩。
六、订阅层完整实现,可以直接抄
下面这份是订阅层的完整实现,不含任何测试脚手架,可以直接抄进你的项目。它不依赖图表库,只要你的行情推送是订阅频道加推消息这个模型,就能用。
⑩ 订阅层完整实现,一张表管住频道与听众
// socket-client.js 订阅层:一张表管住频道与听众的关系
class SocketClient {
constructor(socket) {
this.socket = socket;
this.channelToSubscription = new Map();
this.socket.addEventListener("message", (event) => this.publish(event.data));
}
subscribeOnStream(symbolInfo, resolution, callback, subscriberUID, onResetCache, lastBar) {
const topic = this.getTopic(symbolInfo.name, resolution);
const current = this.channelToSubscription.get(topic);
if (current) {
current.handlers.push({ id: subscriberUID, callback });
return;
}
this.channelToSubscription.set(topic, {
lastBar,
handlers: [{ id: subscriberUID, callback }],
});
this.emit("SUBSCRIBE", [topic]);
}
publish(rawData) {
const message = JSON.parse(rawData);
const topic = this.getTopic(message.symbol, message.resolution);
const item = this.channelToSubscription.get(topic);
if (!item) return;
const bar = {
time: Number(message.time),
open: Number(message.open),
high: Number(message.high),
low: Number(message.low),
close: Number(message.close),
volume: Number(message.volume),
};
item.lastBar = bar;
item.handlers.forEach((handler) => handler.callback(bar));
}
unsubscribeFromStream(subscriberUID) {
for (const [topic, item] of this.channelToSubscription) {
item.handlers = item.handlers.filter((handler) => handler.id !== subscriberUID);
if (!item.handlers.length) {
this.emit("UNSUBSCRIBE", [topic]);
this.channelToSubscription.delete(topic);
}
}
}
getTopic(symbol, resolution) {
return symbol.toLowerCase() + "@kline_" + resolution;
}
emit(method, topics) {
if (this.socket.readyState !== WebSocket.OPEN) return;
this.socket.send(JSON.stringify({ method, topics }));
}
}用法就两行,接上真实连接,把图表给的回调交给它。
⑪ 接上真实连接
// 接上真实连接,把实例挂出去给 datafeed 用
const socket = new WebSocket("wss://你的行情地址/stream");
const client = new SocketClient(socket);还有一步是收到行情之后怎么落图。判断这包消息该更新最后一根,还是新开一根,依据只有时间,不是消息序号。
⑫ 更新还是追加,只看时间
// 把一包行情落到图表上,更新还是追加,只看时间
function applyTick(lastBar, tick, resolutionSec, onRealtimeCallback) {
const size = resolutionSec * 1000;
const barTime = Math.floor(tick.time / size) * size;
// 时间还落在当前这根里,更新它
if (lastBar && barTime === lastBar.time) {
const merged = {
...lastBar,
high: Math.max(lastBar.high, tick.price),
low: Math.min(lastBar.low, tick.price),
close: tick.price,
volume: lastBar.volume + (tick.volume || 0),
};
onRealtimeCallback(merged);
return merged;
}
// 时间进了下一根,新开一根
const fresh = {
time: barTime,
open: tick.price,
high: tick.price,
low: tick.price,
close: tick.price,
volume: tick.volume || 0,
};
onRealtimeCallback(fresh);
return fresh;
}这份代码也可以直接拿去改。getTopic 换成你自己的频道名规则,publish 里那六个字段的组装换成你数据源的字段名,剩下的一行都不用动。
再说一句数据源怎么接。这类部署里的地址基本都是占位符,我补几句国内能落地的。
上面那种频道名的格式,是公开行情推送里很常见的命名法,连注册都不用,适合先把链路跑通。链路跑通之后,再换成你自己的数据源。渠道大致有三种。券商的行情推送最省事,前提是你有账户和权限。通达信的本地转发适合本来就在用它的,接起来最直接。再退一步,用 akshare 这类库轮询也能跑,日线够用,分钟级就别指望了,轮询的间隔决定了它的下限。
三种渠道的接口长相各不相同,但只要你的 getBars 和订阅回调这两处输出格式不变,上面这套代码一行都不用改。
七、值不值得自己搭
回到开头那个问题。
自己搭的好处是数据在自个儿手上。图表长什么样、加什么指标、留多久的历史,全是你说了算。哪天想换一家数据源,动的只是最下面那一层,上面几层的代码不用碰。
成本也得说清楚。图表库本身要过授权流程,这是最硬的一道门槛。之后是运维,断线重连、心跳、订阅恢复这些活,上线之后会一直跟着你。再往后是标的和周期的扩张,每加一个维度,订阅表的规模都要往上走。
所以我的判断分两种情况。如果你只是想要一张好看的 K 线图,现成的网页版完全够用,真没必要折腾。如果你是要把行情接进自己的策略流程,让回测和看盘用同一份数据,那这一套就值得做,省掉的是一整条数据搬运的链路。
最后一个问题留给你。你自己那套行情看板或者策略脚本,数据是从哪一层进来的,有没有一个地方能统一管住频道名的生成。
觉得有用的话,把这篇转给同样在写行情看板的朋友。
#TradingView #K线图表 #本地部署 #WebSocket #发布订阅 #Vue #数据源接入 #实时行情 #量化工具 #前端工程 #行情看板
本文为技术方案拆解,不构成任何操作建议。文中代码为通用实现示例,接入任何数据源之前请自行确认其授权范围与使用条款。量化工具开发应以学习和技术交流为目的。市场有风险,请合法合规投资。
Be First to Comment