Kết nối WebSocket SSI để nhận giá cổ phiếu live ở client-side
Realtime module dùng EventEmitter pattern, kết nối trực tiếp tới WebSocket SSI (wss://iboard-pushstream.ssi.com.vn/realtime). Không bị CORS nên có thể gọi thẳng từ browser, không cần proxy qua API route.
"use client";
import { useEffect, useState } from "react";
import { realtime } from "vnstock-js";
export function LivePrice() {
const [price, setPrice] = useState<number | null>(null);
useEffect(() => {
const client = realtime.create({ symbols: ["FPT"] });
client.on("quote", (q) => {
if (q.symbol === "FPT") setPrice(q.matched.price);
});
client.connect();
return () => client.disconnect();
}, []);
return <div>FPT: {price != null ? (price * 1000).toLocaleString() : "..."}</div>;
}
Note:
Dữ liệu chỉ có trong giờ giao dịch: thứ 2 - thứ 6, 9:00-11:30 và 13:00-15:00 (giờ VN). Ngoài giờ WebSocket vẫn connect được nhưng sẽ không nhận message nào.
realtime.create(options)Trả về RealtimeClient (extends EventEmitter).
interface RealtimeClientOptions {
symbols?: string[]; // Danh sách mã subscribe khi connect
url?: string; // Custom WS URL (mặc định: SSI)
autoReconnect?: boolean; // Tự động reconnect khi mất kết nối (mặc định: true)
reconnectInterval?: number; // ms giữa các lần retry (mặc định: 3000)
maxReconnectAttempts?: number; // Mặc định: 10
deadManTimeout?: number; // ms không nhận message → reconnect (mặc định: 60000)
}
| Method | Mô tả |
|---|---|
client.connect() | Mở WebSocket, subscribe các symbols khởi tạo |
client.disconnect() | Đóng socket, hủy reconnect |
client.subscribe(symbols) | Thêm mã sub (không cần reconnect) |
client.unsubscribe(symbols) | Bỏ sub mã (không cần reconnect) |
| Event | Payload | Mô tả |
|---|---|---|
quote | RealtimeQuote | Nhận tick giá mới |
connected | - | Socket mở thành công |
disconnected | reason: string | Mất kết nối (chưa bao gồm intentional) |
reconnecting | attempt: number | Đang retry lần thứ N |
error | Error | Lỗi parse / connection / server |
"use client";
import { useEffect, useRef, useState } from "react";
import { realtime } from "vnstock-js";
export function Watchlist() {
const [symbols, setSymbols] = useState<string[]>(["FPT", "VNM"]);
const [quotes, setQuotes] = useState<Record<string, number>>({});
const clientRef = useRef<ReturnType<typeof realtime.create> | null>(null);
const prevSymbolsRef = useRef<string[]>(symbols);
// Connect 1 lần khi mount
useEffect(() => {
const client = realtime.create({ symbols });
client.on("quote", (q) => {
if (!q?.symbol || !q.matched?.price) return;
setQuotes((prev) => ({ ...prev, [q.symbol]: q.matched.price }));
});
client.connect();
clientRef.current = client;
return () => client.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Áp diff khi user đổi watchlist, không reconnect
useEffect(() => {
const client = clientRef.current;
if (!client) return;
const prev = prevSymbolsRef.current;
const added = symbols.filter((s) => !prev.includes(s));
const removed = prev.filter((s) => !symbols.includes(s));
if (added.length) client.subscribe(added);
if (removed.length) client.unsubscribe(removed);
prevSymbolsRef.current = symbols;
}, [symbols]);
return (
<ul>
{symbols.map((s) => (
<li key={s}>
{s}: {quotes[s] ? (quotes[s] * 1000).toLocaleString() : "..."}
</li>
))}
</ul>
);
}
function getMarketStatus() {
const now = new Date();
const utcMs = now.getTime() + now.getTimezoneOffset() * 60000;
const vn = new Date(utcMs + 7 * 60 * 60000);
const day = vn.getDay();
if (day === 0 || day === 6) return "closed";
const t = vn.getHours() * 60 + vn.getMinutes();
if ((t >= 540 && t <= 690) || (t >= 780 && t <= 900)) return "open";
if (t > 690 && t < 780) return "lunch";
return "closed";
}
// Trong component
const [mode, setMode] = useState(getMarketStatus());
useEffect(() => {
const id = setInterval(() => setMode(getMarketStatus()), 60000);
return () => clearInterval(id);
}, []);
useEffect(() => {
if (mode !== "open") return;
const client = realtime.create({ symbols: ["FPT"] });
client.on("quote", handleQuote);
client.connect();
return () => client.disconnect();
}, [mode]);
const [status, setStatus] = useState<"idle" | "connecting" | "live" | "error">("idle");
useEffect(() => {
const client = realtime.create({ symbols: ["FPT"] });
setStatus("connecting");
client.on("connected", () => setStatus("live"));
client.on("disconnected", () => setStatus("connecting"));
client.on("error", () => setStatus("error"));
client.on("quote", handleQuote);
client.connect();
return () => { client.disconnect(); setStatus("idle"); };
}, []);
RealtimeQuoteXem chi tiết tại RealtimeQuote.
Các field quan trọng:
| Field | Mô tả |
|---|---|
symbol | Mã cổ phiếu |
matched.price | Giá khớp (chia 1000, nhân lại để ra VND) |
matched.volume | KL khớp phiên này |
matched.change | Thay đổi so với tham chiếu |
matched.changePercent | % thay đổi (decimal, vd 0.025 = 2.5%) |
bidPrices / askPrices | Top 3 mức giá mua/bán |
totalVolume | Tổng KL giao dịch trong ngày |
side | "buy" hoặc "sell" (bên khớp) |
Sau 10 lần retry fail, client sẽ emit error. Kiểm tra:
maxReconnectAttempts nếu cầnĐây là hành vi bình thường — SSI chỉ stream trong giờ. Dùng stock.priceBoard() để lấy snapshot cuối ngày.
Vài tick đầu có thể noisy. Validate bằng priceBoard lấy reference/ceiling/floor, rồi filter:
const isValid = price >= floor && price <= ceiling;
| v1.0.x (cũ) | v1.1.0+ (hiện tại) |
|---|---|
stock.realtime.connect({onMessage}) | realtime.create({symbols}) |
stock.realtime.subscribe(socket, {...}) | client.subscribe([...]) |
stock.realtime.parseData(raw) | client.on("quote", handler) |
API v1 đã bị xóa hoàn toàn từ v1.1.0 (xem changelog).