Giá lịch sử cổ phiếu Việt Nam bằng Python (EOD)
Dùng finlens lấy OHLCV cuối ngày, sổ lệnh đặt, khối lượng chủ động, chênh lệch phái sinh và dòng tiền nhóm nhà đầu tư của chứng khoán Việt Nam về pandas DataFrame.
Nhóm client.eod trả về giá theo phiên, sổ lệnh đặt, khối lượng khớp
chủ động, chênh lệch phái sinh với chỉ số cơ sở và dòng tiền theo nhóm
nhà đầu tư dưới dạng pandas.DataFrame. Năm namespace con chia theo loại tài
sản, và đó không phải cách đặt tên cho gọn: mỗi loại tài sản một đơn vị, nên một
lời gọi không bao giờ trả về bảng trộn nghìn VND với điểm chỉ số.
import finlens
client = finlens.client() # đọc khoá từ biến FINLENS_API_KEY
df = client.eod.stock.ohlcv("HPG,VCB", start="2026-01-01")
df.attrs["finlens"]["units"]["close"]
# 'kVND'Đổi tên và đổi giá trị so với 0.1.x
| Bản 0.1.x | Bản 1.x |
|---|---|
client.eod.market | client.eod.index |
Cột Date, Open, Close, Volume | date, open, close, volume — snake_case, viết thường |
interval="1M" | interval="1mo" — cả 1M lẫn 1m đều bị từ chối |
Tham số symbol (số ít) | symbols; ở cấp ngành là icb |
Ngành nhận tên tiếng Việt "Ngân hàng" | Ngành nhận mã ICB dạng chuỗi: "8600" |
ohlcv("HPG", start, end, "1W") | Mọi tham số sau mã là keyword-only |
Danh sách đầy đủ ở Changelog.
Năm namespace, năm đơn vị
| Namespace | Dữ liệu | Cột giá | Khối lượng | Phương thức |
|---|---|---|---|---|
client.eod.stock | Cổ phiếu và ETF | nghìn VND | cổ phiếu | ohlcv() · supply_demand() · active_volume() · investor.flow() · investor.breakdown() |
client.eod.index | Chỉ số | điểm chỉ số | cổ phiếu | ohlcv() · investor.flow() |
client.eod.derivative | Hợp đồng phái sinh | điểm chỉ số | hợp đồng | ohlcv() · active_volume() · basis() · investor.flow() · investor.breakdown() |
client.eod.warrant | Chứng quyền có bảo đảm | VND thô | chứng quyền | ohlcv() |
client.eod.sector | Ngành ICB | điểm chỉ số | cổ phiếu | ohlcv() · investor.flow() · investor.breakdown() |
Việc tách namespace chính là thứ bảo đảm đơn vị: client.eod.stock và
client.eod.index không bao giờ trả về cùng một bảng, nên pd.concat hai kết
quả của cùng một namespace luôn an toàn về đơn vị.
Ba ô trống trong bảng trên là AttributeError, không phải bảng rỗng
Bảng chỉ liệt kê những phương thức thực sự tồn tại, và chỗ nào không liệt kê
thì gọi vào sẽ ném AttributeError ngay tại chỗ:
client.eod.warrant.supply_demand("CHPG2628") # AttributeError
client.eod.derivative.supply_demand("VN30F1M") # AttributeError
client.eod.index.active_volume("VNINDEX") # AttributeError
client.eod.stock.basis("HPG") # AttributeErrorSổ lệnh đặt chỉ tồn tại cho cổ phiếu trong dữ liệu; hỏi nó cho chỉ số, ngành,
chứng quyền hay phái sinh cho ra 0 dòng. Còn basis() cần một chỉ số cơ sở
để trừ, và chỉ hợp đồng tương lai mới có thứ đó.
Một sự vắng mặt trong dữ liệu phải là một sự vắng mặt trong API — cùng lý do
client.intraday.index không có net_active_value(). Phát ra một phương thức
luôn trả bảng rỗng là bắt bạn tự đoán xem đó là "không có giao dịch" hay "không
có dữ liệu".
client.eod.sector có hai phương thức, hai thứ nguyên
client.eod.sector.ohlcv() trả về chỉ số ngành, tính bằng điểm chỉ số.
client.eod.sector.investor.flow() trả về dòng tiền, tính bằng VND thô.
Hai bảng cùng khoá theo icb, cùng nằm trong một namespace, nên cám dỗ chia
bảng này cho bảng kia là có thật — và làm vậy là chia hai đại lượng không cùng
thứ nguyên. Không có exception nào được ném.
Luật chung cho mọi hàm trong client.eod
Bảy điều dưới đây đúng cho mọi phương thức của nhóm EOD, nên phần tham chiếu bên dưới không nhắc lại chúng.
1. Chỉ tham số đầu tiên là positional. Đó là symbols (hoặc icb ở cấp
ngành). Mọi tham số còn lại là keyword-only:
client.eod.stock.ohlcv("HPG", start="2026-01-01", interval="1w") # đúng
client.eod.stock.ohlcv("HPG", "2026-01-01", "1w") # TypeErrorĐây là hàng rào cho một lớp bug đã xảy ra thật ở 0.1.x, nơi thứ tự tham số khiến một chuỗi interval bị gán nhầm vào một cờ boolean — không lỗi, và dữ liệu vẫn về.
2. Một mã, nhiều mã, hay một danh sách đều được. Ba cách viết sau tương đương, và nhiều mã đi trong một lời gọi mạng chứ không phải nhiều lời gọi tuần tự:
client.eod.stock.ohlcv("HPG")
client.eod.stock.ohlcv("HPG,VCB")
client.eod.stock.ohlcv(["HPG", "VCB"])3. interval viết thường, và việc gộp kỳ chạy ở máy chủ. Sáu giá trị:
1d · 1w · 1mo · 3mo · 6mo · 1y. interval="1w" trả về thanh tuần đã
gộp sẵn, không phải thanh ngày để bạn tự gộp.
Một ngoại lệ duy nhất trong cả nhóm EOD: basis() không có tham số
interval — lý do nằm ở mục của chính nó bên dưới.
Không có 1M và không có 1m
Trong từ vựng cũ 1M là một tháng còn 1m là một phút — phân biệt duy
nhất bằng chữ hoa, trong khi hai giá trị đó lệch nhau 43.200 lần. Cả hai đều
nhận một lỗi rõ ràng thay vì bị đoán ý:
Interval '1M' nhập nhằng: có thể là một phút hoặc một tháng.
Dùng '1min' cho một phút, hoặc '1mo' cho một tháng.Với dữ liệu cuối ngày, giá trị bạn cần là 1mo. Lỗi là
finlens.InvalidIntervalError, mã FL_VALIDATION_INTERVAL_AMBIGUOUS.
4. Một kỳ đã gộp được dán nhãn bằng phiên giao dịch CUỐI trong kỳ. Với
interval="1d", date là ngày của chính phiên đó. Với năm giá trị còn lại —
1w, 1mo, 3mo, 6mo, 1y — date là phiên giao dịch cuối cùng thực sự
có dữ liệu trong kỳ. Không phải ngày đầu kỳ, và cũng không phải ngày cuối
theo lịch.
df = client.eod.stock.ohlcv("HPG", start="2025-01-01", end="2025-03-31",
interval="1mo")
df["date"].dt.strftime("%Y-%m-%d").tolist()
# ['2025-01-24', '2025-02-28', '2025-03-31']Tháng 1/2025 ra 2025-01-24 chứ không phải 2025-01-01, và cũng không phải
2025-01-31: thị trường nghỉ Tết từ ngày 25, nên phiên cuối cùng có dữ liệu là
ngày 24. Đó đúng là phiên mà close của dòng ấy lấy giá — ngày và giá kể cùng
một chuyện. Lấy ngày cuối theo lịch thì chúng kể hai chuyện khác nhau, và độ
lệch lớn nhất rơi đúng vào những kỳ có kỳ nghỉ dài.
Một dòng đã gộp là một KỲ, không phải một phiên
date là phiên cuối kỳ, nhưng open là giá mở cửa của phiên đầu kỳ.
Hai cột trên cùng một dòng trỏ tới hai phiên khác nhau, và điều đó là đúng: dòng
ấy mô tả cả kỳ, chỉ được dán nhãn bằng phiên cuối.
Nên đừng đọc một dòng 1mo như thể nó là phiên ngày 24/01. open của nó không
phải giá mở cửa hôm 24/01, và volume của nó không phải khối lượng riêng phiên
ấy — đó là tổng cả tháng.
Hai hệ quả đáng dùng:
- Kỳ đang dở không bao giờ mang một mốc ở tương lai. Kỳ
1mocủa tháng này, hay kỳ1ycủa năm nay, được dán nhãn bằng phiên gần nhất đã có dữ liệu chứ không phải ngày cuối tháng hay 31/12. Nhờ vậydf[df["date"] <= hom_nay]không âm thầm đánh rơi kỳ hiện tại — một cái bẫy mà nhãn theo lịch chắc chắn giăng ra. - Muốn nhãn theo kỳ thì tự đổi, đừng suy ra bằng phép trừ. Khoảng cách giữa
datevà mốc đầu kỳ không cố định, nêndf["date"] - pd.offsets.MonthBegin()là sai. Dùngdf["date"].dt.to_period("M")— nó lấy kỳ chứa ngày đó.
5. refresh=False — bỏ qua bản đã lưu trong cache. Dùng khi bạn biết dữ
liệu vừa đổi. Đừng đặt mặc định là True: nó vô hiệu hoá chính thứ giữ cho một
lời gọi 50 mã rẻ như một lời gọi.
6. on_error="warn" — một mã hỏng không làm mất các mã còn lại.
| Giá trị | Hành vi |
|---|---|
"warn" (mặc định) | Giữ phần lấy được, phát finlens.PartialDataWarning, ghi mã hỏng vào df.attrs["finlens"]["failed"] |
"raise" | Ném finlens.PartialFetchError — ngoại lệ này mang theo phần đã lấy được ở .data |
"ignore" | Giữ phần lấy được, không cảnh báo |
df = client.eod.stock.ohlcv("HPG,KHONGTONTAI")
df.attrs["finlens"]["partial"] # True
df.attrs["finlens"]["failed"] # {'KHONGTONTAI': {'code': ..., 'message': ...}}Chi tiết ở Xử lý lỗi.
7. Kết quả rỗng vẫn đủ cột và đúng kiểu. Ngày thị trường nghỉ, hay khoảng
thời gian không có dữ liệu, bạn nhận về một DataFrame 0 dòng nhưng đủ cột —
nên df["close"] không ném KeyError và bạn không cần viết if not df.empty
quanh mọi lời gọi.
ohlcv() — giá theo phiên
Có mặt ở bốn namespace: stock, index, derivative, warrant. Bản cấp ngành
nhận mã ICB nên có chữ ký riêng — xem mục Chỉ số ngành ICB bên dưới.
ohlcv(symbols, *, start=None, end=None, interval="1d",
adjusted=True, refresh=False, on_error="warn")| Tham số | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
symbols | SymbolsArg | — | "HPG", "HPG,VCB", hoặc ["HPG", "VCB"]. Mọi mã phải cùng loại tài sản với namespace đang gọi. Positional. |
start | DateArg | None | Ngày bắt đầu. Mặc định 1 năm trước end. |
end | DateArg | None | Ngày kết thúc. Mặc định hôm nay. |
interval | EodInterval | "1d" | 1d · 1w · 1mo · 3mo · 6mo · 1y. |
adjusted | bool | True | Giá đã điều chỉnh quyền hay giá khớp lệnh thật. Xem mục dưới. |
refresh | bool | False | Bỏ qua cache. |
on_error | OnError | "warn" | warn · raise · ignore. |
Cột trả về
| Cột | Kiểu | Có thể NaN | Đơn vị | Mô tả |
|---|---|---|---|---|
symbol | string | không | — | Mã chứng khoán, viết hoa. |
date | datetime64[ns] | không | — | Ngày giao dịch, không mang múi giờ. Với interval có gộp kỳ, đây là phiên cuối trong kỳ. |
open | float64 | có | theo namespace | Giá mở cửa kỳ — giá mở cửa của phiên đầu kỳ, không phải phiên mà date trỏ tới. |
high | float64 | có | theo namespace | Giá cao nhất trong kỳ. |
low | float64 | có | theo namespace | Giá thấp nhất trong kỳ. |
close | float64 | có | theo namespace | Giá đóng cửa kỳ — giá đóng cửa của đúng phiên mà date trỏ tới. |
volume | float64 | có | cổ phiếu / hợp đồng | Khối lượng khớp, cộng dồn trong kỳ. |
Bảng được sắp theo symbol rồi date, chỉ mục là RangeIndex mặc định.
Hai chi tiết hay làm người đọc vấp:
datecố ý không mang múi giờ. Một ngày giao dịch không phải một thời điểm; gắn múi giờ vào nó tạo ra một thời điểm giả lúc 00:00 mà không ai muốn nói tới. Cộttimecủa dữ liệu trong phiên thì ngược lại — nó là một thời điểm nên có mang múi giờ.volumelàfloat64chứ không phảiint64, vì cột này nullable vàint64của pandas không mang đượcNaN. Một kỳ không có dữ liệu phải raNaNchứ không phải0— "không có dữ liệu" khác "khối lượng bằng 0". Kiểm bằng.isna(), đừng so với0.
adjusted — giá điều chỉnh hay giá khớp lệnh thật
# Mặc định: giá đã điều chỉnh quyền — chuỗi liên tục, dùng để tính lợi suất
df = client.eod.stock.ohlcv("VCB", start="2020-01-01")
df.attrs["finlens"]["price_basis"]
# 'adjusted'
# Giá khớp lệnh thật của phiên đó — dùng khi đối chiếu sao kê hay tính thuế
raw = client.eod.stock.ohlcv("VCB", start="2020-01-01", adjusted=False)
raw.attrs["finlens"]["price_basis"]
# 'raw'Giá điều chỉnh được scale lại sau mỗi lần chia tách hay trả cổ tức, nên chúng không khớp với giá bảng điện của ngày đó. Ba hệ quả đáng nhớ:
- Giá điều chỉnh thay đổi theo thời gian. Sau mỗi sự kiện quyền, toàn bộ
lịch sử được tính lại — cùng một lời gọi chạy hôm nay và tháng sau có thể ra
số khác. Nếu bạn lưu kết quả ra parquet để dùng lâu dài, hãy lưu kèm bản
adjusted=False; chỉ giá khớp lệnh mới là bất biến. - Cột
volumekhông đổi theo cờ này. Nó luôn là số cổ phiếu thật, nên vớiadjusted=Truethìclose * volumekhông bằng giá trị giao dịch thật của phiên. - Chỉ số, phái sinh và chứng quyền không có giá điều chỉnh. Với chúng
price_basisluôn làraw, bất kể bạn truyền gì vàoadjusted.
Giá luôn được làm tròn 2 chữ số thập phân: HOSE có bước giá 10 đồng, nên chữ số thứ ba trở đi chỉ là phần dư của phép chia khi tính giá điều chỉnh.
Bốn namespace trong thực tế
# Cổ phiếu và ETF — nghìn VND. 23.17 nghĩa là 23.170 đồng.
client.eod.stock.ohlcv("HPG,VCB,FPT", start="2026-01-01")
# Chỉ số — điểm chỉ số, không phải nghìn VND.
client.eod.index.ohlcv("VNINDEX", interval="1mo")
# Phái sinh — điểm chỉ số, khối lượng tính bằng HỢP ĐỒNG.
client.eod.derivative.ohlcv("VN30F1M")
# Chứng quyền — VND THÔ. Cộng thẳng với giá cổ phiếu là sai 1000 lần.
client.eod.warrant.ohlcv("CHPG2628")Đơn vị của chứng quyền ngược với cổ phiếu
client.eod.stock trả giá bằng nghìn VND, client.eod.warrant trả giá bằng
VND thô. Đây đúng là kịch bản sai âm thầm: hai bảng cùng tên cột, cùng kiểu,
và phép trừ giữa chúng vẫn chạy. Đọc
df.attrs["finlens"]["units"]["close"] trước khi tính bất cứ thứ gì bắc cầu
giữa hai namespace.
client.eod.sector.ohlcv() — chỉ số ngành ICB
Chuỗi giá của chỉ số ngành ICB, một lời gọi cho nhiều ngành. Khoá là mã
ngành, không phải mã chứng khoán — nên tham số đầu tiên tên là icb, và chữ
ký khác bốn namespace kia.
ohlcv(icb, *, icb_level=4, start=None, end=None, interval="1d",
refresh=False, on_error="warn")| Tham số | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
icb | IcbArg | — | "8600", "8600,3570", hoặc ["8600", "3570"]. Chuỗi chứ không phải số. Positional. |
icb_level | IcbLevel | 4 | Cấp ICB: 4 hoặc 2. Cấp khác nhận lỗi 400. |
start | DateArg | None | Ngày bắt đầu. Mặc định 1 năm trước end. |
end | DateArg | None | Ngày kết thúc. Mặc định hôm nay. |
interval | EodInterval | "1d" | 1d · 1w · 1mo · 3mo · 6mo · 1y. |
refresh | bool | False | Bỏ qua cache. |
on_error | OnError | "warn" | warn · raise · ignore. |
Không có tham số adjusted ở đây: chỉ số ngành không phải giá của một mã nào
nên không có sự kiện quyền nào để điều chỉnh. price_basis luôn là raw.
Mã ngành phải là chuỗi, vì 8600 viết dạng số sẽ làm mất số 0 đứng đầu ở
các mã ngành khác. Mọi mã trong một lời gọi phải thuộc đúng cấp khai ở
icb_level; mã lệch cấp đi vào phần lỗi theo từng mã chứ không làm hỏng cả
lời gọi. Không nhớ mã ngành thì tra bằng client.meta.sectors(level=4) — xem
Tra cứu danh mục.
Cột trả về
| Cột | Kiểu | Có thể NaN | Đơn vị | Mô tả |
|---|---|---|---|---|
icb | string | không | — | Mã ngành ICB. |
icb_name | string | có | — | Tên ngành. null khi mã ngành có trong bảng chỉ số nhưng chưa có trong danh mục ngành. |
icb_level | int8 | không | — | Cấp của mã ngành: 2 hoặc 4. |
date | datetime64[ns] | không | — | Ngày giao dịch, không mang múi giờ. Với interval có gộp kỳ, đây là phiên cuối trong kỳ. |
open | float64 | có | điểm chỉ số | Giá trị chỉ số của phiên đầu kỳ. |
high | float64 | có | điểm chỉ số | Giá trị chỉ số cao nhất trong kỳ. |
low | float64 | có | điểm chỉ số | Giá trị chỉ số thấp nhất trong kỳ. |
close | float64 | có | điểm chỉ số | Giá trị chỉ số của phiên cuối kỳ — đúng phiên mà date trỏ tới. |
volume | float64 | có | cổ phiếu | Tổng khối lượng khớp của các mã cấu thành, cộng dồn trong kỳ. |
n_stocks | Int32 | có | — | Số mã cấu thành chỉ số trong kỳ. |
df = client.eod.sector.ohlcv("8600", start="2026-01-01")
df.attrs["finlens"]["units"]["close"]
# 'index_point'
# Cấp 2 — cũng là số gốc chứ không phải số cộng lên
df = client.eod.sector.ohlcv("8000", icb_level=2)n_stocks dùng Int32 viết hoa, kiểm bằng .isna()
Int32 viết hoa là kiểu số nguyên nullable của pandas, không phải int32
của numpy. Phiên nào nguồn không có số mã cấu thành thì đó là thiếu, và
int32 thường sẽ biến cái thiếu ấy thành 0 — tức thành "ngành này không có mã
nào", một câu khác hẳn, mà không có lỗi nào được ném.
df[df["n_stocks"].isna()] # đúng
df[df["n_stocks"] == 0] # sai — trộn "thiếu dữ liệu" với "không có mã nào"Cột này được phát ra chứ không bị giấu vì nó giải thích những bước nhảy của chuỗi chỉ số: một ngành từ 3 mã lên 5 mã thì chuỗi đứt đoạn ở đúng chỗ ấy, và không cột nào khác nói ra được.
Khi resample, n_stocks lấy giá trị của phiên cuối kỳ — đúng phiên mà date
trỏ tới — chứ không phải tổng. Nó là một trạng thái, không phải một lượng cộng
dồn được.
Cả hai cấp ICB đều là số gốc ở phương thức này. Chỉ số ngành được tính sẵn ở
cả cấp 2 lẫn cấp 4, nên ở đây không có phép cộng nào. Đó là chỗ nó khác
investor.flow() cùng namespace, nơi con số cấp 2 là tổng do máy chủ cộng từ
các ngành con cấp 4 — đừng chuyển kết luận từ phương thức này sang phương thức
kia.
supply_demand() — khối lượng và số lệnh ĐẶT vào sổ
Chỉ có ở client.eod.stock. Chỉ số, ngành, chứng quyền và phái sinh không có
dữ liệu sổ lệnh nào, nên chúng không phát phương thức này.
supply_demand(symbols, *, start=None, end=None, interval="1d",
refresh=False, on_error="warn")| Tham số | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
symbols | SymbolsArg | — | "HPG", "HPG,VCB", hoặc ["HPG", "VCB"]. Positional. |
interval | EodInterval | "1d" | 1d · 1w · 1mo · 3mo · 6mo · 1y. Khi gộp kỳ, mọi cột đều được CỘNG. |
start end refresh on_error | Như mọi hàm EOD khác. |
Đây là lệnh ĐẶT, không phải lệnh đã khớp — chênh nhau khoảng ba lần
Trung vị của tỷ lệ buy_order_volume / volume — cùng mã, cùng phiên, với
volume là khối lượng khớp của ohlcv() — là 2,756 lần. 99,77% số dòng
có khối lượng đặt lớn hơn hoặc bằng khối lượng khớp, và có rất nhiều phiên khối
lượng khớp bằng 0 mà sổ lệnh vẫn có lệnh đặt.
Đó là lý do mọi tên cột ở đây mang chữ _order_. Trừ một cột của bảng này
cho volume của ohlcv(), hay đặt nó cạnh buy_volume của investor.flow(),
là so hai đại lượng chênh nhau khoảng ba lần — và không có exception nào chặn
phép tính đó.
dat = client.eod.stock.supply_demand("HPG", start="2026-08-01")
khop = client.eod.stock.ohlcv("HPG", start="2026-08-01")
# dat["buy_order_volume"] - khop["volume"] <- vô nghĩa, và vẫn chạyĐại lượng này trả lời "áp lực đặt lệnh bên nào lớn hơn", không trả lời "ai đã mua bao nhiêu".
Cột trả về
Tám cột, luôn đủ kể cả khi không có dòng nào. Sáu cột số chia làm hai họ, hai kiểu dữ liệu.
| Cột | Kiểu | Có thể thiếu | Đơn vị | Mô tả |
|---|---|---|---|---|
symbol | string | không | — | Mã chứng khoán, viết hoa. |
date | datetime64[ns] | không | — | Ngày giao dịch, không mang múi giờ. Với interval có gộp kỳ, đây là phiên cuối trong kỳ. |
buy_order_volume | float64 | có | cổ phiếu | Khối lượng đặt mua trong kỳ. |
sell_order_volume | float64 | có | cổ phiếu | Khối lượng đặt bán trong kỳ. |
net_order_volume | float64 | có | cổ phiếu | buy_order_volume - sell_order_volume. Dương là áp lực đặt mua lớn hơn. |
buy_order_count | Int64 | có | — | Số lệnh đặt mua trong kỳ. |
sell_order_count | Int64 | có | — | Số lệnh đặt bán trong kỳ. |
net_order_count | Int64 | có | — | buy_order_count - sell_order_count. |
Ba cột _count không mang đơn vị: một cái đếm không có thứ nguyên nào, nên
units["buy_order_count"] là None chứ không phải bỏ sót.
df = client.eod.stock.supply_demand("HPG", start="2026-08-01")
df.attrs["finlens"]["units"]["buy_order_volume"]
# 'share'Int64 viết hoa ở ba cột số lệnh — kiểm bằng .isna(), đừng so với 0
Int64 viết hoa là kiểu số nguyên nullable của pandas, không phải int64
của numpy. Máy chủ phát giá trị thiếu khi nguồn không ghi số lệnh trong khi vẫn
ghi khối lượng, và điều đó xảy ra ở khoảng 11% số dòng. int64 thường không
mang được giá trị thiếu nên nó sẽ biến cái thiếu ấy thành 0 — tức thành "phiên
này không ai đặt lệnh mua", một câu khác hẳn, mà không có lỗi nào được ném.
df[df["buy_order_count"].isna()] # đúng
df[df["buy_order_count"] == 0] # sai — trộn "chưa biết" với "không có lệnh"Và khi gộp kỳ: một kỳ chứa dù chỉ một phiên thiếu số lệnh thì cả ba cột
_count của kỳ đó đều thiếu. Một tổng thiếu trông hợp lý còn nguy hiểm hơn một
ô trống. Ba cột _volume không chịu ràng buộc này.
Ba cột khối lượng là float64 chứ không phải một kiểu nguyên, và đó là một lời
hứa được giữ: nguồn lưu chúng ở độ chính xác đơn, nên những con số hàng chục
triệu đã được làm tròn trước khi tới bạn. Khai kiểu nguyên sẽ nói rằng đây là
số đếm chính xác từng cổ phiếu; nó không phải.
Khi gộp kỳ, mọi cột đều cộng dồn — khác quy tắc của OHLCV, nơi open và
close lấy hai đầu kỳ. interval="1w" cho một dòng mỗi tuần, giá trị là tổng
của các phiên trong tuần.
active_volume() — khối lượng khớp lệnh CHỦ ĐỘNG
Có ở client.eod.stock và client.eod.derivative. Cùng bộ tên cột, khác
đơn vị khối lượng.
active_volume(symbols, *, start=None, end=None, interval="1d",
refresh=False, on_error="warn")cp = client.eod.stock.active_volume("HPG", start="2026-08-01")
cp.attrs["finlens"]["units"]["net_active_volume"]
# 'share'
ps = client.eod.derivative.active_volume("VN30F1M")
ps.attrs["finlens"]["units"]["net_active_volume"]
# 'contract'KHÔNG phải net_active_value() trong phiên — cấm cộng hai frame
Hai đại lượng nghe giống nhau và không cộng được với nhau. Cùng một phiên VN30F1M, ba cột tương ứng của hai bảng lệch nhau 57,5% · 520% · 30,1%.
eod.*.active_volume() | intraday.*.net_active_value() | |
|---|---|---|
| Số rổ | hai — mua chủ động, bán chủ động | ba — thêm auction tách riêng |
| Cột tiền | không có cột nào | buy_value, sell_value, net_value, auction_value bằng VND |
| Nguồn | một bảng tổng hợp cuối ngày | dựng từ bảng lệnh đã khớp |
Phép cộng hay phép trừ giữa hai frame không ném exception nào, chỉ cho ra một con số sai. Đó là lý do hai phương thức mang hai cái tên khác hẳn nhau, chứ không phải cùng tên ở hai namespace.
Cột trả về
Năm cột, luôn đủ kể cả khi không có dòng nào. Không có cột nào mang giá trị tiền.
| Cột | Kiểu | Có thể NaN | Đơn vị | Mô tả |
|---|---|---|---|---|
symbol | string | không | — | Mã chứng khoán hoặc mã hợp đồng, viết hoa. |
date | datetime64[ns] | không | — | Ngày giao dịch, không mang múi giờ. Với interval có gộp kỳ, đây là phiên cuối trong kỳ. |
active_buy_volume | float64 | có | cổ phiếu / hợp đồng | Khối lượng mua chủ động: bên mua nâng giá chạm bên bán. |
active_sell_volume | float64 | có | cổ phiếu / hợp đồng | Khối lượng bán chủ động: bên bán hạ giá chạm bên mua. |
net_active_volume | float64 | có | cổ phiếu / hợp đồng | active_buy_volume - active_sell_volume. Dương là mua chủ động ròng. |
Đơn vị khối lượng đổi theo namespace trong khi bộ tên cột giữ nguyên: cổ
phiếu ở client.eod.stock, hợp đồng ở client.eod.derivative. Đọc
df.attrs["finlens"]["units"]["net_active_volume"] thay vì giả định — đây đúng
là kịch bản pd.concat hai frame rồi có một cột mang hai đơn vị.
Không có rổ thứ ba — phần khớp lệnh định kỳ không biến mất, nó bị trộn vào
active_buy_volume + active_sell_volume bằng đúng khối lượng khớp cả phiên.
Nguồn chia trọn 100% khối lượng thành đúng hai phía, nên phiên định kỳ ATO/ATC —
vốn không có bên chủ động nào — đã nằm sẵn trong một trong hai cột.
Hệ quả: active_buy_volume ở đây rộng hơn buy_volume của
client.intraday.*.net_active_value(), nơi ATO/ATC có cột auction_volume
riêng. Hai cái tên khác nhau chính vì hai định nghĩa khác nhau.
Muốn tách bạch phần khớp lệnh định kỳ thì dùng bảng trong phiên, không dùng bảng này — xem Dữ liệu trong phiên.
Không có cột *_value nào, và đó không phải một thiếu sót. Nguồn của bảng
này không có cột tiền; quy đổi ra VND phải nhân giá và — với phái sinh — nhân hệ
số hợp đồng. Chỉ net_active_value() trong phiên mới có đủ dữ liệu làm hai phép
tính đó.
Khi gộp kỳ, mọi cột đều cộng dồn.
basis() — chênh lệch phái sinh và chỉ số cơ sở
Chỉ có ở client.eod.derivative, và chỉ phục vụ các mã VN30F*.
basis(symbols, *, start=None, end=None, refresh=False, on_error="warn")Không có tham số interval — ở cả bản theo phiên lẫn bản trong phiên
Đây là phương thức duy nhất của nhóm EOD không nhận interval, và
client.intraday.derivative.basis() cũng vậy.
Gộp một chênh lệch qua nhiều bước không có nghĩa hiển nhiên nào: trung bình
của kỳ, giá trị cuối kỳ và biên độ trong kỳ là ba câu trả lời khác nhau cho cùng
một câu hỏi. Chọn hộ một trong ba là chôn một quyết định phân tích vào thư viện.
Cần thì df.resample(...) trên chính frame này, nơi bạn tự chọn.
Cột trả về
Bảy cột, luôn đủ kể cả khi không có dòng nào.
| Cột | Kiểu | Có thể NaN | Đơn vị | Mô tả |
|---|---|---|---|---|
symbol | string | không | — | Mã hợp đồng phái sinh, ví dụ VN30F1M. |
index_symbol | string | không | — | Mã chỉ số cơ sở đã dùng để tính, ví dụ VN30. |
date | datetime64[ns] | không | — | Ngày giao dịch, không mang múi giờ. Không có gộp kỳ ở đây nên đây luôn là một phiên thật. |
future_close | float64 | có | điểm chỉ số | Giá đóng cửa của hợp đồng tương lai. |
spot_close | float64 | có | điểm chỉ số | Giá đóng cửa của chỉ số cơ sở. Đây là mẫu số của basis_pct. |
basis | float64 | có | điểm chỉ số | future_close - spot_close. |
basis_pct | float64 | có | phần trăm | basis / spot_close * 100. |
basis_pct là cột _pct đầu tiên của thư viện — công thức viết thẳng ra đây
basis = future_close - spot_close # điểm chỉ số
basis_pct = basis / spot_close * 100 # phần trăm, thang 0–100Hai chi tiết không suy ra được từ cái tên, nên cả hai được viết ra:
- Mẫu số là
spot_close, không phảifuture_close. Hai mẫu số chỉ lệch nhau khoảng 0,3% nên chọn nhầm gần như không nhìn ra được trên một biểu đồ. - Thang là 0–100, không phải 0–1. Nhân thêm 100 lần nữa là sai 100 lần, cũng không có gì báo.
basis thì là hiệu tuyệt đối — không chia cho gì, không nhân 100 — nên nó
vẫn mang đơn vị của hai vế: điểm chỉ số. Dương là hợp đồng giao dịch cao hơn
chỉ số (contango), âm là thấp hơn (backwardation).
df = client.eod.derivative.basis("VN30F1M", start="2026-08-01")
df.attrs["finlens"]["units"]["basis"] # 'index_point'
df.attrs["finlens"]["units"]["basis_pct"] # 'pct'
sorted(df["index_symbol"].unique()) # ['VN30']index_symbol là một CỘT THẬT, không phải một chi tiết trong siêu dữ liệu
Lý do rất cụ thể: df.attrs biến mất ngay khi bạn pd.concat hai frame. Khi
đó hai dòng của hai hợp đồng trên hai chỉ số khác nhau trông y hệt nhau, trong
khi basis của chúng đo hai thứ khác nhau — và không cột nào còn nói ra được
điều đó.
Mã phái sinh không có chỉ số cơ sở đi vào phần lỗi theo từng mã, không làm hỏng cả lời gọi
Mã không phải phái sinh nhận lỗi 400 ngay. Nhưng một mã phái sinh có tồn
tại mà không có chuỗi chỉ số cơ sở trong dữ liệu thì đi vào phần lỗi theo từng
mã với mã lỗi FL_DATA_NO_UNDERLYING, ánh xạ về finlens.NoDataError:
df = client.eod.derivative.basis(["VN30F1M", "VN100F1M"])
# PartialDataWarning — df chỉ chứa VN30F1M
df.attrs["finlens"]["failed"]
# {'VN100F1M': {'code': 'FL_DATA_NO_UNDERLYING', 'message': ...}}Mã lỗi này khác FL_DATA_EMPTY vì hai bệnh khác nhau: FL_DATA_EMPTY là
khoảng bạn hỏi không có dòng nào, còn mã này là hợp đồng này không có chỉ số
cơ sở, tức không đổi start/end nào cứu được. Nó cũng không phải
InvalidSymbolError — mã bạn gõ hoàn toàn đúng.
Đây là hành vi mặc định on_error="warn"; đặt on_error="raise" nếu bạn muốn
một mã hỏng làm hỏng cả lời gọi.
Bản bước 1 phút của cùng công thức nằm ở
client.intraday.derivative.basis().
Dòng tiền theo nhóm nhà đầu tư
Hai phương thức, có mặt ở bốn cấp:
| Đường dẫn | flow() | breakdown() | Nhóm phục vụ | Khối lượng |
|---|---|---|---|---|
client.eod.stock.investor | ✔ | ✔ | cả sáu nhóm | cổ phiếu |
client.eod.index.investor | ✔ | — | cả sáu nhóm, nhưng chỉ VNINDEX có dữ liệu | cổ phiếu |
client.eod.sector.investor | ✔ | ✔ | chỉ foreign và proprietary | cổ phiếu |
client.eod.derivative.investor | ✔ | ✔ | chỉ foreign và proprietary | hợp đồng |
flow() hỏi một nhóm, breakdown() hỏi nhiều nhóm. Cả hai trả về
cùng một schema, nên hai bảng ghép được với nhau mà không phải đổi hình.
Giá trị tính bằng VND thô, khác cột giá của namespace cha
client.eod.stock.ohlcv() trả giá bằng nghìn VND, còn
client.eod.stock.investor.flow() trả giá trị bằng VND thô. Khối lượng tính
bằng cổ phiếu, không phải lô.
Đó chính là lý do dòng tiền sống trong một namespace riêng chứ không phải vài
phương thức thêm vào client.eod.stock: hai bộ cột không cùng đơn vị.
Sáu nhóm — và chỉ bốn nhóm cộng được với nhau
Đây là chỗ dễ tính sai nhất của cả trang. Sáu giá trị của tham số group không
thuộc cùng một vũ trụ dữ liệu, và cộng cả sáu lại cho ra một con số vô nghĩa.
Giá trị group | Nghĩa | Có dữ liệu từ | Độ phủ sàn |
|---|---|---|---|
foreign | Nhà đầu tư nước ngoài, gồm cả giao dịch thoả thuận | 2010 | cả ba sàn |
foreign_individual | Cá nhân nước ngoài | 2024 | chỉ HOSE |
foreign_institutional | Tổ chức nước ngoài | 2024 | chỉ HOSE |
local_individual | Cá nhân trong nước | 2024 | chỉ HOSE |
local_institutional | Tổ chức trong nước | 2024 | chỉ HOSE |
proprietary | Tự doanh công ty chứng khoán | 2022 | cả ba sàn |
Bốn nhóm chi tiết là một phân hoạch đầy đủ của khớp lệnh. Tổng net_value
của chúng bằng 0 — mua ròng của nhóm này đúng bằng bán ròng của nhóm kia. Kiểm
được bằng một dòng:
bon_nhom = ["foreign_individual", "foreign_institutional",
"local_individual", "local_institutional"]
df = client.eod.stock.investor.breakdown("HPG", groups=bon_nhom)
df.groupby("date")["net_value"].sum().abs().max()
# xấp xỉ 0foreign và proprietary nằm ngoài phân hoạch đó. Cộng chúng vào bốn nhóm
trên là cộng hai lần, hoặc cộng hai vũ trụ dữ liệu khác nhau:
foreignlấy cả giao dịch thoả thuận chứ không riêng khớp lệnh, nên nó không bằngforeign_individual + foreign_institutional.proprietary(tự doanh) đến từ một nguồn khác hẳn và không phải nhóm con củalocal_institutional, dù trực giác nói ngược lại. Có những phiên tự doanh mua ròng lớn hơn cả tổ chức trong nước.
Ba hệ quả phải nhớ khi chọn nhóm
- Muốn cộng thì chỉ cộng bốn nhóm chi tiết. Chúng là phân hoạch duy nhất.
- Bốn nhóm chi tiết trên thực tế chỉ có dữ liệu cho mã sàn HOSE. Với mã HNX
hay UPCOM, chúng trả về bảng rỗng — hãy dùng
foreign, nhóm phủ đủ ba sàn. - Tự doanh về trễ khoảng ba phiên. Những phiên gần nhất của nhóm
proprietarycó thể chưa có dòng nào, và đó không phải lỗi.
Cuối cùng, không có giá trị "all". Muốn mọi nhóm thì gọi breakdown() và
để groups=None.
flow() — một nhóm
flow(symbols, *, group="foreign", start=None, end=None,
interval="1d", refresh=False, on_error="warn")| Tham số | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
symbols | SymbolsArg | — | "HPG", "HPG,VCB", hoặc ["HPG", "VCB"]. Positional. |
group | InvestorGroup | "foreign" | Đúng một nhóm trong sáu giá trị ở bảng trên. |
start end interval refresh on_error | Như mọi hàm EOD khác. |
df = client.eod.stock.investor.flow("HPG,VCB", start="2026-01-01")
df.attrs["finlens"]["units"]["net_value"]
# 'VND'
# Tự doanh, và chỉ tự doanh
df = client.eod.stock.investor.flow("HPG", group="proprietary")breakdown() — nhiều nhóm trong một lời gọi
breakdown(symbols, *, groups=None, start=None, end=None,
interval="1d", refresh=False, on_error="warn")| Tham số | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
symbols | SymbolsArg | — | Như trên. Positional. |
groups | InvestorGroupsArg | None | "foreign,proprietary" hoặc ["foreign", "proprietary"]. None nghĩa là đầy đủ các nhóm mà cấp này phục vụ, không phải "không nhóm nào". |
start end interval refresh on_error | Như mọi hàm EOD khác. |
Kết quả ở dạng long format: một dòng cho mỗi bộ ba symbol × date ×
group. Muốn bảng rộng thì tự pivot — thư viện cố ý không làm sẵn bước đó, vì
pivot là quyết định trình bày của bạn còn long format mới là thứ ghép và lọc
được.
df = client.eod.stock.investor.breakdown("HPG", groups=bon_nhom)
rong = df.pivot(index="date", columns="group", values="net_value")Cột trả về — cấp mã và cấp chỉ số
Chín cột, luôn đủ kể cả khi không có dòng nào.
| Cột | Kiểu | Có thể NaN | Đơn vị | Mô tả |
|---|---|---|---|---|
symbol | string | không | — | Mã chứng khoán, viết hoa. |
date | datetime64[ns] | không | — | Ngày giao dịch, không mang múi giờ. Với interval có gộp kỳ, đây là phiên cuối trong kỳ. |
group | string | không | — | Nhóm nhà đầu tư của dòng này. |
buy_value | float64 | có | VND thô | Giá trị mua trong kỳ. |
sell_value | float64 | có | VND thô | Giá trị bán trong kỳ. |
net_value | float64 | có | VND thô | buy_value - sell_value. Dương là mua ròng. |
buy_volume | float64 | có | cổ phiếu | Khối lượng mua trong kỳ. |
sell_volume | float64 | có | cổ phiếu | Khối lượng bán trong kỳ. |
net_volume | float64 | có | cổ phiếu | buy_volume - sell_volume. |
Cột group luôn có mặt, kể cả khi lời gọi chỉ hỏi một nhóm. Đó là chỗ duy
nhất mang được "con số này tính trên cơ sở nào" ở mức từng dòng — bỏ nó đi
thì thông tin ấy biến mất ngay khi bạn concat hai bảng.
Không có cột tỷ trọng nào. Tỷ trọng là một phép chia bạn tự làm được, và
0.1.x từng đặt tên cột *_ratio nhưng tính trên thang 0–100 — sai 100 lần, rất
khó thấy.
Khi resample, mọi cột số đều cộng dồn — khác quy tắc của OHLCV.
interval="1w" cho một dòng mỗi tuần mỗi nhóm, giá trị là tổng của các
phiên trong tuần chứ không phải giá trị cuối kỳ.
Riêng cột date theo đúng luật chung của nhóm EOD: nó mang phiên giao dịch
cuối trong kỳ. Và mọi nhóm của cùng một kỳ mang cùng một ngày — nhãn được
tính trên cả kỳ chứ không tính riêng từng nhóm, nên bảng vẫn pivot được theo
date kể cả khi các nhóm có phiên cuối khác nhau.
Cấp chỉ số — chỉ VNINDEX, và chỉ có flow()
df = client.eod.index.investor.flow("VNINDEX", start="2026-01-01")client.eod.index.investor không có breakdown()
Ở cấp chỉ số chỉ có flow(). Và trong thực tế chỉ VNINDEX có dữ liệu —
tài liệu 0.1.x hứa cả VN30, HNXINDEX và UPINDEX, không mã nào trong số đó
có số liệu dòng tiền.
Điều đó nguy hiểm hơn một lỗi, vì mặc định on_error="warn" nghĩa là lời gọi
không thất bại:
df = client.eod.index.investor.flow(["VNINDEX", "VN30"])
# PartialDataWarning — df chỉ chứa VNINDEX
df.attrs["finlens"]["failed"]Đặt on_error="raise" nếu bạn muốn biết ngay thay vì phát hiện muộn.
Cột trả về giống hệt bảng cấp mã ở trên, kể cả tên cột symbol.
Cấp ngành — hai nhóm, hai cấp ICB
flow(icb, *, icb_level=4, group="foreign", start=None, end=None,
interval="1d", refresh=False, on_error="warn")
breakdown(icb, *, icb_level=4, groups=None, start=None, end=None,
interval="1d", refresh=False, on_error="warn")| Tham số | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
icb | IcbArg | — | "8600", "8600,3570", hoặc ["8600", "3570"]. Positional. |
icb_level | IcbLevel | 4 | Cấp ICB: 4 hoặc 2. |
group | SectorInvestorGroup | "foreign" | Chỉ "foreign" hoặc "proprietary" — chỉ ở flow(). |
groups | SectorInvestorGroupsArg | None | None nghĩa là cả hai nhóm — chỉ ở breakdown(). |
start end interval refresh on_error | Như mọi hàm EOD khác. |
df = client.eod.sector.investor.flow("8600", start="2026-01-01")
df.attrs["finlens"]["units"]["net_value"]
# 'VND'
df = client.eod.sector.investor.breakdown("8600")
sorted(df["group"].unique())
# ['foreign', 'proprietary']Cấp ngành chỉ có hai nhóm, và chúng không cộng được với nhau
Bốn nhóm chi tiết (foreign_individual, foreign_institutional,
local_individual, local_institutional) không tồn tại ở cấp ngành — ở bất
kỳ cấp ICB nào. Cái bị chặn là một nguồn, không phải một cấp: nguồn của
bốn nhóm đó thực chất chỉ có sàn HOSE, trong khi ngay ở cấp 4 một ngành đã trải
cả ba sàn — con số tổng hợp sẽ thiếu hẳn phần HNX và UPCOM mà không có gì trong
bảng nói ra.
Yêu cầu chúng nhận một lỗi 400 rõ ràng chứ không phải một bảng rỗng im lặng, và
kiểu SectorInvestorGroup đã chặn ngay lúc bạn gõ code. Muốn bốn nhóm đó thì
hỏi ở cấp mã.
Hai nhóm còn lại nằm trên hai trục rời nhau nên cũng không cộng được với
nhau: foreign lấy cả thoả thuận, còn proprietary đến từ một nguồn khác hẳn.
Cấp 2 của dòng tiền ngành là số CỘNG, không phải số gốc
Cấp 4 đọc thẳng số gốc của nguồn. Cấp 2 là tổng của các ngành con cấp 4 do máy chủ cộng lại.
Phép cộng đó đầy đủ với nhóm foreign. Với proprietary thì có một khoảng
trống đã biết: vài ngành cấp 4 có dữ liệu khối ngoại nhưng không có dữ liệu tự
doanh, nên phần của chúng vắng khỏi tổng cấp 2 — máy chủ phát một cảnh báo nêu
đích danh những ngành đó.
Và đừng chuyển kết luận này sang client.eod.sector.ohlcv(): ở đó cả hai cấp
đều là số gốc, vì chỉ số ngành được tính sẵn ở cả hai cấp.
Ở cấp ngành, hai nhóm cũng không cùng độ dài lịch sử: foreign có lịch sử dài
hơn hẳn proprietary.
Cột trả về — cấp ngành
Mười một cột, luôn đủ kể cả khi không có dòng nào. Khác bảng cấp mã ở chỗ ba cột
symbol được thay bằng bộ icb / icb_name / icb_level.
| Cột | Kiểu | Có thể NaN | Đơn vị | Mô tả |
|---|---|---|---|---|
icb | string | không | — | Mã ngành ICB, cấp 2 hoặc cấp 4 theo icb_level. |
icb_name | string | có | — | Tên ngành. |
icb_level | int64 | không | — | Cấp của mã ngành: 2 hoặc 4. |
date | datetime64[ns] | không | — | Ngày giao dịch, không mang múi giờ. Với interval có gộp kỳ, đây là phiên cuối trong kỳ. |
group | string | không | — | foreign hoặc proprietary. |
buy_value | float64 | có | VND thô | Giá trị mua của cả ngành trong kỳ. |
sell_value | float64 | có | VND thô | Giá trị bán của cả ngành trong kỳ. |
net_value | float64 | có | VND thô | buy_value - sell_value. |
buy_volume | float64 | có | cổ phiếu | Khối lượng mua của cả ngành. |
sell_volume | float64 | có | cổ phiếu | Khối lượng bán của cả ngành. |
net_volume | float64 | có | cổ phiếu | buy_volume - sell_volume. |
Cấp hợp đồng phái sinh — hai nhóm, khối lượng bằng hợp đồng
flow(symbols, *, group="foreign", start=None, end=None,
interval="1d", refresh=False, on_error="warn")
breakdown(symbols, *, groups=None, start=None, end=None,
interval="1d", refresh=False, on_error="warn")| Tham số | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
symbols | SymbolsArg | — | "VN30F1M", "VN30F1M,VN30F2M", hoặc danh sách. Tất cả phải là mã phái sinh. Positional. |
group | DerivativeInvestorGroup | "foreign" | Chỉ "foreign" hoặc "proprietary" — chỉ ở flow(). |
groups | DerivativeInvestorGroupsArg | None | None nghĩa là cả hai nhóm — chỉ ở breakdown(). |
start end interval refresh on_error | Như mọi hàm EOD khác. |
Cùng bộ chín cột với bảng cấp mã ở trên, nên hai bảng ghép được với nhau mà không phải đổi hình. Khác đúng hai chỗ, và cả hai nằm trong kiểu của tham số.
df = client.eod.derivative.investor.flow("VN30F1M", start="2026-01-01")
df.attrs["finlens"]["units"]["net_volume"]
# 'contract'
df = client.eod.derivative.investor.breakdown("VN30F1M")
sorted(df["group"].unique())
# ['foreign', 'proprietary']Chỉ hai nhóm, và mặc định groups=None ở đây nghĩa là HAI chứ không phải sáu
Bốn nhóm chi tiết (foreign_individual, foreign_institutional,
local_individual, local_institutional) không tồn tại ở cấp hợp đồng phái
sinh và nhận lỗi 400, không phải một bảng rỗng: nguồn của chúng khoá theo
danh mục doanh nghiệp niêm yết, nơi không hợp đồng phái sinh nào có mặt.
Kiểu DerivativeInvestorGroup chỉ khai hai giá trị nên IDE gạch đỏ ngay lúc bạn
gõ, trước cả một vòng gọi mạng.
Đây cũng là chỗ breakdown() ở đây khác client.eod.stock.investor.breakdown():
bỏ trống groups ở cấp mã cho sáu nhóm, bỏ trống ở đây cho hai.
Hai nhóm này nằm trên hai trục rời nhau và không cộng được với nhau — cùng lý do với cấp mã và cấp ngành.
Đây là hợp đồng KHỚP ròng và giá trị DANH NGHĨA, không phải vị thế và không phải tiền bỏ ra
Hai chỗ dễ đọc nhầm nhất của bảng này, và cả hai đều không có gì báo:
1. net_volume là chênh lệch hợp đồng ĐÃ KHỚP trong kỳ, không phải vị thế
mở. Một nhóm mở rồi đóng hết trong cùng phiên có net_volume bằng 0 dù đã khớp
hàng nghìn hợp đồng; ngược lại, net_volume dương không nói vị thế mở cuối phiên
là bao nhiêu. Cộng dồn net_volume qua nhiều phiên không cho ra vị thế tích
luỹ — bảng này không mang thông tin đó.
2. Các cột *_value là giá trị DANH NGHĨA của số hợp đồng đó, đã quy đổi ra
VND thô bằng hệ số hợp đồng ở máy chủ — không phải số tiền nhà đầu tư thực
bỏ ra, vốn chỉ là phần ký quỹ. Cũng vì vậy đừng tự nhân net_volume với giá
đóng cửa của ohlcv() rồi so với net_value: bỏ quên hệ số hợp đồng là lệch
100.000 lần, và phép nhân đó vẫn chạy.
df.attrs["finlens"]["units"]["net_value"] # 'VND'
df.attrs["finlens"]["units"]["net_volume"] # 'contract'Bộ tên cột giống hệt bản cổ phiếu, nên pd.concat một frame phái sinh với một
frame cổ phiếu cho ra một cột net_volume mang hai đơn vị — và df.attrs
không sống sót qua concat để nói ra điều đó.
Đơn vị — kiểm bằng một dòng, đừng giả định
Trong riêng nhóm EOD, cột tên close mang ba đơn vị khác nhau ở ba chỗ:
| Gọi gì | units["close"] |
|---|---|
client.eod.stock.ohlcv(...) | 'kVND' — nghìn VND |
client.eod.index.ohlcv(...) · client.eod.derivative.ohlcv(...) · client.eod.sector.ohlcv(...) | 'index_point' — điểm chỉ số |
client.eod.warrant.ohlcv(...) | 'VND' — VND thô |
Và các bảng dòng tiền dùng 'VND' cho mọi cột *_value, bất kể namespace cha
tính bằng gì.
Bốn giá trị units còn lại bạn sẽ gặp trong nhóm EOD:
| Cột | units[...] | Xuất hiện ở |
|---|---|---|
volume, *_order_volume, *_active_volume, *_volume | 'share' hoặc 'contract' | mọi bảng có khối lượng — đổi theo namespace, không đổi theo tên cột |
basis, future_close, spot_close | 'index_point' | basis() |
basis_pct | 'pct' | basis() — thang 0–100 |
buy_order_count, sell_order_count, net_order_count | None | supply_demand() — một cái đếm không có thứ nguyên |
df.attrs["finlens"]["units"] # cột -> đơn vị
df.attrs["finlens"]["price_basis"] # 'adjusted' hoặc 'raw'
df.attrs["finlens"]["as_of"] # mốc nước của dữ liệudf.attrs không sống sót qua concat và merge
DataFrame.attrs là thông tin đi kèm, không phải cơ chế bảo vệ — pandas bỏ
nó khi bạn pd.concat hay df.merge. Đọc đơn vị trước khi ghép bảng.
Bảo vệ thật nằm ở chỗ tách namespace: một lời gọi không bao giờ trộn được hai
đơn vị, vì client.eod.stock và client.eod.index không trả về cùng một bảng.
Chi tiết từng khoá của df.attrs["finlens"] ở
Kiểu dữ liệu.
Xem tiếp
Dữ liệu trong phiên
Thanh giá theo phút, từng lệnh khớp và dòng tiền chủ động. Giá trong phiên là giá thô — không nối thẳng được với chuỗi EOD.
Kiểu dữ liệu
EodInterval, InvestorGroup, IcbLevel và toàn bộ nội dung của
df.attrs["finlens"].
Xử lý lỗi
Cây ngoại lệ, mã FL_*, và on_error trong lời gọi nhiều mã.
Changelog
Những gì đổi tên hoặc đổi hành vi khi nâng từ 0.1.x lên 1.x.
Câu hỏi thường gặp
Làm sao lấy dữ liệu OHLCV của một mã cổ phiếu Việt Nam bằng Python?
Dùng `client.eod.stock.ohlcv()` của thư viện Python finlens: `client = finlens.client()` rồi `client.eod.stock.ohlcv("HPG", start="2026-01-01", end="2026-01-31")`. Hàm trả về `pandas.DataFrame` với bảy cột viết thường `symbol`, `date`, `open`, `high`, `low`, `close`, `volume`. Chỉ `symbols` là tham số vị trí, mọi tham số còn lại keyword-only; `symbols` nhận `"HPG"`, `"HPG,VCB"` hoặc `["HPG", "VCB"]` trong một lời gọi. Tham số `interval` nhận `"1d"`, `"1w"`, `"1mo"`, `"3mo"`, `"6mo"`, `"1y"` — viết thường, không có `"1M"`; ở giá trị có gộp kỳ, `date` là phiên giao dịch cuối trong kỳ.
Lấy dữ liệu chỉ số VNINDEX bằng Python như thế nào?
Namespace chỉ số của thư viện Python finlens tên là `client.eod.index`: gọi `client.eod.index.ohlcv("VNINDEX", start="2026-01-01", interval="1mo")`. Tên `client.eod.market` là của bản 0.1.x và nay ném `AttributeError`. Giá chỉ số tính bằng điểm chỉ số chứ không phải nghìn VND — nhân VNINDEX với 1000 là sai mà không có exception nào báo; kiểm bằng `df.attrs["finlens"]["units"]["close"]`, nó trả `'index_point'`. Dòng tiền cấp chỉ số nằm ở `client.eod.index.investor.flow("VNINDEX")` và hiện chỉ `VNINDEX` có dữ liệu.
Lấy được mua bán ròng của khối ngoại và tự doanh bằng Python không?
Được, bằng `flow()` và `breakdown()` trong namespace `investor` của thư viện Python finlens: `client.eod.stock.investor.flow("HPG", group="foreign")` cho một nhóm, `client.eod.stock.investor.breakdown("HPG")` cho nhiều nhóm trong một frame. Cấp chỉ số là `client.eod.index.investor.flow()`, cấp ngành là `client.eod.sector.investor.flow("8600")`. Kết quả luôn đủ chín cột kể cả khi 0 dòng: `symbol`, `date`, `group`, `buy_value`, `sell_value`, `net_value`, `buy_volume`, `sell_volume`, `net_volume`. Giá trị tính bằng VND thô, khối lượng tính bằng cổ phiếu.
Cộng được những nhóm nhà đầu tư nào lại với nhau?
Chỉ bốn nhóm chi tiết: `foreign_individual`, `foreign_institutional`, `local_individual` và `local_institutional`. Chúng là một phân hoạch đầy đủ của khớp lệnh nên tổng `net_value` bằng 0, và trên thực tế chỉ có dữ liệu cho mã sàn HOSE. Hai nhóm còn lại nằm ngoài phân hoạch đó: `foreign` gồm cả giao dịch thoả thuận nên không bằng tổng hai nhóm ngoại chi tiết, còn `proprietary` (tự doanh) đến từ nguồn khác hẳn và không phải nhóm con của `local_institutional`. Với mã HNX hay UPCOM thì dùng `foreign`, nhóm phủ đủ ba sàn. Không có giá trị `"all"` — muốn mọi nhóm thì gọi `breakdown()` và để `groups=None`.
Lấy dữ liệu giá theo ngành ICB bằng Python thế nào?
Gọi `client.eod.sector.ohlcv("8600", icb_level=4)` của thư viện Python finlens. Tham số đầu tên là `icb`, positional, và nhận mã ngành ICB dạng chuỗi: `"8600"`, `"8600,3570"` hoặc `["8600", "3570"]` — chuỗi chứ không phải số, vì ép sang số làm mất số 0 đứng đầu ở những mã như `"0533"`. `icb_level` nhận `4` hoặc `2`. Frame trả về gồm `icb`, `icb_name`, `icb_level`, `date`, `open`, `high`, `low`, `close`, `volume` và `n_stocks`, đơn vị là điểm chỉ số. Không nhớ mã ngành thì tra bằng `client.meta.sectors(level=4)`.
Giá cổ phiếu trả về đã điều chỉnh quyền chưa?
Mặc định là đã điều chỉnh. Với thư viện Python finlens, `client.eod.stock.ohlcv("VCB")` trả chuỗi giá đã điều chỉnh quyền, còn `client.eod.stock.ohlcv("VCB", adjusted=False)` trả giá khớp lệnh thật của phiên — dùng khi đối chiếu sao kê hoặc dựng lại một lệnh cũ. Cơ sở giá đọc được ở `df.attrs["finlens"]["price_basis"]` với giá trị `'adjusted'` hoặc `'raw'`. Cột `volume` không đổi theo cờ này, nó luôn là số cổ phiếu thật. Chỉ số, phái sinh và chứng quyền không có giá điều chỉnh nên `price_basis` của chúng luôn là `'raw'`.
Lấy khối lượng đặt mua và đặt bán (sổ lệnh) của cổ phiếu Việt Nam bằng Python thế nào?
Gọi `client.eod.stock.supply_demand("HPG", start="2026-08-01")` của thư viện Python finlens. Frame trả về tám cột: `symbol`, `date`, `buy_order_volume`, `sell_order_volume`, `net_order_volume`, `buy_order_count`, `sell_order_count`, `net_order_count`. Đây là lệnh ĐẶT vào sổ chứ không phải lệnh đã khớp — trung vị tỷ lệ `buy_order_volume` trên `volume` của `ohlcv()` là 2,756 lần, nên trừ hai cột đó cho nhau là so hai đại lượng chênh nhau khoảng ba lần, và không có exception nào chặn. Ba cột `_count` dùng `Int64` viết hoa và mang được giá trị thiếu, nên kiểm bằng `.isna()` chứ đừng so với 0. Phương thức chỉ tồn tại ở `client.eod.stock`; gọi nó ở chỉ số, ngành, chứng quyền hay phái sinh ném `AttributeError`.
active_volume cuối ngày khác net_active_value trong phiên ở chỗ nào?
Khác ở cả ba chiều, và hai frame không cộng được với nhau. `client.eod.stock.active_volume()` cùng `client.eod.derivative.active_volume()` của thư viện Python finlens có hai rổ là `active_buy_volume` và `active_sell_volume`, không có cột tiền nào; còn `client.intraday.*.net_active_value()` có ba rổ vì tách riêng khớp lệnh định kỳ ATO/ATC thành `auction_value` và `auction_volume`, đồng thời phát giá trị bằng VND. Cùng một phiên VN30F1M, ba cột tương ứng của hai bảng lệch nhau 57,5%, 520% và 30,1%. Ở bản cuối ngày phần khớp lệnh định kỳ không biến mất mà bị trộn vào hai rổ, nên `active_buy_volume` rộng hơn `buy_volume` của bản trong phiên. Phép cộng giữa hai frame không ném exception nào, chỉ cho ra một con số sai.
Tính chênh lệch giữa hợp đồng VN30F1M và chỉ số cơ sở bằng Python thế nào?
Gọi `client.eod.derivative.basis("VN30F1M")` cho bản theo phiên, hoặc `client.intraday.derivative.basis("VN30F1M")` cho bản bước 1 phút — cả hai đều của thư viện Python finlens. Frame gồm `symbol`, `index_symbol`, `date` (bản trong phiên dùng `time`), `future_close`, `spot_close`, `basis` và `basis_pct`. Công thức là `basis = future_close - spot_close` tính bằng điểm chỉ số, còn `basis_pct = basis / spot_close * 100` trên thang 0–100: mẫu số là giá chỉ số chứ không phải giá hợp đồng, và hai mẫu số chỉ lệch nhau khoảng 0,3% nên chọn nhầm rất khó thấy. Không phương thức nào trong cặp này có tham số `interval`. Mã phái sinh không có chỉ số cơ sở đi vào phần lỗi theo từng mã với mã `FL_DATA_NO_UNDERLYING`, ánh xạ về `finlens.NoDataError`.
Lấy dòng tiền khối ngoại và tự doanh của hợp đồng phái sinh bằng Python thế nào?
Gọi `client.eod.derivative.investor.flow("VN30F1M")` hoặc `client.eod.derivative.investor.breakdown("VN30F1M")` của thư viện Python finlens. Chúng dùng chung schema chín cột với cấp mã — `symbol`, `date`, `group`, `buy_value`, `sell_value`, `net_value`, `buy_volume`, `sell_volume`, `net_volume` — nên hai bảng ghép được với nhau. Nhưng cấp phái sinh chỉ phục vụ hai nhóm `foreign` và `proprietary`: bốn nhóm chi tiết nhận lỗi 400 và đã bị kiểu `DerivativeInvestorGroup` chặn từ lúc gõ code, nên `breakdown()` bỏ trống `groups` hỏi hai nhóm chứ không phải sáu. Khối lượng tính bằng hợp đồng, không phải cổ phiếu. `net_volume` là chênh lệch hợp đồng đã khớp trong kỳ chứ không phải vị thế mở, và các cột `*_value` là giá trị danh nghĩa quy đổi bằng hệ số hợp đồng, không phải số tiền thực bỏ ra.
Cập nhật lần cuối