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

Chiến lược

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ĩaMặc định
name, descriptionTên (hiện ở đầu mục, báo cáo), mô tả"Chiến lược"
universeRổ 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
benchmarkMã so sánh ("VNINDEX", "VN30", ETF…); "" = không so"VNINDEX"
warmupSố phiên nạp thêm trước from để chỉ báo dài (MA200…) có đủ dữ liệu ngay phiên đầu250
paramsTham số — thanh trượt, công tắc trên giao diện (param.*)—
signals · rank + select · weightsCách chọn mã — một trong ba kiểu dưới đây—
rebalanceChu 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, rulesDanh mục, cắt lỗ, khớp lệnh, chi phí, luật sàn — xem bên dướitheo Cài đặt
reportThê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ó exit thì vị thế chỉ thoát theo risk (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ĩaMặc định
topSố 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"
minScoreChỉ chọn mã có điểm lớn hơn ngưỡng (minScore: 0: chỉ mã có điểm dương)—
maxWeightTỷ trọng tối đa một mã; phần dư để tiền mặt—
grossTổ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ố nMỗi n phiên, tính từ phiên đầu kỳ kiểm tra
Chia đều khi ít mã đạt điều kiện

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ĩaMặc định
capitalVốn ban đầu (đồng)1 tỷ (Cài đặt)
maxPositionsSố mã nắm tối đa — kiểu tín hiệu5
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"
cashRateLã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ĩaMặc định
stopLossCắt lỗ khi giá giảm đủ tỷ lệ này so với giá vốn (0.07 = 7%)không
takeProfitChốt lời khi giá tăng đủ tỷ lệ này so với giá vốnkhông
trailingStopDừ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 muakhông
maxHoldDaysBá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ĩaMặc định
at"open": khớp giá mở cửa; "close": khớp ATC"open" (Cài đặt)
delaySố 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áo1
costsÝ nghĩaMặc định
feesPhí giao dịch mỗi chiều0,0015 (Cài đặt)
sellTaxThuế bán0,001 (Cài đặt)
slippageTrượt giá cố định0,001 (Cài đặt)
impactHệ số tác động: trượt thêm = impact × √(KL lệnh ÷ KL bình quân 20 phiên)0,01
rulesÝ nghĩaMặc định
settlementSố phiên thanh toán (T+2)2
lotSizeLô của cổ phiếu, ETF100
maxAdvPctKL một lệnh mua tối đa theo tỷ lệ KL bình quân 20 phiên trước0,1
limitLockKhông khớp khi trần / sàn trắng bêntrue
retryBlockedBuysLệ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.