Báo cáo tài chính Python: lấy BCTC cổ phiếu Việt Nam
Dùng finlens lấy báo cáo tài chính doanh nghiệp niêm yết Việt Nam theo năm hoặc quý về pandas DataFrame: cân đối kế toán, kết quả kinh doanh, lưu chuyển tiền tệ.
Cân đối kế toán, kết quả kinh doanh và lưu chuyển tiền tệ của doanh nghiệp niêm
yết — theo năm hoặc theo quý, trả về pandas.DataFrame.
Ba phương thức, và chúng trả lời ba câu hỏi khác nhau:
| Gọi | Trả lời câu hỏi |
|---|---|
client.financials.statement() | Khoản mục này của kỳ này bằng bao nhiêu? |
client.financials.periods() | Mã này có số liệu ở những kỳ nào? |
client.financials.line_items() | Loại báo cáo này gồm những khoản mục nào? |
Đổi tên từ 0.1.x: client.reporting → client.financials
Namespace nay tên là financials. client.reporting không còn tồn tại và
sẽ ném AttributeError.
Tham số cũng đổi theo, không chỉ đổi tên namespace:
| 0.1.x | 1.x |
|---|---|
symbol= | symbols — tham số vị trí, mọi tham số sau nó là keyword-only |
statement="balance" | kind="balance_sheet" |
period="year" / "quarter" | period="annual" / "quarterly" |
values_format="long" / "cross" | không còn — frame luôn ở dạng long |
start_year bắt buộc | start_year=None là để máy chủ chọn dải thực có |
Bốn loại hình doanh nghiệp, bốn cây khoản mục
Đây là điều chi phối cả trang: không tồn tại một "bảng cân đối kế toán" duy nhất. Có bốn cây khác nhau, một cho mỗi loại hình doanh nghiệp, không cùng số khoản mục và không cùng tên khoản mục.
company_type | Loại hình |
|---|---|
CT | Doanh nghiệp phi tài chính |
NH | Ngân hàng |
CK | Công ty chứng khoán |
BH | Bảo hiểm |
Loại hình của từng mã do máy chủ tra, và nó đi kèm ngay ở cột company_type
của mỗi dòng — bạn không phải gọi thêm gì trước, cũng không phải tự giữ một bảng
tra riêng.
Gọi nhiều mã khác loại hình trong một lời gọi là chuyện bình thường
Frame ở dạng long và mỗi dòng mang company_type của chính nó, nên ba cây
khác nhau nằm chung một bảng được:
df = client.financials.statement("HPG,VCB,SSI")
sorted(df["company_type"].unique()) # ['CK', 'CT', 'NH']Đây là mặc định chứ không phải một trường hợp đặc biệt. Bản 0.1.x bắt bạn kiểm loại hình trước mỗi lần lấy báo cáo và tự loại bỏ mã "sai loại"; ràng buộc đó là do client tự áp, không phải do dữ liệu.
Loại hình thứ năm là QU (quỹ), và nó không có báo cáo tài chính dạng
cây — đó là trạng thái vĩnh viễn của dữ liệu chứ không phải một khoảng trống
tạm thời. Mã thuộc QU đi vào phần lỗi theo từng mã và không làm hỏng phần
còn lại của lời gọi; xem on_error ở Xử lý lỗi.
statement() — số liệu từng khoản mục
client.financials.statement(
symbols,
*,
kind="balance_sheet",
period="annual",
start_year=None,
end_year=None,
quarters=None,
refresh=False,
on_error="warn",
) -> DataFrameChỉ symbols là tham số vị trí; mọi tham số còn lại là keyword-only.
| Tham số | Kiểu | Mặc định | Nội dung |
|---|---|---|---|
symbols | SymbolsArg | — | "HPG", "HPG,VCB" hoặc ["HPG", "VCB"]. Được phép trộn nhiều loại hình doanh nghiệp. |
kind | StatementKind | "balance_sheet" | Đúng một loại báo cáo. Bốn giá trị ở bảng bên dưới. |
period | StatementPeriod | "annual" | "annual" (số liệu cả năm) hoặc "quarterly" (số liệu quý). |
start_year | int hoặc None | None | Năm đầu của khoảng. None là lấy từ đầu dải thực có của từng mã. |
end_year | int hoặc None | None | Năm cuối, tính cả chính nó. None là lấy tới hết dải thực có. |
quarters | str, Sequence[int] hoặc None | None | Lọc theo quý: "1,3" hoặc [1, 3]. None nghĩa là tất cả các quý. |
refresh | bool | False | Bỏ qua bản trong cache và lấy lại từ máy chủ. |
on_error | OnError | "warn" | warn · raise · ignore — số phận của các mã lỗi trong lời gọi nhiều mã. |
Bốn giá trị của kind
| Giá trị | Báo cáo |
|---|---|
balance_sheet | Cân đối kế toán |
income_statement | Kết quả kinh doanh |
cash_flow_direct | Lưu chuyển tiền tệ trực tiếp |
cash_flow_indirect | Lưu chuyển tiền tệ gián tiếp |
Tên chứ không phải số thứ tự: một con số như 3 không tự nói nó là lưu chuyển
tiền tệ trực tiếp hay gián tiếp, nên mỗi người đọc phải giữ một bảng tra nằm
ngoài dữ liệu — và nếu nguồn đánh số lại thì mọi truy vấn vẫn chạy, chỉ trả sai
báo cáo.
15 tổ hợp, không phải 16 — công ty chứng khoán không có cash_flow_direct
Công ty chứng khoán (CK) không lập lưu chuyển tiền tệ trực tiếp. Yêu cầu tổ hợp
đó nhận một lỗi 400 — một finlens.ValidationError nói rõ loại hình, kind
bạn đã hỏi, và những kind còn dùng được — chứ không phải một bảng rỗng.
Phân biệt này quan trọng vì "rỗng" đã mang một nghĩa khác hẳn: khoảng bạn hỏi hợp lệ nhưng không có dòng nào. Dùng lại nó cho tổ hợp này không tồn tại là xoá mất đúng thông tin bạn cần để biết phải làm gì tiếp.
Cột trả về
| Cột | dtype | Nội dung |
|---|---|---|
symbol | string | Mã chứng khoán, viết hoa. |
company_type | string | CT · NH · CK · BH — quyết định cây khoản mục nào áp cho dòng này. |
kind | string | Loại báo cáo của dòng. |
year | int16 | Năm của kỳ. |
quarter | int8 | 0 là cả năm, 1..4 là quý. Không bao giờ null. |
period_label | string | Nhãn kỳ đọc được: "2025" hoặc "Q1 2025". |
item_id | int32 | Mã hiển thị của khoản mục, chỉ có nghĩa trong phạm vi (company_type, kind). |
line_item | string | Tên khoản mục theo nguồn, ví dụ "TỔNG CỘNG TÀI SẢN". |
parent_id | Int32 | item_id của khoản mục cha, null ở node gốc. |
level | int8 | Độ sâu trong cây. |
order_index | int32 | Thứ tự trình bày của bản báo cáo. |
value | float64 | Giá trị của khoản mục, VND thô. null là kỳ đó không có số, không phải bằng 0. |
Mười hai cột luôn có mặt đúng dtype kể cả khi không có dòng nào, nên bạn không
cần bọc if not df.empty quanh mỗi lời gọi.
item_id là một mã hiển thị do máy chủ cấp, không phải id nội bộ của nguồn:
nó được cấp một lần và không đổi theo thời gian, nên con số bạn ghi vào
notebook hôm nay vẫn trỏ đúng khoản mục ấy về sau. Đổi lại, nó chỉ có nghĩa
trong phạm vi một (company_type, kind) — cùng một con số ở hai loại hình
doanh nghiệp là hai khoản mục khác hẳn nhau.
Frame giữ nguyên thứ tự máy chủ gửi, tức kỳ mới nhất đứng trước — đó là thứ
người mở một báo cáo tài chính muốn thấy đầu tiên. Muốn khác thì
df.sort_values(...).
import finlens
client = finlens.client()
df = client.financials.statement("HPG")
df.attrs["finlens"]["units"]["value"] # 'VND'
# Kết quả kinh doanh theo quý, từ 2023
df = client.financials.statement(
"HPG",
kind="income_statement",
period="quarterly",
start_year=2023,
)
# Chỉ quý 1 và quý 3
df = client.financials.statement("HPG", period="quarterly", quarters=[1, 3])quarter = 0 nghĩa là cả năm
df[df['quarter'] > 0] là cách nhanh nhất để mất sạch số liệu năm
Cột quarter không bao giờ null: một kỳ không thuộc quý nào thì mang 0
chứ không mang giá trị thiếu. 0 nghĩa là cả năm, không phải thiếu quý.
ca_nam = df[df["quarter"] == 0] # số liệu năm
theo_quy = df[df["quarter"] > 0] # số liệu quýLọc nhầm không ném lỗi nào — bạn chỉ nhận một bảng ngắn hơn.
Có hai thứ tên gần giống nhau và cố ý không trùng tên:
- Tham số
period=nhận"annual"/"quarterly"— nó nói bạn muốn kỳ loại nào. - Cột
period_labelmang"2025"/"Q1 2025"— nó nói dòng này thuộc kỳ nào.
Hai tập giá trị rời hẳn nhau. Nếu cột đó cũng tên period thì
df[df["period"] == "annual"] sẽ trả bảng rỗng mà không ném lỗi nào — người
viết tin mình đang lọc, thực ra đang xoá sạch. Muốn lọc thì lọc theo quarter.
Dựng lại cây báo cáo từ chính frame này
Mỗi dòng đã mang sẵn parent_id, level và order_index, nên bạn dựng được cây
ngay trên bảng số liệu — không cần gọi line_items() trước:
ky = df[(df["symbol"] == "HPG") & (df["quarter"] == 0) & (df["year"] == 2025)]
goc = ky[ky["parent_id"].isna()] # các node gốc
con = ky.groupby("parent_id")["item_id"].apply(list) # con của từng node
# Đọc theo đúng thứ tự của bản báo cáo giấy
ky.sort_values("order_index")[["level", "line_item", "value"]]parent_id là Int32 viết hoa — kiểm bằng .isna(), đừng so với 0
Int32 (viết hoa) là kiểu số nguyên nullable của pandas, và ở đây đó là điều
kiện để phép dựng cây phía trên đúng: node gốc có parent_id là null.
int32 thường của numpy không mang được giá trị thiếu — nó biến null thành 0,
tức biến mọi node gốc thành con của một node số 0 không tồn tại. Cây bạn dựng
lại sẽ có thêm một tầng bịa ra, và không có lỗi nào được ném.
goc = ky[ky["parent_id"].isna()] # đúng
goc = ky[ky["parent_id"] == 0] # saiJoin hai mã khác loại hình
item_id chỉ có nghĩa trong phạm vi (company_type, kind). Cùng một số ở
hai loại hình là hai khoản mục khác hẳn nhau, nên khoá join phải mang theo cả
hai cột đó:
# Đúng
a.merge(b, on=["company_type", "kind", "item_id"])
# Sai — ghép nhầm hai khoản mục không liên quan
a.merge(b, on="item_id")Vài giá trị vượt ngưỡng chính xác của số thực
Cột value là float64, và một số ít dòng đã bị làm tròn
Một số ít bản ghi trong nguồn có giá trị vượt trần số nguyên an toàn (2⁵³ ≈ 9,007 × 10¹⁵). Máy chủ phát nguyên giá trị thay vì cắt hay clamp: chính con số vô lý đó là tín hiệu duy nhất cho biết bản ghi nguồn đã hỏng, còn sửa nó trong im lặng là sửa dữ liệu của người khác.
Đây không phải chuyện thang đo. Một ngân hàng lớn có những dòng cỡ 10¹⁵ VND hoàn toàn thật và vẫn nằm dưới trần; chia mọi thứ cho 10⁶ "cho an toàn" là làm lệch hàng triệu dòng đúng đắn để né vài dòng sai.
Hệ quả bạn phải biết: cột value là float64 với đúng 53 bit phần định trị, nên
những dòng vượt trần đã bị làm tròn ngay trong bảng. Gặp một giá trị vô lý so
với quy mô doanh nghiệp thì đọc lại từ báo cáo gốc, đừng tin con số trong bảng.
Và đừng chuyển tiếp frame này sang JavaScript mà không xử lý — Number mất độ
chính xác từ sớm hơn nhiều so với int của Python.
periods() — kỳ nào thực sự có số liệu
client.financials.periods(
symbols,
*,
refresh=False,
on_error="warn",
) -> DataFrameMột dòng cho mỗi bộ (symbol, year, quarter). Dùng nó để chọn start_year /
end_year cho đúng, thay vì hỏi một khoảng rồi nhận về bảng rỗng và không biết
vì sao. Rẻ hơn statement() rất nhiều: nó chỉ trả lịch kỳ, không trả khoản mục
nào.
| Cột | dtype | Nội dung |
|---|---|---|
symbol | string | Mã chứng khoán. |
company_type | string | Loại hình doanh nghiệp của mã. |
year | int16 | Năm của kỳ. |
quarter | int8 | 0 là cả năm, 1..4 là quý. |
period_label | string | "2025" hoặc "Q1 2025". |
Không cột nào ở đây mang đơn vị — đây là lịch kỳ, không phải số liệu.
Kết quả sắp tăng dần theo symbol, year, quarter. Đây là chỗ khác
statement() một cách có chủ đích: bảng này là một trục thời gian để chọn khoảng,
mà một trục thời gian thì đọc xuôi.
ky = client.financials.periods("HPG")
nam_co_du_lieu = sorted(ky.loc[ky["quarter"] == 0, "year"].unique())
df = client.financials.statement("HPG", start_year=int(min(nam_co_du_lieu)))periods() không nhận kind
Một dòng ở đây nghĩa là mã này có ít nhất một báo cáo trong kỳ đó. Nó không
hứa rằng cả bốn loại báo cáo đều có mặt trong kỳ ấy — muốn chính xác tới từng
loại thì hỏi thẳng statement().
Một mã thường có cả dòng năm lẫn dòng quý cho cùng một year, và đó không phải
trùng lặp.
Yêu cầu một khoảng rộng hơn dải thực có không phải lỗi: máy chủ cắt về phần
có thật thay vì từ chối cả lời gọi. periods() là cách biết trước dải đó.
line_items() — cây khoản mục, không cần mã nào
client.financials.line_items(
*,
com_type,
kind,
refresh=False,
on_error="warn",
) -> DataFramePhương thức duy nhất trong nhóm không nhận mã chứng khoán, và cũng không có
tham số vị trí nào. Khoá của cây là cặp (com_type, kind) — cấu trúc báo cáo
không thuộc về một doanh nghiệp cụ thể nào.
| Tham số | Kiểu | Mặc định | Nội dung |
|---|---|---|---|
com_type | CompanyType | bắt buộc | "CT" · "NH" · "CK" · "BH". |
kind | StatementKind | bắt buộc | Cùng bốn giá trị với statement(). |
refresh | bool | False | Bỏ qua bản trong cache. |
on_error | OnError | "warn" | Xử lý lỗi. |
Cả hai đều không có giá trị mặc định, và đó là chủ đích: bốn loại hình có bốn
cây khác nhau nên không giá trị nào là trung lập, còn kind thì đoán hộ sẽ trả về
một cây bạn không hỏi. statement() mặc định được balance_sheet vì hỏi báo cáo
mà không nói loại nào thì cân đối kế toán là câu trả lời ít bất ngờ nhất; ở đây
thì không.
"QU" (quỹ) không nằm trong kiểu CompanyType, nên gõ nó vào là một gạch đỏ
ngay trong IDE chứ không phải một lỗi sau một vòng gọi mạng.
Cột trả về
| Cột | dtype | Nội dung |
|---|---|---|
company_type | string | Loại hình doanh nghiệp của cây. |
kind | string | Loại báo cáo của cây. |
item_id | int32 | Mã hiển thị của khoản mục. |
line_item | string | Tên khoản mục. |
parent_id | Int32 | item_id của khoản mục cha, null ở node gốc. |
level | int8 | Độ sâu trong cây. |
order_index | int32 | Thứ tự trình bày. |
Không cột nào mang đơn vị — đây là cấu trúc, không phải số liệu. Năm cột cây
trùng đúng tên và đúng dtype với statement(), nên hai frame join được với nhau
theo (company_type, kind, item_id) mà không phải đổi hình.
line_items() không phải bước bắt buộc trước statement()
statement() đã trả kèm parent_id / level / order_index ở mỗi dòng,
nên bạn dựng được cây ngay trên frame số liệu. Phương thức này dùng khi muốn biết
cấu trúc trước lúc hỏi số, hoặc muốn so cây của hai loại hình doanh nghiệp.
Bản 0.1.x làm ngược: nó gọi một bước kiểm tra trước mỗi lần lấy báo cáo, chỉ để lấy thứ mà máy chủ vốn đã trả kèm.
cay_nh = client.financials.line_items(com_type="NH", kind="balance_sheet")
cay_ct = client.financials.line_items(com_type="CT", kind="balance_sheet")
len(cay_nh) != len(cay_ct) # True — hai cây, hai kích thước
# Đọc cây theo đúng thứ tự trình bày
cay_nh.sort_values("order_index")[["level", "line_item"]]Đơn vị là VND thô
Mọi giá trị trong statement() tính bằng VND thô — không phải nghìn VND như
cột giá cổ phiếu, cũng không phải triệu hay tỷ đồng.
df.attrs["finlens"]["units"]["value"] # 'VND'Nghĩa là tổng tài sản của một doanh nghiệp lớn sẽ là một con số mười lăm chữ số.
Muốn hiển thị cho người đọc thì chia ở tầng trình bày, đừng chia trong dữ liệu —
df["value"] / 1e9 để ra tỷ đồng.
⚠️ DataFrame.attrs không sống sót qua pd.concat hay merge của pandas.
Đọc đơn vị ra biến trước khi ghép bảng. Chi tiết ở
Kiểu dữ liệu.
Bản async
Cả ba phương thức đều có bản bất đồng bộ với cùng chữ ký:
import asyncio
import finlens
async def main():
async with finlens.AsyncClient() as client:
df = await client.financials.statement("HPG,VCB", kind="income_statement")
asyncio.run(main())Xem thêm
Dữ liệu cuối ngày (EOD)
Giá theo phiên và dòng tiền theo nhóm nhà đầu tư.
Kiểu dữ liệu
StatementKind, CompanyType và cách đọc df.attrs["finlens"].
Xử lý lỗi
on_error, ValidationError và cây ngoại lệ FL_*.
FinLens MCP
Hỏi báo cáo tài chính bằng ngôn ngữ tự nhiên qua trợ lý AI.
Câu hỏi thường gặp
Làm sao lấy báo cáo tài chính cổ phiếu Việt Nam bằng Python?
Dùng `client.financials.statement()` của thư viện Python finlens: `client = finlens.client()` rồi `client.financials.statement("HPG", kind="income_statement", period="quarterly", start_year=2023)`. Chỉ `symbols` là tham số vị trí, mọi tham số còn lại keyword-only. Kết quả là `pandas.DataFrame` ở dạng long — một dòng là một khoản mục của một kỳ — với các cột `symbol`, `company_type`, `kind`, `year`, `quarter`, `period_label`, `line_item`, `value` cùng ba cột dựng cây `parent_id`, `level`, `order_index`. Giá trị tính bằng VND thô.
finlens hỗ trợ những loại báo cáo tài chính nào?
Tham số `kind` của `client.financials.statement()` nhận bốn giá trị: `"balance_sheet"` (cân đối kế toán), `"income_statement"` (kết quả kinh doanh), `"cash_flow_direct"` (lưu chuyển tiền tệ trực tiếp) và `"cash_flow_indirect"` (lưu chuyển tiền tệ gián tiếp). Chu kỳ chọn qua `period` với `"annual"` hoặc `"quarterly"`, khoảng thời gian qua `start_year` và `end_year` — bỏ trống là lấy trọn dải thực có — và lọc quý qua `quarters=[1, 3]`. Công ty chứng khoán không lập lưu chuyển tiền tệ trực tiếp, nên tổ hợp đó nhận một lỗi 400 rõ ràng chứ không phải một bảng rỗng.
Lấy báo cáo tài chính của nhiều mã khác loại hình cùng lúc được không?
Được, và đó là mặc định chứ không phải trường hợp đặc biệt. `client.financials.statement("HPG,VCB,SSI")` của thư viện Python finlens chạy bình thường dù HPG là doanh nghiệp phi tài chính, VCB là ngân hàng và SSI là công ty chứng khoán: frame ở dạng long và mỗi dòng mang `company_type` của chính nó. Bốn loại hình `CT`, `NH`, `CK`, `BH` có bốn cây khoản mục khác nhau nên `item_id` chỉ có nghĩa trong phạm vi `(company_type, kind)` — khoá join phải mang theo cả hai cột đó. Quỹ (`QU`) không có báo cáo dạng cây và đi vào phần lỗi theo từng mã.
Cột quarter bằng 0 trong bảng báo cáo tài chính nghĩa là gì?
Nghĩa là dòng đó là số liệu cả năm, không phải thiếu quý. Trong thư viện Python finlens, cột `quarter` không bao giờ `null`: `0` là cả năm, `1` đến `4` là quý. Vì vậy `df[df["quarter"] > 0]` lọc ra số liệu quý và loại sạch số liệu năm, còn `df[df["quarter"] == 0]` mới cho số liệu năm — lọc nhầm không ném lỗi nào, bạn chỉ nhận một bảng ngắn hơn. Nhãn kỳ đọc được nằm ở cột `period_label` với giá trị dạng `"2025"` hoặc `"Q1 2025"`.
client.reporting của bản 0.1.x còn dùng được không?
Không. Ở bản 1.x namespace tên là `client.financials`, và `client.reporting` ném `AttributeError`. Tham số cũng đổi chứ không chỉ đổi tên namespace: `symbol=` thành `symbols` ở vị trí đầu (mọi tham số sau nó là keyword-only), `statement="balance"` thành `kind="balance_sheet"`, `period="year"` và `"quarter"` thành `period="annual"` và `"quarterly"`, còn `values_format` không còn vì frame luôn ở dạng long. `start_year` nay bỏ trống được để máy chủ chọn trọn dải thực có.
Cập nhật lần cuối