Logo FinLensFinLens Docs

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ở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.xBản 1.x
client.eod.marketclient.eod.index
Cột Date, Open, Close, Volumedate, 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ị

NamespaceDữ liệuCột giáKhối lượngPhương thức
client.eod.stockCổ phiếu và ETFnghìn VNDcổ phiếuohlcv() · supply_demand() · active_volume() · investor.flow() · investor.breakdown()
client.eod.indexChỉ sốđiểm chỉ sốcổ phiếuohlcv() · investor.flow()
client.eod.derivativeHợp đồng phái sinhđiểm chỉ sốhợp đồngohlcv() · active_volume() · basis() · investor.flow() · investor.breakdown()
client.eod.warrantChứng quyền có bảo đảmVND thôchứng quyềnohlcv()
client.eod.sectorNgành ICBđiểm chỉ sốcổ phiếuohlcv() · investor.flow() · investor.breakdown()

Việc tách namespace chính là thứ bảo đảm đơn vị: client.eod.stockclient.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")                    # AttributeError

Sổ 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, 1ydatephiê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ỳ 1mo của tháng này, hay kỳ 1y củ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ậy df[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 date và mốc đầu kỳ không cố định, nên df["date"] - pd.offsets.MonthBegin() là sai. Dùng df["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ểuMặc địnhMô tả
symbolsSymbolsArg"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.
startDateArgNoneNgày bắt đầu. Mặc định 1 năm trước end.
endDateArgNoneNgày kết thúc. Mặc định hôm nay.
intervalEodInterval"1d"1d · 1w · 1mo · 3mo · 6mo · 1y.
adjustedboolTrueGiá đã điều chỉnh quyền hay giá khớp lệnh thật. Xem mục dưới.
refreshboolFalseBỏ qua cache.
on_errorOnError"warn"warn · raise · ignore.

Cột trả về

CộtKiểuCó thể NaNĐơn vịMô tả
symbolstringkhôngMã chứng khoán, viết hoa.
datedatetime64[ns]khôngNgà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ỳ.
openfloat64theo namespaceGiá 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.
highfloat64theo namespaceGiá cao nhất trong kỳ.
lowfloat64theo namespaceGiá thấp nhất trong kỳ.
closefloat64theo namespaceGiá đóng cửa kỳ — giá đóng cửa của đúng phiên mà date trỏ tới.
volumefloat64cổ phiếu / hợp đồngKhố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:

  • date cố ý 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ột time của dữ liệu trong phiên thì ngược lại — nó một thời điểm nên có mang múi giờ.
  • volumefloat64 chứ không phải int64, vì cột này nullable và int64 của pandas không mang được NaN. Một kỳ không có dữ liệu phải ra NaN chứ không phải 0 — "không có dữ liệu" khác "khối lượng bằng 0". Kiểm bằng .isna(), đừng so với 0.

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 volume không đổi theo cờ này. Nó luôn là số cổ phiếu thật, nên với adjusted=True thì close * volume khô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_basis luôn là raw, bất kể bạn truyền gì vào adjusted.

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ểuMặc địnhMô tả
icbIcbArg"8600", "8600,3570", hoặc ["8600", "3570"]. Chuỗi chứ không phải số. Positional.
icb_levelIcbLevel4Cấp ICB: 4 hoặc 2. Cấp khác nhận lỗi 400.
startDateArgNoneNgày bắt đầu. Mặc định 1 năm trước end.
endDateArgNoneNgày kết thúc. Mặc định hôm nay.
intervalEodInterval"1d"1d · 1w · 1mo · 3mo · 6mo · 1y.
refreshboolFalseBỏ qua cache.
on_errorOnError"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ộtKiểuCó thể NaNĐơn vịMô tả
icbstringkhôngMã ngành ICB.
icb_namestringTê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_levelint8khôngCấp của mã ngành: 2 hoặc 4.
datedatetime64[ns]khôngNgà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ỳ.
openfloat64điểm chỉ sốGiá trị chỉ số của phiên đầu kỳ.
highfloat64điểm chỉ sốGiá trị chỉ số cao nhất trong kỳ.
lowfloat64điểm chỉ sốGiá trị chỉ số thấp nhất trong kỳ.
closefloat64điểm chỉ sốGiá trị chỉ số của phiên cuối kỳ — đúng phiên mà date trỏ tới.
volumefloat64cổ phiếuTổng khối lượng khớp của các mã cấu thành, cộng dồn trong kỳ.
n_stocksInt32Số 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ểuMặc địnhMô tả
symbolsSymbolsArg"HPG", "HPG,VCB", hoặc ["HPG", "VCB"]. Positional.
intervalEodInterval"1d"1d · 1w · 1mo · 3mo · 6mo · 1y. Khi gộp kỳ, mọi cột đều được CỘNG.
start end refresh on_errorNhư 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ộtKiểuCó thể thiếuĐơn vịMô tả
symbolstringkhôngMã chứng khoán, viết hoa.
datedatetime64[ns]khôngNgà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_volumefloat64cổ phiếuKhối lượng đặt mua trong kỳ.
sell_order_volumefloat64cổ phiếuKhối lượng đặt bán trong kỳ.
net_order_volumefloat64cổ phiếubuy_order_volume - sell_order_volume. Dương là áp lực đặt mua lớn hơn.
buy_order_countInt64Số lệnh đặt mua trong kỳ.
sell_order_countInt64Số lệnh đặt bán trong kỳ.
net_order_countInt64buy_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"]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 openclose 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.stockclient.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ủ độngba — thêm auction tách riêng
Cột tiềnkhông có cột nàobuy_value, sell_value, net_value, auction_value bằng VND
Nguồnmột bảng tổng hợp cuối ngàydự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ộtKiểuCó thể NaNĐơn vịMô tả
symbolstringkhôngMã chứng khoán hoặc mã hợp đồng, viết hoa.
datedatetime64[ns]khôngNgà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_volumefloat64cổ phiếu / hợp đồngKhối lượng mua chủ động: bên mua nâng giá chạm bên bán.
active_sell_volumefloat64cổ phiếu / hợp đồngKhối lượng bán chủ động: bên bán hạ giá chạm bên mua.
net_active_volumefloat64cổ phiếu / hợp đồngactive_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 đồngclient.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ộtKiểuCó thể NaNĐơn vịMô tả
symbolstringkhôngMã hợp đồng phái sinh, ví dụ VN30F1M.
index_symbolstringkhôngMã chỉ số cơ sở đã dùng để tính, ví dụ VN30.
datedatetime64[ns]khôngNgà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_closefloat64điểm chỉ sốGiá đóng cửa của hợp đồng tương lai.
spot_closefloat64điểm chỉ sốGiá đóng cửa của chỉ số cơ sở. Đây là mẫu số của basis_pct.
basisfloat64điểm chỉ sốfuture_close - spot_close.
basis_pctfloat64phần trămbasis / 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–100

Hai 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ải future_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_EMPTYkhoả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ẫnflow()breakdown()Nhóm phục vụKhối lượng
client.eod.stock.investorcả sáu nhómcổ phiếu
client.eod.index.investorcả sáu nhóm, nhưng chỉ VNINDEX có dữ liệucổ phiếu
client.eod.sector.investorchỉ foreignproprietarycổ phiếu
client.eod.derivative.investorchỉ foreignproprietaryhợ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ị groupNghĩaCó dữ liệu từĐộ phủ sàn
foreignNhà đầu tư nước ngoài, gồm cả giao dịch thoả thuận2010cả ba sàn
foreign_individualCá nhân nước ngoài2024chỉ HOSE
foreign_institutionalTổ chức nước ngoài2024chỉ HOSE
local_individualCá nhân trong nước2024chỉ HOSE
local_institutionalTổ chức trong nước2024chỉ HOSE
proprietaryTự doanh công ty chứng khoán2022cả 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ỉ 0

foreignproprietary 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:

  • foreign lấy cả giao dịch thoả thuận chứ không riêng khớp lệnh, nên nó không bằng foreign_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ủa local_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

  1. 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.
  2. 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.
  3. Tự doanh về trễ khoảng ba phiên. Những phiên gần nhất của nhóm proprietary có 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ểuMặc địnhMô tả
symbolsSymbolsArg"HPG", "HPG,VCB", hoặc ["HPG", "VCB"]. Positional.
groupInvestorGroup"foreign"Đúng một nhóm trong sáu giá trị ở bảng trên.
start end interval refresh on_errorNhư 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ểuMặc địnhMô tả
symbolsSymbolsArgNhư trên. Positional.
groupsInvestorGroupsArgNone"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_errorNhư 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ộtKiểuCó thể NaNĐơn vịMô tả
symbolstringkhôngMã chứng khoán, viết hoa.
datedatetime64[ns]khôngNgà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ỳ.
groupstringkhôngNhóm nhà đầu tư của dòng này.
buy_valuefloat64VND thôGiá trị mua trong kỳ.
sell_valuefloat64VND thôGiá trị bán trong kỳ.
net_valuefloat64VND thôbuy_value - sell_value. Dương là mua ròng.
buy_volumefloat64cổ phiếuKhối lượng mua trong kỳ.
sell_volumefloat64cổ phiếuKhối lượng bán trong kỳ.
net_volumefloat64cổ phiếubuy_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, HNXINDEXUPINDEX, 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ểuMặc địnhMô tả
icbIcbArg"8600", "8600,3570", hoặc ["8600", "3570"]. Positional.
icb_levelIcbLevel4Cấp ICB: 4 hoặc 2.
groupSectorInvestorGroup"foreign"Chỉ "foreign" hoặc "proprietary" — chỉ ở flow().
groupsSectorInvestorGroupsArgNoneNone nghĩa là cả hai nhóm — chỉ ở breakdown().
start end interval refresh on_errorNhư 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ộtKiểuCó thể NaNĐơn vịMô tả
icbstringkhôngMã ngành ICB, cấp 2 hoặc cấp 4 theo icb_level.
icb_namestringTên ngành.
icb_levelint64khôngCấp của mã ngành: 2 hoặc 4.
datedatetime64[ns]khôngNgà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ỳ.
groupstringkhôngforeign hoặc proprietary.
buy_valuefloat64VND thôGiá trị mua của cả ngành trong kỳ.
sell_valuefloat64VND thôGiá trị bán của cả ngành trong kỳ.
net_valuefloat64VND thôbuy_value - sell_value.
buy_volumefloat64cổ phiếuKhối lượng mua của cả ngành.
sell_volumefloat64cổ phiếuKhối lượng bán của cả ngành.
net_volumefloat64cổ phiếubuy_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ểuMặc địnhMô tả
symbolsSymbolsArg"VN30F1M", "VN30F1M,VN30F2M", hoặc danh sách. Tất cả phải là mã phái sinh. Positional.
groupDerivativeInvestorGroup"foreign"Chỉ "foreign" hoặc "proprietary" — chỉ ở flow().
groupsDerivativeInvestorGroupsArgNoneNone nghĩa là cả hai nhóm — chỉ ở breakdown().
start end interval refresh on_errorNhư 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ộtunits[...]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_countNonesupply_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ệu

df.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.stockclient.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

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

Nội dung trang