Logo FinLensFinLens Docs

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ọiTrả 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.x1.x
symbol=symbolstham 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ộcstart_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_typeLoại hình
CTDoanh nghiệp phi tài chính
NHNgân hàng
CKCông ty chứng khoán
BHBả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ămQU (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_errorXử 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",
) -> DataFrame

Chỉ symbols là tham số vị trí; mọi tham số còn lại là keyword-only.

Tham sốKiểuMặc địnhNội dung
symbolsSymbolsArg"HPG", "HPG,VCB" hoặc ["HPG", "VCB"]. Được phép trộn nhiều loại hình doanh nghiệp.
kindStatementKind"balance_sheet"Đúng một loại báo cáo. Bốn giá trị ở bảng bên dưới.
periodStatementPeriod"annual""annual" (số liệu cả năm) hoặc "quarterly" (số liệu quý).
start_yearint hoặc NoneNoneNăm đầu của khoảng. None là lấy từ đầu dải thực có của từng mã.
end_yearint hoặc NoneNoneNăm cuối, tính cả chính nó. None là lấy tới hết dải thực có.
quartersstr, Sequence[int] hoặc NoneNoneLọc theo quý: "1,3" hoặc [1, 3]. None nghĩa là tất cả các quý.
refreshboolFalseBỏ qua bản trong cache và lấy lại từ máy chủ.
on_errorOnError"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_sheetCân đối kế toán
income_statementKết quả kinh doanh
cash_flow_directLưu chuyển tiền tệ trực tiếp
cash_flow_indirectLư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ộtdtypeNội dung
symbolstringMã chứng khoán, viết hoa.
company_typestringCT · NH · CK · BH — quyết định cây khoản mục nào áp cho dòng này.
kindstringLoại báo cáo của dòng.
yearint16Năm của kỳ.
quarterint80 là cả năm, 1..4 là quý. Không bao giờ null.
period_labelstringNhãn kỳ đọc được: "2025" hoặc "Q1 2025".
item_idint32Mã hiển thị của khoản mục, chỉ có nghĩa trong phạm vi (company_type, kind).
line_itemstringTên khoản mục theo nguồn, ví dụ "TỔNG CỘNG TÀI SẢN".
parent_idInt32item_id của khoản mục cha, null ở node gốc.
levelint8Độ sâu trong cây.
order_indexint32Thứ tự trình bày của bản báo cáo.
valuefloat64Giá trị của khoản mục, VND thô. nullkỳ đó 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_label mang "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, levelorder_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_idnull.

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]       # sai

Join 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 valuefloat64 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",
) -> DataFrame

Mộ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ộtdtypeNội dung
symbolstringMã chứng khoán.
company_typestringLoại hình doanh nghiệp của mã.
yearint16Năm của kỳ.
quarterint80 là cả năm, 1..4 là quý.
period_labelstring"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",
) -> DataFrame

Phươ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ểuMặc địnhNội dung
com_typeCompanyTypebắt buộc"CT" · "NH" · "CK" · "BH".
kindStatementKindbắt buộcCùng bốn giá trị với statement().
refreshboolFalseBỏ qua bản trong cache.
on_errorOnError"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ộtdtypeNội dung
company_typestringLoại hình doanh nghiệp của cây.
kindstringLoại báo cáo của cây.
item_idint32Mã hiển thị của khoản mục.
line_itemstringTên khoản mục.
parent_idInt32item_id của khoản mục cha, null ở node gốc.
levelint8Độ sâu trong cây.
order_indexint32Thứ 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_indexmỗ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

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

Nội dung trang