Chiến lược
- 🇻🇳 Tiếng Việt
- 🇬🇧 English
- 🇰🇷 한국어
- 🇨🇳 中文
strategy({…}) khai báo một chiến lược để backtest: chọn mã nào, mua bán lúc nào, nắm bao nhiêu, rồi để Studio mô phỏng lệnh theo luật sàn Việt Nam trên dữ liệu quá khứ. Trang này mô tả từng trường của khai báo, ba kiểu chiến lược và các ví dụ chạy được rút từ mẫu thật.
Khai báo chung
| Trường | Ý nghĩa | Mặc định |
|---|---|---|
name, description | Tên (hiện ở đầu mục, báo cáo), mô tả | "Chiến lược" |
universe | Rổ mã: universe("HOSE").liquid(10e9).top(240), universe("VN30"), universe(["FPT", "MWG"]) — xem Rổ mã | VN30 |
period | { from, to }: "2016-01-01", "2016" hoặc "01/01/2016" | 10 năm gần nhất, tới phiên đã chốt |
benchmark | Mã so sánh ("VNINDEX", "VN30", ETF…); "" = không so | "VNINDEX" |
warmup | Số phiên nạp thêm trước from để chỉ báo dài (MA200…) có đủ dữ liệu ngay phiên đầu | 250 |
params | Tham số — thanh trượt, công tắc trên giao diện (param.*) | — |
signals · rank + select · weights | Cách chọn mã — một trong ba kiểu dưới đây | — |
rebalance | Chu kỳ tái cân bằng của kiểu xoay vòng, tỷ trọng | "month" |
filter | Điều kiện được phép mua (cả ba kiểu) | — |
portfolio, risk, execution, costs, rules | Danh mục, cắt lỗ, khớp lệnh, chi phí, luật sàn — xem bên dưới | theo Cài đặt |
report | Thêm số liệu, biểu đồ, bảng riêng vào cuối báo cáo | — |
Hàm signals, rank, weights, filter nhận (d, p): d là dữ liệu, p là giá trị tham số hiện tại (p.len, p.stop…). Các trường select, portfolio, risk, execution, costs, rules là đối tượng, hoặc hàm của tham số: risk: (p) => ({ stopLoss: p.stop }).
Kiểu 1: tín hiệu mua, bán (signals)
signals: (d, p) => ({ entry: Panel, exit: Panel, score: Panel }),
entry— điều kiện mua, xét ở cuối mỗi phiên. Studio tự ghép thêm: mã có giao dịch ở phiên đó, đang thuộc rổ (rổ theo thời điểm), đạt.liquid(…)của rổ vàfilter. Lệnh mua khớp ở phiên sau.exit(tuỳ chọn) — điều kiện bán vị thế đang giữ. Không cóexitthì vị thế chỉ thoát theorisk(cắt lỗ, dừng lỗ trượt…).score(tuỳ chọn) — điểm ưu tiên khi số mã có tín hiệu nhiều hơn số chỗ trống: điểm cao mua trước; không có điểm thì mã có KL bình quân lớn hơn được ưu tiên.- Trả thẳng một Panel (
signals: (d) => d.close.crossAbove(ta.sma(d.close, 50))) nghĩa là chỉ cóentry.
entry có thể là sự kiện (crossAbove: đúng một phiên) hoặc trạng thái (gt: đúng nhiều phiên liền). Với trạng thái, mỗi phiên mọi mã đang thỏa đều phát lệnh mua: danh mục đầy thì các lệnh đó bị bỏ (khối Khớp lệnh mô phỏng có nhiều Bỏ do đủ số mã), và khi c ó chỗ trống, score quyết định mã nào được mua. Muốn chỉ mua lúc điều kiện vừa xảy ra, dùng sự kiện.
strategy({
name: "RSI hồi phục trong xu hướng tăng",
universe: universe("VN30"),
period: { from: "2016-01-01" },
params: {
len: param.int(14, { min: 2, max: 30, label: "Chu kỳ RSI" }),
lower: param.num(30, { min: 10, max: 45, label: "Ngưỡng mua" }),
upper: param.num(70, { min: 55, max: 90, label: "Ngưỡng bán" }),
stop: param.pct(0.07, { min: 0.03, max: 0.2, label: "Cắt lỗ" }),
},
signals: (d, p) => {
const r = ta.rsi(d.close, p.len);
const uptrend = d.close.gt(ta.sma(d.close, 200));
return {
entry: r.crossAbove(p.lower).and(uptrend),
exit: r.gt(p.upper).or(d.close.crossBelow(ta.sma(d.close, 50))),
score: r.neg(), // RSI càng thấp càng được mua trước
};
},
portfolio: { maxPositions: 5 },
risk: (p) => ({ stopLoss: p.stop }),
});
Kiểu 2: xoay vòng theo xếp hạng (rank)
Mỗi kỳ tái cân bằng, xếp hạng các mã theo điểm rồi nắm nhóm đầu.
rank: (d, p) => Panel, // điểm: cao hơn = tốt hơn; NaN = không xét
select: { top: 10, weight: "equal" }, // hoặc (p) => ({ … })
rebalance: "month",
select | Ý nghĩa | Mặc định |
|---|---|---|
top | Số mã nắm giữ | 10 |
weight | "equal": chia đều cho số mã được chọn; "score": theo điểm (điểm âm tính 0); "invvol": nghịch với độ lệch chuẩn lợi nhuận 20 phiên — mã ít biến động được nhiều tỷ trọng hơn | "equal" |
minScore | Chỉ chọn mã có điểm lớn hơn ngưỡng (minScore: 0: chỉ mã có điểm dương) | — |
maxWeight | Tỷ trọng tối đa một mã; phần dư để tiền mặt | — |
gross | Tổng tỷ trọng đầu tư (1 = 100% tài sản) | 1 |
rebalance | Đổi danh mục ở |
|---|---|
"day", "week", "month", "quarter", "year" | Phiên đầu tiên của mỗi ngày / tuần / tháng / quý / năm |
số n | Mỗi n phiên, tính từ phiên đầu kỳ kiểm tra |
weight: "equal" chia cho số mã thực sự được chọn: top: 10 mà chỉ một mã qua filter thì mã đó nhận 100% vốn. Thêm maxWeight: 1 / p.top để mỗi mã tối đa một phần mười, phần còn lại giữ tiền mặt — các mẫu xoay vòng đều làm vậy.
strategy({
name: "Momentum 6-1 xoay vòng hàng tháng",
universe: universe("HOSE").liquid(10e9).top(240),
period: { from: "2016-01-01" },
rebalance: "month",
params: {
lookback: param.int(126, { min: 60, max: 252, label: "Số phiên đo đà tăng" }),
skip: param.int(21, { min: 0, max: 42, label: "Bỏ qua gần nhất" }),
top: param.int(10, { min: 3, max: 30, label: "Số mã nắm giữ" }),
calm: param.pct(0.5, { min: 0.2, max: 1, label: "Nhóm biến động thấp (% rổ)" }),
market: param.bool(true, { label: "Chỉ mua khi VNINDEX > MA200" }),
},
rank: (d, p) => d.close.shift(p.skip).pct(p.lookback - p.skip),
filter: (d, p) => {
// Bỏ các mã biến động mạnh nhất (đà tăng của mã đầu cơ dễ gãy), đứng ngoài khi VNINDEX dưới MA200
const ok = d.close.gt(ta.sma(d.close, 100)).and(d.ret.rolling(60).std().rank().lte(p.calm));
return p.market ? ok.and(d.bench.close.gt(ta.sma(d.bench.close, 200))) : ok;
},
// Mỗi mã tối đa 1/top vốn: ít mã đủ điều kiện thì phần còn lại để tiền mặt
select: (p) => ({ top: p.top, weight: "equal", maxWeight: 1 / p.top }),
});
d.close.shift(21).pct(105) là lợi nhuận 6 tháng tính tới 1 tháng trước — bỏ tháng gần nhất vì cổ phiếu tăng mạnh trong tháng thường điều chỉnh tháng sau (hiệu ứng đảo chiều ngắn hạn).
Kiểu 3: tỷ trọng tự tính (weights)
weights: (d, p) => Panel, // tỷ trọng 0..1 của từng mã, tổng mỗi phiên ≤ 1
rebalance: "month",
Ở mỗi phiên tái cân bằng, Studio đưa danh mục về đúng tỷ trọng đó (ô NaN hoặc không đạt filter tính 0%; phần còn lại là tiền mặt). Hợp với chiến lược một vài tài sản hoặc phân bổ theo công thức riêng.
strategy({
name: "Dual momentum ETF VN30 / tiền mặt",
universe: universe(["E1VFVN30"]),
period: { from: "2016-01-01" },
rebalance: "month",
params: { lookback: param.int(250, { min: 60, max: 250, label: "Số phiên đo đà" }) },
// 98% vào ETF khi lợi nhuận 12 tháng dương và giá trên MA200 (2% để trả phí), ngược lại giữ tiền mặt
weights: (d, p) => d.close.pct(p.lookback).gt(0).and(d.close.gt(ta.sma(d.close, 200))).mul(0.98),
});
Điều kiện được phép mua (filter)
filter: (d, p) => Panel áp cho cả ba kiểu: tín hiệu mua chỉ có hiệu lực khi filter đúng; kiểu xoay vòng chỉ xếp hạng các mã đạt filter (không mã nào đạt thì kỳ đó giữ tiền mặt); kiểu tỷ trọng đặt 0% cho mã không đạt.
Lọc theo thị trường chung là cách đơn giản để không mở vị thế mới khi cả thị trường đang giảm — phần lớn mẫu chiến lược có công tắc Chỉ mua khi VNINDEX > MA200 làm đúng việc này. Điều kiện của VNINDEX là Panel một hàng; ghép nó với điều kiện của từng mã:
filter: (d, p) => {
const ok = d.close.gt(ta.sma(d.close, 100));
return p.market ? ok.and(d.bench.close.gt(ta.sma(d.bench.close, 200))) : ok;
},
Với kiểu tín hiệu, filter chỉ chặn mua; vị thế đang giữ vẫn theo exit. Muốn bán khi thị trường xấu đi, đưa điều kiện ngược vào exit — ví dụ chiến lược có lọc vĩ mô:
strategy({
name: "VN30 theo xu hướng, chỉ khi vĩ mô thuận lợi",
universe: universe("VN30"),
period: { from: "2016-01-01" },
params: {
maxRate: param.num(4, { min: 2, max: 8, step: 0.5, label: "LS qua đêm tối đa (%)" }),
maxCpi: param.num(4.5, { min: 2, max: 8, step: 0.5, label: "CPI tối đa (%)" }),
},
signals: (d, p) => {
// Vĩ mô thuận lợi: lãi suất liên ngân hàng qua đêm và lạm phát đều thấp (số đã biết ở từng phiên)
const ok = d.macro("ib_on").lt(p.maxRate).and(d.macro("cpi_yoy").lt(p.maxCpi));
const up = d.close.gt(ta.sma(d.close, 50));
return { entry: up.and(ok), exit: up.not().or(ok.not()), score: d.close.pct(60) };
},
portfolio: { maxPositions: 10 },
});
exit ở đây là up.not().or(ok.not()) chứ không chỉ ok.not(): exit và score phải là Panel nhiều mã. exit: ok.not() đứng riêng là Panel một hàng — chỉ mã đầu tiên của rổ đọc đúng, các mã còn lại bị coi như luôn có tín hiệu bán nên không mua được.
Danh mục, rủi ro, khớp lệnh, chi phí
Mỗi trường là đối tượng hoặc hàm của tham số. Giá trị trong code ghi đè Cài đặt của bạn; khối Khớp lệnh mô phỏng cuối tầng Chi tiết của báo cáo ghi giá trị thực tế đã dùng.
portfolio | Ý nghĩa | Mặc định |
|---|---|---|
capital | Vốn ban đầu (đồng) | 1 tỷ (Cài đặt) |
maxPositions | Số mã nắm tối đa — kiểu tín hiệu | 5 |
sizing | "equal": mỗi lệnh = tài sản ÷ maxPositions; số 0..1: mỗi lệnh bằng tỷ lệ đó của tài sản (0.2 = 20%) | "equal" |
cashRate | Lãi suất năm của tiền nhàn rỗi (0.04 = 4%/năm, cộng theo ngày) | 0 |
risk | Ý nghĩa | Mặc định |
|---|---|---|
stopLoss | Cắt lỗ khi giá giảm đủ tỷ lệ này so với giá vốn (0.07 = 7%) | không |
takeProfit | Chốt lời khi giá tăng đủ tỷ lệ này so với giá vốn | không |
trailingStop | Dừng lỗ trượt: bán khi giảm đủ tỷ lệ này so với giá đóng cửa cao nhất từ khi mua | không |
maxHoldDays | Bán sau n phiên giữ | không |
stopMode | "close": xét theo giá đóng cửa, bán phiên sau; "intraday": cắt lỗ ngay trong phiên khi giá chạm ngưỡng | "close" |
execution | Ý nghĩa | Mặc định |
|---|---|---|
at | "open": khớp giá mở cửa; "close": khớp ATC | "open" (Cài đặt) |
delay | Số phiên từ tín hiệu tới khớp. Khớp sớm nhất ở phiên sau tín hiệu: khai báo 0 vẫn khớp như 1, chỉ thêm cảnh báo | 1 |
costs | Ý nghĩa | Mặc định |
|---|---|---|
fees | Phí giao dịch mỗi chiều | 0,0015 (Cài đặt) |
sellTax | Thuế bán | 0,001 (Cài đặt) |
slippage | Trượt giá cố định | 0,001 (Cài đặt) |
impact | Hệ số tác động: trượt thêm = impact × √(KL lệnh ÷ KL bình quân 20 phiên) | 0,01 |
rules | Ý nghĩa | Mặc định |
|---|---|---|
settlement | Số phiên thanh toán (T+2) | 2 |
lotSize | Lô của cổ phiếu, ETF | 100 |
maxAdvPct | KL một lệnh mua tối đa theo tỷ lệ KL bình quân 20 phiên trước | 0,1 |
limitLock | Không khớp khi trần / sàn trắng bên | true |
retryBlockedBuys | Lệnh mua gặp trần trắng bên / phiên không giao dịch thì thử lại phiên sau thay vì bỏ | false |
Chi tiết từng luật: Mô phỏng khớp lệnh.
Thêm khối riêng vào báo cáo (report)
report: (d, result, out) => { … } chạy sau khi mô phỏng xong. out có metric, chart, table, md như đầu ra của phân tích; các khối hiện cuối báo cáo ở cả hai tầng Tóm tắt, Chi tiết (trước Ghi chú dữ liệu). result gồm days, equity (đường vốn), benchmark, drawdown, exposure, metrics (cagr, sharpe, maxDrawdown, trades, winRate…), monthly, yearly ({ year, ret, bench }), trades (symbol, entryDay, exitDay, entryPrice, exitPrice, qty, pnl, ret, bars, reason…), holdings. Lỗi trong report chỉ ghi vào Nhật ký chạy, không làm hỏng lần chạy.
report: (d, result, out) => {
const held = new Set(result.trades.map((t) => t.symbol));
out.metric("Số mã từng nắm", held.size, "integer", `${result.trades.length} lệnh đã đóng`);
out.table(
result.yearly.map((y) => ({ "Năm": String(y.year), "Lợi nhuận": y.ret, "VNINDEX": y.bench })),
{ title: "Lợi nhuận theo năm", format: { "Lợi nhuận": "percent", "VNINDEX": "percent" } },
);
},
Ví dụ: BCTC theo ngày công bố
Chất lượng bền vững: chỉ xét doanh nghiệp có ROE từ 15% ở cả ba BCTC năm gần nhất đã công bố, rồi mỗi tháng mua mã có đà tăng mạnh nhất trong nhóm biến động thấp. d.fin(…) chỉ thấy báo cáo đã có trước phiên tái cân bằng nên không nhìn trước.
const roeYear = (d, ago) => d.fin("ROE", { period: "year", ago });
strategy({
name: "ROE từ 15% ba năm liền + động lượng",
universe: universe("ALL").liquid(5e9).top(240),
period: { from: "2016-01-01" },
rebalance: "month",
params: {
minRoe: param.pct(0.15, { min: 0.08, max: 0.3, label: "ROE tối thiểu mỗi năm" }),
top: param.int(10, { min: 5, max: 30, label: "Số mã nắm giữ" }),
calm: param.pct(0.6, { min: 0.2, max: 1, label: "Nhóm biến động thấp (% rổ)" }),
market: param.bool(true, { label: "Chỉ mua khi VNINDEX > MA200" }),
},
rank: (d) => d.close.shift(21).pct(105),
filter: (d, p) => {
const ok = roeYear(d, 0)
.gte(p.minRoe)
.and(roeYear(d, 1).gte(p.minRoe))
.and(roeYear(d, 2).gte(p.minRoe))
.and(d.close.gt(ta.sma(d.close, 100)))
.and(d.ret.rolling(60).std().rank().lte(p.calm));
return p.market ? ok.and(d.bench.close.gt(ta.sma(d.bench.close, 200))) : ok;
},
select: (p) => ({ top: p.top, weight: "equal", maxWeight: 1 / p.top }),
});
Ví dụ: dòng tiền khối ngoại
Mỗi tháng nắm 8 mã HOSE thanh khoản cao được khối ngoại mua ròng 20 phiên mạnh nhất so với GT giao dịch; minScore: 0 để chỉ chọn mã đang được mua ròng. Dữ liệu khối ngoại được tự nạp vì code đọc d.foreignNet.
strategy({
name: "Xoay vòng theo khối ngoại mua ròng",
universe: universe("HOSE").top(60),
period: { from: "2019-01-01" },
rebalance: "month",
params: {
days: param.int(20, { min: 5, max: 60, label: "Số phiên cộng dồn" }),
top: param.int(8, { min: 3, max: 20, label: "Số mã nắm giữ" }),
},
rank: (d, p) => d.foreignNet.rolling(p.days).sum().div(d.value.rolling(p.days).sum()),
select: (p) => ({ top: p.top, weight: "equal", minScore: 0 }),
});
Ví dụ: mua khi phá vỡ mẫu hình giá
ta.patterns cho Panel breakout = 1 ở phiên giá đóng cửa lần đầu vượt mức của mẫu hình với khối lượng xác nhận — dùng thẳng làm entry. Đỉnh, đáy của mẫu chỉ được công nhận khi giá đã quay đầu đủ ngưỡng, nên tín hiệu không nhìn trước. Mẫu hình tích lũy hàng tháng nên thoát lệnh bằng thời gian giữ và cắt lỗ rộng, không bằng tín hiệu ngắn hạn:
strategy({
name: "Mua khi hai đáy vượt đỉnh giữa",
universe: universe("ALL").liquid(3e9).top(240),
period: { from: "2016-01-01" },
params: {
minVol: param.num(1.5, { min: 1, max: 3, step: 0.1, label: "KL phiên phá vỡ / TB20 từ" }),
hold: param.int(120, { min: 20, max: 250, label: "Giữ tối đa (phiên)" }),
},
signals: (d, p) => {
const pt = ta.patterns(d, { only: ["doubleBottom"], minVol: p.minVol });
const market = d.bench.close.gt(ta.sma(d.bench.close, 200));
// Phiên phá vỡ có khối lượng; nhiều mã cùng phiên thì ưu tiên KL phá vỡ lớn
return { entry: pt.breakout.gt(0).and(market), score: pt.volRatio };
},
portfolio: { maxPositions: 10 },
risk: (p) => ({ stopLoss: 0.12, maxHoldDays: p.hold }),
});
pt.breakout.gt(0).and(market) ghép điều kiện một hàng của VNINDEX vào Panel nhiều mã, nên entry đúng cho mọi mã. Mẫu Mua khi phá vỡ mẫu hình tăng giá (Danh sách mẫu) là bản đầy đủ: ba mẫu hình, công tắc cho từng mẫu và cho bộ lọc VNINDEX.
Trước khi tin một kết quả
- Đủ lệnh, đủ dài: dưới 30 lệnh đã đóng hoặc dưới 3 năm, kết quả dễ do may mắn (báo cáo hạ độ tin cậy).
- Rổ theo thời điểm: dùng
universe("ALL"),"HOSE"hoặc theo ngành cho backtest dài; VN30, HNX30… là danh sách hôm nay. - Bỏ năm tốt nhất: nhiều chiến lược thắng VNINDEX ch ỉ nhờ một năm (thường là 2021) — xem bảng theo năm trong báo cáo.
- Lân cận tham số: chu kỳ 14 tốt mà 12, 16 tệ là dấu hiệu quá khớp — kiểm tra bằng Tối ưu và kiểm định.
- Chi phí: chiến lược đổi danh mục nhiều cần thắng được khoảng 0,65% mỗi vòng mua – bán (ví dụ).
- Không nhìn trước: dòng cuối báo cáo phải ghi kiểm tra dữ liệu tương lai: đạt.
strategy({…}) declares a strategy to backtest: which tickers to pick, when to buy and sell, how much to hold, and then the Studio simulates orders under Vietnamese exchange rules on historical data. This page describes each field of the declaration, the three strategy styles, and runnable examples taken from real templates.
Common declaration
| Field | Meaning | Default |
|---|---|---|
name, description | Name (shown at the top of the item and the report), description | "Chiến lược" |
universe | Ticker basket: universe("HOSE").liquid(10e9).top(240), universe("VN30"), universe(["FPT", "MWG"]) — see Ticker baskets | VN30 |
period | { from, to }: "2016-01-01", "2016" or "01/01/2016" | last 10 years, up to the latest closed session |
benchmark | Benchmark ticker ("VNINDEX", "VN30", ETF…); "" = no comparison | "VNINDEX" |
warmup | Extra sessions loaded before from so long indicators (MA200…) have enough data from the first session | 250 |
params | Parameters — sliders and switches in the interface (param.*) | — |
signals · rank + select · weights | How tickers are picked — one of the three styles below | — |
rebalance | Rebalancing cycle for the rotation and weights styles | "month" |
filter | Condition under which buying is allowed (all three styles) | — |
portfolio, risk, execution, costs, rules | Portfolio, stop loss, order filling, costs, exchange rules — see below | per Settings |
report | Adds your own figures, charts and tables at the end of the report | — |
The signals, rank, weights and filter functions receive (d, p): d is the data, p holds the current parameter values (p.len, p.stop…). The select, portfolio, risk, execution, costs and rules fields are objects, or functions of the parameters: risk: (p) => ({ stopLoss: p.stop }).
Style 1: buy and sell signals (signals)
signals: (d, p) => ({ entry: Panel, exit: Panel, score: Panel }),
entry— the buy condition, checked at the end of each session. The Studio automatically adds: the ticker traded in that session, is in the basket (point-in-time basket), meets the basket's.liquid(…)andfilter. Buy orders fill at the next session.exit(optional) — the condition for selling a position you hold. Withoutexit, positions only exit throughrisk(stop loss, trailing stop…).score(optional) — the priority score when more tickers have signals than there are free slots: higher scores are bought first; without a score, tickers with a higher average volume take priority.- Returning a Panel directly (
signals: (d) => d.close.crossAbove(ta.sma(d.close, 50))) means there is only anentry.
entry can be an event (crossAbove: true for one session) or a state (gt: true for many sessions in a row). With a state, every qualifying ticker issues a buy order at every session: when the portfolio is full those orders are dropped (the Khớp lệnh mô phỏng (Simulated fills) block shows many Bỏ do đủ số mã (Dropped: portfolio full)), and when a slot opens, score decides which ticker is bought. If you only want to buy right when the condition occurs, use an event.
strategy({
name: "RSI hồi phục trong xu hướng tăng",
universe: universe("VN30"),
period: { from: "2016-01-01" },
params: {
len: param.int(14, { min: 2, max: 30, label: "Chu kỳ RSI" }),
lower: param.num(30, { min: 10, max: 45, label: "Ngưỡng mua" }),
upper: param.num(70, { min: 55, max: 90, label: "Ngưỡng bán" }),
stop: param.pct(0.07, { min: 0.03, max: 0.2, label: "Cắt lỗ" }),
},
signals: (d, p) => {
const r = ta.rsi(d.close, p.len);
const uptrend = d.close.gt(ta.sma(d.close, 200));
return {
entry: r.crossAbove(p.lower).and(uptrend),
exit: r.gt(p.upper).or(d.close.crossBelow(ta.sma(d.close, 50))),
score: r.neg(), // RSI càng thấp càng được mua trước
};
},
portfolio: { maxPositions: 5 },
risk: (p) => ({ stopLoss: p.stop }),
});
Style 2: rotation by ranking (rank)
At each rebalancing period, tickers are ranked by score and the top group is held.
rank: (d, p) => Panel, // điểm: cao hơn = tốt hơn; NaN = không xét
select: { top: 10, weight: "equal" }, // hoặc (p) => ({ … })
rebalance: "month",
select | Meaning | Default |
|---|---|---|
top | Number of tickers held | 10 |
weight | "equal": split equally among the selected tickers; "score": by score (negative scores count as 0); "invvol": inversely proportional to the 20-session standard deviation of returns — less volatile tickers get more weight | "equal" |
minScore | Only pick tickers with a score greater than the threshold (minScore: 0: only tickers with a positive score) | — |
maxWeight | Maximum weight per ticker; the remainder stays in cash | — |
gross | Total invested weight (1 = 100% of assets) | 1 |
rebalance | Changes the portfolio at |
|---|---|
"day", "week", "month", "quarter", "year" | The first session of each day / week / month / quarter / year |
a number n | Every n sessions, counting from the first session of the test period |
weight: "equal" divides by the number of tickers actually selected: with top: 10 and only one ticker passing filter, that ticker gets 100% of the capital. Add maxWeight: 1 / p.top so each ticker gets at most one tenth and the rest stays in cash — all rotation templates do this.
strategy({
name: "Momentum 6-1 xoay vòng hàng tháng",
universe: universe("HOSE").liquid(10e9).top(240),
period: { from: "2016-01-01" },
rebalance: "month",
params: {
lookback: param.int(126, { min: 60, max: 252, label: "Số phiên đo đà tăng" }),
skip: param.int(21, { min: 0, max: 42, label: "Bỏ qua gần nhất" }),
top: param.int(10, { min: 3, max: 30, label: "Số mã nắm giữ" }),
calm: param.pct(0.5, { min: 0.2, max: 1, label: "Nhóm biến động thấp (% rổ)" }),
market: param.bool(true, { label: "Chỉ mua khi VNINDEX > MA200" }),
},
rank: (d, p) => d.close.shift(p.skip).pct(p.lookback - p.skip),
filter: (d, p) => {
// Bỏ các mã biến động mạnh nhất (đà tăng của mã đầu cơ dễ gãy), đứng ngoài khi VNINDEX dưới MA200
const ok = d.close.gt(ta.sma(d.close, 100)).and(d.ret.rolling(60).std().rank().lte(p.calm));
return p.market ? ok.and(d.bench.close.gt(ta.sma(d.bench.close, 200))) : ok;
},
// Mỗi mã tối đa 1/top vốn: ít mã đủ điều kiện thì phần còn lại để tiền mặt
select: (p) => ({ top: p.top, weight: "equal", maxWeight: 1 / p.top }),
});
d.close.shift(21).pct(105) is the 6-month return up to 1 month ago — the latest month is skipped because stocks that rose sharply during a month often pull back the next month (the short-term reversal effect).
Style 3: custom weights (weights)
weights: (d, p) => Panel, // tỷ trọng 0..1 của từng mã, tổng mỗi phiên ≤ 1
rebalance: "month",
At each rebalancing session, the Studio brings the portfolio back to exactly those weights (a NaN cell or a ticker failing filter counts as 0%; the remainder is cash). Suited to strategies with a few assets or allocation by your own formula.
strategy({
name: "Dual momentum ETF VN30 / tiền mặt",
universe: universe(["E1VFVN30"]),
period: { from: "2016-01-01" },
rebalance: "month",
params: { lookback: param.int(250, { min: 60, max: 250, label: "Số phiên đo đà" }) },
// 98% vào ETF khi lợi nhuận 12 tháng dương và giá trên MA200 (2% để trả phí), ngược lại giữ tiền mặt
weights: (d, p) => d.close.pct(p.lookback).gt(0).and(d.close.gt(ta.sma(d.close, 200))).mul(0.98),
});
Condition for allowing buys (filter)
filter: (d, p) => Panel applies to all three styles: a buy signal only takes effect when filter is true; the rotation style only ranks tickers that pass filter (if none pass, that period holds cash); the weights style sets 0% for tickers that fail.
Filtering by the overall market is a simple way to avoid opening new positions while the whole market is falling — most strategy templates have a Chỉ mua khi VNINDEX > MA200 (Only buy when VNINDEX > MA200) switch that does exactly this. The VNINDEX condition is a single-row Panel; combine it with each ticker's condition:
filter: (d, p) => {
const ok = d.close.gt(ta.sma(d.close, 100));
return p.market ? ok.and(d.bench.close.gt(ta.sma(d.bench.close, 200))) : ok;
},
In the signals style, filter only blocks buying; positions you hold still follow exit. To sell when the market turns bad, put the opposite condition into exit — for example, a strategy with a macro filter:
strategy({
name: "VN30 theo xu hướng, chỉ khi vĩ mô thuận lợi",
universe: universe("VN30"),
period: { from: "2016-01-01" },
params: {
maxRate: param.num(4, { min: 2, max: 8, step: 0.5, label: "LS qua đêm tối đa (%)" }),
maxCpi: param.num(4.5, { min: 2, max: 8, step: 0.5, label: "CPI tối đa (%)" }),
},
signals: (d, p) => {
// Vĩ mô thuận lợi: lãi suất liên ngân hàng qua đêm và lạm phát đều thấp (số đã biết ở từng phiên)
const ok = d.macro("ib_on").lt(p.maxRate).and(d.macro("cpi_yoy").lt(p.maxCpi));
const up = d.close.gt(ta.sma(d.close, 50));
return { entry: up.and(ok), exit: up.not().or(ok.not()), score: d.close.pct(60) };
},
portfolio: { maxPositions: 10 },
});
The exit here is up.not().or(ok.not()) rather than just ok.not(): exit and score must be multi-ticker Panels. exit: ok.not() on its own is a single-row Panel — only the first ticker in the basket reads it correctly; the other tickers are treated as always having a sell signal, so they can never be bought.
Portfolio, risk, order filling, costs
Each field is an object or a function of the parameters. Values in the code override your Settings; the Khớp lệnh mô phỏng (Simulated fills) block at the end of the report's Chi tiết (Details) level records the values actually used.
portfolio | Meaning | Default |
|---|---|---|
capital | Starting capital (VND) | VND 1 bn (Settings) |
maxPositions | Maximum number of tickers held — signals style | 5 |
sizing | "equal": each order = assets ÷ maxPositions; a number 0..1: each order is that fraction of assets (0.2 = 20%) | "equal" |
cashRate | Annual interest rate on idle cash (0.04 = 4%/year, accrued daily) | 0 |
risk | Meaning | Default |
|---|---|---|
stopLoss | Cut losses when the price falls by this fraction from the cost price (0.07 = 7%) | none |
takeProfit | Take profit when the price rises by this fraction from the cost price | none |
trailingStop | Trailing stop: sell when the price falls by this fraction from the highest close since buying | none |
maxHoldDays | Sell after holding for n sessions | none |
stopMode | "close": checked on the close, sold at the next session; "intraday": stop out within the session as soon as the price touches the threshold | "close" |
execution | Meaning | Default |
|---|---|---|
at | "open": fill at the opening price; "close": fill at ATC | "open" (Settings) |
delay | Number of sessions from signal to fill. The earliest fill is the session after the signal: declaring 0 still fills as 1, with an added warning | 1 |
costs | Meaning | Default |
|---|---|---|
fees | Trading fee per side | 0.0015 (Settings) |
sellTax | Sales tax | 0.001 (Settings) |
slippage | Fixed slippage | 0.001 (Settings) |
impact | Impact coefficient: extra slippage = impact × √(KL lệnh ÷ KL bình quân 20 phiên) (order volume ÷ 20-session average volume) | 0.01 |
rules | Meaning | Default |
|---|---|---|
settlement | Settlement sessions (T+2) | 2 |
lotSize | Board lot for stocks and ETFs | 100 |
maxAdvPct | Maximum volume of one buy order as a fraction of the average volume of the previous 20 sessions | 0.1 |
limitLock | No fill when locked at the ceiling / floor with no opposite orders | true |
retryBlockedBuys | A buy order that hits a locked ceiling / a non-trading session is retried at the next session instead of being dropped | false |
Details of each rule: Order fill simulation.
Adding your own blocks to the report (report)
report: (d, result, out) => { … } runs after the simulation finishes. out has metric, chart, table, md like the analysis outputs; the blocks appear at the end of the report at both the Tóm tắt (Summary) and Chi tiết (Details) levels (before Ghi chú dữ liệu (Data notes)). result contains days, equity (equity curve), benchmark, drawdown, exposure, metrics (cagr, sharpe, maxDrawdown, trades, winRate…), monthly, yearly ({ year, ret, bench }), trades (symbol, entryDay, exitDay, entryPrice, exitPrice, qty, pnl, ret, bars, reason…), holdings. Errors in report are only written to the Nhật ký chạy (Run log) and do not break the run.
report: (d, result, out) => {
const held = new Set(result.trades.map((t) => t.symbol));
out.metric("Số mã từng nắm", held.size, "integer", `${result.trades.length} lệnh đã đóng`);
out.table(
result.yearly.map((y) => ({ "Năm": String(y.year), "Lợi nhuận": y.ret, "VNINDEX": y.bench })),
{ title: "Lợi nhuận theo năm", format: { "Lợi nhuận": "percent", "VNINDEX": "percent" } },
);
},
Example: financial statements by release date
Sustainable quality: only consider companies with ROE of at least 15% in all three of the latest published annual financial statements, then each month buy the tickers with the strongest momentum within the low-volatility group. d.fin(…) only sees reports available before the rebalancing session, so there is no look-ahead.
const roeYear = (d, ago) => d.fin("ROE", { period: "year", ago });
strategy({
name: "ROE từ 15% ba năm liền + động lượng",
universe: universe("ALL").liquid(5e9).top(240),
period: { from: "2016-01-01" },
rebalance: "month",
params: {
minRoe: param.pct(0.15, { min: 0.08, max: 0.3, label: "ROE tối thiểu mỗi năm" }),
top: param.int(10, { min: 5, max: 30, label: "Số mã nắm giữ" }),
calm: param.pct(0.6, { min: 0.2, max: 1, label: "Nhóm biến động thấp (% rổ)" }),
market: param.bool(true, { label: "Chỉ mua khi VNINDEX > MA200" }),
},
rank: (d) => d.close.shift(21).pct(105),
filter: (d, p) => {
const ok = roeYear(d, 0)
.gte(p.minRoe)
.and(roeYear(d, 1).gte(p.minRoe))
.and(roeYear(d, 2).gte(p.minRoe))
.and(d.close.gt(ta.sma(d.close, 100)))
.and(d.ret.rolling(60).std().rank().lte(p.calm));
return p.market ? ok.and(d.bench.close.gt(ta.sma(d.bench.close, 200))) : ok;
},
select: (p) => ({ top: p.top, weight: "equal", maxWeight: 1 / p.top }),
});
Example: foreign money flow
Each month, hold the 8 liquid HOSE tickers with the strongest 20-session foreign net buying relative to trading value; minScore: 0 ensures only tickers currently being net bought are picked. Foreign trading data is loaded automatically because the code reads d.foreignNet.
strategy({
name: "Xoay vòng theo khối ngoại mua ròng",
universe: universe("HOSE").top(60),
period: { from: "2019-01-01" },
rebalance: "month",
params: {
days: param.int(20, { min: 5, max: 60, label: "Số phiên cộng dồn" }),
top: param.int(8, { min: 3, max: 20, label: "Số mã nắm giữ" }),
},
rank: (d, p) => d.foreignNet.rolling(p.days).sum().div(d.value.rolling(p.days).sum()),
select: (p) => ({ top: p.top, weight: "equal", minScore: 0 }),
});
Example: buying on price pattern breakouts
ta.patterns gives a breakout Panel = 1 at the session where the close first breaks above the pattern's level with volume confirmation — use it directly as entry. A pattern's peaks and troughs are only confirmed once the price has reversed by the threshold, so the signal has no look-ahead. Patterns build up over months, so exit by holding time and a wide stop loss rather than by short-term signals:
strategy({
name: "Mua khi hai đáy vượt đỉnh giữa",
universe: universe("ALL").liquid(3e9).top(240),
period: { from: "2016-01-01" },
params: {
minVol: param.num(1.5, { min: 1, max: 3, step: 0.1, label: "KL phiên phá vỡ / TB20 từ" }),
hold: param.int(120, { min: 20, max: 250, label: "Giữ tối đa (phiên)" }),
},
signals: (d, p) => {
const pt = ta.patterns(d, { only: ["doubleBottom"], minVol: p.minVol });
const market = d.bench.close.gt(ta.sma(d.bench.close, 200));
// Phiên phá vỡ có khối lượng; nhiều mã cùng phiên thì ưu tiên KL phá vỡ lớn
return { entry: pt.breakout.gt(0).and(market), score: pt.volRatio };
},
portfolio: { maxPositions: 10 },
risk: (p) => ({ stopLoss: 0.12, maxHoldDays: p.hold }),
});
pt.breakout.gt(0).and(market) combines VNINDEX's single-row condition into a multi-ticker Panel, so entry is correct for every ticker. The Mua khi phá vỡ mẫu hình tăng giá (Buy on bullish pattern breakouts) template (Template list) is the full version: three patterns, with a switch for each pattern and for the VNINDEX filter.
Before you trust a result
- Enough trades, long enough: with fewer than 30 closed trades or less than 3 years, results can easily be luck (the report lowers its confidence level).
- Point-in-time basket: use
universe("ALL"),"HOSE"or a sector basket for long backtests; VN30, HNX30… are today's lists. - Drop the best year: many strategies beat VNINDEX only thanks to one year (often 2021) — check the by-year table in the report.
- Neighboring parameters: if period 14 is good but 12 and 16 are bad, that is a sign of overfitting — check with Optimization and validation.
- Costs: a strategy that turns over its portfolio often needs to beat about 0.65% per buy–sell round trip (example).
- No look-ahead: the last line of the report must read kiểm tra dữ liệu tương lai: đạt (look-ahead check: passed).
strategy({…})는 백테스트할 전략을 선언합니다: 어떤 종목을 고를지, 언제 사고팔지, 얼마나 보유할지 정한 뒤, Studio가 과거 데이터에서 베트남 거래소 규칙에 따라 주문을 시뮬레이션하게 합니다. 이 페이지는 선언의 각 필드, 세 가지 전략 유형, 실제 템플릿에서 가져온 실행 가능한 예제를 설명합니다.
공통 선언
| 필드 | 의미 | 기본값 |
|---|---|---|
name, description | 이름(항목 상단, 보고서에 표시), 설명 | "Chiến lược" |
universe | 종목 바스켓: universe("HOSE").liquid(10e9).top(240), universe("VN30"), universe(["FPT", "MWG"]) — 종목 바스켓 참고 | VN30 |
period | { from, to }: "2016-01-01", "2016" 또는 "01/01/2016" | 최근 10년, 마감된 세션까지 |
benchmark | 비교 대상 종목("VNINDEX", "VN30", ETF…); "" = 비교 안 함 | "VNINDEX" |
warmup | 긴 지표(MA200…)가 첫 세션부터 충분한 데이터를 갖도록 from 이전에 추가로 불러올 세션 수 | 250 |
params | 매개변수 — 화면의 슬라이더, 스위치(param.*) | — |
signals · rank + select · weights | 종목 선택 방식 — 아래 세 유형 중 하나 | — |
rebalance | 로테이션형, 비중형의 리밸런싱 주기 | "month" |
filter | 매수가 허용되는 조건(세 유형 모두) | — |
portfolio, risk, execution, costs, rules | 포트폴리오, 손절, 체결, 비용, 거래소 규칙 — 아래 참고 | 설정을 따름 |
report | 보고서 끝에 직접 만든 수치, 차트, 표 추가 | — |
signals, rank, weights, filter 함수는 (d, p)를 받습니다: d는 데이터, p는 현재 매개변수 값(p.len, p.stop…)입니다. select, portfolio, risk, execution, costs, rules 필드는 객체이거나 매개변수의 함수입니다: risk: (p) => ({ stopLoss: p.stop }).
유형 1: 매수·매도 신호(signals)
signals: (d, p) => ({ entry: Panel, exit: Panel, score: Panel }),
entry— 매수 조건으로, 각 세션 종료 시 판단합니다. Studio가 자동으로 다음 조건을 덧붙입니다: 그 세션에 거래가 있는 종목, 바스켓에 속해 있음(시점 기준 바스켓), 바스켓의.liquid(…)와filter충족. 매수 주문은 다음 세션에 체결됩니다.exit(선택) — 보유 중인 포지션의 매도 조건.exit가 없으면 포지션은risk(손절, 트레일링 스톱…)로만 청산됩니다.score(선택) — 신호가 난 종목 수가 빈자리보다 많을 때의 우선순위 점수: 점수가 높은 종목부터 매수합니다. 점수가 없으면 평균 KL(거래량)이 큰 종목이 우선입니다.- Panel 하나를 바로 반환하면(
signals: (d) => d.close.crossAbove(ta.sma(d.close, 50)))entry만 있다는 뜻입니다.
entry는 이벤트(crossAbove: 한 세션만 참)일 수도 있고 상태(gt: 여러 세션 연속 참)일 수도 있습니다. 상태라면 세션마다 조건을 충족하는 모든 종목이 매수 주문을 냅니다. 포트폴리오가 차 있으면 그 주문들은 버려지고(Khớp lệnh mô phỏng(시뮬레이션 체결) 블록에 Bỏ do đủ số mã(종목 수 가득으로 취소)가 많이 나타남), 빈자리가 생기면 score가 어떤 종목을 살지 정합니다. 조건이 막 발생한 시점에만 매수하려면 이벤트를 쓰세요.
strategy({
name: "RSI hồi phục trong xu hướng tăng",
universe: universe("VN30"),
period: { from: "2016-01-01" },
params: {
len: param.int(14, { min: 2, max: 30, label: "Chu kỳ RSI" }),
lower: param.num(30, { min: 10, max: 45, label: "Ngưỡng mua" }),
upper: param.num(70, { min: 55, max: 90, label: "Ngưỡng bán" }),
stop: param.pct(0.07, { min: 0.03, max: 0.2, label: "Cắt lỗ" }),
},
signals: (d, p) => {
const r = ta.rsi(d.close, p.len);
const uptrend = d.close.gt(ta.sma(d.close, 200));
return {
entry: r.crossAbove(p.lower).and(uptrend),
exit: r.gt(p.upper).or(d.close.crossBelow(ta.sma(d.close, 50))),
score: r.neg(), // RSI càng thấp càng được mua trước
};
},
portfolio: { maxPositions: 5 },
risk: (p) => ({ stopLoss: p.stop }),
});
유형 2: 순위 기반 로테이션(rank)
리밸런싱 주기마다 점수로 종목 순위를 매기고 상위 그룹을 보유합니다.
rank: (d, p) => Panel, // điểm: cao hơn = tốt hơn; NaN = không xét
select: { top: 10, weight: "equal" }, // hoặc (p) => ({ … })
rebalance: "month",
select | 의미 | 기본값 |
|---|---|---|
top | 보유 종목 수 | 10 |
weight | "equal": 선택된 종목 수로 균등 배분; "score": 점수에 비례(음수 점수는 0으로 계산); "invvol": 20세션 수익률 표준편차에 반비례 — 변동이 적은 종목이 더 큰 비중을 받음 | "equal" |
minScore | 점수가 기준보다 큰 종목만 선택(minScore: 0: 점수가 양수인 종목만) | — |
maxWeight | 종목당 최대 비중; 남는 부분은 현금으로 보유 | — |
gross | 총 투자 비중(1 = 자산의 100%) | 1 |
rebalance | 포트폴리오 교체 시점 |
|---|---|
"day", "week", "month", "quarter", "year" | 매일 / 매주 / 매월 / 매 분기 / 매년의 첫 세션 |
숫자 n | 검증 기간 첫 세션부터 n세션마다 |
weight: "equal"은 실제로 선택된 종목 수로 나눕니다: top: 10인데 filter를 통과한 종목이 하나뿐이면 그 종목이 자금의 100%를 받습니다. maxWeight: 1 / p.top을 추가하면 종목마다 최대 10분의 1만 받고 나머지는 현금으로 보유합니다 — 로테이션 템플릿은 모두 이렇게 합니다.
strategy({
name: "Momentum 6-1 xoay vòng hàng tháng",
universe: universe("HOSE").liquid(10e9).top(240),
period: { from: "2016-01-01" },
rebalance: "month",
params: {
lookback: param.int(126, { min: 60, max: 252, label: "Số phiên đo đà tăng" }),
skip: param.int(21, { min: 0, max: 42, label: "Bỏ qua gần nhất" }),
top: param.int(10, { min: 3, max: 30, label: "Số mã nắm giữ" }),
calm: param.pct(0.5, { min: 0.2, max: 1, label: "Nhóm biến động thấp (% rổ)" }),
market: param.bool(true, { label: "Chỉ mua khi VNINDEX > MA200" }),
},
rank: (d, p) => d.close.shift(p.skip).pct(p.lookback - p.skip),
filter: (d, p) => {
// Bỏ các mã biến động mạnh nhất (đà tăng của mã đầu cơ dễ gãy), đứng ngoài khi VNINDEX dưới MA200
const ok = d.close.gt(ta.sma(d.close, 100)).and(d.ret.rolling(60).std().rank().lte(p.calm));
return p.market ? ok.and(d.bench.close.gt(ta.sma(d.bench.close, 200))) : ok;
},
// Mỗi mã tối đa 1/top vốn: ít mã đủ điều kiện thì phần còn lại để tiền mặt
select: (p) => ({ top: p.top, weight: "equal", maxWeight: 1 / p.top }),
});
d.close.shift(21).pct(105)는 1개월 전까지의 6개월 수익률입니다 — 한 달 동안 크게 오른 주식은 다음 달에 조정받는 경우가 많기 때문에(단기 반전 효과) 최근 1개월을 제외합니다.
유형 3: 직접 계산한 비중(weights)
weights: (d, p) => Panel, // tỷ trọng 0..1 của từng mã, tổng mỗi phiên ≤ 1
rebalance: "month",
리밸런싱 세션마다 Studio는 포트폴리오를 정확히 그 비중으로 맞춥니다(NaN 칸이나 filter를 충족하지 못한 종목은 0%로 계산; 나머지는 현금). 자산이 몇 개뿐인 전략이나 자체 공식에 따른 배분에 적합합니다.
strategy({
name: "Dual momentum ETF VN30 / tiền mặt",
universe: universe(["E1VFVN30"]),
period: { from: "2016-01-01" },
rebalance: "month",
params: { lookback: param.int(250, { min: 60, max: 250, label: "Số phiên đo đà" }) },
// 98% vào ETF khi lợi nhuận 12 tháng dương và giá trên MA200 (2% để trả phí), ngược lại giữ tiền mặt
weights: (d, p) => d.close.pct(p.lookback).gt(0).and(d.close.gt(ta.sma(d.close, 200))).mul(0.98),
});
매수 허용 조건(filter)
filter: (d, p) => Panel은 세 유형 모두에 적용됩니다: 매수 신호는 filter가 참일 때만 유효합니다. 로테이션형은 filter를 충족한 종목만 순위를 매깁니다(충족 종목이 없으면 그 기간은 현금 보유). 비중형은 충족하지 못한 종목에 0%를 줍니다.
시장 전체를 기준으로 거르는 것은 시장 전반이 하락할 때 새 포지션을 열지 않는 간단한 방법입니다 — 대부분의 전략 템플릿에 있는 Chỉ mua khi VNINDEX > MA200(VNINDEX > MA200일 때만 매수) 스위치가 바로 이 일을 합니다. VNINDEX의 조건은 한 행짜리 Panel이므로 종목별 조건과 결합하세요:
filter: (d, p) => {
const ok = d.close.gt(ta.sma(d.close, 100));
return p.market ? ok.and(d.bench.close.gt(ta.sma(d.bench.close, 200))) : ok;
},
신호형에서 filter는 매수만 막습니다. 보유 중인 포지션은 계속 exit를 따릅니다. 시장이 나빠질 때 매도하려면 반대 조건을 exit에 넣으세요 — 예: 거시경제 필터를 적용한 전략:
strategy({
name: "VN30 theo xu hướng, chỉ khi vĩ mô thuận lợi",
universe: universe("VN30"),
period: { from: "2016-01-01" },
params: {
maxRate: param.num(4, { min: 2, max: 8, step: 0.5, label: "LS qua đêm tối đa (%)" }),
maxCpi: param.num(4.5, { min: 2, max: 8, step: 0.5, label: "CPI tối đa (%)" }),
},
signals: (d, p) => {
// Vĩ mô thuận lợi: lãi suất liên ngân hàng qua đêm và lạm phát đều thấp (số đã biết ở từng phiên)
const ok = d.macro("ib_on").lt(p.maxRate).and(d.macro("cpi_yoy").lt(p.maxCpi));
const up = d.close.gt(ta.sma(d.close, 50));
return { entry: up.and(ok), exit: up.not().or(ok.not()), score: d.close.pct(60) };
},
portfolio: { maxPositions: 10 },
});
여기서 exit는 ok.not()만이 아니라 up.not().or(ok.not())입니다: exit와 score는 여러 종목의 Panel이어야 하기 때문입니다. exit: ok.not()만 단독으로 쓰면 한 행짜리 Panel이 됩니다 — 바스켓의 첫 번째 종목만 제대로 읽고, 나머지 종목은 항상 매도 신호가 있는 것으로 간주되어 매수할 수 없습니다.
포트폴리오, 위험, 체결, 비용
각 필드는 객체이거나 매개변수의 함수입니다. 코드의 값은 내 설정을 덮어씁니다. 보고서 Chi tiết(상세) 층 끝의 Khớp lệnh mô phỏng(시뮬레이션 체결) 블록에 실제로 사용된 값이 기록됩니다.
portfolio | 의미 | 기본값 |
|---|---|---|
capital | 초기 자금(동) | 10억 동(설정) |
maxPositions | 최대 보유 종목 수 — 신호형 | 5 |
sizing | "equal": 주문마다 = 자산 ÷ maxPositions; 0..1 사이 숫자: 주문마다 자산의 그 비율(0.2 = 20%) | "equal" |
cashRate | 유휴 현금의 연이율(0.04 = 연 4%, 일별 가산) | 0 |
risk | 의미 | 기본값 |
|---|---|---|
stopLoss | 가격이 매입가 대비 이 비율만큼 하락하면 손절(0.07 = 7%) | 없음 |
takeProfit | 가격이 매입가 대비 이 비율만큼 상승하면 이익 실현 | 없음 |
trailingStop | 트레일링 스톱: 매수 이후 최고 종가 대비 이 비율만큼 하락하면 매도 | 없음 |
maxHoldDays | n세션 보유 후 매도 | 없음 |
stopMode | "close": 종가 기준으로 판단해 다음 세션에 매도; "intraday": 가격이 기준에 닿으면 세션 중 즉시 손절 | "close" |
execution | 의미 | 기본값 |
|---|---|---|
at | "open": 시가 체결; "close": ATC 체결 | "open"(설정) |
delay | 신호부터 체결까지의 세션 수. 가장 빨라도 신호 다음 세션에 체결됩니다: 0으로 선언해도 1처럼 체결되며 경고만 추가됩니다 | 1 |
costs | 의미 | 기본값 |
|---|---|---|
fees | 편도 거래 수수료 | 0.0015(설정) |
sellTax | 매도세 | 0.001(설정) |
slippage | 고정 슬리피지 | 0.001(설정) |
impact | 시장 충격 계수: 추가 슬리피지 = impact × √(KL lệnh ÷ KL bình quân 20 phiên)(주문 수량 ÷ 20세션 평균 거래량) | 0.01 |
rules | 의미 | 기본값 |
|---|---|---|
settlement | 결제 세션 수(T+2) | 2 |
lotSize | 주식, ETF의 거래 단위 | 100 |
maxAdvPct | 매수 주문 1건의 최대 수량(직전 20세션 평균 거래량 대비 비율) | 0.1 |
limitLock | 상한가 / 하한가에 반대편 호가가 비어 있으면 체결하지 않음 | true |
retryBlockedBuys | 상한가에 매도 호가가 없거나 거래가 없는 세션에 걸린 매수 주문을 버리지 않고 다음 세션에 재시도 | false |
규칙별 세부 사항: 체결 시뮬레이션.
보고서에 사용자 블록 추가(report)
report: (d, result, out) => { … }는 시뮬레이션이 끝난 뒤 실행됩니다. out에는 분석 출력과 같은 metric, chart, table, md가 있습니다. 블록은 Tóm tắt(요약), Chi tiết(상세) 두 층 모두에서 보고서 끝(Ghi chú dữ liệu(데이터 메모) 앞)에 표시됩니다. result에는 days, equity(자산 곡선), benchmark, drawdown, exposure, metrics(cagr, sharpe, maxDrawdown, trades, winRate…), monthly, yearly({ year, ret, bench }), trades(symbol, entryDay, exitDay, entryPrice, exitPrice, qty, pnl, ret, bars, reason…), holdings가 들어 있습니다. report에서 난 오류는 Nhật ký chạy(실행 로그)에만 기록되고 실행 자체를 망치지 않습니다.
report: (d, result, out) => {
const held = new Set(result.trades.map((t) => t.symbol));
out.metric("Số mã từng nắm", held.size, "integer", `${result.trades.length} lệnh đã đóng`);
out.table(
result.yearly.map((y) => ({ "Năm": String(y.year), "Lợi nhuận": y.ret, "VNINDEX": y.bench })),
{ title: "Lợi nhuận theo năm", format: { "Lợi nhuận": "percent", "VNINDEX": "percent" } },
);
},
예제: 발표일 기준 재무제표
지속적인 퀄리티: 최근 발표된 연간 BCTC(재무제표) 3개 모두에서 ROE가 15% 이상인 기업만 고려하고, 매달 저변동성 그룹에서 모멘텀이 가장 강한 종목을 매수합니다. d.fin(…)은 리밸런싱 세션 이전에 나온 보고서만 보므로 미래를 참조하지 않습니다.
const roeYear = (d, ago) => d.fin("ROE", { period: "year", ago });
strategy({
name: "ROE từ 15% ba năm liền + động lượng",
universe: universe("ALL").liquid(5e9).top(240),
period: { from: "2016-01-01" },
rebalance: "month",
params: {
minRoe: param.pct(0.15, { min: 0.08, max: 0.3, label: "ROE tối thiểu mỗi năm" }),
top: param.int(10, { min: 5, max: 30, label: "Số mã nắm giữ" }),
calm: param.pct(0.6, { min: 0.2, max: 1, label: "Nhóm biến động thấp (% rổ)" }),
market: param.bool(true, { label: "Chỉ mua khi VNINDEX > MA200" }),
},
rank: (d) => d.close.shift(21).pct(105),
filter: (d, p) => {
const ok = roeYear(d, 0)
.gte(p.minRoe)
.and(roeYear(d, 1).gte(p.minRoe))
.and(roeYear(d, 2).gte(p.minRoe))
.and(d.close.gt(ta.sma(d.close, 100)))
.and(d.ret.rolling(60).std().rank().lte(p.calm));
return p.market ? ok.and(d.bench.close.gt(ta.sma(d.bench.close, 200))) : ok;
},
select: (p) => ({ top: p.top, weight: "equal", maxWeight: 1 / p.top }),
});
예제: 외국인 자금 흐름
매달 GT(거래대금) 대비 20세션 외국인 순매수가 가장 큰, 유동성 높은 HOSE 8종목을 보유합니다. minScore: 0으로 순매수 중인 종목만 고릅니다. 코드가 d.foreignNet을 읽으므로 외국인 데이터는 자동으로 불러옵니다.
strategy({
name: "Xoay vòng theo khối ngoại mua ròng",
universe: universe("HOSE").top(60),
period: { from: "2019-01-01" },
rebalance: "month",
params: {
days: param.int(20, { min: 5, max: 60, label: "Số phiên cộng dồn" }),
top: param.int(8, { min: 3, max: 20, label: "Số mã nắm giữ" }),
},
rank: (d, p) => d.foreignNet.rolling(p.days).sum().div(d.value.rolling(p.days).sum()),
select: (p) => ({ top: p.top, weight: "equal", minScore: 0 }),
});
예제: 가격 패턴 돌파 시 매수
ta.patterns는 종가가 거래량 확인과 함께 처음으로 패턴의 기준선을 넘은 세션에 1이 되는 Panel breakout을 제공합니다 — 그대로 entry로 쓰면 됩니다. 패턴의 고점, 저점은 가격이 기준만큼 되돌아선 뒤에야 인정되므로 신호가 미래를 참조하지 않습니다. 패턴은 몇 달에 걸쳐 형성되므로 단기 신호가 아니라 보유 기간과 넓은 손절로 청산하세요:
strategy({
name: "Mua khi hai đáy vượt đỉnh giữa",
universe: universe("ALL").liquid(3e9).top(240),
period: { from: "2016-01-01" },
params: {
minVol: param.num(1.5, { min: 1, max: 3, step: 0.1, label: "KL phiên phá vỡ / TB20 từ" }),
hold: param.int(120, { min: 20, max: 250, label: "Giữ tối đa (phiên)" }),
},
signals: (d, p) => {
const pt = ta.patterns(d, { only: ["doubleBottom"], minVol: p.minVol });
const market = d.bench.close.gt(ta.sma(d.bench.close, 200));
// Phiên phá vỡ có khối lượng; nhiều mã cùng phiên thì ưu tiên KL phá vỡ lớn
return { entry: pt.breakout.gt(0).and(market), score: pt.volRatio };
},
portfolio: { maxPositions: 10 },
risk: (p) => ({ stopLoss: 0.12, maxHoldDays: p.hold }),
});
pt.breakout.gt(0).and(market)는 VNINDEX의 한 행짜리 조건을 여러 종목 Panel에 결합하므로 entry가 모든 종목에 올바르게 적용됩니 다. Mua khi phá vỡ mẫu hình tăng giá(상승 패턴 돌파 시 매수) 템플릿(템플릿 목록)이 완전한 버전입니다: 패턴 세 가지, 패턴별 스위치와 VNINDEX 필터 스위치를 갖추고 있습니다.
결과를 믿기 전에
- 충분한 거래, 충분한 기간: 청산된 거래가 30건 미만이거나 기간이 3년 미만이면 결과가 운일 수 있습니다(보고서가 신뢰도를 낮춤).
- 시점 기준 바스켓: 긴 백테스트에는
universe("ALL"),"HOSE"또는 업종별 바스켓을 쓰세요. VN30, HNX30… 은 오늘의 목록입니다. - 최고의 해 빼기: 많은 전략이 단 한 해(보통 2021년) 덕분에 VNINDEX를 이깁니다 — 보고서의 연도별 표를 확인하세요.
- 매개변수 이웃: 주기 14는 좋은데 12, 16은 나쁘다면 과최적화의 신호입니다 — 최적화와 검증으로 확인하세요.
- 비용: 포트폴리오를 자주 바꾸는 전략은 매수 – 매도 1회전마다 약 0.65%를 넘어서야 합니다(예시).
- 미래 참조 없음: 보고서 마지막 줄에
kiểm tra dữ liệu tương lai: đạt(미래 데이터 검사: 통과)가 적혀 있어야 합니다.
strategy({…}) 声明一个用于回测的策略:选哪些股票、何时买卖、持有多少,然后让 Studio 在历史数据上按越南交易所规则模拟下单。本页介绍声明中的每个字段、三种策略类型,以及取自真实模板、可直接运行的示例。
通用声明
| 字段 | 含义 | 默认值 |
|---|---|---|
name、description | 名称(显示在条目顶部、报告中)、描述 | "Chiến lược"(策略) |
universe | 股票池:universe("HOSE").liquid(10e9).top(240)、universe("VN30")、universe(["FPT", "MWG"])——见股票池 | VN30 |
period | { from, to }:"2016-01-01"、"2016" 或 "01/01/2016" | 最近 10 年,截至已收盘交易日 |
benchmark | 对比代码("VNINDEX"、"VN30"、ETF…);"" = 不对比 | "VNINDEX" |
warmup | 在 from 之前额外加载的交易日数,使长周期指标(MA200…)从第一个交易日起就有足够数据 | 250 |
params | 参数——界 面上的滑块、开关(param.*) | — |
signals · rank + select · weights | 选股方式——下面三种类型之一 | — |
rebalance | 轮动型、权重型的再平衡周期 | "month" |
filter | 允许买入的条件(三种类型均适用) | — |
portfolio、risk、execution、costs、rules | 组合、止损、撮合、费用、交易所规则——见下文 | 按设置 |
report | 在报告末尾添加自定义数据、图表、表格 | — |
函数 signals、rank、weights、filter 接收 (d, p):d 是数据,p 是当前参数值(p.len、p.stop…)。字段 select、portfolio、risk、execution、costs、rules 是对象,或参数的函数:risk: (p) => ({ stopLoss: p.stop })。
类型 1:买入、卖出信号(signals)
signals: (d, p) => ({ entry: Panel, exit: Panel, score: Panel }),
entry——买入条件,在每个交易日收盘时判断。Studio 会自动附加:股票在该交易日有交易、属于股票池(时点股票池)、满足股票池的.liquid(…)和filter。买单在下一交易日成交。exit(可选)——卖出现有持仓的条件。没有exit时,持仓只按risk(止损、移动止损…)退出。score(可选)——有信号的股票多于空位时的优先分数:分数高者先买;没有分数时,平均 KL 较大的股票优先。- 直接返回一个 Panel(
signals: (d) => d.close.crossAbove(ta.sma(d.close, 50)))表示只有entry。
entry 可以是事件(crossAbove:只在一个交易日为真)或状态(gt:连续多个交易日为真)。使用状态时,每个交易日所有满足条件的股票都会发出买单:组合已满时这些订单被舍弃(Khớp lệnh mô phỏng(模拟撮合)区块中会出现很多 Bỏ do đủ số mã(因持股数已满而舍弃)),出现空位时由 score 决定买入哪只股票。如果只想在条件刚发生时买入,请使用事件。
strategy({
name: "RSI hồi phục trong xu hướng tăng",
universe: universe("VN30"),
period: { from: "2016-01-01" },
params: {
len: param.int(14, { min: 2, max: 30, label: "Chu kỳ RSI" }),
lower: param.num(30, { min: 10, max: 45, label: "Ngưỡng mua" }),
upper: param.num(70, { min: 55, max: 90, label: "Ngưỡng bán" }),
stop: param.pct(0.07, { min: 0.03, max: 0.2, label: "Cắt lỗ" }),
},
signals: (d, p) => {
const r = ta.rsi(d.close, p.len);
const uptrend = d.close.gt(ta.sma(d.close, 200));
return {
entry: r.crossAbove(p.lower).and(uptrend),
exit: r.gt(p.upper).or(d.close.crossBelow(ta.sma(d.close, 50))),
score: r.neg(), // RSI càng thấp càng được mua trước
};
},
portfolio: { maxPositions: 5 },
risk: (p) => ({ stopLoss: p.stop }),
});
类型 2:按排名轮动(rank)
每个再平衡周期,按分数对股票排名,然后持有排名靠前的一组。
rank: (d, p) => Panel, // điểm: cao hơn = tốt hơn; NaN = không xét
select: { top: 10, weight: "equal" }, // hoặc (p) => ({ … })
rebalance: "month",
select | 含义 | 默认值 |
|---|---|---|
top | 持股数 | 10 |
weight | "equal":按入选股票数平均分配;"score":按分数(负分按 0 计);"invvol":与 20 日收益标准差成反比——波动小的股票权重更大 | "equal" |
minScore | 只选分数大于阈值的股票(minScore: 0:只选分数为正的股票) | — |
maxWeight | 单只股票的最大权重;多余部分留作现金 | — |
gross | 总投资权重(1 = 100% 资产) | 1 |
rebalance | 调仓时间 |
|---|---|
"day"、"week"、"month"、"quarter"、"year" | 每天 / 周 / 月 / 季度 / 年的第一个交易日 |
数字 n | 每 n 个交易日,从检验期第一个交易日起算 |
weight: "equal" 按实际入选的股票数分配:top: 10 但只有一只股票通过 filter 时,这只股票将获得 100% 资金。添加 maxWeight: 1 / p.top 可使每只股票最多占十分之一,其余部分持有现金——轮动模板都是这样做的。
strategy({
name: "Momentum 6-1 xoay vòng hàng tháng",
universe: universe("HOSE").liquid(10e9).top(240),
period: { from: "2016-01-01" },
rebalance: "month",
params: {
lookback: param.int(126, { min: 60, max: 252, label: "Số phiên đo đà tăng" }),
skip: param.int(21, { min: 0, max: 42, label: "Bỏ qua gần nhất" }),
top: param.int(10, { min: 3, max: 30, label: "Số mã nắm giữ" }),
calm: param.pct(0.5, { min: 0.2, max: 1, label: "Nhóm biến động thấp (% rổ)" }),
market: param.bool(true, { label: "Chỉ mua khi VNINDEX > MA200" }),
},
rank: (d, p) => d.close.shift(p.skip).pct(p.lookback - p.skip),
filter: (d, p) => {
// Bỏ các mã biến động mạnh nhất (đà tăng của mã đầu cơ dễ gãy), đứng ngoài khi VNINDEX dưới MA200
const ok = d.close.gt(ta.sma(d.close, 100)).and(d.ret.rolling(60).std().rank().lte(p.calm));
return p.market ? ok.and(d.bench.close.gt(ta.sma(d.bench.close, 200))) : ok;
},
// Mỗi mã tối đa 1/top vốn: ít mã đủ điều kiện thì phần còn lại để tiền mặt
select: (p) => ({ top: p.top, weight: "equal", maxWeight: 1 / p.top }),
});
d.close.shift(21).pct(105) 是截至 1 个月前的 6 个月收益——剔除最近一个月,是因为当月大涨的股票往往在下个月回调(短期反转效应)。
类型 3:自定义权重(weights)
weights: (d, p) => Panel, // tỷ trọng 0..1 của từng mã, tổng mỗi phiên ≤ 1
rebalance: "month",
在每个再平衡交易日,Studio 将组合调整到该权重(值为 NaN 或不满足 filter 的按 0% 计;其余部分为现金)。适合只有少数几种资产的策略,或按自定义公式分配的策略。
strategy({
name: "Dual momentum ETF VN30 / tiền mặt",
universe: universe(["E1VFVN30"]),
period: { from: "2016-01-01" },
rebalance: "month",
params: { lookback: param.int(250, { min: 60, max: 250, label: "Số phiên đo đà" }) },
// 98% vào ETF khi lợi nhuận 12 tháng dương và giá trên MA200 (2% để trả phí), ngược lại giữ tiền mặt
weights: (d, p) => d.close.pct(p.lookback).gt(0).and(d.close.gt(ta.sma(d.close, 200))).mul(0.98),
});
允许买入的条件(filter)
filter: (d, p) => Panel 适用于全部三种类型:买入信号只在 filter 为真时生效;轮动型只对满足 filter 的股票排名(没有股票满足时该期持有现金);权重型对不满足的股票设为 0%。
按大盘筛选是在整个市场下跌时不开新仓的简单方法——大多数策略模板都有开关 Chỉ mua khi VNINDEX > MA200(仅在 VNINDEX > MA200 时买入)来做这件事。VNINDEX 的条件是单行 Panel;请将它与每只股票的条件组合:
filter: (d, p) => {
const ok = d.close.gt(ta.sma(d.close, 100));
return p.market ? ok.and(d.bench.close.gt(ta.sma(d.bench.close, 200))) : ok;
},
对信号型而言,filter 只阻止买入;现有持仓仍按 exit 处理。如果想在市场转差时卖出,请把相反的条件放进 exit——例如带宏观过滤的策略:
strategy({
name: "VN30 theo xu hướng, chỉ khi vĩ mô thuận lợi",
universe: universe("VN30"),
period: { from: "2016-01-01" },
params: {
maxRate: param.num(4, { min: 2, max: 8, step: 0.5, label: "LS qua đêm tối đa (%)" }),
maxCpi: param.num(4.5, { min: 2, max: 8, step: 0.5, label: "CPI tối đa (%)" }),
},
signals: (d, p) => {
// Vĩ mô thuận lợi: lãi suất liên ngân hàng qua đêm và lạm phát đều thấp (số đã biết ở từng phiên)
const ok = d.macro("ib_on").lt(p.maxRate).and(d.macro("cpi_yoy").lt(p.maxCpi));
const up = d.close.gt(ta.sma(d.close, 50));
return { entry: up.and(ok), exit: up.not().or(ok.not()), score: d.close.pct(60) };
},
portfolio: { maxPositions: 10 },
});
这里的 exit 是 up.not().or(ok.not()),而不只是 ok.not():exit 和 score 必须是多股票 Panel。单独的 exit: ok.not() 是单行 Panel——只有股票池中的第一只股票读取正确,其余股票会被视为始终有卖出信号,因而无法买入。