Buổi 3: Window Functions
Đến hết buổi 2, các anh chị đã quen với GROUP BY: gom nhiều dòng thành một dòng để tính tổng, trung bình, đếm. Trong công việc ở công ty chứng khoán, rất nhiều báo cáo lại đòi hỏi điều ngược lại: giữ nguyên từng dòng nhưng đặt mỗi dòng cạnh một con số của "cả nhóm" — ví dụ giá trị giao dịch của mã VCB và tỷ trọng của nó trong cả thị trường, giá đóng cửa hôm nay và giá phiên trước, thứ hạng của mã trong ngày, đường trung bình động 3 phiên... Đó chính là đất dụng võ của window functions.
Buổi này dùng chủ yếu bảng MarketPrices (12 mã × 10 phiên từ 2026-09-14 đến 2026-09-25) và một chút bảng Trades. Cấu trúc các bảng xem lại ở mục "Bộ dữ liệu mẫu của khóa học" trong Buổi 1. "Hôm nay" của bộ dữ liệu luôn là 2026-09-25.
Lưu ý: Yêu cầu phiên bản: SQL Server 2012 trở lên và MySQL 8.0 trở lên có đầy đủ hàm cửa sổ. MySQL 5.7 trở về trước không hỗ trợ.
Mục tiêu buổi học
- Sau buổi này bạn có thể giải thích khái niệm hàm cửa sổ, viết đúng cú pháp
OVER (PARTITION BY ... ORDER BY ...)và phân biệt vớiGROUP BY. - Sau buổi này bạn có thể dùng
SUM/AVG/MIN/MAX ... OVERđể so sánh từng dòng với tổng thể mà không gộp dòng. - Sau buổi này bạn có thể xếp hạng bằng
ROW_NUMBER,RANK,DENSE_RANK, xử lý đồng hạng và lấy Top N theo nhóm. - Sau buổi này bạn có thể phân tích biến động giá bằng
LAG/LEAD: mức tăng/giảm và % biến động so với phiên trước. - Sau buổi này bạn có thể dùng khung cửa sổ
ROWS BETWEEN ...để tính giá trị lũy kế và đường trung bình động MA3/MA5.
Bản đồ buổi học
Buổi học 3 giờ (180 phút), khoảng 29% lý thuyết + demo + Q&A (~52 phút) và 71% thực hành (~128 phút). Mỗi phần lý thuyết được xen kẽ ngay bằng bài thực hành tương ứng: học đến đâu làm đến đó.
| Phần | Nội dung | Thời lượng gợi ý |
|---|---|---|
| 1 | Tổng quan Window Functions, so sánh với GROUP BY |
10 phút (lý thuyết + demo) |
| 2 | Window Aggregate: SUM/AVG/MIN/MAX/COUNT ... OVER |
8 phút (lý thuyết + demo) |
| 3 | Ranking: ROW_NUMBER, RANK, DENSE_RANK, đồng hạng, Top N |
10 phút (lý thuyết + demo) |
| Thực hành | Bài 3.1 — Xếp hạng và phân loại cổ phiếu | 44 phút |
| 4 | LAG và LEAD — biến động giá |
9 phút (lý thuyết + demo) |
| Thực hành | Bài 3.2 — Chuỗi thời gian và biến động giá | 44 phút |
| 5 | Window Frame và Moving Average | 9 phút (lý thuyết + demo) |
| Thực hành | Bài 3.3 — Giá trị lũy kế và đường trung bình động | 40 phút |
| 6 | Tổng kết và Q&A | 6 phút (thảo luận) |
| Tổng | 180 phút: lý thuyết + demo + Q&A ~52 phút, thực hành ~128 phút (~71%) |
1. Tổng quan Window Functions
1.1. Khái niệm
Một hàm cửa sổ tính toán trên một tập các dòng liên quan đến dòng hiện tại (gọi là "cửa sổ"), nhưng không gộp các dòng lại. Mỗi dòng đầu vào vẫn cho ra đúng một dòng kết quả, cộng thêm cột do hàm cửa sổ tính.
Dấu hiệu nhận biết: luôn có mệnh đề OVER (...) đi sau tên hàm.
hàm(...) OVER (
[PARTITION BY cột_chia_nhóm]
[ORDER BY cột_sắp_xếp [ASC|DESC]]
[khung: ROWS BETWEEN ... AND ...]
)| Thành phần | Vai trò | Nếu bỏ đi |
|---|---|---|
PARTITION BY |
Chia dòng thành các "phân vùng" độc lập (giống GROUP BY nhưng không gộp) |
Cả bảng kết quả là một phân vùng |
ORDER BY |
Thứ tự dòng bên trong phân vùng — quyết định hạng, "phiên trước", tổng lũy kế | Thứ tự không xác định (không dùng được cho xếp hạng, LAG, LEAD) |
Khung (ROWS BETWEEN) |
Cửa sổ trượt quanh dòng hiện tại (mục 5) | Mặc định như mục 5.2 |
Ví dụ nghiệp vụ: Phòng phân tích cần bảng "giá đóng cửa từng phiên kèm giá đóng cửa trung bình của chính mã đó". Không thể dùng
GROUP BYvì sẽ mất từng phiên; hàm cửa sổ giải quyết trọn vẹn trong một câu lệnh.
1.2. So sánh với GROUP BY
Cùng một câu hỏi "giá đóng cửa trung bình và cao nhất của từng mã", hai cách cho hai kết quả khác hẳn về hình dạng.
Với GROUP BY — mỗi mã còn một dòng:
SELECT symbol,
CAST(AVG(close_price) AS DECIMAL(18,2)) AS tb_close,
MAX(close_price) AS cao_nhat
FROM MarketPrices
WHERE symbol IN ('FPT','VCB','HPG')
GROUP BY symbol
ORDER BY symbol;FPT 121440.00 126000.00
HPG 27027.00 27540.00
VCB 92368.00 95680.00Với hàm cửa sổ — vẫn còn từng phiên, mỗi phiên "nhìn thấy" trung bình và đỉnh của cả mã:
SELECT symbol, trade_date, close_price,
CAST(AVG(close_price) OVER (PARTITION BY symbol) AS DECIMAL(18,2)) AS tb_close,
MAX(close_price) OVER (PARTITION BY symbol) AS cao_nhat
FROM MarketPrices
WHERE symbol IN ('FPT','VCB','HPG') AND trade_date >= '2026-09-24'
ORDER BY symbol, trade_date;FPT 2026-09-24 118800.00 117600.00 118800.00
FPT 2026-09-25 116400.00 117600.00 118800.00
HPG 2026-09-24 26460.00 26865.00 27270.00
HPG 2026-09-25 27270.00 26865.00 27270.00
VCB 2026-09-24 92920.00 94300.00 95680.00
VCB 2026-09-25 95680.00 94300.00 95680.00Hình dưới đây đặt hai cách cạnh nhau trên cùng dữ liệu để thấy "gộp dòng" khác "giữ dòng" thế nào.
Dữ liệu gốc (4 dòng)
| symbol | trade_date | volume |
|---|---|---|
| SHS | 09-24 | 100 |
| SHS | 09-25 | 300 |
| PVS | 09-24 | 200 |
| PVS | 09-25 | 400 |
GROUP BY symbol → 2 dòng
| symbol | SUM(volume) |
|---|---|
| SHS | 400 |
| PVS | 600 |
SUM(volume) OVER (PARTITION BY symbol) → 4 dòng
| symbol | volume | tong_ma |
|---|---|---|
| SHS | 100 | 400 |
| SHS | 300 | 400 |
| PVS | 200 | 600 |
| PVS | 400 | 600 |
Với cùng dữ liệu: GROUP BY trả một dòng cho mỗi nhóm; window function vẫn trả đủ 4 dòng, chỉ thêm cột tính trên cả nhóm.
Cách đọc hình: bên trái, GROUP BY nén mỗi nhóm thành một dòng nên các cột chi tiết (ngày, giá từng phiên) biến mất; bên phải, hàm cửa sổ giữ nguyên số dòng đầu vào và chỉ thêm một cột chứa số liệu của nhóm, lặp lại ở mọi dòng thuộc nhóm.
GROUP BY |
Hàm cửa sổ | |
|---|---|---|
| Số dòng kết quả | Ít hơn (một dòng/nhóm) | Bằng số dòng đầu vào |
| Cột không nằm trong nhóm | Không được SELECT (trừ khi gộp) |
SELECT thoải mái |
| Dùng khi | Báo cáo tổng hợp | Đặt số liệu nhóm cạnh chi tiết, xếp hạng, so sánh dòng với dòng |
Thứ tự thực thi logic. Hàm cửa sổ được tính sau WHERE, GROUP BY, HAVING, và trước ORDER BY cuối cùng:
| Bước | Mệnh đề | Ghi chú |
|---|---|---|
| 1 | FROM / JOIN |
Lấy dữ liệu nguồn |
| 2 | WHERE |
Lọc dòng — dòng bị loại không tham gia cửa sổ |
| 3 | GROUP BY / HAVING |
Gộp nhóm (nếu có) |
| 4 | Hàm cửa sổ (OVER) |
Tính trên các dòng còn lại |
| 5 | SELECT (biểu thức, bí danh) |
Đặt tên cột |
| 6 | ORDER BY cuối câu, TOP/LIMIT |
Sắp xếp hiển thị |
Cảnh báo: Hệ quả của thứ tự trên: (1)
WHEREchạy trước nên những dòng bị lọc không tham gia vào cửa sổ (xem bẫy ở mục 4.2); (2) không được đặt hàm cửa sổ trongWHEREhayGROUP BY. Muốn lọc theo kết quả của hàm cửa sổ (ví dụ "chỉ giữ hạng<= 3") phải bọc câu truy vấn thành truy vấn con ở mệnh đề FROM (derived table) rồi lọc ở truy vấn ngoài — chúng ta sẽ dùng mẫu này rất nhiều. (Truy vấn con và CTE sẽ được học kỹ ở Buổi 5.)
1.3. Khác biệt SQL Server và MySQL
Cú pháp cơ bản giống nhau, các bài trong buổi này chạy nguyên văn trên cả hai. Vài điểm cần nhớ:
- Trong
OVER, không dùng bí danh cột (alias) củaSELECT; phải viết lại biểu thức. - Kiểu dữ liệu kết quả có thể khác (xem mục 2.2,
AVGtrên số nguyên). - MySQL hỗ trợ thêm mệnh đề
WINDOW w AS (...)đặt tên cho cửa sổ để dùng lại nhiều lần; T-SQL truyền thống (trước 2022) thì không — nên viết lặpOVER (...).
MySQL — đặt tên cửa sổ, viết một lần dùng nhiều lần:
SELECT trade_date, close_price,
LAG(close_price) OVER w AS prev_close,
SUM(volume) OVER w AS luy_ke_kl
FROM MarketPrices
WHERE symbol = 'HPG' AND trade_date <= '2026-09-17'
WINDOW w AS (ORDER BY trade_date)
ORDER BY trade_date;2026-09-14 27000.00 NULL 7400000
2026-09-15 26190.00 27000.00 12400000
2026-09-16 27000.00 26190.00 19800000
2026-09-17 27540.00 27000.00 25700000SQL Server — viết lặp OVER (...) (chạy được ở mọi phiên bản):
SELECT trade_date, close_price,
LAG(close_price) OVER (ORDER BY trade_date) AS prev_close,
SUM(volume) OVER (ORDER BY trade_date) AS luy_ke_kl
FROM MarketPrices
WHERE symbol = 'HPG' AND trade_date <= '2026-09-17'
ORDER BY trade_date;2026-09-14 27000.00 NULL 7400000
2026-09-15 26190.00 27000.00 12400000
2026-09-16 27000.00 26190.00 19800000
2026-09-17 27540.00 27000.00 25700000SQL Server vs MySQL: Hai câu trên cho cùng kết quả. Khi chuyển từ MySQL sang SQL Server, chỉ cần thay
OVER wbằngOVER (ORDER BY trade_date).
2. Window Aggregate Functions: SUM / AVG / MIN / MAX / COUNT ... OVER
Các hàm tổng hợp quen thuộc thành hàm cửa sổ chỉ bằng cách thêm OVER.
| Hàm | Ý nghĩa khi dùng với OVER |
Ứng dụng chứng khoán |
|---|---|---|
SUM(x) OVER (...) |
Tổng của x trên cửa sổ | Tỷ trọng giá trị giao dịch, lũy kế |
AVG(x) OVER (...) |
Trung bình của x trên cửa sổ | So sánh khối lượng với mức trung bình |
MIN(x) OVER (...) / MAX(x) OVER (...) |
Nhỏ nhất / lớn nhất | Đỉnh/đáy của kỳ, khoảng cách tới đỉnh |
COUNT(*) OVER (...) |
Số dòng trên cửa sổ | Đếm số phiên đã có dữ liệu |
2.1. OVER () rỗng: cả bảng kết quả là một cửa sổ
Bài toán: trong ngày 2026-09-24, mỗi mã chiếm bao nhiêu % tổng khối lượng khớp toàn thị trường?
SELECT symbol, volume,
SUM(volume) OVER () AS tong_ngay,
CAST(ROUND(100.0 * volume / SUM(volume) OVER (), 2) AS DECIMAL(18,2)) AS ty_trong
FROM MarketPrices
WHERE trade_date = '2026-09-24'
ORDER BY volume DESC, symbol;MBS 8700000 56900000 15.29
FPT 8400000 56900000 14.76
SHS 8100000 56900000 14.24
VNM 8100000 56900000 14.24
IDC 7300000 56900000 12.83
NVB 4400000 56900000 7.73
...Cùng một biểu thức SUM(volume) OVER () cho cùng một con số 56.900.000 ở mọi dòng, nên ta có thể chia trực tiếp để ra tỷ trọng — điều mà với GROUP BY phải viết truy vấn con hoặc tự nối bảng.
Tương tự, so sánh từng mã với mức trung bình của cả thị trường trong ngày:
SELECT symbol, volume,
CASE WHEN volume * 1.0 > AVG(volume * 1.0) OVER () THEN 'Tren TB' ELSE 'Duoi TB' END AS so_voi_tb
FROM MarketPrices
WHERE trade_date = '2026-09-25'
ORDER BY volume DESC, symbol;NVB 8400000 Tren TB
CEO 7800000 Tren TB
VCB 7500000 Tren TB
IDC 7000000 Tren TB
PVS 7000000 Tren TB
...Mẹo: Có thể đặt hàm cửa sổ bên trong
CASEnhư trên, nhưng vẫn không thể đặt trongWHERE. Muốn giữ riêng các mã trên trung bình, hãy bọc thành truy vấn con rồi lọcso_voi_tb = 'Tren TB'.
2.2. PARTITION BY: cửa sổ theo nhóm
SUM(volume) OVER (PARTITION BY symbol) là tổng khối lượng của riêng mã đó trên các dòng đang xét. Ví dụ trên bảng Trades, đặt cạnh mỗi giao dịch tổng khối lượng khớp của mã trong giai đoạn 24–25/09:
SELECT trade_id, symbol, quantity,
SUM(quantity) OVER (PARTITION BY symbol) AS tong_theo_ma
FROM Trades
WHERE trade_date >= '2026-09-24'
ORDER BY symbol, trade_id;32 CEO 1000 3500
40 CEO 2500 3500
18 FPT 500 500
78 IDC 100 150
80 IDC 50 150
2 MBS 2000 2000
...Bốn hàm cần nhớ: SUM, AVG, MIN, MAX (thêm COUNT). Ví dụ dùng MIN/MAX để đo khoảng cách của giá hiện tại tới đỉnh 10 phiên của chính mã đó. Vì cần cả chuỗi 10 phiên để tìm đỉnh/đáy nên phải tính trong truy vấn con rồi mới lọc ngày ở truy vấn ngoài:
SELECT symbol, close_price, dinh, day_gia,
CAST(ROUND(100.0 * (dinh - close_price) / dinh, 2) AS DECIMAL(18,2)) AS cach_dinh_pct
FROM (
SELECT symbol, trade_date, close_price,
MAX(close_price) OVER (PARTITION BY symbol) AS dinh,
MIN(close_price) OVER (PARTITION BY symbol) AS day_gia
FROM MarketPrices
) t
WHERE trade_date = '2026-09-25'
ORDER BY cach_dinh_pct DESC, symbol;SHS 14720.00 16320.00 14720.00 9.80
FPT 116400.00 126000.00 116400.00 7.62
CEO 20460.00 22000.00 19140.00 7.00
VNM 65960.00 70720.00 65960.00 6.73
VCS 56400.00 59400.00 54600.00 5.05
...Khi có cả PARTITION BY lẫn ORDER BY, phép tính trở thành lũy kế (xem mục 5).
SQL Server vs MySQL — bẫy AVG trên số nguyên. Cột volume kiểu BIGINT. AVG trong T-SQL trên kiểu nguyên trả về số nguyên (cắt bỏ phần thập phân), còn MySQL trả về số thập phân:
SQL Server:
SELECT symbol, volume, AVG(volume) OVER () AS tb_ngay
FROM MarketPrices WHERE trade_date = '2026-09-24' ORDER BY symbol;CEO 2300000 4741666
FPT 8400000 4741666
HPG 200000 4741666
...MySQL:
SELECT symbol, volume, AVG(volume) OVER () AS tb_ngay
FROM MarketPrices WHERE trade_date = '2026-09-24' ORDER BY symbol;CEO 2300000 4741666.6667
FPT 8400000 4741666.6667
HPG 200000 4741666.6667
...Cách viết an toàn cho cả hai: CAST(ROUND(AVG(volume * 1.0) OVER (), 2) AS DECIMAL(18,2)) — nhân 1.0 để ép sang số thập phân, rồi ROUND + CAST để in đúng hai chữ số. Quy tắc này áp dụng cho mọi bài chấm tự động của khóa.
SELECT symbol, volume,
CAST(ROUND(AVG(volume * 1.0) OVER (), 2) AS DECIMAL(18,2)) AS tb_ngay
FROM MarketPrices WHERE trade_date = '2026-09-24' ORDER BY symbol;CEO 2300000 4741666.67
FPT 8400000 4741666.67
HPG 200000 4741666.67
...2.3. Giá trị giao dịch: vài lưu ý nghiệp vụ
Trong buổi này, giá trị giao dịch của một mã trong phiên được xấp xỉ bằng close_price * volume. (Giá trị khớp thực tế phải cộng từng lệnh khớp; ở đây ta dùng công thức đơn giản để có dữ liệu cho cả 12 mã trong cả 10 phiên.) Tổng giá trị cả 12 mã ngày 25/09 vào cỡ 2.770 tỷ đồng, nên hãy luôn CAST(... AS DECIMAL(18,2)) để số in ra có 2 chữ số thập phân và không bị tràn kiểu.
Cảnh báo: Quên rằng
OVER ()chỉ nhìn thấy các dòng còn lại sauWHERE— lọc ngày 25/09 xong thìSUM(...) OVER ()chỉ là tổng của ngày 25/09. DùngGROUP BYrồi vẫnSELECTthêm cột chi tiết sẽ bị báo lỗi; đó là lúc nên chuyển sang hàm cửa sổ.
Bài tập 3.1c (tỷ trọng giá trị giao dịch với SUM/AVG/MIN/MAX ... OVER ()) dùng đúng kiến thức của mục này; các bài còn lại của nhóm 3.1 nằm ở phần xếp hạng ngay bên dưới, nên bạn sẽ làm cả nhóm sau mục 3.
3. Ranking Functions: ROW_NUMBER, RANK, DENSE_RANK
Ba hàm xếp hạng đều bắt buộc có ORDER BY trong OVER, khác nhau ở cách đối xử với các dòng đồng hạng (ties):
| Hàm | Đồng hạng | Thứ hạng kế tiếp | Ví dụ với giá trị 10, 10, 8 |
|---|---|---|---|
ROW_NUMBER() |
Vẫn được số khác nhau (tùy ý nếu không có khóa phụ) | Liền mạch | 1, 2, 3 |
RANK() |
Cùng hạng | Nhảy cóc | 1, 1, 3 |
DENSE_RANK() |
Cùng hạng | Liền mạch | 1, 1, 2 |
Ngày 2026-09-23 có hai mã đồng khối lượng đứng đầu (PVS và VCS cùng 8.400.000). So sánh ba hàm:
SELECT symbol, volume,
ROW_NUMBER() OVER (ORDER BY volume DESC, symbol) AS rn,
RANK() OVER (ORDER BY volume DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY volume DESC) AS drnk
FROM MarketPrices
WHERE trade_date = '2026-09-23'
ORDER BY rn;PVS 8400000 1 1 1
VCS 8400000 2 1 1
NVB 8200000 3 3 2
VNM 8100000 4 4 3
MBS 7400000 5 5 4
...Nhìn dòng NVB: RANK = 3 (vì có 2 mã hạng 1 đứng trước, hạng 2 "bị bỏ"), còn DENSE_RANK = 2.
3.1. Xử lý đồng hạng — chọn hàm theo nghiệp vụ
- Muốn mỗi dòng một số duy nhất (đánh số phiên, chọn đúng 1 dòng đầu mỗi nhóm):
ROW_NUMBER(), luôn thêm khóa phụ vàoORDER BY(nhưsymbolở trên) để kết quả tất định — nếu không, hai lần chạy có thể ra hai thứ tự khác nhau. - Muốn "Top N" công bằng, đồng hạng thì cùng được vào:
RANK()(hoặcDENSE_RANK()), rồi lọchang <= N. VớiRANK, Top 3 có thể ra hơn 3 dòng nếu đồng hạng ở vị trí 3. - Muốn biết mã đứng thứ k khác biệt về giá trị (thứ hai cao nhất, thứ ba cao nhất...):
DENSE_RANK().
Ví dụ DENSE_RANK: ngày 25/09, "mức khối lượng cao thứ tư" là bao nhiêu, mã nào đạt mức đó? Vì DENSE_RANK không nhảy cóc nên ba mức cao hơn (NVB, CEO, VCB) chiếm hạng 1–3, và hai mã đồng khối lượng IDC và PVS cùng đứng hạng 4 (nếu dùng RANK thì họ đứng hạng 4 nhưng mức "thứ năm" sẽ không tồn tại):
SELECT symbol, volume
FROM (
SELECT symbol, volume,
DENSE_RANK() OVER (ORDER BY volume DESC) AS d
FROM MarketPrices
WHERE trade_date = '2026-09-25'
) t
WHERE d = 4
ORDER BY symbol;IDC 7000000
PVS 7000000Còn khi cần đúng một dòng mỗi nhóm (ví dụ phiên có giá đóng cửa cao nhất của mỗi mã; đồng giá thì lấy phiên sớm hơn), dùng ROW_NUMBER() với khóa phụ rồi giữ rn = 1:
SELECT symbol, trade_date, close_price
FROM (
SELECT symbol, trade_date, close_price,
ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY close_price DESC, trade_date) AS rn
FROM MarketPrices
) t
WHERE rn = 1
ORDER BY symbol;CEO 2026-09-14 22000.00
FPT 2026-09-17 126000.00
HPG 2026-09-17 27540.00
IDC 2026-09-24 54500.00
MBS 2026-09-25 26880.00
...3.2. Xếp hạng theo từng nhóm (PARTITION BY) và Top N mỗi nhóm
Thêm PARTITION BY trade_date để xếp hạng riêng cho từng ngày. Vì không thể lọc hạng trong WHERE, ta bọc vào truy vấn con:
SELECT trade_date, symbol, volume, hang
FROM (
SELECT trade_date, symbol, volume,
RANK() OVER (PARTITION BY trade_date ORDER BY volume DESC) AS hang
FROM MarketPrices
WHERE trade_date BETWEEN '2026-09-22' AND '2026-09-24'
) t
WHERE hang <= 2
ORDER BY trade_date, hang, symbol;2026-09-22 VCS 8600000 1
2026-09-22 NVB 8000000 2
2026-09-23 PVS 8400000 1
2026-09-23 VCS 8400000 1
2026-09-24 MBS 8700000 1
2026-09-24 FPT 8400000 2Ngày 23/09 có hai mã đồng hạng 1 và ta vẫn giữ cả hai, đồng thời không có hạng 2 — đúng tính chất của RANK. Đây là mẫu "Top N theo nhóm" mà bài 3.1 sẽ dùng.
Ví dụ nghiệp vụ: Ban lãnh đạo muốn xem mỗi ngày "3 mã có giá trị giao dịch lớn nhất". Đó là Top N theo nhóm với nhóm = ngày giao dịch:
RANK() OVER (PARTITION BY trade_date ORDER BY gia_tri DESC)rồi lọchang <= 3ở truy vấn ngoài.
3.3. Phân loại theo thứ hạng: CASE và NTILE
Sau khi có hạng, ta gắn nhãn bằng CASE WHEN (buổi 4 sẽ học kỹ hơn):
CASE WHEN hang <= 3 THEN 'TOP3'
WHEN hang <= 6 THEN 'KHA'
ELSE 'YEU' END AS nhomNếu chỉ cần chia đều thành k nhóm, hàm NTILE(k) làm luôn (mã ở nhóm 1 là nhóm đầu). Với 12 mã chia 3 nhóm, mỗi nhóm 4 mã (nhớ thêm khóa phụ để tất định):
SELECT symbol, volume, NTILE(3) OVER (ORDER BY volume DESC, symbol) AS nhom
FROM MarketPrices WHERE trade_date = '2026-09-24'
ORDER BY volume DESC, symbol;MBS 8700000 1
FPT 8400000 1
SHS 8100000 1
VNM 8100000 1
IDC 7300000 2
NVB 4400000 2
...Lưu ý NTILE cắt cứng theo số dòng: SHS và VNM đồng khối lượng nhưng nếu nằm ở ranh giới nhóm vẫn có thể bị tách ra hai nhóm. Khi cần công bằng với đồng hạng, hãy dùng RANK + CASE.
Sai lầm thường gặp: dùng ROW_NUMBER() để "Top N" khi đề nói "đồng hạng cùng vào" (sẽ bỏ sót); quên ORDER BY trong OVER của hàm xếp hạng (lỗi cú pháp); quên khóa phụ khiến kết quả không ổn định giữa các lần chạy.
Thực hành ngay: bài 3.1
Vừa học xong tổng hợp OVER và ba hàm xếp hạng, hãy làm nhóm bài 3.1 để đánh số phiên, so sánh ROW_NUMBER/RANK/DENSE_RANK, tính tỷ trọng, lấy Top 3 mỗi ngày và phân loại cổ phiếu theo hạng.
4. LAG và LEAD — phân tích biến động
LAG lấy giá trị của dòng đứng trước, LEAD lấy giá trị của dòng đứng sau trong cùng phân vùng, theo thứ tự ORDER BY của cửa sổ.
LAG (biểu_thức [, n [, giá_trị_mặc_định]]) OVER (PARTITION BY ... ORDER BY ...)
LEAD(biểu_thức [, n [, giá_trị_mặc_định]]) OVER (PARTITION BY ... ORDER BY ...)| Tham số | Ý nghĩa | Mặc định |
|---|---|---|
biểu_thức |
Cột/biểu thức cần lấy từ dòng trước/sau | (bắt buộc) |
n |
Lùi/tiến bao nhiêu dòng | 1 |
giá_trị_mặc_định |
Giá trị dùng khi không có dòng trước/sau | NULL |
Ví dụ với VNM, 5 phiên cuối:
SELECT trade_date, close_price,
LAG(close_price) OVER (ORDER BY trade_date) AS prev_close,
LEAD(close_price) OVER (ORDER BY trade_date) AS next_close
FROM MarketPrices
WHERE symbol = 'VNM' AND trade_date >= '2026-09-21'
ORDER BY trade_date;2026-09-21 66640.00 NULL 68000.00
2026-09-22 68000.00 66640.00 66640.00
2026-09-23 66640.00 68000.00 67320.00
2026-09-24 67320.00 66640.00 65960.00
2026-09-25 65960.00 67320.00 NULLPhiên đầu không có phiên trước (NULL), phiên cuối không có phiên sau (NULL). Nếu dùng tham số thứ ba, LAG(close_price, 2, 0) lùi 2 phiên và thay NULL bằng 0:
SELECT trade_date, close_price,
LAG(close_price, 2, 0) OVER (ORDER BY trade_date) AS gia_2_phien_truoc
FROM MarketPrices
WHERE symbol = 'VNM' AND trade_date >= '2026-09-21'
ORDER BY trade_date;2026-09-21 66640.00 0.00
2026-09-22 68000.00 0.00
2026-09-23 66640.00 66640.00
2026-09-24 67320.00 68000.00
2026-09-25 65960.00 66640.004.1. Mức tăng/giảm và % biến động so với phiên trước
Công thức chuẩn trong ngành:
- Mức thay đổi =
close - prev_close - % thay đổi =
100 * (close - prev_close) / prev_close
4.2. Bẫy lớn: lọc ngày trước khi gọi LAG
Nhiều bạn muốn xem % thay đổi của riêng phiên 25/09 và viết ngay WHERE trade_date = '2026-09-25'. Vì WHERE chạy trước hàm cửa sổ, chỉ còn đúng một dòng trong cửa sổ và LAG trả về NULL. Ví dụ dưới đây lọc từ 24/09 trở đi nên phiên 24/09 mất phiên trước:
-- SAI: prev_close của phiên đầu luôn NULL vì phiên 23/09 đã bị WHERE loại
SELECT trade_date, close_price,
LAG(close_price) OVER (ORDER BY trade_date) AS prev_close
FROM MarketPrices
WHERE symbol = 'VNM' AND trade_date >= '2026-09-24'
ORDER BY trade_date;2026-09-24 67320.00 NULL
2026-09-25 65960.00 67320.00Cách đúng: tính LAG trên cả chuỗi thời gian trong truy vấn con, rồi lọc ngày ở truy vấn ngoài:
SELECT trade_date, close_price, prev_close,
CAST(ROUND(100.0 * (close_price - prev_close) / prev_close, 2) AS DECIMAL(18,2)) AS pct
FROM (
SELECT trade_date, close_price,
LAG(close_price) OVER (ORDER BY trade_date) AS prev_close
FROM MarketPrices
WHERE symbol = 'VNM'
) t
WHERE trade_date >= '2026-09-23'
ORDER BY trade_date;2026-09-23 66640.00 68000.00 -2.00
2026-09-24 67320.00 66640.00 1.02
2026-09-25 65960.00 67320.00 -2.02Cảnh báo:
WHERElọc ngày trướcLAG= mất phiên trước. Luôn tính hàm cửa sổ trên tập dữ liệu đầy đủ ở truy vấn con, rồi lọc ở truy vấn ngoài.
4.3. Phân vùng theo mã khi có nhiều mã
Với nhiều mã, bắt buộc PARTITION BY symbol — nếu không, phiên đầu của mã B sẽ "lấy giá phiên trước" là phiên cuối của mã A, một lỗi logic rất khó thấy. Cũng nhớ rằng "phiên trước" là dòng đứng trước theo thứ tự, không phải ngày liền kề: nếu mã nghỉ giao dịch, phiên trước là phiên có dữ liệu gần nhất.
Ứng dụng thường gặp: gắn nhãn phiên tăng/giảm/đi ngang bằng CASE trên kết quả LAG (phiên đầu không có phiên trước nên nhãn là NULL):
SELECT trade_date, close_price, prev_close,
CASE WHEN close_price > prev_close THEN 'TANG'
WHEN close_price < prev_close THEN 'GIAM'
WHEN close_price = prev_close THEN 'DUNG' END AS xu_huong
FROM (
SELECT trade_date, close_price,
LAG(close_price) OVER (PARTITION BY symbol ORDER BY trade_date) AS prev_close
FROM MarketPrices
WHERE symbol = 'FPT'
) t
WHERE trade_date <= '2026-09-17'
ORDER BY trade_date;2026-09-14 123600.00 NULL NULL
2026-09-15 120000.00 123600.00 GIAM
2026-09-16 122400.00 120000.00 TANG
2026-09-17 126000.00 122400.00 TANGSai lầm thường gặp: chia cho prev_close = 0 hoặc NULL (kết quả NULL/lỗi chia 0 — hãy lọc prev_close IS NOT NULL), so sánh close_price > prev_close với NULL (luôn không đúng), quên PARTITION BY.
Mẹo: Một số hàm "họ hàng" cùng cú pháp:
FIRST_VALUE,LAST_VALUE(giá trị dòng đầu/cuối của khung) — sẽ học ở mục 5.5 vì chúng phụ thuộc vào khung cửa sổ.
Thực hành ngay: bài 3.2
Vừa học xong LAG/LEAD và bẫy "lọc ngày trước khi tính", hãy làm nhóm bài 3.2 để tính giá phiên trước/sau của HPG, % biến động, đếm phiên tăng/giảm và tìm phiên giảm sâu nhất của từng mã.
5. Window Frame và Moving Average
5.1. Khung cửa sổ
Khi có ORDER BY, ta còn kiểm soát được những dòng nào của phân vùng được đưa vào phép tính cho dòng hiện tại bằng khung:
ROWS BETWEEN <đầu> AND <cuối>Với <đầu>/<cuối> là một trong:
| Từ khóa | Nghĩa |
|---|---|
UNBOUNDED PRECEDING |
Từ dòng đầu tiên của phân vùng |
n PRECEDING |
n dòng trước dòng hiện tại |
CURRENT ROW |
Dòng hiện tại |
n FOLLOWING |
n dòng sau dòng hiện tại |
UNBOUNDED FOLLOWING |
Tới dòng cuối của phân vùng |
Hình dưới minh họa khung ROWS BETWEEN 2 PRECEDING AND CURRENT ROW — chính là khung của đường trung bình động MA3.
| trade_date | close | Khung của dòng 09-22 |
|---|---|---|
| 09-19 | 100 | |
| 09-20 | 102 | 2 PRECEDING |
| 09-21 | 105 | 1 PRECEDING |
| 09-22dòng hiện tại | 104 | CURRENT ROW → MA3 = 103.67 |
| 09-23 | 108 |
Với dòng hiện tại 09-22, khung gồm 2 dòng trước và chính nó. MA3 = (102 + 105 + 104) / 3 = 103.67. Khi trượt xuống dòng kế tiếp, khung dịch theo.
Cách đọc hình: với mỗi dòng hiện tại (được tô đậm), khung "trượt" bao gồm nó và 2 dòng đứng ngay trước; khi dòng hiện tại dịch xuống một phiên, khung cũng dịch theo. Ở hai dòng đầu, khung bị "cắt" ở biên phân vùng nên chỉ có 1–2 dòng.
Ví dụ nhanh trên mã PVI, các phiên đến 18/09 — cột luy_ke (từ đầu đến nay) và tong_3_phien (3 phiên gần nhất):
SELECT trade_date, volume,
SUM(volume) OVER (ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS luy_ke,
SUM(volume) OVER (ORDER BY trade_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS tong_3_phien
FROM MarketPrices
WHERE symbol = 'PVI' AND trade_date <= '2026-09-18'
ORDER BY trade_date;2026-09-14 1100000 1100000 1100000
2026-09-15 6500000 7600000 7600000
2026-09-16 800000 8400000 8400000
2026-09-17 500000 8900000 7800000
2026-09-18 6000000 14900000 7300000Chú ý dòng đầu: chưa đủ 3 phiên nên tong_3_phien chỉ cộng những phiên hiện có (khung bị "cắt" ở biên phân vùng).
5.2. Mặc định của khung — và vì sao nên viết rõ ROWS
Nếu có ORDER BY mà không viết khung, chuẩn SQL dùng RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Với RANGE, các dòng có cùng giá trị sắp xếp (đồng hạng) được coi là cùng một "điểm" và cùng được cộng vào. Trên dữ liệu ngày 25/09 có đồng khối lượng:
SELECT symbol, volume,
SUM(volume) OVER (ORDER BY volume DESC) AS mac_dinh
FROM MarketPrices
WHERE trade_date = '2026-09-25' AND symbol IN ('NVB','IDC','PVS','VNM','MBS','SHS')
ORDER BY volume DESC, symbol;NVB 8400000 8400000
IDC 7000000 22400000
PVS 7000000 22400000
VNM 5400000 27800000
MBS 5000000 37800000
SHS 5000000 37800000Ở kết quả trên, IDC và PVS cùng khối lượng nên cùng nhận tổng 22.400.000; MBS và SHS cũng vậy. Còn ROWS đếm từng dòng vật lý nên cho tổng lũy kế "đúng nghĩa từng dòng" — nhưng thứ tự giữa hai dòng đồng giá trị phải được chốt bằng khóa phụ, nếu không SQL Server và MySQL có thể sắp IDC/PVS theo thứ tự khác nhau và cho ra hai kết quả lũy kế khác nhau ở hai dòng này. Khuyến nghị cho khóa học:
Lưu ý: Luôn viết tường minh
ROWS BETWEEN ... AND ...và đặtORDER BYcó khóa duy nhất (ví dụORDER BY trade_datekhi đãPARTITION BY symbol— vì(symbol, trade_date)là khóa chính).
Trên SQL Server, ROWS còn nhanh hơn RANGE đáng kể với bảng lớn. Hai hệ đều hỗ trợ ROWS và RANGE trong buổi này.
5.3. Giá trị lũy kế
Tổng lũy kế = tổng từ đầu đến dòng hiện tại: SUM(x) OVER (PARTITION BY k ORDER BY t ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). Ứng dụng: dòng tiền vào/ra tích lũy, khối lượng giao dịch tích lũy trong kỳ, giá trị danh mục theo thời gian.
Có thể kết hợp GROUP BY rồi hàm cửa sổ trên kết quả đã gộp: hàm cửa sổ chạy sau GROUP BY, nên được phép viết hàm tổng hợp bên trong hàm cửa sổ. Ví dụ giá trị giao dịch từng ngày trên bảng Trades và lũy kế toàn công ty:
SELECT trade_date,
SUM(quantity * price) AS gia_tri_ngay,
SUM(SUM(quantity * price)) OVER (ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS luy_ke
FROM Trades
WHERE trade_date >= '2026-09-14'
GROUP BY trade_date
ORDER BY trade_date;2026-09-14 87800000.00 87800000.00
2026-09-15 473000000.00 560800000.00
2026-09-16 377250000.00 938050000.00
2026-09-17 189000000.00 1127050000.00
...Ví dụ nghiệp vụ: Bộ phận thanh toán theo dõi "giá trị giao dịch lũy kế trong tuần" của từng mã để cảnh báo mã tập trung dòng tiền:
SUM(gia_tri) OVER (PARTITION BY symbol ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW).
5.4. Đường trung bình động (Moving Average)
Trung bình động N phiên (MAN) làm mượt dao động ngắn hạn để nhìn xu hướng: trung bình giá của phiên hiện tại và N−1 phiên liền trước.
AVG(close_price) OVER (ORDER BY trade_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) -- MA3
AVG(close_price) OVER (ORDER BY trade_date ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) -- MA5| Đường | Khung | Số dòng trong khung | Nhạy với biến động |
|---|---|---|---|
| MA3 | 2 PRECEDING AND CURRENT ROW |
3 | Cao (bám sát giá) |
| MA5 | 4 PRECEDING AND CURRENT ROW |
5 | Thấp hơn (mượt hơn) |
Ví dụ với VCB: cột so_phien cho biết khung đang có bao nhiêu dòng, để biết MA ở đầu chuỗi chưa "đủ" N phiên:
SELECT trade_date, close_price,
COUNT(*) OVER (ORDER BY trade_date ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) AS so_phien,
CAST(AVG(close_price) OVER (ORDER BY trade_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS DECIMAL(18,2)) AS ma3,
CAST(AVG(close_price) OVER (ORDER BY trade_date ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) AS DECIMAL(18,2)) AS ma5
FROM MarketPrices
WHERE symbol = 'VCB' AND trade_date <= '2026-09-18'
ORDER BY trade_date;2026-09-14 93840.00 1 93840.00 93840.00
2026-09-15 91080.00 2 92460.00 92460.00
2026-09-16 92000.00 3 92306.67 92306.67
2026-09-17 90160.00 4 91080.00 91770.00
2026-09-18 92920.00 5 91693.33 92000.00Khung cũng có thể có tâm, dùng cả phiên sau (1 PRECEDING đến 1 FOLLOWING) — hữu ích để làm mượt dữ liệu lịch sử khi phân tích, nhưng không dùng được để "dự báo" vì nhìn trước tương lai:
SELECT trade_date, close_price,
CAST(AVG(close_price) OVER (ORDER BY trade_date ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS DECIMAL(18,2)) AS tb_co_tam
FROM MarketPrices
WHERE symbol = 'VCB' AND trade_date <= '2026-09-18'
ORDER BY trade_date;2026-09-14 93840.00 92460.00
2026-09-15 91080.00 92306.67
2026-09-16 92000.00 91080.00
2026-09-17 90160.00 91693.33
2026-09-18 92920.00 91540.00Đọc tín hiệu: nếu MA ngắn hạn (MA3) cắt lên trên MA dài hạn (MA5) thường được xem là tín hiệu tăng, cắt xuống là tín hiệu giảm — đây chính là ý tưởng của bài 3.3.
Sai lầm thường gặp:
- Quên
PARTITION BY symbolkhi tính MA cho nhiều mã: MA của mã này "dính" giá mã khác. - Nhầm
ROWS BETWEEN 3 PRECEDINGvới MA3:3 PRECEDING AND CURRENT ROWgồm 4 dòng; MA3 phải là2 PRECEDING. - Tin rằng MA ở các phiên đầu chưa đủ N phiên là "đúng" — thực tế đó là trung bình của các phiên hiện có; nếu cần chỉ tính khi đủ dữ liệu, thêm
COUNT(*) OVER (...)hoặcROW_NUMBER()rồi lọc.
5.5. FIRST_VALUE và LAST_VALUE — bẫy của khung mặc định
FIRST_VALUE(x) trả giá trị của dòng đầu khung, LAST_VALUE(x) trả giá trị của dòng cuối khung. Bẫy kinh điển: với khung mặc định (kết thúc ở CURRENT ROW), LAST_VALUE chỉ trả về chính dòng hiện tại. Muốn lấy giá cuối kỳ phải mở rộng khung tới UNBOUNDED FOLLOWING:
SELECT trade_date, close_price,
FIRST_VALUE(close_price) OVER (ORDER BY trade_date) AS gia_dau_ky,
LAST_VALUE(close_price) OVER (ORDER BY trade_date) AS last_mac_dinh,
LAST_VALUE(close_price) OVER (ORDER BY trade_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS gia_cuoi_ky
FROM MarketPrices
WHERE symbol = 'FPT' AND trade_date <= '2026-09-17'
ORDER BY trade_date;2026-09-14 123600.00 123600.00 123600.00 126000.00
2026-09-15 120000.00 123600.00 120000.00 126000.00
2026-09-16 122400.00 123600.00 122400.00 126000.00
2026-09-17 126000.00 123600.00 126000.00 126000.00Cột last_mac_dinh chỉ lặp lại close_price của từng dòng — kết quả "sai" so với mong đợi; cột gia_cuoi_ky mới đúng là giá của phiên cuối trong tập đang xét.
Mẹo: Nhờ
FIRST_VALUEta tính được "hiệu suất kể từ đầu kỳ" =100.0 * (close_price - gia_dau_ky) / gia_dau_kymà không cần tự nối bảng.
Thực hành ngay: bài 3.3
Vừa học xong khung cửa sổ, lũy kế và trung bình động, hãy làm nhóm bài 3.3 để tính giá trị giao dịch lũy kế của từng mã, đường MA3/MA5 của FPT và nhận diện tín hiệu xu hướng.
6. Tổng kết và Q&A
Tóm tắt
| Chủ đề | Cú pháp chính | Ghi nhớ |
|---|---|---|
| Hàm cửa sổ | f(...) OVER (PARTITION BY ... ORDER BY ...) |
Không gộp dòng, chạy sau WHERE/GROUP BY |
| Tổng hợp | SUM/AVG/MIN/MAX/COUNT ... OVER |
OVER () = cả tập kết quả; AVG số nguyên khác nhau giữa hai hệ |
| Xếp hạng | ROW_NUMBER, RANK, DENSE_RANK, NTILE |
Đồng hạng: RANK nhảy cóc, DENSE_RANK liền mạch; thêm khóa phụ |
| Top N theo nhóm | Truy vấn con + RANK() OVER (PARTITION BY ...) + WHERE hang <= N |
Không lọc hạng trực tiếp trong WHERE |
| Dịch dòng | LAG, LEAD |
Cần ORDER BY; đầu/cuối là NULL; tính trước khi lọc ngày |
| Khung | ROWS BETWEEN ... AND ... |
Viết rõ ROWS; MA N phiên = N-1 PRECEDING |
| Đầu/cuối khung | FIRST_VALUE, LAST_VALUE |
LAST_VALUE cần khung tới UNBOUNDED FOLLOWING |
Q&A thường gặp
- Có thể dùng hai hàm cửa sổ với hai
OVERkhác nhau trong cùng mộtSELECTkhông? Có, như MA3 và MA5 ở trên. - Hàm cửa sổ có chậm không? Thường nhanh hơn tự nối bảng. Trên bảng lớn nên có chỉ mục theo
(cột PARTITION BY, cột ORDER BY). - Có dùng
ORDER BYcuối câu lệnh khác vớiORDER BYtrongOVERkhông? Có: cái trongOVERchỉ định thứ tự để tính, cái cuối câu quyết định thứ tự hiển thị; hai cái độc lập. - Khi nào dùng
GROUP BY, khi nào dùng hàm cửa sổ? Muốn báo cáo tổng hợp (mỗi nhóm một dòng) dùngGROUP BY; muốn giữ chi tiết cạnh số liệu nhóm, xếp hạng, so sánh phiên trước/sau, lũy kế: hàm cửa sổ.
Sai lầm thường gặp
| Sai lầm | Hậu quả | Cách đúng |
|---|---|---|
Lọc ngày trong WHERE rồi mới gọi LAG/SUM OVER |
Mất phiên trước, tổng chỉ tính trên phần đã lọc | Tính hàm cửa sổ ở truy vấn con, lọc ở truy vấn ngoài |
Đặt hàm cửa sổ trong WHERE |
Lỗi cú pháp | Bọc thành truy vấn con rồi lọc cột hạng |
Quên PARTITION BY symbol khi có nhiều mã |
Giá/khung "dính" sang mã khác | Luôn chia phân vùng theo mã |
ROW_NUMBER cho Top N có đồng hạng |
Bỏ sót mã đồng hạng | Dùng RANK() hoặc DENSE_RANK() |
ORDER BY không có khóa duy nhất |
Kết quả khác nhau giữa lần chạy/giữa hai hệ | Thêm khóa phụ (symbol, trade_date) |
3 PRECEDING cho MA3 |
Trung bình 4 phiên | MA3 = 2 PRECEDING AND CURRENT ROW |
LAST_VALUE với khung mặc định |
Trả lại chính dòng hiện tại | Khung ... AND UNBOUNDED FOLLOWING |
AVG số nguyên không ép kiểu |
SQL Server cắt phần thập phân | Nhân 1.0 rồi CAST(ROUND(...) AS DECIMAL(18,2)) |
Bài tập thực hành của buổi
Các bài đều chấm tự động trên cả SQL Server và MySQL và dùng ngày cố định 2026-09-25 làm "hôm nay". Nếu bạn chưa làm hết các nhóm bài đã gắn trong bài giảng, đây là danh sách đầy đủ của buổi.
Gợi ý mở rộng
- Thử
PERCENT_RANK()vàCUME_DIST()để xếp hạng theo phần trăm; thửLAST_VALUEvới khungROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGcho nhiều mã cùng lúc. - Tự tính độ biến động (volatility) của từng mã: độ lệch chuẩn của % thay đổi 5 phiên gần nhất (
STDEVtrong T-SQL,STDDEV_SAMPtrong MySQL — hai tên hàm khác nhau). - Tính chỉ báo giao cắt vàng/giao cắt tử thần: tìm phiên mà MA3 vượt MA5 lần đầu (kết hợp
LAGtrên chính MA3 - MA5). - Chuẩn bị cho Buổi 4: các bài này dùng
CASE,CAST,ROUND— buổi tới sẽ học hệ thống các hàm xử lý chuỗi, ngày giờ, toán học và điều kiện.