Thuyết minh BCTC bằng Python — nhóm nợ, tồn kho, giá vốn
Lấy thuyết minh BCTC của 30 tổ chức tín dụng và 1.443 doanh nghiệp Việt Nam về pandas DataFrame: năm nhóm chất lượng nợ, tồn kho, giá vốn, hao mòn tài sản.
Bốn báo cáo chính cho bạn một con số. Thuyết minh cho bạn biết con số ấy gồm những gì.
Với ngân hàng: cho vay cho ai, ngành nào, và bao nhiêu trong đó đang có vấn đề. Với doanh nghiệp thường: hàng tồn kho gồm những gì, giá vốn cấu thành ra sao, tài sản cố định đã hao mòn bao nhiêu.
Hai phương thức, cùng nằm trong client.financials:
| Phương thức | Trả về |
|---|---|
notes(symbols, section=…) | Từng dòng thuyết minh theo kỳ, dạng long |
note_sections() | Danh mục 51 nhóm, kèm độ phủ đo lúc gọi |
| Loại doanh nghiệp | company_type | Số mã | Số nhóm |
|---|---|---|---|
| Tổ chức tín dụng | NH | 30 | 22 |
| Doanh nghiệp thường | CT | 1.443 | 29 |
| Công ty chứng khoán | CK | — | chưa phát hành |
| Doanh nghiệp bảo hiểm | BH | — | nguồn không có |
Năm nhóm nợ — thứ hầu hết người dùng tìm
import finlens
client = finlens.client()
df = client.financials.notes("VCB", section="loan_quality", start_year=2025, end_year=2025)
# Bỏ dòng gốc, giữ năm nhóm con
la = df[df["parent_code"].notna() & (df["quarter"] == 2)]
print(la[["code", "line_item", "value"]]) code line_item value
4.1 4.1. Nợ đủ tiêu chuẩn 1.536317e+15
4.2 4.2. Nợ cần chú ý 3.876597e+12
4.3 4.3. Nợ dưới tiêu chuẩn 2.106631e+12
4.4 4.4. Nợ nghi ngờ 2.013210e+12
4.5 4.5. Nợ xấu có khả năng mất vốn 1.145609e+13Đây là phân nhóm theo Thông tư 11. Nhóm 3, 4 và 5 hợp thành nợ xấu:
goc = df[df["parent_code"].isna() & (df["quarter"] == 2)]["value"].iloc[0]
xau = la[la["code"].isin(["4.3", "4.4", "4.5"])]["value"].sum()
print(f"Tỷ lệ nợ xấu: {xau / goc:.2%}")Ba thứ sẽ làm bạn đọc sai số nếu bỏ qua
quarter = 0nghĩa là CẢ NĂM, không phải "thiếu quý". Lọcdf[df["quarter"] > 0]là cách nhanh nhất để mất sạch số liệu năm, im lặng.quarter = 0mang HAI nghĩa, tuỳ nhóm. Xem mục ngay dưới — đây là chỗ dễ sai nhất của cả trang.- Đừng tự cắt chuỗi
codeđể suy ra cha. Dùng cộtparent_code— server chịu trách nhiệm cho quan hệ đó.
⚠️ Dòng cả năm: bằng quý 4, hay bằng tổng bốn quý?
Cùng một giá trị quarter = 0, nhưng nó mang hai nghĩa khác nhau tuỳ nhóm — và
nhầm chiều nào cũng sai gần bốn lần.
| Loại nhóm | quarter = 0 là | Số nhóm |
|---|---|---|
| Số dư — tồn kho, dư nợ, vốn chủ sở hữu | bằng đúng quý 4 | 36 |
| Kết quả kinh doanh — doanh thu, giá vốn, thu nhập lãi | tổng bốn quý | 14 |
Đừng đoán. Cột full_year_equals_q4 của note_sections() nói thẳng:
ds = client.financials.note_sections()
print(ds[["company_type", "section", "full_year_equals_q4"]].head())
# Nhóm nào cộng dồn bốn quý ra số cả năm?
cong_duoc = ds[ds["full_year_equals_q4"] == False]["section"].tolist()Hệ quả cụ thể: với inventory thì đọc quý 4 là số cuối năm. Với revenue thì
quý 4 chỉ là một quý — đọc nó như doanh thu cả năm là hiểu thiếu khoảng bốn lần.
Đừng đọc cột này là 'chỉ tiêu dòng hay số dư'
Bốn nhóm biến động tài sản của doanh nghiệp thường — tangible_asset_cost,
tangible_asset_depreciation, goodwill_cost, goodwill_amortization — có dòng
con tên tăng trong kỳ, nghe đúng như một chỉ tiêu dòng.
Nhưng nguồn lưu chúng luỹ kế từ đầu năm, nên quý 4 đã là số cả năm và cộng bốn
quý là cộng lặp. full_year_equals_q4 của bốn nhóm ấy là True.
Đó là lý do cột mang tên ấy chứ không mang tên is_flow: câu hỏi bạn cần trả lời
là "tôi có được cộng không", không phải "đây là loại chỉ tiêu gì".
car là nhóm duy nhất có full_year_equals_q4 là <NA> — không kỳ nào của nó có
đủ bốn quý để so, nên thư viện nói chưa đo được thay vì đoán một trong hai.
Dựng lại cây bằng một groupby
Mỗi dòng mang parent_code, level và order_index, nên cấu trúc thuyết minh
dựng lại được ngay trên frame — không phải gọi thêm một endpoint thứ hai:
df = client.financials.notes("VCB", section="loan_by_industry", start_year=2026)
ky = df[df["quarter"] == 2]
for cha, con in ky[ky["parent_code"].notna()].groupby("parent_code"):
print(cha)
for _, r in con.sort_values("order_index").iterrows():
print(f" {r['code']:<6} {r['line_item'][:44]:<44} {r['value']:,.0f}")code là số thứ tự in trong chính bản BCTC (4.1, 4.2), không phải một id
nội bộ — nên nó khớp đúng dòng trong bản PDF bạn mở cạnh.
Doanh nghiệp thường: tồn kho, giá vốn, hao mòn
df = client.financials.notes("HPG", section="inventory", start_year=2025, end_year=2025)
ky = df[(df["quarter"] == 2) & df["parent_code"].notna()]
print(ky[["code", "line_item", "value"]])Nhóm này có tối đa chín dòng con: nguyên vật liệu, công cụ dụng cụ, chi phí sản xuất dở dang, thành phẩm, hàng hoá, hàng gửi đi bán… — tức đủ để thấy tồn kho đang đọng ở đâu, thứ mà một con số tổng trên bảng cân đối không nói.
"Tối đa" là chữ đúng: mỗi doanh nghiệp chỉ công bố những khoản nó có. HPG quý
2/2025 ra bảy dòng, không chín — và số dòng thiếu ấy là thông tin, không phải
lỗi. Đừng viết code giả định một số dòng cố định.
`start_year` không tự đóng ở năm hiện tại
Bỏ end_year thì dải mặc định chạy tới năm sau, vì kỳ Q4 của năm nay được công
bố sang năm sau. Nên start_year=2025 một mình trả cả 2025 và 2026, và một phép
lọc quarter == 2 sẽ ra hai bộ dòng chứ không một.
Đây là chỗ dễ vấp nhất khi đọc bằng mắt: bảng in ra trông đúng cấu trúc, chỉ nhiều
gấp đôi. Kẹp end_year, hoặc lọc thêm year, khi bạn muốn đúng một kỳ.
Bốn nhóm hay dùng nhất của doanh nghiệp thường:
| Token | Nội dung | Cộng dồn được? |
|---|---|---|
inventory | Hàng tồn kho, 9 dòng con | không — là số dư |
cost_of_goods_sold | Giá vốn theo loại | có |
production_cost_by_element | Chi phí SXKD theo yếu tố: NVL, nhân công, khấu hao | có |
tangible_asset_depreciation | Hao mòn luỹ kế TSCĐ hữu hình | không |
Cặp tangible_asset_cost / tangible_asset_depreciation là chỗ đáng để ý: có cả
hai mới tính được giá trị còn lại và suy ra tuổi tài sản.
51 nhóm, và độ phủ của từng nhóm
ds = client.financials.note_sections()
print(ds[["company_type", "code", "section", "organization_count", "last_year"]])
# Danh mục của riêng doanh nghiệp thường
print(ds[ds["company_type"] == "CT"])Bảng này tồn tại thay cho một danh sách tĩnh trong tài liệu, vì
organization_count và last_year được đo lại mỗi lần gọi. Đọc chúng trước
khi kết luận một nhóm là "không có dữ liệu".
Bảy nhóm hay dùng nhất của tổ chức tín dụng:
| Token | Nội dung |
|---|---|
loan_quality | Cho vay theo chất lượng nợ — năm nhóm Thông tư 11 |
loan_by_industry | Cho vay theo ngành kinh tế |
loan_by_customer_type | Cho vay theo đối tượng khách hàng |
loan_provision | Dự phòng cho vay khách hàng |
deposit_by_type | Tiền gửi theo loại (không kỳ hạn, có kỳ hạn…) |
service_income | Lãi thuần từ hoạt động dịch vụ, chi tiết từng khoản |
operating_expense | Chi phí hoạt động |
Mỗi token thuộc đúng MỘT loại doanh nghiệp
notes("HPG", section="loan_quality") cho ra bảng rỗng, không phải lỗi: token
hợp lệ, mã hợp lệ, chỉ cặp đôi là không tồn tại — HPG không phải ngân hàng nên
cây của nó không có nhóm ấy.
Lọc note_sections() theo company_type để biết token nào dùng cho loại nào.
Bỏ trống section thì trả mọi nhóm của những loại có mặt trong symbols — hỏi
một mã ngân hàng thì 29 nhóm của doanh nghiệp thường không tham gia. Tham số nhận
một giá trị; muốn hai nhóm thì bỏ trống rồi lọc trên frame:
df = client.financials.notes("VCB", start_year=2026)
df = df[df["section"].isin(["loan_quality", "loan_provision"])]Bốn nhóm của nguồn cố ý không có token
Ba nhóm của ngân hàng bị loại vì gần như không có dữ liệu: cho vay theo vị trí
địa lý (ngừng cập nhật từ 2017, chỉ 9 tổ chức), tài sản sinh lãi (5 dòng
trên 20 năm) và công nợ phải trả lãi (4 dòng). Một nhóm của doanh nghiệp
thường cũng vậy: 34. Những thông tin khác — 0 dòng dữ liệu, nó là một
tiêu đề chứ không phải một chuỗi số.
Cấp token cho chúng là mời bạn gõ một thứ luôn trả gần như rỗng — mà một bảng rỗng thì không phân biệt được "tổ chức này không có khoản đó" với "mình gõ sai". Gõ một token không tồn tại là lỗi có thông báo, kèm danh sách token hợp lệ.
Ai có dữ liệu, và từ năm nào
Cả hai loại đều từ 2006 tới nay: 30 tổ chức tín dụng và 1.443 doanh nghiệp thường. Nhưng độ phủ không đều — với nhóm ngân hàng, năm 2006 chỉ có 9 tổ chức và chỉ bão hoà 28–30 từ 2016.
30 mã, nhưng 28 ngân hàng
EVF (ICB 8773) và TIN (ICB 8771) là công ty tài chính, không phải ngân
hàng. Chúng có mặt vì nguồn danh mục xếp chúng vào loại doanh nghiệp NH.
Cần đúng nhóm ngân hàng thì lọc trước bằng
client.meta.symbols(icb="8355")["symbol"].
Một lời gọi trộn được hai loại — và đó là chỗ có một cái bẫy:
df = client.financials.notes("VCB,HPG", start_year=2025, quarters=[0])
print(df["company_type"].unique()) # ['NH' 'CT']`code` chỉ duy nhất TRONG một loại doanh nghiệp
code là số thứ tự in trong bản BCTC, và hai loại doanh nghiệp có hai bản BCTC
khác nhau — nên cả hai đều có 1, 4.1, 11.2. Đo trên chính lời gọi trên: 41
giá trị code xuất hiện ở cả hai bên.
df.groupby("code")["value"].sum() # ✗ cộng hai khoản mục lạ
df.groupby(["section", "code"])["value"].sum() # ✓groupby(["section", "code"]) an toàn vì không token nhóm nào trùng giữa hai
loại. Hoặc tách thẳng bằng df[df["company_type"] == "CT"].
Mã của công ty chứng khoán đi vào phần lỗi theo từng mã, kèm thông báo nói đúng nguyên nhân — cây thuyết minh của họ tồn tại trong nguồn nhưng chưa được phát hành, khác hẳn "mã này không có dữ liệu":
df = client.financials.notes("VCB,SSI", start_year=2025)
print(df.attrs["finlens"]["failed"]["SSI"]["message"])Đơn vị là VND thô, trừ đúng một nhóm
Cột value tính bằng đồng Việt Nam nguyên, khác kVND của cột giá cổ phiếu.
Đừng giả định — đọc thẳng:
print(df.attrs["finlens"]["units"]["value"]) # 'VND'value là <NA> khi nguồn không có ô đó — không phải 0.
`car` là phần trăm, không phải tiền
Nhóm car (tỷ lệ an toàn vốn) mang đơn vị pct: 12.16 nghĩa là 12,16 phần
trăm, không phải 12,16 đồng.
df = client.financials.notes("VCB", section="car", start_year=2024)
print(df.attrs["finlens"]["units"]["value"]) # 'pct'Hỏi car cùng một nhóm khác trong một lời gọi thì frame trộn hai đơn vị, và
units["value"] chỉ nói được một. Khi đó thư viện phát một DataQualityWarning
mã NOTES_UNIT_MIXED nêu đúng nhóm nào lệch. Cần một frame thuần đơn vị thì hỏi
car riêng.
Chỉ có báo cáo hợp nhất
Nguồn không có báo cáo riêng lẻ cho nhóm này, nên không có tham số nào để chọn. Mọi con số là số hợp nhất.
Không có cột tỷ lệ nào
Nhóm này trả con số tuyệt đối, không phát thêm một cột npl_ratio. Lý do:
indicators() đã có chỉ tiêu đó, và hai đường đã được đối chiếu — NPL suy từ
thuyết minh cho 1,0012% với VCB quý 2/2025 so với 0,01001 của indicators(),
lệch dưới 3e-05.
Cần tỷ lệ thì dùng indicators(); cần chính các con số của từng nhóm thì dùng
notes().
Nếu bạn cũng dùng công cụ MCP của FinLens
Hai sản phẩm dùng hai bộ tên cho cùng dữ liệu, và điều đó có thật ở cả hai loại doanh nghiệp. Vài chỗ lệch hay gặp:
| Thư viện Python | MCP |
|---|---|
loan_quality | loans_by_quality |
deposit_by_type | deposits_by_type |
operating_expense | operating_expenses |
short_term_investment | st_investments |
cost_of_goods_sold | cogs |
tangible_asset_cost | fixed_assets |
Thư viện dùng số ít, viết đủ chữ, không viết tắt — cùng quy ước với 22 token ngân hàng đã phát hành từ trước.
Và thư viện phủ rộng hơn ở nhóm doanh nghiệp thường: 29 nhóm so với 20 của MCP.
Chín nhóm MCP không có là toàn bộ họ nguyên giá / hao mòn của tài sản thuê tài
chính, tài sản vô hình, bất động sản đầu tư, lợi thế thương mại, cộng
tangible_asset_depreciation — khoảng 619 nghìn dòng, tức 16% dữ liệu. Riêng
nhóm hao mòn TSCĐ hữu hình có 230.857 dòng trên 1.442 tổ chức, và không có nó thì
không tính được giá trị còn lại.
Bản async
import asyncio
import finlens
async def main():
async with finlens.async_client() as client:
df = await client.financials.notes("VCB", section="loan_quality")
print(len(df))
asyncio.run(main())Xem thêm
Báo cáo tài chính & chỉ tiêu
Bốn báo cáo chính và chỉ tiêu tính sẵn, gồm npl_ratio.
Tra cứu danh mục
Lọc đúng 28 mã ngân hàng bằng icb="8355".
Kiểu dữ liệu
Quy ước đơn vị và cách đọc df.attrs["finlens"].
Xử lý lỗi
on_error, ValidationError và cây ngoại lệ FL_*.
Câu hỏi thường gặp
Làm sao lấy dư nợ chia theo năm nhóm chất lượng nợ của một ngân hàng?
Gọi `client.financials.notes("VCB", section="loan_quality")` của thư viện Python finlens. Nhóm `loan_quality` là thuyết minh cho vay theo chất lượng nợ theo Thông tư 11, gồm năm dòng con: nợ đủ tiêu chuẩn, nợ cần chú ý, nợ dưới tiêu chuẩn, nợ nghi ngờ và nợ xấu có khả năng mất vốn. Frame còn có một dòng gốc mang tổng dư nợ, phân biệt bằng cột `parent_code` rỗng. Dữ liệu phủ 30 tổ chức tín dụng từ năm 2006 tới nay, và đơn vị là đồng Việt Nam thô.
Cột quarter bằng 0 nghĩa là gì, có phải dữ liệu bị thiếu không?
Không, `0` nghĩa là CẢ NĂM. Đây là quy ước chung của thư viện Python finlens ở mọi endpoint báo cáo tài chính: `quarter` mang `0` cho số liệu năm và `1` tới `4` cho từng quý. Lọc `df[df["quarter"] > 0]` là cách nhanh nhất để mất sạch số liệu năm mà không có lỗi nào báo. Bảng dữ liệu gốc dùng số `5` cho cả năm, nhưng máy chủ đã quy đổi nên `5` không bao giờ xuất hiện trong frame; nếu bạn truyền `quarters=5` thì thư viện từ chối kèm chỉ dẫn dùng `0`.
Cộng bốn quý lại có ra số cả năm không?
Tuỳ nhóm, và đây là chỗ dễ sai nhất. Với 36 nhóm số dư — dư nợ theo chất lượng nợ, hàng tồn kho, vốn chủ sở hữu — dòng cả năm bằng đúng dòng quý 4, nên cộng bốn quý rồi cộng thêm dòng cả năm là cộng quý 4 hai lần. Với 14 nhóm kết quả kinh doanh — doanh thu, giá vốn, thu nhập lãi, chi phí hoạt động — dòng cả năm bằng đúng tổng bốn quý, nên đọc quý 4 như số cả năm là hiểu thiếu gần bốn lần. Đừng đoán: cột `full_year_equals_q4` của `client.financials.note_sections()` nói thẳng nhóm nào thuộc loại nào, và nó nhận `<NA>` cho nhóm `car` vì không kỳ nào của nhóm ấy có đủ bốn quý để so.
Thuyết minh có dùng được cho doanh nghiệp thường, không phải ngân hàng?
Có. Thư viện Python finlens phục vụ hai loại: tổ chức tín dụng với 22 nhóm trên 30 mã, và doanh nghiệp thường với 29 nhóm trên 1.443 mã. Gọi `client.financials.notes("HPG", section="inventory")` để bóc hàng tồn kho ra tối đa chín dòng con — nguyên vật liệu, dở dang, thành phẩm, hàng hoá — và mỗi doanh nghiệp chỉ công bố những khoản nó có, nên số dòng thực tế thường ít hơn. Hoặc `section="cost_of_goods_sold"` cho giá vốn theo loại, hoặc cặp `tangible_asset_cost` và `tangible_asset_depreciation` để tính giá trị còn lại của tài sản cố định. Công ty chứng khoán có cây thuyết minh trong nguồn nhưng chưa được phát hành, nên mã của họ đi vào phần lỗi theo từng mã kèm thông báo nói đúng nguyên nhân; doanh nghiệp bảo hiểm thì nguồn không có cây nào.
Vì sao mỗi token nhóm chỉ dùng được cho một loại doanh nghiệp?
Vì hai loại doanh nghiệp có hai bản báo cáo tài chính khác nhau, nên hai cây thuyết minh khác nhau. Trong thư viện Python finlens, `section="loan_quality"` chỉ tồn tại ở cây của tổ chức tín dụng và `section="inventory"` chỉ tồn tại ở cây của doanh nghiệp thường. Truyền một token của loại này cho mã của loại kia cho ra bảng rỗng chứ không phải lỗi — token hợp lệ, mã hợp lệ, chỉ cặp đôi là không tồn tại. Lọc `client.financials.note_sections()` theo cột `company_type` để biết token nào dùng cho loại nào.
Vì sao có 30 mã nhưng tài liệu nói chỉ 28 ngân hàng?
Vì hai mã trong đó là công ty tài chính chứ không phải ngân hàng. Trong 30 tổ chức tín dụng mà thư viện Python finlens trả về ở nhóm này, 28 mã thuộc ngành ICB 8355 Ngân hàng, còn `EVF` thuộc ICB 8773 và `TIN` thuộc ICB 8771 — hai mã tài chính tổng hợp. Chúng có mặt vì nguồn danh mục xếp chúng vào loại doanh nghiệp `NH`, tức mâu thuẫn nằm giữa hai cột của chính nguồn ấy. Nếu bạn cần đúng nhóm ngân hàng, hãy lọc bằng `client.meta.symbols(icb="8355")`.
Tỷ lệ nợ xấu tính từ thuyết minh có khớp với chỉ tiêu npl_ratio có sẵn không?
Có, và hai đường đã được đối chiếu. Lấy tổng ba nhóm cuối chia cho tổng năm nhóm của thư viện Python finlens cho ra 1,0012 phần trăm với VCB quý 2 năm 2025, so với 0,01001 mà `client.financials.indicators()` trả cho chỉ tiêu `npl_ratio` — lệch dưới ba phần trăm nghìn. Vì hai con số đã khớp, nhóm thuyết minh không phát thêm một cột tỷ lệ nào: dùng `indicators()` nếu bạn chỉ cần tỷ lệ, và dùng `notes()` khi bạn cần chính các con số tuyệt đối của từng nhóm.
Cập nhật lần cuối