TradingView Advanced Charts + Web Worker: Từ Tick Đến Candle Ở Cả Frontend Lẫn Backend
Bài này có bản tiếng Anh. Read in English →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Màn hình chart trắng trơn. Candle thì đã có, đẹp, xanh đỏ đúng chuẩn TradingView. Nhưng cột volume bên dưới sai bét: candle khớp lệnh dăm ba lệnh mà volume cao bằng cả buổi phiên.
Đó là buổi chiều mình nhớ nhất trong hai tháng làm chart cho một platform giao dịch phái sinh ở Việt Nam — thời hạn của dự án cũng chỉ có bấy nhiêu: hai tháng để research, code, rồi ship lên production. Bài này đi xuyên qua cả pipeline, từ tick rời khỏi sàn đến candle hiện trên màn hình — cả phần frontend lẫn phần backend, vì hai bên cùng tạo ra cái nút thắt, và optimize cũng phải hai bên cùng làm. Cuối bài là bảng tổng hợp từng điểm cần sửa và solution cụ thể.
Chọn chart library trước khi chọn kiến trúc
Cái bảng giá trong đầu trader là bảng giá của TradingView. Muốn họ dùng platform của mình mà không thấy chart “nghèo”, con đường rẻ nhất nhìn có vẻ là nhúng widget free của TradingView — một iframe từ s.tradingview.tv, chạy được sau năm phút.
Nhưng iframe đó không phải của mình. Data là data của TradingView, còn hợp đồng của mình là phái sinh hàng hóa niêm yết qua một sàn tập trung — giá đi từ sàn, qua một tầng trung gian, rồi mới tới frontend. Không đường nào đưa thứ data đó vào iframe của TradingView. Với một platform giao dịch, chart không chỉ để ngắm — nó là nơi trader ra lệnh.
Các lựa chọn thực tế quanh bài toán này:
- Free widget (
s.tradingview.tv): nhúng nhanh nhất, nhưng data không kiểm soát được, không custom được symbol của mình. Loại ngay. - lightweight-charts: open source của chính TradingView, cực nhẹ. Nhưng nó chỉ vẽ series — không có toolbar, không indicator có sẵn, không drawing tools. Cái gì cũng phải tự dựng.
- KLineCharts (klinecharts.com): open source, đi được xa hơn lightweight-charts nhiều — indicator, drawing tools có sẵn, cách nạp data cũng theo kiểu “bạn tự feed”. Indicator thì có thật: hàng chục built-in (MACD, BOLL, EMA, KDJ, RSI, CCI, DMI, SAR…), muốn thêm loại riêng thì viết qua
registerIndicatorrồi dùng như built-in. Về triết lý thì gần Advanced Charts nhất trong đám open source. - TradingView Advanced Charts (
charting_library): bản thương mại, self-host. Cả UI charting của TradingView nằm trong bundle của bạn. Cái giá: library chỉ là máy render, mọi câu hỏi về data nó đều hỏi bạn qua một JS API gọi là datafeed.
Chọn Advanced Charts vì hai thứ. Thứ nhất là độ sâu của indicator: trader phân tích kỹ thuật muốn hàng trăm study có sẵn, muốn indicator hoạt động đúng y như họ quen trên TradingView — KLineCharts có nhưng phần nào tự lo phần đó, và hệ sinh thái extension của nó mỏng hơn nhiều. Thứ hai là giao diện quen tay: trader mở chart ra phải thấy ngay “cái này là TradingView”. Tự dựng những thứ đó bằng lightweight-charts thì hai tháng sẽ hết có mỗi chart. KLineCharts là phương án đáng cân nhắc nếu ngân sách không có license — cân nhắc đầu tiên của mình sẽ là dò danh sách indicator built-in của nó trước khi cam kết.
Còn Advanced Charts khoai ở đúng chỗ mà quảng cáo không nói: data do bạn nấu. Cả hai đầu bếp.
Toàn cảnh pipeline: ba con đường của data
Widget khởi tạo bằng một object config, trong đó datafeed là object mình viết:
tvWidgetRef.current = new window.TradingView.widget({
symbol, // mã hợp đồng
datafeed: customDataFeed({ /* deps */ }),
library_path: "/charting_library/charting_library/",
interval: savedResolution, // đọc từ localStorage, mặc định "60"
timezone: "Asia/Ho_Chi_Minh",
// ... enabled_features, disabled_features, studies_overrides
});
Từ lúc đó, widget sẽ liên tục hỏi datafeed của mình. Data đi vào chart qua ba con đường, mỗi con đường một tính chất:
flowchart LR
subgraph FE["Frontend React"]
TV["TradingView widget"]
DF["custom datafeed"]
W["fetchHistoricalData.worker"]
SOCK["socket.io /price-feed"]
end
subgraph BE["Backend"]
GQL["GraphQL service"]
PS["Price feed service"]
end
TV -- "getBars với from/to" --> DF
DF -- "postMessage yêu cầu" --> W
W -- "fetch phân trang" --> GQL
W -- "postMessage toàn bộ candle" --> DF
DF -- "onHistoryCallback bars" --> TV
SOCK -- "nano-price-update liên tục" --> DF
DF -- "onRealtimeCallback bar" --> TV
Lịch sử đi qua Web Worker vì nó nặng. Tick realtime đi qua socket vì nó cần nhanh. Hai luồng gặp nhau ở đúng một chỗ: datafeed — nơi quyết định candle nào được đưa vào chart, lúc nào, theo hình dạng nào.
Datafeed: contract với widget
Datafeed là một object với một loạt callback mà TV gọi theo đúng thứ tự nó cần: onReady, searchSymbols, resolveSymbol, getBars, subscribeBars. Viết thiếu hoặc trả sai shape, chart không báo lỗi — nó im lặng, hoặc hiện “no data”, và bạn ngồi debug mù.
resolveSymbol là chỗ khai báo “hợp đồng này trông như thế nào” — và với phái sinh, nó khác chứng khoán ở vài chỗ:
resolveSymbol: (name, ok, err) => {
ok({
name,
description: contractName,
session: "24x7", // phái sinh khớp gần như liên tục
timezone: "Asia/Ho_Chi_Minh",
exchange: "ACM",
minmov: 1,
pricescale: 1000, // tick size 0.001
has_intraday: true,
has_weekly_and_monthly: false, // không dựng weekly từ daily — không có daily
supported_resolutions: ["1", "5", "15", "30", "60", "240", "1D"],
data_status: "streaming",
});
};
has_weekly_and_monthly: false là quyết định mình từng sai: mặc định bật thì TV tự cộng dồn candle 1D thành 1W, nhưng dữ liệu backend chỉ có tới daily, kết quả là weekly hiện sai trầm xuống không ai giải thích được.
Còn về getBars, TV hoạt động kiểu viewport-driven: vào chart thì hỏi đúng range màn hình đang thấy, vuốt trái chạm mép data thì mới gọi tiếp range cũ hơn. Code của mình can thiệp vào nhịp đó một chỗ — lần gọi đầu tiên luôn fetch từ mốc bắt đầu dữ liệu thật:
// TV gọi getBars với from/to tính bằng GIÂY. Backend muốn ISO UTC.
if (!fromSec || !toSec || !dataFetched) {
from = dayjs("2025-07-30 00:00:00").utc().toISOString(); // mốc data thật
to = dayjs().utc().toISOString();
} else {
from = dayjs.unix(fromSec).utc().toISOString();
to = dayjs.unix(toSec).utc().toISOString();
}
Lần đầu fetch full range là một trade-off có chủ đích: TV nắm được data boundary, vuốt ngược mượt vì mọi thứ đã về rồi. Cái giá của nó sẽ quay lại ở phần sau — và là một trong những điểm optimize chính.
Web Worker: fetch hàng chục nghìn candle mà không đóng băng UI
Một hợp đồng active trong phiên có thể trả vài chục nghìn candle cho khung 1 phút. JSON.parse của mấy payload đó cộng với việc map thành object, sort theo timestamp — đủ khiến main thread khựng rõ rệt trong lúc chart đang dựng hình.
Giải pháp: đẩy cả mối nối GraphQL vào worker. Worker được khởi tạo kiểu module ngay trong datafeed, tự chạy loop phân trang đến hết data:
let page = 1, hasNextPage = true, allData = [];
while (hasNextPage) {
const res = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json",
Authorization: `Bearer ${TOKEN}` },
body: JSON.stringify({ query, variables: {
filter: { instrumentCode: symbol, interval,
eventTimestampFrom: from, eventTimestampTo: to },
pagination: { limit: pageSize, page },
}}),
});
const payload = (await res.json()).data.getHistoricalPricesByInterval;
allData = [...allData, ...payload.edges];
hasNextPage = payload.pageInfo.hasNextPage;
page++;
}
self.postMessage({ candleData: allData, isComplete: true, error: null });
Worker tự fetch, tự parse JSON, tự gộp trang. Main thread chỉ nhận một message duy nhất chứa mảng hoàn chỉnh. Chart vẫn mượt trong suốt mấy giây load.
Worker cũng là nơi gánh mấy chuyện không phải parse data:
- Auth hết hạn giữa chừng — worker dò GraphQL error, thấy message dạng “Invalid or expired” là gắn cờ
authError: true. Main thread nhận cờ, toast, xoá storage, redirect login. - Retry có backoff — tối đa 3 lần, đợi
1000 * retryCountms giữa các lần. - Login lại từ tab khác — datafeed nghe
storageevent, thấyACCESS_TOKENđổi là tự fetch lại.
Volume: backend trả tích lũy, chart cần từng cây
Đây là chỗ ăn thời gian nhất, và cũng là chỗ ít tài liệu nói nhất.
Backend lưu volume dạng cumulative: candle 09:05 chứa tổng khớp lệnh từ đầu phiên đến 09:05, không phải khối lượng của riêng khung 09:05–09:10. Chart thì cần cái sau. Volume mỗi candle phải tự tính:
// chỉ tin khi candle kế tiếp cách ĐÚNG một interval
if (next && dayjs.utc(next.eventTimestamp).valueOf() - ts === intervalMs) {
actual = Math.max(0, parseVolume(next.volume) - parseVolume(c.volume));
}
Nguyên tắc: chỉ trừ khi candle kế tiếp cách đúng chuẩn một interval. Có gap (không giao dịch trong khung) thì hiệu sai — fallback về volume đã tính trước của bucket, hoặc tối thiểu 1 để candle không biến mất. Trước vòng lặp còn một filter nữa: bỏ candle có thời điểm ở tương lai theo giờ Việt Nam, vì server thỉnh thoảng trả candle “từ tương lai” do cách snap thời gian.
Cột trái là data thô từ backend (chỉ đi lên), cột phải là volume thật sau khi lấy hiệu. Mỗi mốc bị thiếu kế tiếp rơi về volume tối thiểu.
Timezone là mồi lửa thêm. Backend trả ISO UTC, phiên tính theo Asia/Ho_Chi_Minh, “hiện tại” phải là hiện tại của user. Một buổi tối mò bug chỉ vì so sánh dayjs(c.eventTimestamp) với dayjs() ở hai múi giờ khác nhau. Từ đó toàn bộ so sánh thời gian trong module này đi qua dayjs.utc(...).tz("Asia/Ho_Chi_Minh") một cách tường minh.
Realtime: nhét tick vào đúng cây candle đang chạy
Lịch sử xong là xong. Phần khó hơn là lúc chart đang mở, server đẩy giá liên tục qua socket.io namespace /price-feed:
sequenceDiagram
participant FE as ChartPrice component
participant SK as socket.io
participant TV as TradingView datafeed
FE->>SK: emit subscribe-price (contractCode)
loop mỗi tick
SK-->>FE: nano-price-update (lastPrice, tradingVolume)
FE->>FE: updateCurrentBar (bucket theo resolution)
FE->>TV: realtimeCallbackRef bar mới
TV->>TV: vẽ lại candle cuối
end
Mỗi tick đến không push thẳng vào chart. Phải dựng lại đúng cây candle mà khung thời gian hiện tại đang nắm — tick 09:07:23 trên khung 5 phút thuộc bucket 09:05 — rồi merge: close là giá mới nhất, high/low mở rộng nếu tick vượt biên, volume lấy hiệu giữa tradingVolume tích lũy hiện tại và initialVolume lúc bar mở.
Và một cái guard thêm vào sau khi TV tự vẽ ra những cây candle quay ngược thời gian:
if (existingBar && barTime <= existingBar.time) {
console.warn(`Time violation prevented: ${barTime} <= ${existingBar.time}`);
return currentTime; // không cho bar lùi thời gian
}
Server thỉnh thoảng đẩy tick lệch thời gian (retry, clock skew). TV rất gắt: bar mới có time nhỏ hơn bar trước là vẽ sai hoặc bỏ. Guard này không sửa được dữ liệu — nó chỉ khiến chart không phá hình.
Bên kia sông: backend đang nấu candle thế nào
Đến đây là hết phần frontend. Giờ mở nắp hầm phía backend — nơi datafeed gọi vào — và đây là phần mình không nhìn thấy khi đang code frontend, chỉ thấy được khi quay lại review cả hai đầu.
Bảng instrument_historical_prices lưu tick thô — mỗi row là một bản tin giá từ sàn: last_price, trading_volume, timestamp. Có index composite (instrument_code, event_timestamp) dựng sẵn, chuẩn. Vấn đề không nằm ở DB nằm trống index. Vấn đề nằm ở chỗ: bảng chỉ có tick, không có candle.
Mỗi request getHistoricalPricesByInterval đi theo đường này:
sequenceDiagram participant W as Worker (FE) participant R as GraphQL resolver participant DB as bảng tick thô participant N as Node — aggregate trong RAM W->>R: page 1 (limit 1000) R->>DB: SELECT toàn bộ tick trong range DB-->>N: mọi tick row về bộ nhớ N->>N: group theo interval, tính OHLC trong RAM N-->>W: 1000 candle đầu W->>R: page 2 R->>DB: SELECT lại toàn bộ tick N->>N: aggregate lại từ đầu N-->>W: 1000 candle kế Note over DB,N: page nào cũng quét + nấu lại toàn bộ
Ba chi tiết đáng giá:
- Không pagination ở tầng DB.
getAllHistoricalPricesbỏ qua pagination và kéo toàn bộ tick trong range về Node — comment trong code tự cảnh báoWARNING: Use with caution - can return large datasets. Sau đó mới slice trong RAM để trả đúng page. - Aggregate bằng tay trong JS. Group tick theo bucket,
Math.max/Math.mincho high/low, lấylastPricecủa tick đầu/cuối cho open/close. Ổn với range nhỏ. Với 2.5 tháng tick 1 phút thì mỗi request là một cơn ăn RAM và CPU của Node. - Volume cumulative có nguồn gốc từ đây.
volumecủa candle làtradingVolumecủa tick cuối trong bucket — tức volume tích lũy từ đầu phiên. Phần tính delta ở frontend phía trên chính là hậu quả trực tiếp của thiết kế này.
Và phép nhân điền linh: worker ở frontend chia data thành 10 page, thì backend quét + nấu lại toàn bộ 10 lần. Chi phí của một lần load chart nhân với số page, không phải cộng.
Điểm neo để hai bên bắt tay: candle đã đóng là bất biến
Đây là nhận xét duy nhất mà cả frontend lẫn backend cùng lợi dụng.
Một candle 09:05 trên khung 5 phút, khi bucket khép lại, OHLCV của nó không bao giờ đổi nữa. Candle duy nhất còn thay đổi là candle đang mở của hôm nay — và cái đó realtime merge đã quản từ đầu. Từ tính chất này, chiến lược optimize hai bên là một:
- Frontend: cache mọi candle đã đóng trong worker store, kèm
coverage(các range đã có).getBarshỏi range nào, thiếu gap nào mới fetch gap đó. - Backend: không nấu lại candle đã đóng trong mỗi request. Pre-aggregate một lần lúc ghi — tick vào thì cập nhật bảng candle theo từng interval — query chỉ còn index range scan. Cộng thêm cache cho range đã đóng, TTL vô hạn vì chúng bất biến.
flowchart LR
subgraph FE["Frontend"]
TV["TradingView widget"]
DF["datafeed"]
ST["Worker store + coverage"]
end
subgraph BE["Backend"]
ING["Tick ingestion"]
PRE["Pre-aggregate khi ghi"]
CDB["Bảng candle theo interval"]
RC["Cache range đã đóng"]
end
TV -- "getBars" --> DF
DF -- "hỏi gap còn thiếu" --> ST
ST -- "miss mới đi mạng" --> RC
RC -- "hit: trả ngay" --> ST
RC -- "miss" --> CDB
ING --> PRE --> CDB
ST -- "postMessage bars" --> DF
DF -- "onRealtimeCallback" --> TV
Contract giữa hai bên cũng gọn theo: BE trả range chính xác được hỏi + true data boundary (noData); FE gap-fill từ lastCachedTimestamp → now mỗi lần socket reconnect, vì đó là khoảng duy nhất có thể xuất hiện data mới trong quá khứ gần. Cái split “worker lấy lịch sử tới hôm qua, Apollo lấy riêng hôm nay” tự tan — worker giữ toàn bộ candle, Apollo chỉ còn phục vụ order book và UI khác.
Bảng điểm cần cải thiện và solution
Gom toàn bộ những gì hai đầu pipeline đang trả giá, kèm solution cụ thể:
| # | Vấn đề | Bên | Solution | Ưu tiên |
|---|---|---|---|---|
| 1 | Aggregate candle bằng tay trong Node, mỗi request một lần | BE | Pre-aggregate lúc ghi — bảng candle theo interval hoặc materialized view; query chỉ còn index range scan | Cao |
| 2 | Offset pagination: mỗi page quét lại từ đầu | BE | Keyset/cursor pagination theo (instrument_code, event_timestamp) | Cao |
| 3 | Payload ISO string, JSON phồng | BE | Trả epoch millis; cân nhắc nén columnar hoặc gzip | Vừa |
| 4 | Không cache cho range đã đóng | BE | Cache layer theo (symbol, interval, bucket) — bất biến nên TTL vô hạn | Vừa |
| 5 | Datafeed không có cache tầng mình | FE | Worker store + coverage, fetch đúng gap, trả { noData: true } ở true boundary | Cao |
| 6 | Race khi đổi symbol nhanh | FE | Generation ID + AbortController trong worker | Vừa |
| 7 | Volume calc chạy trên main thread | FE | Đẩy vào worker, trả Float64Array transferable về | Vừa |
| 8 | Mỗi tick một re-render | FE | Throttle ~100ms hoặc batch tick stream | Vừa |
| 9 | Split 2 đường data (worker lịch sử + Apollo hôm nay) | Cả hai | Worker giữ toàn bộ candle; gap-fill sau reconnect; Apollo chỉ phục vụ UI khác | Cao |
| 10 | dayjs parse trong hot path | FE | Nhận epoch từ BE, parse nội bộ bằng Date.parse | Thấp |
Đọc bảng theo cột ưu tiên thì thấy rõ trật tự làm việc: số 1 và 2 ở backend là nơi trả lãi nhanh nhất, vì chúng nhân với mọi request từ frontend hiện tại. Số 5 và 9 ở frontend phụ thuộc contract ở số 1 — làm BE trước thì FE cache có chỗ mà bám.
Kết
Chart vẫn đang chạy trên production. Người dùng của nó hầu như không biết sau một cây candle xanh có một worker lặng lẽ gom vài trang GraphQL, một phép trừ volume, một guard chống tick lùi thời gian — và phía sau đó nữa, một Node process nấu lại toàn bộ lịch sử tick cho từng page request.
Còn mình, mỗi lần mở chart lên vẫn nhìn cột volume trước. Nhìn nó đứng ngay ngắn từng cây mới yên tâm phần còn lại đang chạy đúng.
Hai tháng không đủ để làm mọi thứ đúng. Nhưng đủ để nhìn thấy chính xác cái gì đang sai, ở cả hai đầu — đấy là phần mình giữ lại cho lần sau.
Đọc tiếp
Cần người cho dự án contract hoặc freelance?
Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.