Logo FinLensFinLens Docs

BCTC & chỉ tiêu P/E, ROE cổ phiếu Việt Nam bằng Python

Dùng finlens lấy báo cáo tài chính và chỉ tiêu tính sẵn của doanh nghiệp niêm yết Việt Nam về pandas DataFrame: P/E, ROE, NIM, nợ xấu theo năm, quý hoặc TTM.

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=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ộ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ă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",
) -> 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ô. 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_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, 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]       # 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 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",
) -> 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.
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.

`company_type` KHÔNG có ở bảng này

Frame của periods() có bốn cột. Đang đọc df["company_type"] ở đây thì đổi sang client.meta.symbols() để ánh xạ mã → loại hình.

Từ 1.4.0 cột ấy đã được gỡ, nên df["company_type"] ném KeyError. Ở 1.3.0 trở về trước nó có mặt nhưng luôn rỗng — đo trên API thật: 205/205 ô là <NA>. Máy chủ chưa bao giờ gửi cột đó; client khai thừa, rồi tầng chuẩn hoá tự thêm vào frame một cột server không gửi.

statement() và indicators() vẫn có company_type thật.

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_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"]]

indicators() — chỉ tiêu tài chính tính sẵn

client.financials.indicators(
    symbols,
    *,
    codes=None,
    groups=None,
    period="annual",
    start_year=None,
    end_year=None,
    quarters=None,
    refresh=False,
    on_error="warn",
) -> DataFrame

statement() trả về khoản mục thô; muốn P/E, ROE, NIM hay nợ xấu thì bạn phải tự dựng lại — tức tự viết lại đúng phần khó nhất. indicators() trả về 135 mã chỉ tiêu đã tính, một dòng cho mỗi chỉ tiêu của mỗi kỳ.

import finlens

client = finlens.client()

# Toàn bộ chỉ tiêu của một ngân hàng
chi_tieu = client.financials.indicators("VCB", start_year=2025)

# Vài chỉ tiêu, nhiều mã, trộn loại hình doanh nghiệp
chi_tieu = client.financials.indicators(
    "VCB,HPG,SSI", codes="roe,roa,total_assets", start_year=2023
)
CộtdtypeNội dung
symbolstringMã chứng khoán.
company_typestringCT · NH · CK · BH.
yearint16Năm của kỳ.
quarterint80 là cả năm; với period="ttm" là quý cuối cửa sổ.
period_labelstring"2025" · "Q1 2025" · "TTM Q2 2026".
period_typestringannual · quarterly · ttm.
codestringMã chỉ tiêu, snake_case.
groupstringNhóm chỉ tiêu.
unitstringĐơn vị của chính dòng này.
valuefloat64Giá trị. null khi không có số.

Bốn loại hình, bốn bộ chỉ tiêu

Chúng khác nhau một cách có nội dung, không phải vì thiếu số liệu: nim chỉ tồn tại ở ngân hàng, margin_to_equity chỉ ở công ty chứng khoán, loss_ratio_retained chỉ ở doanh nghiệp bảo hiểm.

Loại hìnhSố chỉ tiêuNhóm chỉ loại này mới có
CT phi tài chính61cashflow · quality
NH ngân hàng43asset_quality
CK chứng khoán38margin_lending · prop_book
BH bảo hiểm39underwriting · reserves · investment

Cột bên phải chỉ liệt kê nhóm độc quyền của một loại hình. Phần lớn nhóm dùng chung ở nhiều loại: capital có ở cả CT, NH và BH; solvency có ở CT và CK; income_structure có ở NH, CK và BH. Cộng bốn con số bên trái ra 181 — đó là số cặp (loại hình, chỉ tiêu), không phải số mã, vì một mã như roe xuất hiện ở cả bốn loại.

Xin một mã không áp cho loại hình đó thì đơn giản là không có dòng nào — nó khác hẳn một mã gõ sai, vốn bị từ chối bằng lỗi cho cả lời gọi.

⚠️ Đơn vị nằm ở CỘT, không ở df.attrs

Một frame chỉ tiêu mang nhiều đơn vị cùng lúc — roe là tỷ lệ, total_assets là VND, inventory_days là ngày — nên df.attrs["finlens"]["units"] không trả lời được câu hỏi ở đây. Đọc cột unit của từng dòng.

⚠️ Khoá units vẫn có mặt, và units["value"] là None chứ không ném KeyError. Nghĩa là một đoạn code kiểu if "value" in units: vẫn chạy vào nhánh trong rồi lấy ra None — hàng rào duy nhất có tác dụng là đọc cột unit.

chi_tieu.attrs["finlens"]["units"]["value"]   # None — không phải 'VND'

Từ vựng đơn vị đóng, và không có %: VND · VND/share · x · ratio · days.

⚠️ ratio là phân số, và nó KHÔNG bị chặn trong 0–1

roe = 0.16721 nghĩa là 16,721% — nhân 100 khi hiển thị.

Nhưng đừng viết "giá trị lớn hơn 1 thì chắc đang ở thang phần trăm, chia 100":

Đo trên nguồnGiá trịBản chất
NVB cir kỳ 2024Q11,827Thật — chi phí hoạt động vượt thu nhập hoạt động
ACB equity_to_loan kỳ 2007Q21,895Thật — vốn chủ lớn hơn dư nợ

Phép "chuẩn hoá" ấy biến chúng thành 0,018 và 0,019: đúng ở những kỳ bình thường, sai ở đúng những kỳ đáng chú ý nhất.

period="ttm" — luỹ kế bốn quý

ttm = client.financials.indicators("HPG", period="ttm")

Chỉ tiêu dòng (kết quả kinh doanh, lưu chuyển tiền) được cộng bốn quý; số dư (cân đối kế toán) và tỷ số lấy ở cuối cửa sổ. Cộng một số dư qua bốn quý sẽ ra tổng tài sản gấp bốn lần sự thật.

Cửa sổ thiếu quý thì kỳ đó không tồn tại — một tổng ba quý phát ra như kỳ bốn quý là con số sai duy nhất bạn không phát hiện được từ chính nó.

⚠️ period="ttm" không có ở statement(), và đó là chủ đích: một cây khoản mục luỹ kế bốn quý không phải báo cáo nào doanh nghiệp từng nộp.

⚠️ quarters= chỉ có nghĩa với period="quarterly". Truyền kèm "ttm" bị từ chối chứ không bị bỏ qua — lọc bớt quý sẽ làm mọi cửa sổ bốn quý đứt.

⚠️ Lọc kỳ bằng period_type, đừng lọc bằng quarter một mình

quarter = 2 xuất hiện ở cả một quý rời rạc lẫn một cửa sổ TTM kết thúc ở quý đó, và hai đại lượng ấy chênh nhau khoảng bốn lần.

chi_tieu[chi_tieu["period_type"] == "ttm"]   # đúng
chi_tieu[chi_tieu["quarter"] == 2]           # trộn hai loại kỳ

indicator_catalog() — nhãn, đơn vị và công thức

client.financials.indicator_catalog(
    *,
    com_type=None,
    groups=None,
    refresh=False,
    on_error="warn",
) -> DataFrame

indicators() không trả label và formula: chúng là thuộc tính của code, không của từng kỳ — gắn vào mỗi dòng thì một frame 40 chỉ tiêu × 12 kỳ chở cùng một chuỗi công thức 12 lần.

Gọi một lần rồi giữ lại, merge theo cặp (company_type, code):

values = client.financials.indicators("VCB", start_year=2025)
catalog = client.financials.indicator_catalog()

named = values.merge(
    catalog[["company_type", "code", "label", "formula", "higher_is_better"]],
    on=["company_type", "code"],
)
print(named[["symbol", "period_label", "label", "value", "unit"]])

⚠️ Lấy đúng những cột bạn cần thay vì merge cả bảng: hai frame cùng có group và unit, nên values.merge(catalog, on=[...]) đổi tên chúng thành group_x, unit_x, group_y, unit_y — và dòng print ngay trên ném KeyError: "['unit'] not in index".

CộtdtypeNội dung
company_typestringCT · NH · CK · BH.
codestringMã chỉ tiêu.
labelstringNhãn tiếng Việt.
groupstringNhóm chỉ tiêu.
unitstringĐơn vị của chỉ tiêu.
formulastringCông thức, viết cho người đọc.
higher_is_betterbooleannull nghĩa là không xếp hạng được.

Cột formula trả lời câu hỏi "roic của các anh tính thế nào" mà không phải đi hỏi ai:

catalog.loc[catalog["code"] == "roic", "formula"].iloc[0]
# 'EBIT × (1 − thuế suất thực tế) ÷ (vốn chủ sở hữu + nợ vay có lãi − tiền).
#  Thuế suất thực tế lùi về 20% khi không tính được.'

⚠️ higher_is_better bằng null là một khẳng định, không phải ô quên điền: total_assets lớn không "tốt hơn" nhỏ. Xếp hạng hay tô màu theo một chỉ tiêu null là bịa ra một chiều tốt/xấu không tồn tại.

Đơ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.

⚠️ Mục này không áp cho indicators(): ở đó một frame mang nhiều đơn vị cùng lúc nên đơn vị nằm ở cột unit của từng dòng, và df.attrs không nói gì về nó.

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ả năm 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ó.

Làm sao tính P/E, ROE của cổ phiếu Việt Nam bằng Python?

Không phải tự tính: dùng `client.financials.indicators()` của thư viện Python finlens, ví dụ `client.financials.indicators("VCB", codes="pe,pb,roe,roa", start_year=2023)`. Thư viện trả về 135 mã chỉ tiêu đã tính sẵn ở dạng long — một dòng là một chỉ tiêu của một kỳ — với các cột `symbol`, `company_type`, `year`, `quarter`, `period_label`, `period_type`, `code`, `group`, `unit`, `value`. Nhãn tiếng Việt và công thức nằm ở `client.financials.indicator_catalog()`, ghép vào theo cặp `company_type` và `code`.

Chỉ tiêu tài chính của ngân hàng và công ty chứng khoán có khác không?

Khác, và khác một cách có nội dung chứ không phải vì thiếu số liệu. Trong thư viện Python finlens, bốn loại hình có bốn bộ chỉ tiêu: doanh nghiệp phi tài chính `CT` có 61 chỉ tiêu, ngân hàng `NH` có 43, công ty chứng khoán `CK` có 38, doanh nghiệp bảo hiểm `BH` có 39. `nim`, `cir`, `npl_ratio`, `car` chỉ tồn tại ở ngân hàng; `margin_to_equity` và `prop_to_equity` chỉ ở công ty chứng khoán; `loss_ratio_retained` và `ceded_ratio` chỉ ở doanh nghiệp bảo hiểm. Xin một mã không áp cho loại hình đó thì đơn giản là không có dòng nào, khác hẳn một mã gõ sai vốn bị từ chối cho cả lời gọi. Tra `client.financials.indicator_catalog(com_type="NH")` để biết loại hình nào có chỉ tiêu nào.

Lấy chỉ tiêu tài chính lũy kế bốn quý (TTM) bằng finlens thế nào?

Truyền `period="ttm"`: `client.financials.indicators("HPG", period="ttm")`. Chỉ tiêu dòng như doanh thu và lợi nhuận được cộng bốn quý, còn số dư cân đối kế toán và các tỷ số lấy ở cuối cửa sổ — cộng một số dư qua bốn quý sẽ ra tổng tài sản gấp bốn lần sự thật. Cửa sổ thiếu quý thì kỳ đó không tồn tại chứ không phải một tổng ba quý. Cột `period_type` mang `ttm` để bạn lọc, và đó là cách lọc đúng: `quarter` bằng 2 xuất hiện ở cả một quý rời rạc lẫn một cửa sổ TTM kết thúc ở quý đó.

Vì sao giá trị ROE trong finlens là 0.16 chứ không phải 16?

Vì đơn vị `ratio` trong thư viện Python finlens là phân số: `roe = 0.16721` nghĩa là 16,721 phần trăm, nhân 100 khi hiển thị. Đơn vị nằm ở cột `unit` của từng dòng chứ không ở `df.attrs`, vì một frame chỉ tiêu mang nhiều đơn vị cùng lúc — `roe` là `ratio`, `total_assets` là `VND`, `inventory_days` là `days`. Đừng viết quy tắc kiểu giá trị lớn hơn 1 thì chia 100: `ratio` không bị chặn trong khoảng 0 đến 1, và những giá trị vượt 1 thường là số thật của một kỳ bất thường, ví dụ một ngân hàng có chi phí hoạt động vượt thu nhập hoạt động.

Cập nhật lần cuối

Nội dung trang