Logo FinLensFinLens Docs

Thuyết minh BCTC ngân hàng, phân nhóm nợ bằng Python

Dùng finlens lấy thuyết minh BCTC của 30 tổ chức tín dụng Việt Nam về pandas DataFrame: dư nợ theo năm nhóm chất lượng nợ, cho vay theo ngành, cơ cấu tiền gửi.

Bốn báo cáo chính nói ngân hàng cho vay bao nhiêu. Thuyết minh nói cho vay cho ai, ngành nào, và bao nhiêu trong đó đang có vấn đề.

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 22 nhóm, kèm độ phủ đo lúc gọi

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)

# 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. Dòng cả năm bằng đúng quý 4 với số dư cân đối kế toá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.
  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 lại cây bằng một groupby

Mỗi dòng mang parent_code, levelorder_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}")

codesố 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.

22 nhóm, và độ phủ của từng nhóm

ds = client.financials.note_sections()
print(ds[["code", "section", "node_count", "organization_count", "last_year"]])

Bảng này tồn tại thay cho một danh sách tĩnh trong tài liệu, vì organization_countlast_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:

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

Bỏ trống section thì trả cả 22 nhóm. 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"])]

Ba nhóm của nguồn cố ý không có token

Nguồn có 25 nhóm; thư viện phơi ra 22. Ba nhóm 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).

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

30 tổ chức tín dụng, từ 2006 tới nay. Nhưng độ phủ không đều: 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ã không phải tổ chức tín dụng đ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:

df = client.financials.notes("VCB,HPG", section="loan_quality", start_year=2026)
# HPG vào df.attrs["finlens"]["failed"]; VCB vẫn trả về bình thường

Đơn vị là VND thô

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<NA> khi nguồn không có ô đó — không phải 0.

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().

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?

Không, và với số dư cân đối kế toán thì cộng như vậy là nhân bốn lần sai. Dư nợ theo nhóm là số dư tại một thời điểm, nên dòng cả năm bằng đúng dòng quý 4 — đo trên dữ liệu thật của thư viện Python finlens, cả hai đều cho cùng một con số. Nếu bạn lấy cả năm dòng của một năm rồi cộng lại, bạn cộng bốn quý cộng thêm một bản sao của quý 4. Hãy lọc `quarter` trước khi tổng hợp.

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