Bộ lọc và phân tích
- 🇻🇳 Tiếng Việt
- 🇬🇧 English
- 🇰🇷 한국어
- 🇨🇳 中文
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ĩa | Mặc định |
|---|---|---|
name | Tên bộ lọc | "Bộ lọc" |
universe | Rổ mã — danh sách mã hôm nay (Rổ mã) | "ALL": cổ phiếu 3 ký tự trên HOSE, HNX, UPCoM |
params | Tham 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 |
limit | Số dòng hiện (1–500) | 100 |
lookback | Số phiên dữ liệu nạp — đủ cho chỉ báo dài nhất (MA200 cần ≥ 200) | 260 (≈ 1 năm) |
asOf | Lọc tại một ngày trong quá khứ, ví dụ "2024-12-31" | phiên mới nhất |
includeToday | Trong giờ giao dịch, dùng nến tạm của phiên đang diễn ra | true |
Cách Studio tính:
- Nạp
lookbackphiê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). - Tính
columns, rồifilter; nếu rổ có.liquid(x), mã phải có GTGD bình quân 20 phiên từ x ở phiên lọc. - 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ệnlimitdòng đầu kèm tổng số mã thỏa và đường giá 30 phiên của từng mã. - Định dạng cột: khai báo
formatrõ 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 ghiformat: "percent". - Tiêu đề và cột chữ:
labellà chữ hiện ở tiêu đề cột (tên khoá vẫn dùng trongfilter,sort).levelsbiế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ủata.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). - 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. - Cột và
filterphải là Panel nhiều mã. Panel một hàng (chuỗid.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)). - Khối ngoại, tự doanh có sau 19:00: đặt
includeToday: falsevàfillna(0)trước khi cộng dồn. asOfcho 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ĩa | Mặc định |
|---|---|---|
from, to | Khoảng thời gian: "2010-01-01", "2010", "01/01/2010" hoặc số ngày | 10 năm gần nhất → phiên mới nhất |
fields | Trường cần thêm ngoài giá, KL: ["foreignNet", "pe", "fin:NetSale:ttm", "macro:cpi_yoy"] | — |
warmup | Số 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 |
includeToday | Trong 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ốt | true |
- Phải khai báo
fieldscho mọi trường ngoàiopen,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.barslà 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èmsymbol,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.symbolsthì 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ọcd.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ả

① 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àm | Dù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, height | Tiêu đề, chú thích dưới biểu đồ, chiều cao (px) |
x | Nhã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 |
tones | Màu theo thứ tự chuỗi: "accent", "up", "down", "warn", "muted", "ref" |
kinds | Trộn dạng theo chuỗi: ["bar", "line"] |
right, rightFormat | Tê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, horizontal | Xế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, tones | Tê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, yLabels | Giá 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 |
|---|---|
title | Tiêu đề |
format | Mộ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") |
columns | Chọn và xếp thứ tự cột hiện |
sort, desc | Sắp theo cột (mặc định giảm dần; desc: false để tăng dần) |
limit | Số 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.`;
},
});
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ú ý.
A screener (screen) answers which tickers meet the conditions today: it computes the columns at the latest session and keeps the tickers that pass. An analysis (research) is a free-form report: it loads data itself, computes, then decides which figures, charts, tables and text to show. How to read the results in the interface is covered in Screener and analysis results; this page is about writing the code.
Screeners: screen({…})
| Field | Meaning | Default |
|---|---|---|
name | Screener name | "Bộ lọc" |
universe | Ticker basket — the ticker list today (Ticker baskets) | "ALL": 3-letter stocks on HOSE, HNX, UPCoM |
params | Parameters | — |
columns: (d, p) => ({ "Tên cột": … }) | The columns of the results table; each column is a Panel or { value: Panel, format, label, levels } | required |
filter: (c, d, p) => Panel | Condition for keeping a ticker; c["Tên cột"] is that column's Panel | no filter |
sort | "Tên cột" (descending) or ["Tên cột", "asc" | "desc"] | first column, descending |
limit | Number of rows shown (1–500) | 100 |
lookback | Number of sessions of data to load — enough for the longest indicator (MA200 needs ≥ 200) | 260 (≈ 1 year) |
asOf | Screen as of a past date, for example "2024-12-31" | latest session |
includeToday | During trading hours, use the provisional candle of the ongoing session | true |
How the Studio computes it:
- Loads
lookbacksessions up to the screening date for the basket's tickers (if the basket exceeds the ticker limit, the most liquid tickers are kept). - Computes
columns, thenfilter; if the basket has.liquid(x), a ticker must have an average 20-session trading value of at least x at the screening session. - Takes each column's value at the last session, only for tickers with a price at that session; sorts by
sort(empty cells go last), and shows the firstlimitrows along with the total number of matching tickers and a 30-session price line for each ticker. - Column format: declare
formatexplicitly —"percent","price","compact"(abbreviated billions / millions),"integer","ratio","number". If you don't, the Studio guesses from the column name: names containing%,tăng(gain),giảm(drop),lợi nhuận(return/profit),thay đổi(change),biến động(volatility),cách(distance),roe,roa,biên(margin) are shown as %; names withgtgd(trading value),giá trị(value),vốn hóa(market cap) are shown as abbreviated money; names withgiá(price),đóng cửa(close) are shown as prices. A ratio column whose name contains “GTGD” (for example"NN ròng / GTGD", foreign net / trading value) will be shown as money — setformat: "percent". - Headers and text columns:
labelis the text shown in the column header (the key name is still used infilterandsort).levelsturns numeric codes into text: value k is shown aslevels[k], while filtering and sorting still use the number — for example the pattern name and status fromta.patterns:"Mẫu hình": { value: pt.pattern, levels: pt.names },"Trạng thái": { value: pt.stage, levels: pt.stages }(the screeners in the Patterns group are written this way). - Screeners have no
d.bench(VNINDEX). To compare with the market, compare within the basket:d.close.pct(63).rank()is the 3-month gain rank relative to the other tickers. - Columns and
filtermust be multi-ticker Panels. A single-row Panel (ad.macro(…)series, the result ofsumAcross()) used directly as a column or condition gives wrong results: the column only has a number for the first ticker, and the condition may only apply to the first ticker. Combine it with a multi-ticker Panel, for example keep tickers up over 3 months while the overnight rate is below 4%:c["Tăng 3 tháng"].gt(0).and(d.macro("ib_on").lt(4)). - Foreign and proprietary trading data is available after 19:00: set
includeToday: falseandfillna(0)before accumulating. asOftells you which tickers the screener would have returned on a past date (no provisional candle); the basket is still today's list, so tickers delisted since then are not in the results.
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,
});
A screener with parameters and foreign trading data:
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,
});
A screener with text columns — which tickers have a bullish pattern near the breakout point or that just broke out:
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"],
});
The Mẫu hình (Pattern) and Trạng thái (Status) columns show text (Hai đáy (W) (Double bottom, W), Sát điểm phá vỡ (Near breakout)…) but filter and sort by the numeric code: gt(0) keeps tickers with a pattern, and sorting in descending order puts Vừa phá vỡ (Just broke out) (2) ahead of Sát điểm phá vỡ (1).
From screener to backtest
A screener only tells you which tickers qualify today; to find out whether that condition has ever made money, turn it into a strategy: the screening condition becomes entry (signal style) or filter (rotation style), and the sort column becomes score / rank. The Backtest bộ lọc (Backtest screener) button in the results asks the Quant assistant to do this (buy the qualifying tickers, rank by the sort column, rebalance monthly, up to 10 tickers, compare with VNINDEX since 2016).
Analyses: research(…)
Two ways to declare one:
// 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) => { /* … */ },
});
Loading data
await data.bars(mã, tuỳChọn) returns data d like a strategy's (Data and Panel), plus d.bench, which is VNINDEX on the same session calendar. mã is a single ticker ("VNINDEX", "FPT"), a list (["FPT", "HPG"] or "FPT,HPG") or a universe(…) basket.
| Option | Meaning | Default |
|---|---|---|
from, to | Time range: "2010-01-01", "2010", "01/01/2010" or a number of days | last 10 years → latest session |
fields | Fields to add besides price and volume: ["foreignNet", "pe", "fin:NetSale:ttm", "macro:cpi_yoy"] | — |
warmup | Extra sessions to load before from (so MA200 has values right from from; the Panel starts earlier than from) | 0 |
includeToday | During trading hours, adds the provisional candle of the ongoing session (d.partialLast = true); false uses only closed sessions | true |
- You must declare
fieldsfor every field other thanopen,high,low,close,volume,tradable,value,ret; derived fields pull in their source fields ("pe"→ unadjusted price, EPS). If you forget, you get the errorChưa nạp "…" — trong research thêm fields: ["…"]…(“… not loaded — in research, add fields: [“…”]…”). - Each
data.barscall is a separate load: Panels from two loads with different ticker lists cannot be computed together. await data.symbols(rổ)returns the ticker list withsymbol,name,exchange,industry,industryShort,sector,adv20,marketCap(today's information) — use it to group and pick tickers before loading prices.- Your plan's ticker limit applies to each
data.barscall; it does not apply todata.symbols. - In an analysis, a basket's
.liquid(x)only pre-filters roughly by the current 20-session trading value (keeping tickers from 20% of x), and does not apply the exact threshold at each session as a backtest does. If you need the exact threshold, select with.where((m) => m.adv20 >= x)or filter yourself withd.value.rolling(20).mean().gte(x). - Sector groups:
universe({ industry: "Ngân hàng" })matches every sector name containing that text, including"Ngân hàng đầu tư và Dịch vụ môi giới"(investment banking and brokerage, i.e. securities companies). If you want banks only, filter with.where((m) => m.industryShort === "Ngân hàng"). - World indices (
"^GSPC","^IXIC","^N225"…) are aligned to the Vietnamese session calendar: the US session on day t closes after the Vietnamese session on day t, so to look at “the US yesterday affecting VNINDEX today”, shift the US series back one session (.shift(1)). - Looping session by session over a few tickers is normal in an analysis (
d.close.row(i)gives the price array of the i-th ticker); with many tickers, use Panel operations for speed.
Output blocks

① Parameter bar — from params: param.symbol becomes a ticker input, param.int becomes a slider.
② metric(…) — a key figure box, with a secondary line.
③ chart.line(…) — a line chart on a date axis.
④ chart.area(…) — an area chart, here with refLines reference lines.
Blocks appear in the order the functions are called. An easy-to-read layout: metric (2–4 key figures) → 2–4 charts → table → an md conclusion with figures.
| Function | Used for |
|---|---|
metric(nhãn, giáTrị, định dạng?, dòngPhụ?) | Figure box (label, value, format, secondary line). Formats: "percent", "number", "ratio", "integer", "text" |
chart.line(dữLiệu, tuỳChọn) · chart.area · chart.bar | Line, area, bar |
chart.scatter(điểm, tuỳChọn) | Scatter: [{ x, y, label, group, size }] |
chart.hist(mảng, { bins, range }) | Distribution (30 bins by default; values outside range go into the two edge bins) |
chart.heatmap(dữLiệu, tuỳChọn) | Heat map: byYearMonth(panelTháng) or { xLabels, yLabels, values } |
table(dữLiệu, tuỳChọn) | Table: an array of objects, a Row (panel.last()), { columns, rows } |
| ``md`…``` | A Markdown paragraph (bold, italic, lists, links); ${giáTrị} inserts a value |
log(…) | Prints to the Nhật ký chạy (Run log), not shown in the results |
The dữLiệu (data) argument of chart.line / area / bar accepts: a Panel (one series per ticker, date axis); { "Tên chuỗi": Panel hoặc mảng số } (series name: a Panel or a number array; a number array needs x as the horizontal-axis labels); { nhãn: số } (label: number); an array of [nhãn, số] pairs; an array of objects [{ name, value }] (the text column is the label, each number column is a series); a Row. A chart with no plottable points shows Không có dữ liệu để vẽ (No data to plot) with the reason in Ghi chú dữ liệu (Data notes).
| Chart option | Meaning |
|---|---|
title, note, height | Title, caption under the chart, height (px) |
x | Horizontal-axis labels; d.days (or a Panel's dates) gives a date axis |
format, yLog | Vertical-axis format ("percent", "price", "number"…), logarithmic axis |
tones | Colors in series order: "accent", "up", "down", "warn", "muted", "ref" |
kinds | Mix chart types by series: ["bar", "line"] |
right, rightFormat | Names of the series plotted on the right axis (different units) and the right axis's format |
refLines | Reference lines (up to 12): [{ y: 0.2, label: "Quá bán", tone: "down" }, { x: "2020-03-23", label: "Đáy Covid" }] |
stack, horizontal | Stacked bars / areas; horizontal bars (rankings with long labels) |
Scatter: xName, yName, xFormat, yFormat, labels, trend, quadrants, tones | Axis names, format of each axis, show point labels (default when ≤ 40 points have labels), regression line with R², four quadrants { x, y, labels: [phải trên, phải dưới, trái dưới, trái trên] } (top right, bottom right, bottom left, top left), colors by group |
Heat map: center, scale, corner, xLabels, yLabels | Center value of the color scale (0 for returns), color by "column" / "row", corner cell label |
| Table option | Meaning |
|---|---|
title | Title |
format | One format for all columns, or per column: { "Lợi nhuận": "percent", "Số mã": "integer" } (also "price", "compact", "date") |
columns | Choose and order the columns shown |
sort, desc | Sort by a column (descending by default; desc: false for ascending) |
limit | Number of rows (up to 500) |
Complete examples
After sharp down sessions, how do the next 20 sessions usually compare with normal — change the ticker and the drop size right on the parameter bar:
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)}%**.`;
},
});
Many tickers, financial statement data, grouping — P/B vs. ROE for banks:
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.`;
},
});
stats.forward(giá, h) (the return h sessions later) is only for measuring outcomes after an event in an analysis, never as a signal. Always compare with any random session (the average of forward over all sessions) and with VNINDEX over the same period; with fewer than a few dozen events, results can easily be due to chance — only a stats.tstat(mảng) with an absolute value above 2 is worth attention.
스크리너(screen)는 오늘 어떤 종목이 조건을 충족하는가에 답합니다. 최신 세션에서 열을 계산한 뒤 조건을 충족한 종목만 남깁니다. 분석(research)은 자유 형식 보고서입니다. 데이터를 직접 불러와 계산한 뒤 어떤 수치, 차트, 표, 텍스트를 보여 줄지 직접 정합니다. 화면에서 결과를 읽는 방법은 스크리너와 분석 결과에 있으며, 이 페이지는 코드 작성 부분입니다.
스크리너: screen({…})
| 필드 | 의미 | 기본값 |
|---|---|---|
name | 스크리너 이름 | "Bộ lọc" |
universe | 종목 바스켓 — 오늘의 종목 목록(종목 바스켓) | "ALL": HOSE, HNX, UPCoM의 3자리 코드 주식 |
params | 매개변수 | — |
columns: (d, p) => ({ "Tên cột": … }) | 결과 표의 열. 각 열은 Panel 또는 { value: Panel, format, label, levels } | 필수 |
filter: (c, d, p) => Panel | 종목을 남기는 조건. c["Tên cột"]는 해당 열의 Panel | 필터 없음 |
sort | "Tên cột"(내림차순) 또는 ["Tên cột", "asc" | "desc"] | 첫 번째 열, 내림차순 |
limit | 표시할 행 수(1–500) | 100 |
lookback | 불러올 데이터 세션 수 — 가장 긴 지표에 충분해야 함(MA200은 ≥ 200 필요) | 260(≈ 1년) |
asOf | 과거 특정 날짜 기준으로 필터링. 예: "2024-12-31" | 최신 세션 |
includeToday | 거래 시간 중 진행 중인 세션의 잠정 캔들 사용 | true |
Studio의 계산 방식:
- 바스켓 종목에 대해 필터링 날짜까지
lookback세션을 불러옵니다(바스켓이 종목 수 한도를 넘으면 유동성이 가장 높은 종목만 남김). columns를 계산한 뒤filter를 적용합니다. 바스켓에.liquid(x)가 있으면 종목의 20세션 평균 GTGD(거래대금)가