Logo FinLensFinLens Docs

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ứcTrả 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ệpcompany_typeSố mãSố nhóm
Tổ chức tín dụngNH3022
Doanh nghiệp thườngCT1.44329
Công ty chứng khoánCK—chưa phát hành
Doanh nghiệp bảo hiểmBH—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

  1. quarter = 0 nghĩa là CẢ NĂM, không phải "thiếu quý". Lọc df[df["quarter"] > 0] là cách nhanh nhất để mất sạch số liệu năm, im lặng.
  2. quarter = 0 mang HAI nghĩa, tuỳ nhóm. Xem mục ngay dưới — đây là chỗ dễ sai nhất của cả trang.
  3. Đừng tự cắt chuỗi code để suy ra cha. Dùng cột parent_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ómquarter = 0 làSố nhóm
Số dư — tồn kho, dư nợ, vốn chủ sở hữubằng đúng quý 436
Kết quả kinh doanh — doanh thu, giá vốn, thu nhập lãitổ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:

TokenNội dungCộng dồn được?
inventoryHàng tồn kho, 9 dòng conkhông — là số dư
cost_of_goods_soldGiá vốn theo loạicó
production_cost_by_elementChi phí SXKD theo yếu tố: NVL, nhân công, khấu haocó
tangible_asset_depreciationHao mòn luỹ kế TSCĐ hữu hìnhkhô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:

TokenNội dung
loan_qualityCho vay theo chất lượng nợ — năm nhóm Thông tư 11
loan_by_industryCho vay theo ngành kinh tế
loan_by_customer_typeCho vay theo đối tượng khách hàng
loan_provisionDự phòng cho vay khách hàng
deposit_by_typeTiền gửi theo loại (không kỳ hạn, có kỳ hạn…)
service_incomeLãi thuần từ hoạt động dịch vụ, chi tiết từng khoản
operating_expenseChi 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 PythonMCP
loan_qualityloans_by_quality
deposit_by_typedeposits_by_type
operating_expenseoperating_expenses
short_term_investmentst_investments
cost_of_goods_soldcogs
tangible_asset_costfixed_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

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

Nội dung trang