Đi tới nội dung chính
Chuyển tới nội dung chính

Bộ lọc và phân tích

Bộ lọc (screen) trả lời hôm nay mã nào thỏa điều kiện: tính các cột ở phiên mới nhất rồi giữ mã đạt. Phân tích (research) là báo cáo tự do: tự nạp dữ liệu, tính, rồi quyết định hiện số liệu, biểu đồ, bảng, văn bản nào. Cách đọc kết quả trên giao diện ở Kết quả bộ lọc và phân tích; trang này là phần viết code.

Bộ lọc: screen({…})​

TrườngÝ nghĩaMặc định
nameTên bộ lọc"Bộ lọc"
universeRổ mã — danh sách mã hôm nay (Rổ mã)"ALL": cổ phiếu 3 ký tự trên HOSE, HNX, UPCoM
paramsTham số—
columns: (d, p) => ({ "Tên cột": … })Các cột của bảng kết quả, mỗi cột là Panel hoặc { value: Panel, format, label, levels }bắt buộc
filter: (c, d, p) => PanelĐiều kiện giữ mã; c["Tên cột"] là Panel của cột đókhông lọc
sort"Tên cột" (giảm dần) hoặc ["Tên cột", "asc" | "desc"]cột đầu, giảm dần
limitSố dòng hiện (1–500)100
lookbackSố phiên dữ liệu nạp — đủ cho chỉ báo dài nhất (MA200 cần ≥ 200)260 (≈ 1 năm)
asOfLọc tại một ngày trong quá khứ, ví dụ "2024-12-31"phiên mới nhất
includeTodayTrong giờ giao dịch, dùng nến tạm của phiên đang diễn ratrue

Cách Studio tính:

  1. Nạp lookback phiên tới ngày lọc cho các mã của rổ (rổ vượt giới hạn số mã thì giữ mã thanh khoản nhất).
  2. Tính columns, rồi filter; nếu rổ có .liquid(x), mã phải có GTGD bình quân 20 phiên từ x ở phiên lọc.
  3. Lấy giá trị ở phiên cuối của mỗi cột, chỉ với mã có giá ở phiên đó; sắp theo sort (ô trống xếp cuối), hiện limit dòng đầu kèm tổng số mã thỏa và đường giá 30 phiên của từng mã.
  4. Định dạng cột: khai báo format rõ ràng — "percent", "price", "compact" (rút gọn tỷ / tr), "integer", "ratio", "number". Không khai báo thì Studio đoán theo tên cột: tên có %, tăng, giảm, lợi nhuận, thay đổi, biến động, cách, roe, roa, biên hiện dạng %; có gtgd, giá trị, vốn hóa hiện dạng rút gọn tiền; có giá, đóng cửa hiện dạng giá. Cột tỷ lệ mà tên có "GTGD" (ví dụ "NN ròng / GTGD") sẽ bị hiện như tiền — hãy ghi format: "percent".
  5. Tiêu đề và cột chữ: label là chữ hiện ở tiêu đề cột (tên khoá vẫn dùng trong filter, sort). levels biến mã số thành chữ: giá trị k hiện là levels[k], còn lọc và xếp vẫn theo số — ví dụ tên mẫu hình và trạng thái của ta.patterns: "Mẫu hình": { value: pt.pattern, levels: pt.names }, "Trạng thái": { value: pt.stage, levels: pt.stages } (các bộ lọc nhóm Mẫu hình viết như vậy).
  6. Bộ lọc không có d.bench (VNINDEX). Cần so với thị trường thì so trong rổ: d.close.pct(63).rank() là hạng tăng giá 3 tháng so với các mã còn lại.
  7. Cột và filter phải là Panel nhiều mã. Panel một hàng (chuỗi d.macro(…), kết quả sumAcross()) đưa thẳng vào cột hay làm điều kiện cho kết quả sai: cột chỉ có số ở mã đầu tiên, điều kiện có thể chỉ áp cho mã đầu tiên. Ghép nó với một Panel nhiều mã, ví dụ giữ các mã tăng trong 3 tháng khi lãi suất qua đêm dưới 4%: c["Tăng 3 tháng"].gt(0).and(d.macro("ib_on").lt(4)).
  8. Khối ngoại, tự doanh có sau 19:00: đặt includeToday: false và fillna(0) trước khi cộng dồn.
  9. asOf cho biết bộ lọc đã cho ra những mã nào ở một ngày cũ (không dùng nến tạm); rổ vẫn là danh sách hôm nay, nên mã đã hủy niêm yết từ đó tới nay không có trong kết quả.
screen({
name: "Vượt đỉnh 20 phiên kèm khối lượng đột biến",
universe: universe("ALL").liquid(2e9).top(240),
columns: (d) => ({
"Thay đổi phiên": d.close.pct(1),
"KL / TB20": d.volume.div(d.volume.rolling(20).mean()),
"GTGD": { value: d.value, format: "compact" },
}),
// Đóng cửa bằng mức cao nhất 20 phiên (tính cả giá cao nhất trong phiên) và KL hơn 2 lần bình quân
filter: (c, d) => d.close.gte(d.high.highest(20)).and(c["KL / TB20"].gt(2)),
sort: ["KL / TB20", "desc"],
limit: 50,
});

Bộ lọc có tham số và dữ liệu khối ngoại:

screen({
name: "Khối ngoại mua ròng mạnh",
universe: universe("ALL").top(100),
lookback: 80,
includeToday: false, // khối ngoại có sau 19:00 → chỉ xét các phiên đã chốt
params: {
days: param.int(10, { min: 3, max: 40, label: "Số phiên" }),
share: param.pct(0.05, { min: 0, max: 0.3, label: "Mua ròng / GTGD từ" }),
},
columns: (d, p) => {
const net = d.foreignNet.fillna(0);
return {
"Mua ròng": { value: net.rolling(p.days).sum(), format: "compact" },
"Mua ròng / GTGD": { value: net.rolling(p.days).sum().div(d.value.rolling(p.days).sum()), format: "percent" },
"Số phiên mua ròng": { value: net.gt(0).rolling(p.days).sum(), format: "integer" },
"Thay đổi giá": d.close.pct(p.days),
};
},
filter: (c, d, p) => c["Mua ròng / GTGD"].gte(p.share).and(c["Số phiên mua ròng"].gte(p.days * 0.6)),
sort: ["Mua ròng", "desc"],
limit: 50,
});

Bộ lọc có cột chữ — mã nào đang có mẫu hình tăng giá sát điểm phá vỡ hoặc vừa phá vỡ:

screen({
name: "Mẫu hình tăng giá hôm nay",
universe: universe("ALL").liquid(3e9).top(240),
lookback: 500, // mẫu hình dài tới 250 phiên
includeToday: false, // khối lượng phiên đang giao dịch chưa đủ
columns: (d) => {
const pt = ta.patterns(d, { only: ["cup", "doubleBottom", "ascTriangle"] });
return {
"Mẫu hình": { value: pt.pattern, levels: pt.names },
"Trạng thái": { value: pt.stage, levels: pt.stages },
"Cách mức": { value: pt.distance, format: "percent" },
"Mức phá vỡ": { value: pt.level, format: "price" },
};
},
filter: (c) => c["Trạng thái"].gt(0),
sort: ["Trạng thái", "desc"],
});

Cột Mẫu hình, Trạng thái hiện chữ (Hai đáy (W), Sát điểm phá vỡ…) nhưng lọc và xếp theo mã số: gt(0) giữ mã có mẫu, xếp giảm dần đưa mã Vừa phá vỡ (2) lên trước Sát điểm phá vỡ (1).

Từ bộ lọc sang backtest​

Bộ lọc chỉ nói hôm nay mã nào thỏa; muốn biết điều kiện đó có từng sinh lời, chuyển thành chiến lược: điều kiện lọc thành entry (kiểu tín hiệu) hoặc filter (kiểu xoay vòng), cột sắp xếp thành score / rank. Nút Backtest bộ lọc ở kết quả nhờ trợ lý Quant làm việc này (mua các mã thỏa điều kiện, xếp theo cột sắp xếp, tái cân bằng hằng tháng, tối đa 10 mã, so với VNINDEX từ 2016).

Phân tích: research(…)​

Hai cách khai báo:

// Không tham số
research(async ({ data, chart, table, md, metric, log }) => { /* … */ });

// Có tham số (hiện trên thanh tham số, đổi là chạy lại)
research({
name: "Tên phân tích",
params: { symbol: param.symbol("FPT", { label: "Mã" }) },
run: async ({ data, chart, table, md, metric, log }, p) => { /* … */ },
});

Nạp dữ liệu​

await data.bars(mã, tuỳChọn) trả về dữ liệu d như của chiến lược (Dữ liệu và Panel), kèm d.bench là VNINDEX cùng lịch phiên. mã là một mã ("VNINDEX", "FPT"), danh sách (["FPT", "HPG"] hoặc "FPT,HPG") hoặc rổ universe(…).

Tuỳ chọnÝ nghĩaMặc định
from, toKhoảng thời gian: "2010-01-01", "2010", "01/01/2010" hoặc số ngày10 năm gần nhất → phiên mới nhất
fieldsTrường cần thêm ngoài giá, KL: ["foreignNet", "pe", "fin:NetSale:ttm", "macro:cpi_yoy"]—
warmupSố phiên nạp thêm trước from (để MA200 có số ngay từ from; Panel bắt đầu sớm hơn from)0
includeTodayTrong giờ giao dịch thêm nến tạm của phiên đang diễn ra (d.partialLast = true); false chỉ dùng phiên đã chốttrue
  • Phải khai báo fields cho mọi trường ngoài open, high, low, close, volume, tradable, value, ret; trường dẫn xuất kéo theo trường gốc ("pe" → giá gốc, EPS). Quên khai báo: lỗi Chưa nạp "…" — trong research thêm fields: ["…"]….
  • Mỗi lần data.bars là một lần nạp riêng: Panel của hai lần nạp khác danh sách mã không tính chung với nhau được.
  • await data.symbols(rổ) trả danh sách mã kèm symbol, name, exchange, industry, industryShort, sector, adv20, marketCap (thông tin hôm nay) — dùng để gom nhóm, chọn mã trước khi nạp giá.
  • Giới hạn số mã của gói áp cho mỗi lần data.bars; data.symbols thì không.
  • Trong phân tích, .liquid(x) của rổ chỉ lọc thô theo GTGD 20 phiên hiện tại (giữ mã từ 20% của x), không áp đúng ngưỡng ở từng phiên như backtest. Cần đúng ngưỡng thì chọn bằng .where((m) => m.adv20 >= x) hoặc tự lọc d.value.rolling(20).mean().gte(x).
  • Nhóm ngành: universe({ industry: "Ngân hàng" }) khớp mọi tên ngành chứa chữ đó, gồm cả "Ngân hàng đầu tư và Dịch vụ môi giới" (công ty chứng khoán). Cần đúng ngân hàng thì lọc .where((m) => m.industryShort === "Ngân hàng").
  • Chỉ số thế giới ("^GSPC", "^IXIC", "^N225"…) căn theo lịch phiên Việt Nam: phiên Mỹ ngày t đóng cửa sau phiên Việt Nam ngày t, nên muốn xem "Mỹ hôm qua tác động VNINDEX hôm nay" thì lùi chuỗi Mỹ một phiên (.shift(1)).
  • Vòng lặp theo từng phiên trên vài mã là bình thường trong phân tích (d.close.row(i) cho mảng giá của mã thứ i); với nhiều mã, dùng phép tính trên Panel để nhanh.

Khối kết quả​

Kết quả phân tích Lịch sử sụt giảm: thanh tham số, số liệu, hai biểu đồ

① Thanh tham số — từ params: param.symbol thành ô nhập mã, param.int thành thanh trượt.

② metric(…) — ô số liệu chính, có dòng phụ.

③ chart.line(…) — biểu đồ đường theo trục ngày.

④ chart.area(…) — biểu đồ vùng, ở đây có đường tham chiếu refLines.

Khối hiện theo thứ tự gọi hàm. Bố cục dễ đọc: metric (2–4 số chính) → 2–4 biểu đồ → bảng → md kết luận có số liệu.

HàmDùng cho
metric(nhãn, giáTrị, định dạng?, dòngPhụ?)Ô số liệu. Định dạng: "percent", "number", "ratio", "integer", "text"
chart.line(dữLiệu, tuỳChọn) · chart.area · chart.barĐường, vùng, cột
chart.scatter(điểm, tuỳChọn)Phân tán: [{ x, y, label, group, size }]
chart.hist(mảng, { bins, range })Phân phối (mặc định 30 cột; giá trị ngoài range dồn vào hai cột biên)
chart.heatmap(dữLiệu, tuỳChọn)Bản đồ nhiệt: byYearMonth(panelTháng) hoặc { xLabels, yLabels, values }
table(dữLiệu, tuỳChọn)Bảng: mảng đối tượng, Row (panel.last()), { columns, rows }
``md`…```Đoạn văn Markdown (đậm, nghiêng, danh sách, liên kết); ${giáTrị} chèn số
log(…)In ra Nhật ký chạy, không hiện trong kết quả

dữLiệu của chart.line / area / bar nhận: một Panel (mỗi mã một chuỗi, trục ngày); { "Tên chuỗi": Panel hoặc mảng số } (mảng số cần x làm nhãn trục ngang); { nhãn: số }; mảng cặp [nhãn, số]; mảng đối tượng [{ name, value }] (cột chữ làm nhãn, mỗi cột số một chuỗi); một Row. Biểu đồ không có điểm nào vẽ được hiện Không có dữ liệu để vẽ kèm lý do trong Ghi chú dữ liệu.

Tuỳ chọn biểu đồÝ nghĩa
title, note, heightTiêu đề, chú thích dưới biểu đồ, chiều cao (px)
xNhãn trục ngang; d.days (hoặc ngày của Panel) thành trục ngày
format, yLogĐịnh dạng trục dọc ("percent", "price", "number"…), trục logarit
tonesMàu theo thứ tự chuỗi: "accent", "up", "down", "warn", "muted", "ref"
kindsTrộn dạng theo chuỗi: ["bar", "line"]
right, rightFormatTên các chuỗi vẽ theo trục phải (khác đơn vị) và định dạng của trục phải
refLinesĐường tham chiếu (tối đa 12): [{ y: 0.2, label: "Quá bán", tone: "down" }, { x: "2020-03-23", label: "Đáy Covid" }]
stack, horizontalXếp chồng cột / vùng; cột nằm ngang (xếp hạng có nhãn dài)
Phân tán: xName, yName, xFormat, yFormat, labels, trend, quadrants, tonesTên trục, định dạng từng trục, hiện nhãn điểm (mặc định khi ≤ 40 điểm có nhãn), đường hồi quy kèm R², chia 4 góc { x, y, labels: [phải trên, phải dưới, trái dưới, trái trên] }, màu theo nhóm
Bản đồ nhiệt: center, scale, corner, xLabels, yLabelsGiá trị giữa thang màu (0 với lợi nhuận), tô màu theo "column" / "row", nhãn ô góc
Tuỳ chọn bảngÝ nghĩa
titleTiêu đề
formatMột định dạng cho mọi cột, hoặc theo cột: { "Lợi nhuận": "percent", "Số mã": "integer" } (thêm "price", "compact", "date")
columnsChọn và xếp thứ tự cột hiện
sort, descSắp theo cột (mặc định giảm dần; desc: false để tăng dần)
limitSố dòng (tối đa 500)

Ví dụ đầy đủ​

Sau những phiên giảm mạnh, 20 phiên sau thường thế nào so với bình thường — đổi mã và mức giảm ngay trên thanh tham số:

research({
name: "Sau phiên giảm mạnh",
params: {
symbol: param.symbol("VNINDEX", { label: "Mã" }),
drop: param.pct(0.03, { min: 0.01, max: 0.07, label: "Giảm từ" }),
},
run: async ({ data, chart, md, metric }, p) => {
const d = await data.bars(p.symbol, { from: "2010-01-01" });
if (!d.symbols.length) return md`Không có dữ liệu ${p.symbol}`;
const c = d.close.row(0), r = d.ret.row(0), f20 = stats.forward(c, 20);
const ev = [];
for (let t = 1; t < r.length; t++) if (r[t] <= -p.drop && Number.isFinite(f20[t])) ev.push(t);
const after = ev.map((t) => f20[t]);
metric("Số sự kiện", ev.length, "integer");
metric("20 phiên sau (TB)", stats.mean(after), "percent", "bình thường " + (stats.mean(f20) * 100).toFixed(1) + "%");
chart.line({ [p.symbol]: d.close, MA200: ta.sma(d.close, 200) }, { title: "Giá", format: "price" });
chart.scatter(
ev.map((t) => ({ x: r[t], y: f20[t], label: dates.format(d.days[t]) })),
{ xName: "Thay đổi phiên", yName: "20 phiên sau", format: "percent", trend: true, refLines: [{ y: 0 }] },
);
md`Sau ${ev.length} phiên giảm từ ${(p.drop * 100).toFixed(1)}%, 20 phiên sau trung bình **${(stats.mean(after) * 100).toFixed(2)}%**.`;
},
});

Nhiều mã, dữ liệu BCTC, gom nhóm — P/B so với ROE của các ngân hàng:

research({
name: "Ngân hàng: P/B và ROE",
params: { minValue: param.num(5, { min: 1, max: 50, label: "GTGD tối thiểu (tỷ)" }) },
run: async ({ data, chart, table, md, metric }, p) => {
// industryShort === "Ngân hàng": universe({ industry: "Ngân hàng" }) gồm cả công ty chứng khoán
const banks = universe("ALL").where((m) => m.industryShort === "Ngân hàng" && (m.adv20 ?? 0) >= p.minValue * 1e9);
const d = await data.bars(banks, { from: "2021-01-01", fields: ["pb", "roe"], includeToday: false });
if (!d.symbols.length) return md`Không có ngân hàng nào đạt GTGD tối thiểu.`;
const pb = d.pb.last();
const roe = d.roe.last();
const rows = d.symbols
.map((s) => ({ "Mã": s, "P/B": pb.get(s), "ROE": roe.get(s), "Tăng 1 năm": d.close.pct(250).last().get(s) }))
.filter((r) => Number.isFinite(r["P/B"]) && Number.isFinite(r["ROE"]));
metric("Số ngân hàng", rows.length, "integer", `GTGD từ ${p.minValue} tỷ`);
metric("P/B trung vị", stats.median(rows.map((r) => r["P/B"])), "ratio");
metric("ROE trung vị", stats.median(rows.map((r) => r["ROE"])), "percent");
chart.scatter(
rows.map((r) => ({ x: r["ROE"], y: r["P/B"], label: r["Mã"] })),
{ title: "P/B so với ROE", xName: "ROE", yName: "P/B", xFormat: "percent", yFormat: "ratio", trend: true },
);
chart.line({ "P/B bình quân": d.pb.where(d.pb.gt(0)).meanAcross() }, { title: "P/B bình quân của nhóm", format: "ratio" });
table(rows, { title: "Chi tiết", format: { "P/B": "ratio", "ROE": "percent", "Tăng 1 năm": "percent" }, sort: "ROE" });
const fit = stats.linreg(rows.map((r) => r["ROE"]), rows.map((r) => r["P/B"]));
md`Hồi quy P/B theo ROE: R² **${fit.r2.toFixed(2)}** — R² càng gần 0, ROE càng ít giải thích được chênh lệch P/B giữa các ngân hàng.`;
},
});
Nghiên cứu sự kiện đúng cách

stats.forward(giá, h) (lợi nhuận h phiên sau) chỉ dùng để đo kết quả sau sự kiện trong phân tích, không dùng làm tín hiệu. Luôn so với một phiên bất kỳ (trung bình của forward trên mọi phiên) và với VNINDEX cùng kỳ; số sự kiện dưới vài chục thì kết quả dễ do ngẫu nhiên — stats.tstat(mảng) có trị tuyệt đối trên 2 mới đáng chú ý.