Bài phân tích của tôi sẽ trình bày một quy trình phân tích định lượng toàn diện về sức khỏe tài chính và hiệu quả hoạt động của Công ty Cổ phần Vàng bạc Đá quý Phú Nhuận (PNJ), một trong những doanh nghiệp đầu ngành bán lẻ trang sức tại Việt Nam.
Mục tiêu cốt lõi là giải mã các động lực tăng trưởng, bóc tách cấu trúc tài chính, và các yếu tố quyết định đến khả năng sinh lời của PNJ trong giai đoạn từ Quý 1 2017 đến nay (Quý 2 2025), dựa trên dữ liệu báo cáo tài chính công bố.
Thông qua việc áp dụng các phương pháp phân tích BCTC, chúng tôi hướng đến việc trả lời các câu hỏi then chốt:
data_path2 <- if (exists("params") && !is.null(params$data_path2)) params$data_path2 else
"BCTC_PNJ.xlsx"
if (!file.exists(data_path2)) stop("Không tìm thấy file: ", data_path2)
message("[INFO] Đọc dữ liệu từ: ", data_path2) # hoặc cat("[INFO] ...\n")
#------
data_path2 <- if (exists("params") && !is.null(params$data_path2)) params$data_path2 else
"BCTC_PNJ.xlsx"
#-----
if (!file.exists(data_path2)) stop("Không tìm thấy file: ", data_path2)
df_raw <- readxl::read_excel(path = data_path2, sheet = 1)
dplyr::glimpse(df_raw)
## Rows: 17
## Columns: 35
## $ Indicator <chr> "ta", "equity", "tl", "ca", "cl", "inv_net", "ar", "ap", "re…
## $ `Q1 2017` <dbl> 3.595808e+12, 1.749066e+12, 1.846742e+12, 3.106734e+12, 1.78…
## $ `Q2 2017` <dbl> 3.561007e+12, 1.735442e+12, 1.825565e+12, 3.055472e+12, 1.77…
## $ `Q3 2017` <dbl> 4.063461e+12, 2.728456e+12, 1.335006e+12, 3.480268e+12, 1.31…
## $ `Q4 2017` <dbl> 4.492513e+12, 2.950301e+12, 1.542211e+12, 3.896409e+12, 1.52…
## $ `Q1 2018` <dbl> 4.688396e+12, 3.198904e+12, 1.489493e+12, 3.872677e+12, 1.40…
## $ `Q2 2018` <dbl> 5.173682e+12, 3.366712e+12, 1.806970e+12, 4.233071e+12, 1.74…
## $ `Q3 2018` <dbl> 5.481233e+12, 3.382261e+12, 2.098972e+12, 4.491050e+12, 2.07…
## $ `Q4 2018` <dbl> 6.303185e+12, 3.745313e+12, 2.557872e+12, 5.280216e+12, 2.54…
## $ `Q1 2019` <dbl> 6.152152e+12, 4.040218e+12, 2.111934e+12, 5.065795e+12, 2.09…
## $ `Q2 2019` <dbl> 6.526498e+12, 4.092534e+12, 2.433964e+12, 5.333230e+12, 2.42…
## $ `Q3 2019` <dbl> 7.625552e+12, 4.120300e+12, 3.505252e+12, 6.396513e+12, 3.49…
## $ `Q4 2019` <dbl> 8.600312e+12, 4.574035e+12, 4.026277e+12, 7.330560e+12, 4.01…
## $ `Q1 2020` <dbl> 8.389422e+12, 4.985528e+12, 3.403894e+12, 7.097183e+12, 3.39…
## $ `Q2 2020` <dbl> 8.162334e+12, 4.701173e+12, 3.461161e+12, 6.863106e+12, 3.45…
## $ `Q3 2020` <dbl> 8.091042e+12, 4.903259e+12, 3.187783e+12, 6.786222e+12, 3.17…
## $ `Q4 2020` <dbl> 8.483371e+12, 5.241862e+12, 3.241509e+12, 7.144154e+12, 3.23…
## $ `Q1 2021` <dbl> 8.136212e+12, 5.752949e+12, 2.383263e+12, 6.821148e+12, 2.37…
## $ `Q2 2021` <dbl> 9.175654e+12, 5.718861e+12, 3.456793e+12, 7.870492e+12, 3.44…
## $ `Q3 2021` <dbl> 9168881692481, 5559332736154, 3609548956327, 7887307408212, …
## $ `Q4 2021` <dbl> 1.054664e+13, 6.016510e+12, 4.530132e+12, 9.220118e+12, 4.52…
## $ `Q1 2022` <dbl> 1.141021e+13, 8.008233e+12, 3.401972e+12, 1.009283e+13, 3.32…
## $ `Q2 2022` <dbl> 1.101943e+13, 8.029628e+12, 2.989799e+12, 9.707247e+12, 2.98…
## $ `Q3 2022` <dbl> 1.246321e+13, 8.318218e+12, 4.144991e+12, 1.115783e+13, 4.13…
## $ `Q4 2022` <dbl> 1.332110e+13, 8.587800e+12, 4.733304e+12, 1.195792e+13, 4.72…
## $ `Q1 2023` <dbl> 1.283083e+13, 9.192688e+12, 3.638140e+12, 1.143193e+13, 3.62…
## $ `Q2 2023` <dbl> 1.349285e+13, 9.194055e+12, 4.298800e+12, 1.208256e+13, 4.28…
## $ `Q3 2023` <dbl> 1.305516e+13, 9.436999e+12, 3.618164e+12, 1.161576e+13, 3.60…
## $ `Q4 2023` <dbl> 1.442994e+13, 9.806566e+12, 4.623377e+12, 1.296011e+13, 4.61…
## $ `Q1 2024` <dbl> 1.296893e+13, 1.047438e+13, 2.494545e+12, 1.153522e+13, 2.48…
## $ `Q2 2024` <dbl> 1.296771e+13, 1.071481e+13, 2.252909e+12, 1.153787e+13, 2.24…
## $ `Q3 2024` <dbl> 1.496249e+13, 1.052443e+13, 4.438053e+12, 1.353531e+13, 4.42…
## $ `Q4 2024` <dbl> 1.720773e+13, 1.125531e+13, 5.952424e+12, 1.569260e+13, 5.94…
## $ `Q1 2025` <dbl> 1.741932e+13, 1.173029e+13, 5.689026e+12, 1.594966e+13, 5.67…
## $ `Q2 2025` <dbl> 1.715378e+13, 1.196938e+13, 5.184392e+12, 1.571393e+13, 5.17…
Code này đọc và kiểm tra cấu trúc một file Excel chứa báo cáo tài chính.
data_path2 để lưu đường dẫn file. Điều này giúp Ta chỉ cần sửa một nơi khi đường dẫn thay đổi.file.exists() để xác nhận file tồn tại. Nếu không, stop() dừng chương trình và báo lỗi để ngăn các vấn đề phát sinh sau này.readxl::read_excel. Kết quả được lưu vào biến df_raw, cho biết đây là dữ liệu thô.dplyr::glimpse() để xem tóm tắt cấu trúc dữ liệu. Hàm này hiển thị thông tin rõ ràng khi có nhiều cột.Kết quả cho thấy dữ liệu có cấu trúc dạng rộng (wide format).
Indicator.Q1 2017, chứa giá trị của các chỉ số theo từng quý.Code này tái cấu trúc dữ liệu từ dạng rộng sang dạng dài để Ta phân tích chuỗi thời gian.
column_to_rownames để chuyển cột Indicator thành tên hàng. Việc này tách dữ liệu chữ ra khỏi dữ liệu số, chuẩn bị cho bước chuyển vị.t() để hoán đổi hàng và cột. Các chỉ số tài chính trở thành cột, các quý trở thành hàng.lapply và as.numeric để đảm bảo mọi cột dữ liệu đều là kiểu số. Đây là bước an toàn để chuẩn bị cho tính toán.Cấu trúc dữ liệu mới đã sẵn sàng cho phân tích.
# Đơn vị (Ngàn tỷ)
df_numeric <- df_numeric / 1e12
# In 5 dòng đầu tiên của dữ liệu đã chuẩn hóa để kiểm tra
print(t(head(df_numeric, 1)))
## Q1 2017
## ta 3.595808173
## equity 1.749065808
## tl 1.846742365
## ca 3.106733885
## cl 1.784913164
## inv_net 2.846786699
## ar 0.040388070
## ap 0.323535911
## revenue 3.130971058
## cogs -2.580113338
## gp 0.550857720
## ebit 0.311240665
## ebt 0.311075206
## ni 0.248739097
## cfo 0.204604720
## cfi -0.007393507
## cff -0.225897519
Code này chuẩn hóa đơn vị dữ liệu.
1e12).df_analysis <- df_numeric
# cogs nên dương (magnitude)
if ("cogs" %in% names(df_analysis)) {
df_analysis$cogs <- abs(df_analysis$cogs)
message("[INFO] Đã chuẩn hóa 'cogs' sang giá trị dương.")
} else {
warning("[WARN] Không tìm thấy cột 'cogs'.")
}
## [INFO] Đã chuẩn hóa 'cogs' sang giá trị dương.
# Các biến nên dương tuyệt đối
positive_cols <- c('ta', 'equity', 'tl', 'ca', 'cl', 'inv_net', 'ar', 'ap', 'revenue')
for (col in positive_cols) {
if (col %in% names(df_analysis)) {
df_analysis[[col]] <- abs(df_analysis[[col]])
} else {
warning(paste("[WARN] Không tìm thấy cột '", col, "' để chuẩn hóa dương.", sep=""))
}
}
message("[INFO] Hoàn tất chuẩn hóa nền tảng.")
## [INFO] Hoàn tất chuẩn hóa nền tảng.
Code này làm sạch dữ liệu bằng cách áp dụng các quy tắc tài chính.
df_analysis. Hành động này bảo vệ dữ liệu gốc của Ta.cogs thành số dương bằng hàm abs(). Dữ liệu kế toán thường ghi cogs là số âm, nhưng phân tích cần độ lớn thực của chi phí.ta (tổng tài sản).abs() cho tất cả các cột trong danh sách đó.# Hàm tiện ích dùng xuyên suốt
avg_lag1 <- function(x) {
(x + dplyr::lag(x, n = 1)) / 2
}
Code này tạo ra các hàm để tiện tính toán trong quá trình phân tích
avg_lag1 để tính trung bình của một giá trị tại kỳ hiện tại và kỳ trước đó.dplyr::lag() để lấy giá trị của kỳ trước, sau đó tính trung bình cộng của giá trị hiện tại và giá trị đó.Mục đích:
avg_lag1 tính toán chính xác giá trị trung bình này, giúp các tỷ số tài chính chính xác hơn.# 1) Khả năng sinh lời — Biên lợi nhuận
if (all(c("gp","ebit","ni","revenue") %in% names(df_analysis))) {
df_analysis <- df_analysis %>%
dplyr::mutate(
gross_margin = gp / revenue,
operating_margin = ebit / revenue,
net_margin = ni / revenue
)
message("[INFO] Đã tính biên lợi nhuận (gross/operating/net).")
} else {
warning("[WARN] Thiếu một trong gp/ebit/ni/revenue — bỏ qua margins.")
}
## [INFO] Đã tính biên lợi nhuận (gross/operating/net).
Code này tính toán các biên lợi nhuận để đánh giá khả năng sinh lời của công ty.
all() để đảm bảo các cột gp, ebit, ni, và revenue đều tồn tại.dplyr::mutate để tạo ba cột mới: gross_margin, operating_margin, và net_margin.Mỗi biên lợi nhuận đo lường hiệu quả ở một giai đoạn khác nhau
# Biến trung bình cho ROA/ROE/Efficiency
need <- c("ta","equity","inv_net","ar")
if (any(need %in% names(df_analysis))) {
df_analysis <- df_analysis %>%
dplyr::mutate(
avg_ta = avg_lag1(ta),
avg_equity = avg_lag1(equity),
avg_inv_net = avg_lag1(inv_net),
avg_ar = avg_lag1(ar)
)
message("[INFO] Đã tạo các biến trung bình: avg_ta, avg_equity, avg_inv_net, avg_ar.")
}
## [INFO] Đã tạo các biến trung bình: avg_ta, avg_equity, avg_inv_net, avg_ar.
Code này tạo ra các cột dữ liệu trung bình để chuẩn bị cho các phép tính tỷ số.
avg_lag1 đã tạo trước đó.avg_ta và avg_equity, chứa giá trị trung bình của chỉ số gốc và chỉ số kỳ trước.# ROA & ROE
if (all(c("ni","avg_ta","avg_equity") %in% names(df_analysis))) {
df_analysis <- df_analysis %>%
dplyr::mutate(
roa = ni / avg_ta,
roe = ni / avg_equity
)
message("[INFO] Đã tính ROA, ROE.")
} else {
warning("[WARN] Thiếu ni/avg_ta/avg_equity — bỏ qua ROA/ROE.")
}
## [INFO] Đã tính ROA, ROE.
Code này tính Tỷ suất sinh lời trên tài sản (ROA) và Tỷ suất sinh lời trên vốn chủ sở hữu (ROE).
mutate để tạo hai cột mới: roa và roe.ni) và các giá trị tài sản (avg_ta) hoặc vốn chủ sở hữu (avg_equity) trung bình.ROE có thể được phân tích thành ba yếu tố chính.
# 2) Tăng trưởng YOY (theo quý)
if (all(c("revenue","gp","ebit","ni") %in% names(df_analysis))) {
df_analysis <- df_analysis %>%
dplyr::mutate(
revenue_yoy = (revenue / dplyr::lag(revenue, 4)) - 1,
gp_yoy = (gp / dplyr::lag(gp, 4)) - 1,
ebit_yoy = (ebit / dplyr::lag(ebit, 4)) - 1,
ni_yoy = (ni / dplyr::lag(ni, 4)) - 1
)
message("[INFO] Đã tính tăng trưởng YOY (revenue/gp/ebit/ni).")
}
## [INFO] Đã tính tăng trưởng YOY (revenue/gp/ebit/ni).
Code này tính toán tăng trưởng so với cùng kỳ năm trước (Year-over-Year).
dplyr::lag(x, 4) để lấy dữ liệu từ 4 quý trước.(hiện tại / quá khứ) - 1 chuyển đổi tỷ lệ thành phần trăm tăng trưởng.Phép tính này loại bỏ yếu tố mùa vụ.
(Giá trị hiện tại / Giá trị cùng kỳ năm trước) - 1.# 3) Hiệu quả — Vòng quay
if (all(c("revenue","avg_ta","cogs","avg_inv_net","avg_ar") %in% names(df_analysis))) {
df_analysis <- df_analysis %>%
dplyr::mutate(
asset_turnover = revenue / avg_ta,
inventory_turnover = cogs / avg_inv_net,
receivables_turnover = revenue / avg_ar
)
message("[INFO] Đã tính asset/inventory/receivables turnover.")
}
## [INFO] Đã tính asset/inventory/receivables turnover.
Code này tính các tỷ số vòng quay để đo lường hiệu quả hoạt động.
revenue chia cho avg_ta. Chỉ số này đo lường Ta tạo ra bao nhiêu doanh thu từ mỗi đồng tài sản.cogs chia cho avg_inv_net. Ta phải dùng cogs (giá vốn) vì hàng tồn kho được ghi nhận theo giá vốn, không phải giá bán.revenue chia cho avg_ar. Chỉ số này đo tốc độ Ta thu tiền từ khách hàng.# 3) Hiệu quả — DSO/DIO/DPO/CCC
if (all(c("ar","revenue","inv_net","cogs","ap") %in% names(df_analysis))) {
df_analysis <- df_analysis %>%
dplyr::mutate(
dso = 365 * ar / revenue,
dio = 365 * inv_net / cogs,
dpo = 365 * ap / cogs,
ccc = dso + dio - dpo
)
message("[INFO] Đã tính DSO/DIO/DPO/CCC.")
}
## [INFO] Đã tính DSO/DIO/DPO/CCC.
Code này tính toán chu kỳ chuyển đổi tiền mặt.
DSO + DIO - DPO# 4) Đòn bẩy tài chính
if (all(c("tl","equity","ta") %in% names(df_analysis))) {
df_analysis <- df_analysis %>%
dplyr::mutate(
debt_to_equity = tl / equity,
financial_leverage = ta / equity
)
message("[INFO] Đã tính debt_to_equity, financial_leverage.")
}
## [INFO] Đã tính debt_to_equity, financial_leverage.
Code này tính toán các tỷ số đòn bẩy tài chính để đánh giá rủi ro.
debt_to_equity). Công thức này cho biết Ta có bao nhiêu đồng nợ cho mỗi đồng vốn chủ sở hữu.financial_leverage). Nó đo lường Ta kiểm soát bao nhiêu đồng tài sản cho mỗi đồng vốn chủ sở hữu.Các công thức này dựa trên phương trình kế toán:
# 5) Thanh khoản
if (all(c("ca","cl","inv_net") %in% names(df_analysis))) {
df_analysis <- df_analysis %>%
dplyr::mutate(
current_ratio = ca / cl,
quick_ratio = (ca - inv_net) / cl
)
message("[INFO] Đã tính current_ratio, quick_ratio.")
}
## [INFO] Đã tính current_ratio, quick_ratio.
Code này tính toán các tỷ số thanh khoản để đánh giá khả năng trả nợ ngắn hạn.
current_ratio). Công thức này cho biết Ta có bao nhiêu đồng tài sản ngắn hạn để trả cho mỗi đồng nợ ngắn hạn.quick_ratio). Tỷ số này loại bỏ hàng tồn kho. Hàng tồn kho là tài sản khó chuyển thành tiền nhất. Do đó, đây là một bài kiểm tra nghiêm ngặt hơn về khả năng trả nợ của Ta.So sánh hai tỷ số này để đánh giá rủi ro. Nếu current_ratio cao nhưng quick_ratio thấp, thanh khoản của Ta phụ thuộc vào việc bán hàng tồn kho.
# 6) Dòng tiền
if (all(c("cfo","ni","cfi","revenue") %in% names(df_analysis))) {
df_analysis <- df_analysis %>%
dplyr::mutate(
cfo_ni = cfo / ni,
capex_proxy = -pmin(cfi, 0, na.rm = TRUE),
fcf_proxy = cfo - capex_proxy,
fcf_margin = fcf_proxy / revenue,
fcf_margin_proxy_crude = (cfo + cfi) / revenue
)
message("[INFO] Đã tính CFO/NI, CAPEX proxy, FCF & FCF margin.")
}
## [INFO] Đã tính CFO/NI, CAPEX proxy, FCF & FCF margin.
Code này tính các chỉ số dòng tiền, bao gồm Dòng tiền tự do (FCF).
cfo / ni. Tỷ số này đo lường chất lượng lợi nhuận bằng cách so sánh tiền mặt thực tế với lợi nhuận trên giấy tờ.-pmin(cfi, 0). Công thức này lọc ra các khoản chi đầu tư âm từ dòng tiền đầu tư (cfi) và đổi nó thành số dương.cfo - capex_proxy. Đây là lượng tiền còn lại sau khi trừ chi phí hoạt động và đầu tư.fcf_margin). Chỉ số này cho biết Ta tạo ra bao nhiêu tiền mặt tự do từ mỗi đồng doanh thu.# Conditional — EBITDA margin
if ("ebitda" %in% names(df_analysis) && "revenue" %in% names(df_analysis)) {
df_analysis <- df_analysis %>% dplyr::mutate(ebitda_margin = ebitda / revenue)
message("[INFO] Đã tính ebitda_margin.")
} else {
warning("[WARN] Thiếu ebitda hoặc revenue — bỏ qua ebitda_margin.")
}
Code này tính Biên lợi nhuận EBITDA.
mutate để tạo cột ebitda_margin bằng ebitda chia cho revenue.So sánh các biên lợi nhuận khác nhau.
# Conditional — ROIC (cần ic & tax_rate)
if (all(c("ic","tax_rate","ebit") %in% names(df_analysis))) {
df_analysis <- df_analysis %>% dplyr::mutate(
avg_ic = avg_lag1(ic),
roic = ebit * (1 - tax_rate) / avg_ic
)
message("[INFO] Đã tính ROIC.")
} else {
warning("[WARN] Thiếu ic/tax_rate/ebit — bỏ qua ROIC.")
}
Code này tính Tỷ suất sinh lời trên Vốn đầu tư (ROIC) để Ta đo lường khả năng tạo ra giá trị.
EBIT * (1 - tax_rate).avg_ic). Việc này đảm bảo Ta so sánh lợi nhuận trong kỳ với vốn hoạt động trong kỳ.Ta phải so sánh ROIC với Chi phí vốn bình quân gia quyền (WACC).
# Conditional — Net Debt / EBITDA
req <- c("st_debt","lt_debt","cash_eq","ebitda")
if (all(req %in% names(df_analysis))) {
df_analysis <- df_analysis %>% dplyr::mutate(
net_debt = st_debt + lt_debt - cash_eq,
net_debt_ebitda = net_debt / ebitda
)
message("[INFO] Đã tính net_debt và net_debt_ebitda.")
} else {
warning("[WARN] Thiếu một trong st_debt/lt_debt/cash_eq/ebitda — bỏ qua NetDebt/EBITDA.")
}
Code này tính toán tỷ số Nợ ròng trên EBITDA. Ta dùng nó để đánh giá khả năng trả nợ.
net_debt) bằng tổng nợ trừ đi tiền mặt. Công thức này phản ánh gánh nặng nợ thực tế vì Ta có thể dùng tiền mặt để trả nợ ngay lập tức.# Conditional — Interest Coverage
if (all(c("ebit","int_exp") %in% names(df_analysis))) {
df_analysis <- df_analysis %>% dplyr::mutate(interest_coverage = ebit / abs(int_exp))
message("[INFO] Đã tính interest_coverage.")
} else {
warning("[WARN] Thiếu ebit hoặc int_exp — bỏ qua interest_coverage.")
}
Code này tính Tỷ số Bảm đảm Trả lãi vay. Dùng nó để đánh giá mức độ an toàn của công ty.
ebit chia cho abs(int_exp).ebit (lợi nhuận trước lãi vay). Đây là khoản lợi nhuận có sẵn để Ta trả lãi.abs() vì chi phí lãi vay thường là số âm. Hàm này lấy giá trị dương của nó.# Conditional — Cash Ratio
if (all(c("cash_eq","cl") %in% names(df_analysis))) {
df_analysis <- df_analysis %>% dplyr::mutate(cash_ratio = cash_eq / cl)
message("[INFO] Đã tính cash_ratio.")
} else {
warning("[WARN] Thiếu cash_eq hoặc cl — bỏ qua cash_ratio.")
}
Code này tính Tỷ số Tiền mặt. Ta dùng nó để đánh giá khả năng trả nợ trong kịch bản xấu nhất.
cash_eq) cho nợ ngắn hạn (cl).Tỷ số này là bài kiểm tra thanh khoản nghiêm ngặt nhất. Nó nằm trong một hệ thống gồm ba cấp độ:
# Dọn dẹp — thay Inf bằng NA
df_analysis[] <- lapply(df_analysis, function(x) {
x[is.infinite(x)] <- NA
x
})
message("[INFO] Hoàn tất tạo biến và dọn dẹp giá trị Inf.")
## [INFO] Hoàn tất tạo biến và dọn dẹp giá trị Inf.
Code này dọn dẹp các giá trị Inf (vô cùng) trong dữ liệu.
Inf xuất hiện khi Ta thực hiện phép chia cho 0.Inf và thay thế chúng bằng NA.# 1) Tự đảm bảo có hàm ptbl()
if (!exists("ptbl")) {
suppressPackageStartupMessages(library(kableExtra))
ptbl <- function(x, caption = NULL, digits = 3) {
stopifnot(is.data.frame(x) || inherits(x, "matrix"))
fmt <- if (knitr::is_latex_output()) "latex" else "html"
kb <- knitr::kable(x, format = fmt, caption = caption, digits = digits,
booktabs = TRUE, longtable = TRUE, linesep = "")
if (knitr::is_latex_output()) {
kb |>
kableExtra::kable_styling(
latex_options = c("hold_position", "repeat_header", "scale_down"),
position = "center", font_size = 10
)
} else {
kb |>
kableExtra::kable_styling(full_width = FALSE,
bootstrap_options = c("striped","condensed")) |>
kableExtra::scroll_box(width = "100%")
}
}
}
# 2) Tạo từ điển biến
suppressPackageStartupMessages(library(tibble))
dict <- tibble::tribble(
~Biến, ~Mô_tả, ~Nhóm,
"revenue", "Doanh thu thuần", "Input",
"gp", "Lợi nhuận gộp", "Input",
"ebit", "EBIT", "Input",
"ni", "LN ròng", "Input",
"ta", "Tổng tài sản", "Input",
"equity", "Vốn chủ sở hữu", "Input",
"cogs", "Giá vốn hàng bán", "Input",
"inv_net", "HTK ròng", "Input",
"ar", "Phải thu KH", "Input",
"ap", "Phải trả NB", "Input",
"ca", "TS ngắn hạn", "Input",
"cl", "Nợ ngắn hạn", "Input",
"cfo", "LCTT HĐKD", "Input",
"cfi", "LCTT HĐĐT", "Input",
"ebitda", "EBITDA", "Optional",
"st_debt", "Nợ vay ngắn hạn", "Optional",
"lt_debt", "Nợ vay dài hạn", "Optional",
"cash_eq", "Tiền & TĐT", "Optional",
"tax_rate", "Thuế suất hiệu dụng", "Optional",
"gross_margin", "Biên gộp", "Derived",
"operating_margin", "Biên EBIT", "Derived",
"net_margin", "Biên ròng", "Derived",
"avg_ta", "TS bình quân", "Derived",
"avg_equity", "VCSH bình quân", "Derived",
"roa", "ROA", "Derived",
"roe", "ROE", "Derived",
"asset_turnover", "Vòng quay TS", "Derived",
"inventory_turnover", "Vòng quay HTK", "Derived",
"receivables_turnover","Vòng quay PT", "Derived",
"dso", "DSO (ngày)", "Derived",
"dio", "DIO (ngày)", "Derived",
"dpo", "DPO (ngày)", "Derived",
"ccc", "Chu kỳ tiền (CCC)", "Derived",
"debt_to_equity", "Nợ/VCSH", "Derived",
"current_ratio", "Current ratio", "Derived",
"quick_ratio", "Quick ratio", "Derived",
"cfo_ni", "CFO/NI", "Derived",
"fcf_margin", "Biên FCF", "Derived"
)
# 3) In bảng ngay trong chunk
ptbl(dict, caption = "Từ điển biến", digits = 2)
| Biến | Mô_tả | Nhóm |
|---|---|---|
| revenue | Doanh thu thuần | Input |
| gp | Lợi nhuận gộp | Input |
| ebit | EBIT | Input |
| ni | LN ròng | Input |
| ta | Tổng tài sản | Input |
| equity | Vốn chủ sở hữu | Input |
| cogs | Giá vốn hàng bán | Input |
| inv_net | HTK ròng | Input |
| ar | Phải thu KH | Input |
| ap | Phải trả NB | Input |
| ca | TS ngắn hạn | Input |
| cl | Nợ ngắn hạn | Input |
| cfo | LCTT HĐKD | Input |
| cfi | LCTT HĐĐT | Input |
| ebitda | EBITDA | Optional |
| st_debt | Nợ vay ngắn hạn | Optional |
| lt_debt | Nợ vay dài hạn | Optional |
| cash_eq | Tiền & TĐT | Optional |
| tax_rate | Thuế suất hiệu dụng | Optional |
| gross_margin | Biên gộp | Derived |
| operating_margin | Biên EBIT | Derived |
| net_margin | Biên ròng | Derived |
| avg_ta | TS bình quân | Derived |
| avg_equity | VCSH bình quân | Derived |
| roa | ROA | Derived |
| roe | ROE | Derived |
| asset_turnover | Vòng quay TS | Derived |
| inventory_turnover | Vòng quay HTK | Derived |
| receivables_turnover | Vòng quay PT | Derived |
| dso | DSO (ngày) | Derived |
| dio | DIO (ngày) | Derived |
| dpo | DPO (ngày) | Derived |
| ccc | Chu kỳ tiền (CCC) | Derived |
| debt_to_equity | Nợ/VCSH | Derived |
| current_ratio | Current ratio | Derived |
| quick_ratio | Quick ratio | Derived |
| cfo_ni | CFO/NI | Derived |
| fcf_margin | Biên FCF | Derived |
Code này tạo một từ điển dữ liệu để Ta ghi lại ý nghĩa của tất cả các biến.
tibble::tribble để tạo từ điển vì cú pháp của nó rõ ràng. Một hàm khác in từ điển ra thành một bảng chuyên nghiệp.Input (dữ liệu gốc), Optional (dữ liệu có thể thiếu), và Derived (các biến Ta đã tạo ra).# 1) Tự đảm bảo có hàm ptbl()
if (!exists("ptbl")) {
suppressPackageStartupMessages(library(kableExtra))
ptbl <- function(x, caption = NULL, digits = 3) {
stopifnot(is.data.frame(x) || inherits(x, "matrix"))
fmt <- if (knitr::is_latex_output()) "latex" else "html"
kb <- knitr::kable(x, format = fmt, caption = caption, digits = digits,
booktabs = TRUE, longtable = TRUE, linesep = "")
if (knitr::is_latex_output()) {
kb |>
kableExtra::kable_styling(
latex_options = c("hold_position", "repeat_header", "scale_down"),
position = "center", font_size = 10
)
} else {
kb |>
kableExtra::kable_styling(full_width = FALSE,
bootstrap_options = c("striped","condensed")) |>
kableExtra::scroll_box(width = "100%")
}
}
}
# 2) Tự đảm bảo có từ điển
if (!exists("dict")) {
suppressPackageStartupMessages(library(tibble))
dict <- tibble::tribble(
~Biến, ~Mô_tả, ~Nhóm,
"revenue","Doanh thu thuần","Input",
"gp","Lợi nhuận gộp","Input",
"ebit","EBIT","Input",
"ni","LN ròng","Input",
"ta","Tổng tài sản","Input",
"equity","Vốn chủ sở hữu","Input",
"cogs","Giá vốn hàng bán","Input",
"inv_net","HTK ròng","Input",
"ar","Phải thu KH","Input",
"ap","Phải trả NB","Input",
"ca","TS ngắn hạn","Input",
"cl","Nợ ngắn hạn","Input",
"cfo","LCTT HĐKD","Input",
"cfi","LCTT HĐĐT","Input",
"gross_margin","Biên gộp","Derived",
"operating_margin","Biên EBIT","Derived",
"net_margin","Biên ròng","Derived",
"roa","ROA","Derived",
"roe","ROE","Derived"
)
}
# 3) Xác định dữ liệu tham chiếu
suppressPackageStartupMessages(library(dplyr))
data_ref <- if (exists("df_analysis")) df_analysis else if (exists(
"df_numeric")) df_numeric else df_raw
vars_avail <- intersect(dict$Biến, names(data_ref))
# 4) Lọc & in
if (length(vars_avail) == 0) {
message("[INFO] Không thấy biến nào trong dữ liệu khớp từ điển.")
} else {
dict_in_use <- dict |>
dplyr::filter(Biến %in% vars_avail) |>
dplyr::arrange(Nhóm, Biến) |>
dplyr::mutate(`Đã có` = "\u2713")
ptbl(dict_in_use, caption = "Danh mục biến dùng trong phân tích")
}
| Biến | Mô_tả | Nhóm | Đã có |
|---|---|---|---|
| asset_turnover | Vòng quay TS | Derived | ✓ |
| avg_equity | VCSH bình quân | Derived | ✓ |
| avg_ta | TS bình quân | Derived | ✓ |
| ccc | Chu kỳ tiền (CCC) | Derived | ✓ |
| cfo_ni | CFO/NI | Derived | ✓ |
| current_ratio | Current ratio | Derived | ✓ |
| debt_to_equity | Nợ/VCSH | Derived | ✓ |
| dio | DIO (ngày) | Derived | ✓ |
| dpo | DPO (ngày) | Derived | ✓ |
| dso | DSO (ngày) | Derived | ✓ |
| fcf_margin | Biên FCF | Derived | ✓ |
| gross_margin | Biên gộp | Derived | ✓ |
| inventory_turnover | Vòng quay HTK | Derived | ✓ |
| net_margin | Biên ròng | Derived | ✓ |
| operating_margin | Biên EBIT | Derived | ✓ |
| quick_ratio | Quick ratio | Derived | ✓ |
| receivables_turnover | Vòng quay PT | Derived | ✓ |
| roa | ROA | Derived | ✓ |
| roe | ROE | Derived | ✓ |
| ap | Phải trả NB | Input | ✓ |
| ar | Phải thu KH | Input | ✓ |
| ca | TS ngắn hạn | Input | ✓ |
| cfi | LCTT HĐĐT | Input | ✓ |
| cfo | LCTT HĐKD | Input | ✓ |
| cl | Nợ ngắn hạn | Input | ✓ |
| cogs | Giá vốn hàng bán | Input | ✓ |
| ebit | EBIT | Input | ✓ |
| equity | Vốn chủ sở hữu | Input | ✓ |
| gp | Lợi nhuận gộp | Input | ✓ |
| inv_net | HTK ròng | Input | ✓ |
| ni | LN ròng | Input | ✓ |
| revenue | Doanh thu thuần | Input | ✓ |
| ta | Tổng tài sản | Input | ✓ |
Code này tạo một từ điển dữ liệu tự động. Nó kiểm tra dữ liệu của Ta và tạo một báo cáo về các biến hiện có.
df_analysis, df_numeric, hoặc df_raw).# 1) Tự đảm bảo có hàm ptbl()
if (!exists("ptbl")) {
suppressPackageStartupMessages(library(kableExtra))
ptbl <- function(x, caption = NULL, digits = 3) {
stopifnot(is.data.frame(x) || inherits(x, "matrix"))
fmt <- if (knitr::is_latex_output()) "latex" else "html"
kb <- knitr::kable(x, format = fmt, caption = caption, digits = digits,
booktabs = TRUE, longtable = TRUE, linesep = "")
if (knitr::is_latex_output()) {
kb |>
kableExtra::kable_styling(
latex_options = c("hold_position", "repeat_header", "scale_down"),
position = "center", font_size = 10
)
} else {
kb |>
kableExtra::kable_styling(full_width = FALSE,
bootstrap_options = c("striped","condensed")) |>
kableExtra::scroll_box(width = "100%")
}
}
}
# 2) Tự đảm bảo có dict & data_ref
if (!exists("dict")) {
suppressPackageStartupMessages(library(tibble))
dict <- tibble::tribble(
~Biến, ~Mô_tả, ~Nhóm,
"revenue","Doanh thu thuần","Input",
"gp","Lợi nhuận gộp","Input",
"ebit","EBIT","Input",
"ni","LN ròng","Input",
"ta","Tổng tài sản","Input",
"equity","Vốn chủ sở hữu","Input",
"cogs","Giá vốn hàng bán","Input",
"inv_net","HTK ròng","Input",
"ar","Phải thu KH","Input",
"ap","Phải trả NB","Input",
"ca","TS ngắn hạn","Input",
"cl","Nợ ngắn hạn","Input"
)
}
data_ref <- if (exists("df_analysis")) df_analysis else if (
exists("df_numeric")) df_numeric else df_raw
vars_avail <- intersect(dict$Biến, names(data_ref))
num_vars <- vars_avail[sapply(data_ref[vars_avail], is.numeric)]
# 3) Tính & in
if (length(num_vars) > 0) {
M <- sapply(data_ref[num_vars], function(x) c(
n = sum(!is.na(x)),
mean = mean(x, na.rm = TRUE),
sd = stats::sd(x, na.rm = TRUE),
min = suppressWarnings(min(x, na.rm = TRUE)),
max = suppressWarnings(max(x, na.rm = TRUE))
))
summ <- data.frame(Biến = colnames(M), t(M), row.names = NULL, check.names = FALSE)
ptbl(summ, caption = "Thống kê nhanh các biến số (đơn giản)")
} else {
message("[INFO] Không có biến số nào để tóm tắt.")
}
| Biến | n | mean | sd | min | max |
|---|---|---|---|---|---|
| revenue | 34 | 5.933 | 2.842 | 0.877 | 12.594 |
| gp | 34 | 1.103 | 0.520 | 0.156 | 2.149 |
| ebit | 34 | 0.445 | 0.269 | -0.193 | 0.941 |
| ni | 34 | 0.352 | 0.216 | -0.160 | 0.749 |
| ta | 34 | 9.739 | 4.092 | 3.561 | 17.419 |
| equity | 34 | 6.465 | 3.110 | 1.735 | 11.969 |
| cogs | 34 | 4.830 | 2.337 | 0.721 | 10.445 |
| inv_net | 34 | 7.449 | 3.105 | 2.847 | 13.709 |
| ar | 34 | 0.069 | 0.034 | 0.027 | 0.200 |
| ap | 34 | 0.385 | 0.173 | 0.128 | 0.823 |
| ca | 34 | 8.535 | 3.851 | 3.055 | 15.950 |
| cl | 34 | 3.254 | 1.230 | 1.313 | 5.942 |
| cfo | 34 | 0.044 | 0.707 | -1.573 | 1.887 |
| cfi | 34 | -0.058 | 0.282 | -0.965 | 0.767 |
| gross_margin | 34 | 0.186 | 0.016 | 0.156 | 0.219 |
| operating_margin | 34 | 0.067 | 0.055 | -0.221 | 0.114 |
| net_margin | 34 | 0.053 | 0.045 | -0.182 | 0.090 |
| avg_ta | 33 | 9.720 | 3.953 | 3.578 | 17.314 |
| avg_equity | 33 | 6.453 | 3.015 | 1.742 | 11.850 |
| roa | 33 | 0.038 | 0.019 | -0.017 | 0.073 |
| roe | 33 | 0.059 | 0.030 | -0.028 | 0.110 |
| asset_turnover | 33 | 0.627 | 0.170 | 0.096 | 0.924 |
| inventory_turnover | 33 | 0.669 | 0.192 | 0.096 | 1.039 |
| receivables_turnover | 33 | 91.904 | 41.495 | 24.624 | 164.907 |
| dso | 34 | 5.170 | 3.535 | 2.225 | 18.570 |
| dio | 34 | 668.030 | 573.493 | 324.351 | 3801.405 |
| dpo | 34 | 35.762 | 25.327 | 8.158 | 157.608 |
| ccc | 34 | 637.439 | 553.836 | 305.407 | 3655.352 |
| debt_to_equity | 34 | 0.567 | 0.194 | 0.210 | 1.056 |
| current_ratio | 34 | 2.618 | 0.730 | 1.723 | 5.143 |
| quick_ratio | 34 | 0.319 | 0.218 | 0.078 | 0.814 |
| cfo_ni | 34 | -0.095 | 1.663 | -3.509 | 2.565 |
| fcf_margin | 34 | -0.031 | 0.120 | -0.400 | 0.168 |
Code này tính toán và trình bày một bảng thống kê mô tả cho dữ liệu.
sapply để tính toán số lượng (n), trung bình (mean), độ lệch chuẩn (sd), giá trị nhỏ nhất (min), và lớn nhất (max) cho mỗi biến.sd lớn cho thấy dữ liệu phân tán rộng.min là số âm.Code này thiết lập một môi trường để Ta vẽ các biểu đồ chuyên nghiệp và nhất quán.
use_plotly và save_plots cho phép Ta bật chế độ tương tác hoặc tự động lưu file. Ta thay đổi chúng ở một nơi duy nhất.theme_pro định nghĩa phông chữ, màu sắc và bố cục. Ta dùng theme_set để áp dụng nó làm mặc định cho mọi biểu đồ.rownames thành một cột Date thực sự. ggplot2 yêu cầu cột Date này để vẽ biểu đồ chuỗi thời gian chính xác.quarter_to_date <- function(Quarter) {
q <- stringr::str_trim(as.character(Quarter))
m1 <- stringr::str_match(q, "^Q([1-4])[-_/ ]*(\\d{4})$")
m2 <- stringr::str_match(q, "^(\\d{4})\\s*Q([1-4])$")
m3 <- stringr::str_match(q, "^(\\d{4})Q([1-4])$")
qv <- suppressWarnings(as.integer(dplyr::coalesce(m1[,2], m2[,3], m3[,3])))
yv <- suppressWarnings(as.integer(dplyr::coalesce(m1[,3], m2[,2], m3[,2])))
out <- rep(as.Date(NA), length(q))
ok <- !is.na(qv) & !is.na(yv)
mo <- (qv - 1L) * 3L + 1L
out[ok] <- as.Date(sprintf("%04d-%02d-01", yv[ok], mo[ok]))
out
}
df_plot <- df_analysis %>%
tibble::rownames_to_column(var = "Quarter") %>%
dplyr::mutate(
Date = quarter_to_date(Quarter),
Year = factor(stringr::str_extract(Quarter, "\\d{4}")),
qtr = as.integer(((as.integer(format(Date, "%m")) - 1) %/% 3) + 1),
yr = as.integer(format(Date, "%Y"))
) %>%
dplyr::arrange(Date)
message("Đã tạo 'df_plot' sẵn sàng cho trực quan hóa.")
theme_pro <- function() {
ggplot2::theme_minimal(base_family = "sans", base_size = 12) +
ggplot2::theme(
plot.title = ggplot2::element_text(face = "bold", size = 16, color = "#222222"),
plot.subtitle = ggplot2::element_text(size = 12, color = "#555555", margin = ggplot2::margin(b = 10)),
plot.caption = ggplot2::element_text(size = 9, color = "#888888", face = "italic", hjust = 0),
axis.title = ggplot2::element_text(face = "bold", size = 11, color = "#333333"),
axis.text = ggplot2::element_text(size = 10, color = "#444444"),
legend.position = "bottom",
legend.title = ggplot2::element_blank(),
panel.grid.minor = ggplot2::element_blank(),
panel.grid.major.x = ggplot2::element_blank(),
panel.grid.major.y = ggplot2::element_line(color = "#E5E7EB", linetype = "dashed"),
plot.background = ggplot2::element_rect(fill = "#FAFAFA", color = NA),
panel.background = ggplot2::element_rect(fill = "#FAFAFA", color = NA),
strip.text = ggplot2::element_text(face = "bold", size = 12, hjust = 0, color = "#333333"),
plot.margin = ggplot2::margin(15, 15, 15, 15)
)
}
ggplot2::theme_set(theme_pro())
date_break_years <- function(n = 1) list(
ggplot2::scale_x_date(date_breaks = paste0(n, " year"), date_labels = "%Y"),
ggplot2::theme(axis.text.x = ggplot2::element_text(angle = 45, hjust = 1))
)
add_xdate <- function(p, n = 1) { if (inherits(p, "gg")) p + date_break_years(n) else p }
safe_coef <- function(lhs, rhs) {
a <- suppressWarnings(max(lhs, na.rm = TRUE))
b <- suppressWarnings(max(rhs, na.rm = TRUE))
if (!is.finite(a) || !is.finite(b) || b == 0) return(1)
a / b
}
cap_default <- "Đơn vị: Ngàn Tỷ Đồng đối với số tuyệt đối; % với tỷ lệ. Dữ liệu đã được chuẩn hóa."
num_fmt <- scales::label_number(big.mark = ".", decimal.mark = ",")
pct_fmt <- scales::label_percent(accuracy = 0.1, big.mark = ".", decimal.mark = ",")
qty_fmt <- scales::label_number(accuracy = 0.1, big.mark = ",", decimal.mark = ",")
Code này thiết lập một môi trường để vẽ các biểu đồ chuyên nghiệp và nhất quán.
rownames thành một cột Date thực sự. ggplot2 yêu cầu cột Date này để vẽ biểu đồ chuỗi thời gian chính xác.theme_pro định nghĩa phông chữ, màu sắc và bố cục. Ta dùng theme_set để áp dụng nó làm mặc định cho mọi biểu đồ.if (all(c("revenue","revenue_yoy","Date") %in% names(df_plot))) {
# Tính hệ số quy đổi cho trục phụ
s <- safe_coef(df_plot$revenue, df_plot$revenue_yoy)
# Bảng màu
col_rev <- "#2563EB" # xanh royal cho cột
col_yoy <- "#F9F316" # đường YoY
col_yoy_trend <- "#FB923C" # xu hướng
zero_line <- 0 * s
# (MỚI) Độ rộng cột ~ 80% khoảng cách giữa các mốc thời gian
bw <- max(10, as.numeric(median(diff(sort(unique(df_plot$Date))))) * 0.8)
# Điểm cuối để gắn nhãn % YoY
last_df <- df_plot %>%
dplyr::filter(!is.na(revenue_yoy), !is.na(Date)) %>%
dplyr::slice_tail(n = 1) %>%
dplyr::mutate(
y_scaled = revenue_yoy * s,
yoy_label = scales::percent(revenue_yoy, accuracy = 0.1)
)
p1 <- ggplot(df_plot, aes(x = Date)) +
# --- CỘT DOANH THU (to hơn) ---
geom_col(aes(y = revenue, fill = "Revenue"),
width = bw, alpha = 0.95, color = NA) +
# --- ĐƯỜNG 0% của YoY ---
geom_hline(yintercept = zero_line, linetype = "dotted", color = "#9CA3AF") +
# --- Hiệu ứng glow cho YoY ---
geom_line(aes(y = revenue_yoy * s),
linewidth = 2.2, color = col_yoy, alpha = 0.12,
show.legend = FALSE) +
# --- Đường & điểm YoY ---
geom_line(aes(y = revenue_yoy * s, color = "YoY"),
linewidth = 1.25, lineend = "round") +
geom_point(aes(y = revenue_yoy * s, color = "YoY"), size = 2.1, stroke = 0.2) +
# --- Xu hướng LOESS của YoY ---
geom_smooth(aes(y = revenue_yoy * s, color = "YoY (LOESS)"),
method = "loess", se = FALSE, linewidth = 0.9, span = 0.5) +
# --- Nhãn % tại điểm cuối ---
geom_text(data = last_df,
aes(x = Date, y = y_scaled, label = yoy_label),
vjust = -0.8, size = 3.3, color = col_yoy) +
# --- Thang & trục ---
scale_y_continuous(
name = "Doanh thu (Ngàn Tỷ)", labels = num_fmt,
expand = expansion(mult = c(0.02, 0.1)),
sec.axis = sec_axis(~ . / s, name = "Tăng trưởng YoY", labels = pct_fmt)
) +
scale_fill_manual(values = c("Revenue" = col_rev)) +
scale_color_manual(values = c("YoY" = col_yoy, "YoY (LOESS)" = col_yoy_trend)) +
labs(
title = "Doanh thu & Tăng trưởng YoY",
x = NULL, caption = cap_default
) +
guides(fill = guide_legend(order = 1), color = guide_legend(order = 2)) +
theme_pro() +
theme(
legend.box = "horizontal",
plot.title.position = "plot"
)
print(maybe_plotly(add_xdate(p1)))
maybe_save(add_xdate(p1), "01_revenue_yoy_dual_axis.png") }
Phân tích Code:
geom_col để vẽ các cột doanh thu trên trục chính bên trái.geom_line và geom_smooth để vẽ các đường tăng trưởng trên trục phụ bên phải.s, sau đó dùng sec_axis(~ . / s) để hiển thị lại giá trị gốc.geom_smooth với phương pháp loess để làm nổi bật xu hướng dài hạn, loại bỏ các biến động nhiễu trong ngắn hạn.Kết quả:
cols <- c("revenue_yoy","gp_yoy","ebit_yoy","ni_yoy")
avail <- intersect(cols, names(df_plot))
if (length(avail) >= 2) {
df_long <- df_plot |>
dplyr::select(Date, dplyr::all_of(avail)) |>
tidyr::pivot_longer(-Date, names_to = "metric", values_to = "value") |>
dplyr::mutate(
metric_lab = dplyr::recode(
metric,
"revenue_yoy" = "Doanh thu (YoY)",
"gp_yoy" = "Lợi nhuận gộp (YoY)",
"ebit_yoy" = "EBIT (YoY)",
"ni_yoy" = "Lợi nhuận ròng (YoY)"
)
)
# Màu sắc đậm nhưng dịu, phân biệt tốt
pal_yoy <- c(
"Doanh thu (YoY)" = "#2563EB",
"Lợi nhuận gộp (YoY)" = "#10B981",
"EBIT (YoY)" = "#F59E0B",
"Lợi nhuận ròng (YoY)" = "#EF4444"
)
# Nhãn ở điểm gần nhất hiện có
last_pts <- df_long |>
dplyr::filter(!is.na(value)) |>
dplyr::group_by(metric_lab) |>
dplyr::slice_max(order_by = Date, n = 1, with_ties = FALSE) |>
dplyr::ungroup() |>
dplyr::mutate(lbl = scales::percent(value, accuracy = 0.1))
has_repel <- requireNamespace("ggrepel", quietly = TRUE)
p2 <- ggplot(df_long, aes(Date, value, color = metric_lab, group = metric_lab)) +
geom_hline(yintercept = 0, linetype = "dotted", color = "#9CA3AF") +
geom_line(linewidth = 1.05, lineend = "round") +
geom_point(size = 1.9, stroke = 0.4, shape = 21, fill = "white", show.legend = FALSE) +
{
if (has_repel) {
ggrepel::geom_text_repel(
data = last_pts, aes(label = lbl),
size = 3.2, segment.color = "#D1D5DB",
box.padding = 0.25, point.padding = 0.2,
max.overlaps = Inf, seed = 1
)
} else {
geom_text(data = last_pts, aes(label = lbl), vjust = -0.6, size = 3.2)
}
} +
scale_color_manual(values = pal_yoy) +
scale_y_continuous(labels = pct_fmt, breaks = scales::breaks_pretty(n = 4)) +
labs(
title = "Phân rã tăng trưởng YoY (tách theo chỉ tiêu)",
subtitle = "Mỗi ô một chỉ tiêu, trục Y riêng giúp nhìn xu hướng rõ ràng hơn.",
x = NULL, y = "Tăng trưởng YoY", caption = cap_default
) +
theme_pro() +
theme(legend.position = "none") +
facet_wrap(~ metric_lab, ncol = 2, scales = "free_y")
print(maybe_plotly(add_xdate(p2)))
maybe_save(add_xdate(p2), "02_growth_decomposition_facets.png")
}
Phân tích Code:
pivot_longer để chuyển dữ liệu từ dạng rộng sang dạng dài. ggplot2 yêu cầu định dạng này để tạo biểu đồ.facet_wrap để tạo một biểu đồ nhỏ riêng cho mỗi chỉ tiêu.scales = "free_y". Đây là tham số quan trọng nhất. Nó cho phép mỗi biểu đồ nhỏ có trục tung riêng, giúp các biến động nhỏ không bị ẩn đi.Kết quả:
if (all(c("ta","ca","Date") %in% names(df_plot))) {
# Chuẩn bị dữ liệu + đặt tên hiển thị đẹp
df_area <- df_plot |>
dplyr::transmute(
Date,
`Tài sản ngắn hạn (CA)` = ca,
`Tài sản dài hạn (TA-CA)` = ta - ca
) |>
tidyr::pivot_longer(-Date, names_to = "Khoản mục", values_to = "Giá trị") |>
dplyr::mutate(`Khoản mục` = factor(
`Khoản mục`,
levels = c("Tài sản ngắn hạn (CA)", "Tài sản dài hạn (TA-CA)")
))
# Nhãn % tại quý gần nhất (đặt giữa mỗi lớp)
labels_last <- df_area |>
dplyr::filter(Date == max(Date, na.rm = TRUE)) |>
dplyr::arrange(`Khoản mục`) |>
dplyr::mutate(
Tổng = sum(`Giá trị`, na.rm = TRUE),
Tỷ_trọng = dplyr::if_else(is.finite(Tổng) & Tổng != 0, `Giá trị`/Tổng, NA_real_),
y_mid = cumsum(`Giá trị`) - `Giá trị`/2
) |>
dplyr::filter(!is.na(Tỷ_trọng)) |>
dplyr::mutate(lbl = scales::percent(Tỷ_trọng, accuracy = 0.1))
# Bảng màu: teal + violet (tươi, dễ phân biệt)
pal_asset <- c(
"Tài sản ngắn hạn (CA)" = "#06B6D4", # teal
"Tài sản dài hạn (TA-CA)" = "#8B5CF6" # violet
)
p3 <- ggplot(df_area, aes(Date, `Giá trị`, fill = `Khoản mục`, group = `Khoản mục`)) +
# nền nhẹ giúp nổi khối
annotate("rect", xmin = -Inf, xmax = Inf, ymin = 0, ymax = Inf,
fill = "#F8FAFC", alpha = 0.6) +
# stacked area
geom_area(alpha = 0.92, color = NA) +
# nhãn % trong ô ở quý gần nhất (màu chữ trắng, không viền)
geom_label(
data = labels_last,
aes(x = max(df_area$Date, na.rm = TRUE), y = y_mid, label = lbl, fill = `Khoản mục`),
color = "white", size = 3.2, label.size = 0, label.padding = grid::unit(0.12, "lines"),
inherit.aes = FALSE
) +
scale_fill_manual(values = pal_asset) +
scale_y_continuous(labels = num_fmt, expand = expansion(mult = c(0.02, 0.12))) +
labs(
title = "Cấu trúc Tài sản",
subtitle = "Stacked area: CA vs. (TA−CA). Nhãn hiển thị tỷ trọng tại quý gần nhất.",
x = NULL, y = "Ngàn Tỷ", caption = cap_default
) +
theme_pro() +
theme(
legend.position = "top",
legend.box = "horizontal",
plot.title.position = "plot"
)
print(maybe_plotly(add_xdate(p3)))
maybe_save(add_xdate(p3), "03_asset_structure.png")
}
Phân tích Code:
ta - ca, sau đó dùng pivot_longer để chuyển dữ liệu sang dạng dài. Định dạng này là yêu cầu để ggplot2 vẽ biểu đồ xếp chồng.geom_area để vẽ biểu đồ và geom_label để thêm các nhãn đã tính toán.
Biểu đồ này cho thấy:Kết quả:
if (all(c("roe","roa","Date") %in% names(df_plot))) {
# 1) Dữ liệu gốc
df_lr <- df_plot |>
dplyr::arrange(Date) |>
dplyr::transmute(Date, ROE = roe, ROA = roa)
# 2) Chèn điểm giao nhau (gap = 0) giữa hai quý nếu có đổi dấu
add_crossings <- function(d){
d <- d[order(d$Date), ]
out <- d[1, , drop = FALSE]
if (nrow(d) >= 2) {
for (i in seq_len(nrow(d) - 1)) {
r1 <- d[i, ]; r2 <- d[i + 1, ]
g1 <- r1$ROE - r1$ROA; g2 <- r2$ROE - r2$ROA
if (is.finite(g1) && is.finite(g2) && g1 * g2 < 0) {
t <- abs(g1) / (abs(g1) + abs(g2)) # t ∈ (0,1)
cross <- r1
cross$Date <- r1$Date + as.difftime(as.numeric(r2$Date - r1$Date) * t, units = "days")
cross$ROE <- r1$ROE + (r2$ROE - r1$ROE) * t
cross$ROA <- r1$ROA + (r2$ROA - r1$ROA) * t
out <- rbind(out, cross, r2)
} else {
out <- rbind(out, r2)
}
}
}
out
}
rib <- add_crossings(df_lr) |>
dplyr::filter(!is.na(ROE), !is.na(ROA)) |>
dplyr::mutate(
gap = ROE - ROA,
ymin = pmin(ROE, ROA),
ymax = pmax(ROE, ROA),
sign = ifelse(gap >= 0, "Đòn bẩy dương (ROE > ROA)", "Đòn bẩy âm (ROE < ROA)")
) |>
dplyr::arrange(Date) |>
dplyr::mutate( # chia polygon theo đoạn cùng dấu
sg = ifelse(gap >= 0, 1L, -1L),
grp = cumsum(dplyr::coalesce(sg != dplyr::lag(sg), FALSE))
)
# Nhãn Δ tại quý gần nhất có đủ cặp
last_pair <- rib |>
dplyr::slice_tail(n = 1) |>
dplyr::mutate(
y_mid = (ROE + ROA) / 2,
label_gap = paste0(ifelse(gap >= 0, "+", ""),
scales::number(gap * 100, accuracy = 0.1, decimal.mark = ","),
" điểm %"),
col_gap = ifelse(gap >= 0, "#10B981", "#EF4444")
)
col_line <- c("ROE" = "#F59E0B", "ROA" = "#2563EB")
col_fill <- c("Đòn bẩy dương (ROE > ROA)" = "#10B981",
"Đòn bẩy âm (ROE < ROA)" = "#EF4444")
# Zoom trục Y ~30% (thu hẹp phạm vi để phóng lớn)
rng <- range(c(df_lr$ROE, df_lr$ROA), na.rm = TRUE)
mid <- mean(rng); half <- diff(rng) / 2
ylim_zoom <- c(mid - half * 0.70, mid + half * 0.70)
p6 <- ggplot() +
annotate("rect", xmin=-Inf, xmax=Inf, ymin=0, ymax=Inf, fill="#F8FAFC", alpha=.6) +
geom_ribbon(data = rib,
aes(Date, ymin = ymin, ymax = ymax, fill = sign, group = grp),
alpha = .25, show.legend = TRUE) +
geom_line(data = df_lr, aes(Date, ROE), linewidth = 2, color = col_line["ROE"], alpha = .12) +
geom_line(data = df_lr, aes(Date, ROE, color = "ROE"), linewidth = 1.25, lineend = "round") +
geom_point(data = df_lr, aes(Date, ROE, color = "ROE"), size = 2, stroke = .3) +
geom_line(data = df_lr, aes(Date, ROA), linewidth = 2, color = col_line["ROA"], alpha = .12) +
geom_line(data = df_lr, aes(Date, ROA, color = "ROA"), linewidth = 1.25, lineend = "round") +
geom_point(data = df_lr, aes(Date, ROA, color = "ROA"), size = 2, stroke = .3) +
geom_hline(yintercept = 0, linetype = "dotted", color = "#9CA3AF") +
geom_label(
data = last_pair,
aes(Date, y_mid, label = label_gap),
inherit.aes = FALSE, size = 2, label.size = 0, fill = "white",
color = last_pair$col_gap[1], fontface = "bold"
) +
scale_y_continuous(labels = pct_fmt, expand = expansion(mult = c(.06, .18))) +
scale_color_manual(values = col_line, name = NULL) +
scale_fill_manual(values = col_fill, name = NULL) +
scale_x_date(expand = expansion(add = c(30, 150)), date_breaks = "1 year", date_labels = "%Y") +
coord_cartesian(ylim = ylim_zoom, clip = "off") +
labs(
title = "ROE vs ROA",
x = NULL, y = "Tỷ lệ", caption = cap_default
) +
theme_pro() +
theme(legend.position = "top", legend.box = "horizontal",
plot.title.position = "plot", axis.text.x = element_text(angle = 45, hjust = 1))
print(maybe_plotly(p6))
maybe_save(p6, "06_roe_roa.png")
}
Phân tích Code:
geom_ribbon để tô màu vùng giữa hai đường ROE và ROA.add_crossings. Hàm này chèn thêm các điểm dữ liệu tại đúng vị trí hai đường cắt nhau. Việc này đảm bảo vùng tô màu chính xác.grp. Biến này giúp geom_ribbon vẽ các vùng màu riêng biệt cho từng giai đoạn đòn bẩy dương và âm.geom_line và geom_point được vẽ lên trên để làm rõ xu hướng của từng chỉ số.Kết quả:
if (all(c("roe","net_margin","asset_turnover","financial_leverage","Date") %in% names(df_plot))) {
# Hệ số quy đổi an toàn cho trục phụ
s <- suppressWarnings(
max(df_plot$roe, na.rm = TRUE) /
max(c(df_plot$net_margin, df_plot$asset_turnover, df_plot$financial_leverage), na.rm = TRUE)
)
if (!is.finite(s) || s <= 0) s <- 1
uq <- sort(unique(df_plot$Date))
step_days <- if (length(uq) > 1) median(diff(uq)) else 90
bar_w <- as.numeric(step_days) * 0.75 # ~75% khoảng cách giữa 2 quý
# Long-format cho các thành tố
comps <- df_plot %>%
dplyr::select(Date, net_margin, asset_turnover, financial_leverage) %>%
tidyr::pivot_longer(-Date, names_to = "component", values_to = "value") %>%
dplyr::mutate(
component_lab = dplyr::recode(
component,
net_margin = "Net margin",
asset_turnover = "Asset turnover",
financial_leverage= "Financial leverage"
),
value_scaled = value * s
)
# Nhãn ở quý gần nhất
last_labels <- comps %>%
dplyr::filter(!is.na(value)) %>%
dplyr::group_by(component_lab) %>%
dplyr::slice_max(order_by = Date, n = 1, with_ties = FALSE) %>%
dplyr::ungroup() %>%
dplyr::mutate(
label = dplyr::case_when(
component_lab == "Net margin" ~ scales::percent(value, accuracy = 0.1),
component_lab == "Asset turnover" ~ scales::number(value, accuracy = 0.01, suffix = "x",
big.mark = ".", decimal.mark = ","),
component_lab == "Financial leverage"~ scales::number(value, accuracy = 0.01, suffix = "x",
big.mark = ".", decimal.mark = ","),
TRUE ~ as.character(value)
)
)
# Bảng màu
col_bar <- "#22C55E" # ROE (emerald)
col_line <- c(
"Net margin" = "#0EA5E9",
"Asset turnover" = "#F59E0B",
"Financial leverage" = "#8B5CF6"
)
has_repel <- requireNamespace("ggrepel", quietly = TRUE)
p7 <- ggplot() +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = 0, ymax = Inf,
fill = "#F8FAFC", alpha = 0.6) +
# CỘT ROE
geom_col(data = dplyr::filter(df_plot, !is.na(roe)),
aes(Date, roe), width = bar_w,
fill = col_bar, alpha = 0.92, color = NA, na.rm = TRUE) +
# GLOW + ĐƯỜNG CÁC THÀNH TỐ (đã scale sang trục trái)
geom_line(data = comps, aes(Date, value_scaled, color = component_lab),
linewidth = 2.3, alpha = 0.10, show.legend = FALSE) +
geom_line(data = comps, aes(Date, value_scaled, color = component_lab),
linewidth = 1.25, lineend = "round") +
geom_point(data = comps, aes(Date, value_scaled, color = component_lab),
size = 1.9, stroke = 0.3, show.legend = FALSE) +
# NHÃN Ở QUÝ GẦN NHẤT
{
if (has_repel) {
ggrepel::geom_text_repel(
data = last_labels,
aes(Date, value_scaled, label = label, color = component_lab),
size = 3.1, segment.color = "#D1D5DB",
box.padding = 0.25, point.padding = 0.2,
max.overlaps = Inf, seed = 1, show.legend = FALSE
)
} else {
geom_text(
data = last_labels,
aes(Date, value_scaled, label = label, color = component_lab),
vjust = -0.6, size = 3.1, show.legend = FALSE
)
}
} +
geom_hline(yintercept = 0, linetype = "dotted", color = "#9CA3AF") +
scale_y_continuous(
name = "ROE",
labels = pct_fmt,
expand = expansion(mult = c(0.05, 0.18)),
sec.axis = sec_axis(~ . / s, name = "Thành tố DuPont")
) +
scale_color_manual(values = col_line, name = NULL) +
labs(
title = "Phân rã DuPont (3 thành tố)",
x = NULL, caption = cap_default
) +
theme_pro() +
theme(
legend.position = "top",
legend.box = "horizontal",
plot.title.position = "plot"
)
print(maybe_plotly(add_xdate(p7))) }
Phân tích Code:
pivot_longer để tái cấu trúc dữ liệu, chuẩn bị các thành tố DuPont cho việc vẽ biểu đồ.Kết quả:
if (all(c("asset_turnover","net_margin","Date") %in% names(df_plot))) {
# Tính median để vẽ trục tham chiếu chiến lược
med_x <- median(df_plot$asset_turnover, na.rm = TRUE)
med_y <- median(df_plot$net_margin, na.rm = TRUE)
# Breaks/labels cho thanh màu thời gian
t_min <- suppressWarnings(min(df_plot$Date, na.rm = TRUE))
t_max <- suppressWarnings(max(df_plot$Date, na.rm = TRUE))
yr_seq <- try(seq(as.Date(cut(t_min, "year")), as.Date(cut(t_max, "year")), by = "2 years"), silent = TRUE)
if (inherits(yr_seq, "try-error") || length(yr_seq) < 2) {
yr_seq <- unique(as.Date(c(t_min, t_max)))
}
# Bảng màu gradient (đẹp, dễ đọc)
time_cols <- c("#2563EB", "#8B5CF6", "#F59E0B", "#EF4444") # blue → violet → amber → coral
p8 <- ggplot(df_plot, aes(x = asset_turnover, y = net_margin)) +
# Nền cho vùng Net margin âm
annotate("rect", xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = 0,
fill = "#FEE2E2", alpha = 0.10) +
# Halo trắng (lớp dưới) để điểm nổi bật trên nền
geom_point(color = "white", size = 3.6, alpha = 0.8, na.rm = TRUE) +
# Điểm màu theo thời gian (lớp trên)
geom_point(aes(color = as.numeric(Date)), size = 2.2, alpha = 0.95, na.rm = TRUE) +
# Đường LOESS mượt (xu hướng phi tuyến)
geom_smooth(aes(color = NULL), method = "loess", se = FALSE, linewidth = 1.1, span = 0.6, color = "#111827") +
# Đường hồi quy tuyến tính tổng quát (trade-off margin vs turnover)
geom_smooth(aes(color = NULL), method = "lm", se = FALSE, linewidth = 0.9, linetype = "dashed", color = "#6B7280") +
# Đường tham chiếu theo median (phân mảnh chiến lược)
geom_vline(xintercept = med_x, linetype = "dotted", color = "#9CA3AF") +
geom_hline(yintercept = med_y, linetype = "dotted", color = "#9CA3AF") +
# Tỷ lệ & thang màu
scale_x_continuous(labels = scales::number_format(accuracy = 0.01, suffix = "x"),
breaks = scales::breaks_pretty(n = 5), expand = expansion(mult = c(0.05, 0.08))) +
scale_y_continuous(labels = pct_fmt,
breaks = scales::breaks_pretty(n = 5), expand = expansion(mult = c(0.05, 0.10))) +
scale_color_gradientn(
colours = time_cols,
breaks = as.numeric(yr_seq),
labels = format(yr_seq, "%Y"),
name = "Thời gian"
) +
labs(
title = "Phân rã ROA: Asset Turnover vs Net Margin",
x = "Asset turnover (lần)",
y = "Net margin",
caption = cap_default
) +
theme_pro() +
theme(
legend.position = "right",
legend.box = "vertical",
plot.title.position = "plot"
) +
coord_cartesian(clip = "off")
# Không dùng add_xdate vì trục X không phải Date
print(maybe_plotly(p8))}
Phân tích Code:
geom_point) với Asset turnover trên trục hoành và Net margin trên trục tung.lm) thể hiện mối quan hệ tổng thể, và một đường loess thể hiện xu hướng phi tuyến cục bộ.Kết quả:
df_plot2 <- df_plot
if (!("roic" %in% names(df_plot2)) || all(is.na(df_plot2$roic))) {
n <- nrow(df_plot2)
base_ic <- if ("ta" %in% names(df_plot2)) df_plot2$ta else rep(NA_real_, n)
cash <- if ("cash_eq" %in% names(df_plot2)) dplyr::coalesce(df_plot2$cash_eq, 0) else 0
nibcl <- if (all(c("cl","ap") %in% names(df_plot2))) dplyr::coalesce(df_plot2$cl - df_plot2$ap, 0) else 0
ic_proxy <- if ("ic" %in% names(df_plot2)) df_plot2$ic else base_ic - cash - nibcl
tax_eff <- if ("tax_rate" %in% names(df_plot2)) pmin(pmax(df_plot2$tax_rate, 0), 1) else rep(0.20, n)
avg_ic <- avg_lag1(ic_proxy)
roic_proxy <- ifelse(is.finite(avg_ic) & avg_ic != 0 & "ebit" %in% names(df_plot2),
df_plot2$ebit * (1 - tax_eff) / avg_ic, NA_real_)
df_plot2$roic <- if ("roic" %in% names(df_plot2)) dplyr::coalesce(df_plot2$roic, roic_proxy) else roic_proxy
}
# --- Vẽ ---
cols <- intersect(c("roic","operating_margin"), names(df_plot2))
if (length(cols) >= 1 && "Date" %in% names(df_plot2)) {
rename_map <- c(roic = "ROIC", operating_margin = "EBIT margin")
df_sel <- df_plot2 %>%
dplyr::select(Date, dplyr::all_of(cols)) %>%
dplyr::rename_with(~ rename_map[.x] %||% .x, .cols = -Date) %>%
tidyr::pivot_longer(-Date, names_to = "metric", values_to = "value")
# Nhãn quý gần nhất (dạng %)
last_labels <- df_sel %>%
dplyr::filter(!is.na(value)) %>%
dplyr::group_by(metric) %>%
dplyr::slice_max(order_by = Date, n = 1, with_ties = FALSE) %>%
dplyr::ungroup() %>%
dplyr::mutate(lbl = scales::percent(value, accuracy = 0.1))
pal_line <- c("ROIC" = "#10B981", "EBIT margin" = "#2563EB", "WACC" = "#EF4444")
has_repel <- requireNamespace("ggrepel", quietly = TRUE)
has_wacc <- "wacc" %in% names(df_plot2)
rib <- if (has_wacc && "roic" %in% names(df_plot2)) {
df_plot2 %>% dplyr::select(Date, roic, wacc) %>%
dplyr::filter(!is.na(roic), !is.na(wacc)) %>%
dplyr::mutate(ymin = pmin(roic, wacc), ymax = pmax(roic, wacc))
} else NULL
p9 <- ggplot() +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = 0, ymax = Inf,
fill = "#F8FAFC", alpha = 0.6) +
{ if (!is.null(rib)) list(
geom_ribbon(data = rib, aes(Date, ymin = ymin, ymax = ymax, fill = "Spread"),
alpha = 0.15, show.legend = FALSE),
scale_fill_manual(values = c("Spread" = "#10B981"))
) else NULL } +
geom_line(data = df_sel, aes(Date, value, color = metric),
linewidth = 2.2, alpha = 0.10, show.legend = FALSE) +
geom_line(data = df_sel, aes(Date, value, color = metric),
linewidth = 1.25, lineend = "round") +
geom_point(data = df_sel, aes(Date, value, color = metric),
size = 1.9, stroke = 0.3, show.legend = FALSE) +
{ if (has_wacc) list(
geom_line(data = df_plot2, aes(Date, wacc, color = "WACC"),
linewidth = 1.1, linetype = "longdash"),
geom_point(data = df_plot2, aes(Date, wacc, color = "WACC"),
size = 1.6, alpha = 0.9, show.legend = FALSE)
) else NULL } +
geom_hline(yintercept = 0, linetype = "dotted", color = "#9CA3AF") +
# ===== NHÃN % =====
{
if (has_repel) {
ggrepel::geom_label_repel(
data = last_labels,
aes(Date, value, label = lbl, color = metric),
fill = "white", label.size = 0, fontface = "bold",
size = 3.1, segment.color = "#D1D5DB",
box.padding = 0.25, point.padding = 0.2,
max.overlaps = Inf, seed = 1, show.legend = FALSE
)
} else {
geom_label(
data = last_labels,
aes(Date, value, label = lbl, color = metric),
fill = "white", label.size = 0, fontface = "bold",
size = 3.1, show.legend = FALSE
)
}
} +
scale_color_manual(values = pal_line, name = NULL) +
scale_y_continuous(labels = pct_fmt,
breaks = scales::breaks_pretty(n = 5),
expand = expansion(mult = c(0.05, 0.16))) +
labs(
title = "ROIC & EBIT margin",
x = NULL, y = "Tỷ lệ", caption = cap_default
) +
theme_pro() +
theme(legend.position = "top", legend.box = "horizontal", plot.title.position = "plot")
print(maybe_plotly(add_xdate(p9)))}
Phân tích Code:
geom_line và geom_point để vẽ xu hướng của hai chỉ số này theo thời gian.Kết quả:
# cột
need <- c("Date","ccc","dso","dio","dpo")
if (all(need %in% names(df_plot))) {
# ===== Chuẩn bị dữ liệu + MA 4 quý =====
df_ccc <- df_plot |> dplyr::arrange(Date) |>
dplyr::mutate(ccc_ma4 = zoo::rollmean(ccc, k = 4, fill = NA, align = "right"))
df_dso <- df_plot |> dplyr::arrange(Date) |>
dplyr::transmute(Date, DSO = dso, MA4 = zoo::rollmean(dso, k = 4, fill = NA, align = "right"))
df_dio <- df_plot |> dplyr::arrange(Date) |>
dplyr::transmute(Date, DIO = dio, MA4 = zoo::rollmean(dio, k = 4, fill = NA, align = "right"))
df_dpo <- df_plot |> dplyr::arrange(Date) |>
dplyr::transmute(Date, DPO = dpo, MA4 = zoo::rollmean(dpo, k = 4, fill = NA, align = "right"))
# ===== Màu sắc nhất quán =====
col_ccc <- "#2563EB" # royal blue
col_ma <- "#F59E0B" # amber
col_dso <- "#0EA5E9" # sky
col_dio <- "#8B5CF6" # violet
col_dpo <- "#EF4444" # coral
# ===== Helper panel gọn =====
base_panel <- list(
theme_pro(),
theme(
legend.position = "none",
plot.title = element_text(size = 11, face = "bold"),
axis.text.x = element_text(size = 8, angle = 45, hjust = 1),
axis.text.y = element_text(size = 8)
),
scale_y_continuous(
labels = scales::label_number(accuracy = 1, big.mark = ".", decimal.mark = ","),
breaks = scales::breaks_pretty(n = 4),
expand = expansion(mult = c(0.05, 0.10))
)
)
# ===== Panel A: CCC tổng =====
p_ccc <- ggplot(df_ccc, aes(Date, ccc)) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf,
fill = "#F8FAFC", alpha = 0.65) +
geom_hline(yintercept = 0, linetype = "dotted", color = "#9CA3AF") +
geom_line(linewidth = 2.2, color = col_ccc, alpha = 0.12) + # halo
geom_line(color = col_ccc, linewidth = 1.15, lineend = "round") +
geom_line(aes(y = ccc_ma4), color = col_ma, linewidth = 0.95, linetype = "longdash", na.rm = TRUE) +
labs(title = "CCC", x = NULL, y = "Ngày", caption = cap_default) +
base_panel
# ===== Panel B: DSO =====
p_dso <- ggplot(df_dso, aes(Date, DSO)) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf,
fill = "#F8FAFC", alpha = 0.65) +
geom_hline(yintercept = 0, linetype = "dotted", color = "#9CA3AF") +
geom_line(linewidth = 2.2, color = col_dso, alpha = 0.12) +
geom_line(color = col_dso, linewidth = 1.10) +
geom_line(aes(y = MA4), color = col_ma, linewidth = 0.9, linetype = "longdash", na.rm = TRUE) +
labs(title = "DSO (Kỳ thu tiền)", x = NULL, y = "Ngày") +
base_panel
# ===== Panel C: DIO =====
p_dio <- ggplot(df_dio, aes(Date, DIO)) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf,
fill = "#F8FAFC", alpha = 0.65) +
geom_hline(yintercept = 0, linetype = "dotted", color = "#9CA3AF") +
geom_line(linewidth = 2.2, color = col_dio, alpha = 0.12) +
geom_line(color = col_dio, linewidth = 1.10) +
geom_line(aes(y = MA4), color = col_ma, linewidth = 0.9, linetype = "longdash", na.rm = TRUE) +
labs(title = "DIO (Kỳ tồn kho)", x = NULL, y = "Ngày") +
base_panel
# ===== Panel D: DPO =====
p_dpo <- ggplot(df_dpo, aes(Date, DPO)) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf,
fill = "#F8FAFC", alpha = 0.65) +
geom_hline(yintercept = 0, linetype = "dotted", color = "#9CA3AF") +
geom_line(linewidth = 2.2, color = col_dpo, alpha = 0.12) +
geom_line(color = col_dpo, linewidth = 1.10) +
geom_line(aes(y = MA4), color = col_ma, linewidth = 0.9, linetype = "longdash", na.rm = TRUE) +
labs(title = "DPO (Kỳ thanh toán)", x = NULL, y = "Ngày") +
base_panel
# ===== Gộp 4 panel thành 1 hình =====
if (requireNamespace("patchwork", quietly = TRUE)) {
combo <- (p_ccc + p_dso) / (p_dio + p_dpo) +
patchwork::plot_annotation(
title = "Phân rã CCC — Đường nét đứt là MA4",
theme = theme_pro() + theme(plot.title = element_text(size = 14, face = "bold"))
)
print(maybe_plotly(combo))
maybe_save(combo, "11_ccc_4panel.png", w = 10, h = 7)
} else if (requireNamespace("gridExtra", quietly = TRUE)) {
gridExtra::grid.arrange(p_ccc, p_dso, p_dio, p_dpo, ncol = 2,
top = grid::textGrob("Phân rã CCC — Đường nét đứt là MA4",
gp = grid::gpar(fontsize = 14, fontface = "bold")))
} else {
# Fallback đơn giản nếu thiếu cả patchwork & gridExtra: in tuần tự
print(p_ccc); print(p_dso); print(p_dio); print(p_dpo)
}
}
Phân tích Code:
zoo::rollmean) cho mỗi chỉ số. Đường trung bình trượt giúp làm mịn dữ liệu và làm nổi bật xu hướng dài hạn.patchwork để ghép bốn biểu đồ này thành một lưới 2x2. patchwork cung cấp một cú pháp đơn giản (+ và /) để sắp xếp các biểu đồ.Kết quả:
cols <- c("asset_turnover","inventory_turnover","receivables_turnover")
avail <- intersect(cols, names(df_plot))
if (length(avail) >= 2 && "Date" %in% names(df_plot)) {
rename_map <- c(
asset_turnover = "T.sản",
inventory_turnover = "T.kho",
receivables_turnover = "P.thu"
)
df_long <- df_plot |>
dplyr::select(Date, dplyr::all_of(avail)) |>
dplyr::rename_with(~ rename_map[.x] %||% .x, .cols = -Date) |>
tidyr::pivot_longer(-Date, names_to = "Chỉ số", values_to = "Giá trị") |>
dplyr::arrange(Date)
df_ma <- df_long |>
dplyr::group_by(`Chỉ số`) |>
dplyr::mutate(`MA 4 quý` = zoo::rollmean(`Giá trị`, k = 4, fill = NA, align = "right")) |>
dplyr::ungroup()
last_lbls <- df_ma |>
dplyr::filter(!is.na(`Giá trị`)) |>
dplyr::group_by(`Chỉ số`) |>
dplyr::slice_max(order_by = Date, n = 1, with_ties = FALSE) |>
dplyr::ungroup() |>
dplyr::mutate(
x_lbl = as.Date(Date) + 90,
y_lbl = `Giá trị`,
lbl = paste0(scales::number(`Giá trị`, accuracy = 0.1, big.mark = ".", decimal.mark = ","), "x")
)
col_map <- c(
"T.sản" = "#10B981", # emerald
"T.kho" = "#8B5CF6", # violet
"P.thu"= "#0EA5E9" # sky
)
col_ma <- "#F59E0B"
p12 <- ggplot(df_ma, aes(x = Date)) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf,
fill = "#F8FAFC", alpha = 0.65) +
geom_line(aes(y = `Giá trị`, color = `Chỉ số`), linewidth = 2.3, alpha = 0.12, show.legend = FALSE) +
geom_line(aes(y = `Giá trị`, color = `Chỉ số`), linewidth = 1.15, lineend = "round") +
geom_line(aes(y = `MA 4 quý`), color = col_ma, linewidth = 0.95, linetype = "longdash", na.rm = TRUE) +
geom_hline(yintercept = 0, linetype = "dotted", color = "#9CA3AF") +
geom_segment(data = last_lbls,
aes(x = Date, xend = x_lbl, y = y_lbl, yend = y_lbl, color = `Chỉ số`),
linewidth = 0.6, alpha = 0.6, show.legend = FALSE) +
geom_label(data = last_lbls,
aes(x = x_lbl, y = y_lbl, label = lbl, color = `Chỉ số`),
fill = "white", label.size = 0, size = 3.1, show.legend = FALSE) +
scale_color_manual(values = col_map, name = NULL) +
scale_y_continuous(
labels = scales::label_number(accuracy = 0.1, big.mark = ".", decimal.mark = ",", suffix = "x"),
breaks = scales::breaks_pretty(n = 3),
expand = expansion(mult = c(0.05, 0.12)),
guide = guide_axis(check.overlap = TRUE)
) +
scale_x_date(date_labels = "%Y", date_breaks = "1 year",
expand = expansion(mult = c(0.02, 0.32))) +
coord_cartesian(clip = "off") +
labs(
title = "Vòng quay",
subtitle = "Dường nét đứt là MA4",
x = NULL, y = NULL, caption = cap_default
) +
theme_pro() +
theme(
legend.position = "top",
strip.placement = "outside",
strip.background = element_rect(fill = "#EEF2FF", color = NA),
strip.text = element_text(face = "bold", margin = margin(b = 4, t = 4)),
axis.text.x = element_text(angle = 45, hjust = 1),
axis.text.y = element_text(margin = margin(r = 8)),
panel.spacing.y = grid::unit(16, "pt"),
plot.margin = margin(10, 46, 10, 24)
) +
facet_wrap(~ `Chỉ số`, ncol = 1, scales = "free_y", strip.position = "left")
print(maybe_plotly(p12))
maybe_save(p12, "12_turnover_ratios.png")
}
Phân tích Code:
pivot_longer để tái cấu trúc dữ liệu. ggplot2 yêu cầu định dạng này để tạo biểu đồ.zoo::rollmean). Đường trung bình trượt giúp làm mịn dữ liệu và làm nổi bật xu hướng.facet_wrap để tạo một biểu đồ nhỏ cho mỗi chỉ số.scales = "free_y". Tham số này là quan trọng nhất. Nó cho phép mỗi biểu đồ có trục tung riêng vì các chỉ số có thang đo rất khác nhau.Kết quả:
if (all(c("cfo","cfi","cff","Date") %in% names(df_plot))) {
# Data
df_cf <- df_plot |>
dplyr::transmute(
Date,
`CFO (HĐKD)` = cfo,
`CFI (Đầu tư)` = cfi,
`CFF (Tài chính)` = cff
) |>
tidyr::pivot_longer(-Date, names_to = "Dòng tiền", values_to = "Giá trị") |>
dplyr::mutate(
`Dòng tiền` = factor(`Dòng tiền`,
levels = c("CFI (Đầu tư)", "CFF (Tài chính)", "CFO (HĐKD)"))
)
# ΔCash theo kỳ
net <- df_cf |>
dplyr::group_by(Date) |>
dplyr::summarise(net = sum(`Giá trị`, na.rm = TRUE), .groups = "drop")
# ---- ĐỘ RỘNG CỘT THEO QUÝ ----
uq_dates <- sort(unique(df_cf$Date))
step_days <- if (length(uq_dates) > 1) median(diff(uq_dates)) else 90
bar_w <- as.numeric(step_days) * 0.75 # ~ 75% độ rộng mỗi quý
# Nhãn ΔCash: chỉ hiển thị điểm nổi bật (top 20% | kỳ cuối) tránh rối
net <- net |>
dplyr::mutate(
keep = abs(net) >= stats::quantile(abs(net), 0.8, na.rm = TRUE) | Date == max(Date),
offset = 0.06 * max(abs(net), na.rm = TRUE),
y_lbl = net + ifelse(net >= 0, offset, -offset),
lbl = scales::number(net, accuracy = 0.1, big.mark = ".", decimal.mark = ","),
col = ifelse(net >= 0, "pos", "neg")
)
# Bảng màu
fill_map <- c(
"CFO (HĐKD)" = "#16A34A", # green-600
"CFI (Đầu tư)" = "#EF4444", # red-500
"CFF (Tài chính)" = "#6366F1" # indigo-500
)
lbl_col <- c(pos = "#059669", neg = "#DC2626")
p18 <- ggplot(df_cf, aes(x = Date, y = `Giá trị`)) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf,
fill = "#F8FAFC", alpha = 0.65) +
geom_hline(yintercept = 0, color = "#9CA3AF", linetype = "dashed") +
geom_col(aes(fill = `Dòng tiền`), width = bar_w, color = NA, alpha = 0.92) +
geom_line(data = net, aes(x = Date, y = net),
color = "#111827", linewidth = 1.1, inherit.aes = FALSE) +
geom_point(data = net, aes(x = Date, y = net),
color = "#111827", size = 1.9, inherit.aes = FALSE) +
geom_label(
data = subset(net, keep),
aes(x = Date, y = y_lbl, label = lbl, color = col),
fill = "white", label.size = 0, size = 3.1, fontface = "bold",
show.legend = FALSE, inherit.aes = FALSE
) +
scale_fill_manual(values = fill_map, name = NULL) +
scale_color_manual(values = lbl_col, guide = "none") +
scale_y_continuous(labels = num_fmt, expand = expansion(mult = c(0.08, 0.12))) +
scale_x_date(date_labels = "%Y", date_breaks = "1 year",
expand = expansion(mult = c(0.02, 0.04))) +
coord_cartesian(clip = "off") +
labs(
title = "Thác nước Dòng tiền (CFO / CFI / CFF)",
subtitle = "Cột theo quý (rộng 75% kỳ)|Đường đen: ΔCash|Nhãn chỉ hiển thị kỳ nổi bật.",
x = NULL, y = "Ngàn Tỷ", caption = cap_default
) +
theme_pro() +
theme(
legend.position = "top",
axis.text.x = element_text(angle = 45, hjust = 1),
panel.grid.major.x = element_blank(),
plot.margin = margin(10, 28, 10, 10)
)
print(maybe_plotly(p18)) # nếu plotly làm nhãn trùng, đổi tạm thành: print(p18)
maybe_save(p18, "18_cashflow_waterfall.png")
}
Phân tích Code:
factor để sắp xếp thứ tự các dòng tiền. Điều này đảm bảo các cột xếp chồng được vẽ một cách logic.net) bằng cách cộng ba dòng tiền thành phần.geom_col để vẽ các thành phần và geom_line để vẽ dòng tiền ròng. Kỹ thuật này cho thấy cả nguyên nhân và kết quả.Kết quả:
* Đầu năm 2022, dòng tiền ròng đạt đỉnh do CFO rất lớn.
* Cuối năm 2022, dòng tiền ròng giảm mạnh do CFO và CFI cùng âm.
Kết luận: Mô hình kinh doanh của công ty lành mạnh, với dòng tiền chính đến từ hoạt động kinh doanh.
if (all(c("Date","cfo","ni") %in% names(df_plot))) {
# hệ số trục phụ an toàn
s <- safe_coef(df_plot$cfo, df_plot$ni)
# độ rộng cột theo khoảng cách quý
uq <- sort(unique(df_plot$Date))
step_days <- if (length(uq) > 1) median(diff(uq)) else 90
bar_w <- as.numeric(step_days) * 0.75
df_cfo <- df_plot |>
dplyr::select(Date, cfo, ni) |>
dplyr::mutate(
`CFO ký hiệu` = dplyr::case_when(cfo > 0 ~ "CFO (+)", cfo < 0 ~ "CFO (-)", TRUE ~ "CFO (0)"),
diverge = dplyr::if_else(sign(cfo) * sign(ni) == -1, TRUE, FALSE, missing = FALSE)
)
# điểm cuối cho nhãn NI (trục phụ)
last_ni <- df_cfo |>
dplyr::filter(!is.na(ni)) |>
dplyr::slice_tail(n = 1) |>
dplyr::mutate(
x_lbl = Date + step_days * 0.7,
y_lbl = ni * s,
lbl = paste0("NI: ", scales::number(ni, accuracy = 0.1, big.mark=".", decimal.mark=",")))
fill_map <- c("CFO (+)"="#16A34A","CFO (-)"="#EF4444","CFO (0)"="#A3A3A3")
line_col <- "#1D4ED8" # xanh dương đậm cho NI
p19 <- ggplot(df_cfo, aes(x = Date)) +
# nền hồng các quý phân kỳ CFO vs NI
geom_rect(
data = dplyr::filter(df_cfo, diverge),
aes(xmin = Date - step_days*0.5, xmax = Date + step_days*0.5,
ymin = -Inf, ymax = Inf),
inherit.aes = FALSE, fill = "#FDE2E2", alpha = 0.35
) +
# đường zero
geom_hline(yintercept = 0, linetype = "dashed", color = "#9CA3AF") +
# cột CFO
geom_col(aes(y = cfo, fill = `CFO ký hiệu`), width = bar_w, alpha = 0.92, color = NA) +
# đường & điểm NI (được scale lên trục trái rồi trả về bằng sec.axis)
geom_line(aes(y = ni * s, color = "NI (trục phụ)", group = 1), linewidth = 1.25) +
geom_point(aes(y = ni * s, color = "NI (trục phụ)"), size = 1.9) +
# nhãn NI ở kỳ cuối
geom_label(
data = last_ni,
aes(x = x_lbl, y = y_lbl, label = lbl),
inherit.aes = FALSE, size = 3.1, label.size = 0,
fill = "white", color = line_col, fontface = "bold"
) +
scale_fill_manual(values = fill_map, name = NULL) +
scale_color_manual(values = c("NI" = line_col), name = NULL) +
scale_y_continuous(
name = "CFO (Ngàn Tỷ)", labels = num_fmt,
sec.axis = sec_axis(~ . / s, name = "Net Income (Ngàn Tỷ)", labels = num_fmt),
expand = expansion(mult = c(0.08, 0.12))
) +
scale_x_date(date_breaks = "1 year", date_labels = "%Y",
expand = expansion(mult = c(0.02, 0.04))) +
coord_cartesian(clip = "off") +
labs(
title = "Chất lượng Lợi nhuận: CFO vs Net Income",
x = NULL, caption = cap_default
) +
theme_pro() +
theme(
legend.position = "top",
axis.text.x = element_text(angle = 45, hjust = 1),
panel.grid.major.x = element_blank(),
plot.margin = margin(10, 28, 10, 10)
)
print(maybe_plotly(p19)) # Nếu tooltip gây rối, dùng print(p19)
maybe_save(p19, "19_quality_of_earnings.png")
}
Phân tích Code:
diverge để xác định các kỳ có sự phân kỳ giữa CFO và Lợi nhuận ròng. Logic này dùng tích của hàm sign() để phát hiện các trường hợp hai chỉ số trái dấu.geom_rect để tô màu nền cho các kỳ có sự phân kỳ. Đây là một cảnh báo trực quan mạnh mẽ.geom_col cho CFO và tô màu xanh/đỏ tùy theo giá trị dương/âm.geom_line và kỹ thuật trục phụ để vẽ Lợi nhuận ròng.Kết quả:
if (all(c("Date","cfo_ni") %in% names(df_plot))) {
# kích thước cột mốc theo khoảng cách quý (dùng cho nhãn lệch)
uq <- sort(unique(df_plot$Date))
step_days <- if (length(uq) > 1) median(diff(uq)) else 90
# dải nền
min_y <- suppressWarnings(min(df_plot$cfo_ni, na.rm = TRUE))
max_y <- suppressWarnings(max(df_plot$cfo_ni, na.rm = TRUE))
max_y <- if (!is.finite(max_y)) 2 else max(max_y, 2)
# điểm cuối để gắn nhãn
last_pt <- df_plot |>
dplyr::filter(!is.na(cfo_ni)) |>
dplyr::slice_tail(n = 1) |>
dplyr::mutate(
x_lbl = Date + step_days*0.7,
y_lbl = cfo_ni,
lbl = paste0(scales::number(cfo_ni, accuracy = 0.01, big.mark=".", decimal.mark=","), "×")
)
line_col <- "#0EA5E9" # cyan-600
loess_col <- "#F59E0B" # amber-500
p20 <- ggplot(df_plot, aes(x = Date, y = cfo_ni, group = 1)) +
# nền: yếu (<1) đỏ nhạt, khỏe (>=1) xanh nhạt
annotate("rect", xmin = -Inf, xmax = Inf, ymin = 1, ymax = Inf,
fill = "#ECFDF5", alpha = 0.45) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = 0, ymax = 1,
fill = "#FDE2E2", alpha = 0.45) +
# nếu có phần <0, tô đỏ đậm hơn
{ if (is.finite(min_y) && min_y < 0)
annotate("rect", xmin = -Inf, xmax = Inf, ymin = min_y, ymax = 0,
fill = "#FECACA", alpha = 0.45) } +
# đường tham chiếu
geom_hline(yintercept = 0, color = "#9CA3AF", linetype = "dotted") +
geom_hline(yintercept = 1, color = "#6B7280", linetype = "dashed") +
geom_hline(yintercept = 2, color = "#CBD5E1", linetype = "dotted") +
# chuỗi chính + LOESS xu hướng
geom_line(color = line_col, linewidth = 1.2) +
geom_point(color = line_col, size = 1.9) +
geom_smooth(method = "loess", se = FALSE, color = loess_col, linewidth = 1) +
geom_label(
data = last_pt,
aes(x = x_lbl, y = y_lbl, label = lbl),
inherit.aes = FALSE, size = 3.1, label.size = 0,
fill = "white", color = line_col, fontface = "bold"
) +
scale_y_continuous(labels = qty_fmt, expand = expansion(mult = c(0.08, 0.12))) +
scale_x_date(date_breaks = "1 year", date_labels = "%Y",
expand = expansion(mult = c(0.02, 0.04))) +
coord_cartesian(clip = "off") +
labs(
title = "Tỷ lệ CFO / NI",
subtitle = "Nền đỏ: Tỷ lệ < 1X | Đường vàng: LOESS xu hướng.",
x = NULL, y = "Lần", caption = cap_default
) +
theme_pro() +
theme(
panel.grid.major.x = element_blank(),
axis.text.x = element_text(angle = 45, hjust = 1),
plot.margin = margin(10, 28, 10, 10)
)
print(maybe_plotly(p20))
maybe_save(p20, "20_cfo_ni_ratio.png")
}
Phân tích Code:
annotate("rect", ...) để tạo các vùng nền màu xanh/hồng/đỏ. Các vùng này cung cấp một bối cảnh trực quan, giúp người xem đánh giá ngay lập tức mức độ “khỏe mạnh” của chỉ số.geom_hline để vẽ các đường tham chiếu tại các ngưỡng quan trọng (0 và 1).geom_line để vẽ dữ liệu gốc và geom_smooth để vẽ một đường xu hướng dài hạn. Đường xu hướng giúp Ta nhìn xuyên qua các biến động ngắn hạn.Kết quả:
key_cols <- intersect(
c("revenue_yoy","net_margin","roe","ccc","debt_to_equity","fcf_margin","asset_turnover"),
names(df_plot)
)
if (length(key_cols) >= 3) {
sub <- df_plot |>
dplyr::select(dplyr::all_of(key_cols)) |>
dplyr::mutate(dplyr::across(dplyr::everything(), as.numeric))
cm <- cor(sub, use = "pairwise.complete.obs")
cm[is.na(cm)] <- 0
# Sắp xếp theo phân cụm dựa trên |cor|
ord <- hclust(as.dist(1 - abs(cm)))$order
cm_ord <- cm[ord, ord]
# Chỉ hiển thị tam giác dưới
cm_ord[upper.tri(cm_ord, diag = FALSE)] <- NA
dfc <- as.data.frame(as.table(cm_ord))
names(dfc) <- c("Var1","Var2","Corr")
dfc <- dplyr::filter(dfc, !is.na(Corr))
dfc$Var1 <- factor(dfc$Var1, levels = rownames(cm_ord))
dfc$Var2 <- factor(dfc$Var2, levels = colnames(cm_ord))
p22 <- ggplot(dfc, aes(x = Var2, y = Var1, fill = Corr)) +
# Ô vuông + lưới trắng mảnh
geom_tile(width = .95, height = .95, color = "white", linewidth = .6) +
# Nhãn số; màu chữ tự động: trắng khi |Corr| lớn
geom_text(
aes(
label = scales::number(Corr, accuracy = 0.01, decimal.mark = ","),
color = ifelse(abs(Corr) >= 0.5, "hi", "lo")
),
size = 3.2, fontface = "bold"
) +
# Palette phân kỳ (đỏ–trắng–xanh), cố định [-1,1]
scale_fill_gradient2(
low = "#EF4444", mid = "#F8FAFC", high = "#22C55E",
midpoint = 0, limits = c(-1, 1), name = "Tương quan"
) +
scale_color_manual(values = c(hi = "white", lo = "#374151"), guide = "none") +
coord_fixed() +
labs(
title = "Ma trận tương quan ",
x = NULL, y = NULL, caption = cap_default
) +
theme_pro() +
theme(
legend.position = "right",
axis.text.x = element_text(angle = 45, hjust = 1),
panel.grid = element_blank(),
plot.margin = margin(10, 20, 10, 10)
)
print(p22) # hoặc maybe_plotly(p22) nếu muốn tương tác
maybe_save(p22, "22_correlation_heatmap.png")
}
Phân tích Code:
cor() để tính ma trận tương quan giữa các biến.hclust) để sắp xếp lại ma trận. Việc này nhóm các biến có tương quan cao lại gần nhau, làm nổi bật các cụm quan hệ.geom_tile để vẽ bản đồ nhiệt.scale_fill_gradient2, một thang màu phân kỳ. Màu xanh cho tương quan thuận, màu đỏ cho tương quan nghịch, và màu trắng cho giá trị gần 0.Kết quả:
series_name <- if ("revenue" %in% names(df_plot)) "revenue" else if ("ni" %in% names(df_plot)) "ni" else NA_character_
if (!is.na(series_name) && sum(!is.na(df_plot[[series_name]])) >= 8) {
y0 <- suppressWarnings(min(df_plot$yr, na.rm = TRUE))
q0 <- df_plot$qtr[which.min(df_plot$Date)]
if (!is.finite(y0) || is.na(y0)) y0 <- 2000
if (is.na(q0)) q0 <- 1
ts_obj <- stats::ts(df_plot[[series_name]], frequency = 4, start = c(y0, q0))
fit <- try(stats::stl(ts_obj, s.window = "periodic", robust = TRUE), silent = TRUE)
if (!inherits(fit, "try-error")) {
comp_wide <- dplyr::tibble(
Date = df_plot$Date,
observed = as.numeric(fit$time.series[, "trend"] + fit$time.series[, "seasonal"] + fit$time.series[, "remainder"]),
trend = as.numeric(fit$time.series[, "trend"]),
seasonal = as.numeric(fit$time.series[, "seasonal"]),
remainder = as.numeric(fit$time.series[, "remainder"])
)
# dữ liệu dài + panel
comp <- comp_wide |>
tidyr::pivot_longer(-Date, names_to = "name", values_to = "value") |>
dplyr::mutate(
panel = dplyr::recode(name,
observed = "Observed & Trend",
trend = "Observed & Trend",
seasonal = "Seasonal",
remainder = "Remainder"
),
sign_season = dplyr::case_when(name == "seasonal" & value >= 0 ~ "Dương",
name == "seasonal" & value < 0 ~ "Âm",
TRUE ~ NA_character_)
)
# nhãn Trend kỳ gần nhất
last_trend <- comp |>
dplyr::filter(name == "trend", !is.na(value)) |>
dplyr::slice_tail(n = 1) |>
dplyr::mutate(lbl = scales::number(value, accuracy = 0.1, big.mark=".", decimal.mark=","))
# màu pro
col_obs <- "#0EA5E9" # cyan
col_trd <- "#F59E0B" # amber
col_rem <- "#8B5CF6" # violet
fill_pos <- "#DCFCE7" # green-100
fill_neg <- "#FEE2E2" # red-100
p23 <- ggplot(comp, aes(x = Date)) +
# Seasonal ribbon (theo dấu)
geom_ribbon(
data = dplyr::filter(comp, name == "seasonal"),
aes(ymin = pmin(value, 0), ymax = pmax(value, 0), fill = sign_season),
alpha = 0.6, color = NA
) +
# đường zero cho Seasonal & Remainder
geom_hline(
data = subset(comp, panel %in% c("Seasonal","Remainder")) |> dplyr::distinct(panel),
aes(yintercept = 0), linetype = "dashed", color = "#9CA3AF"
) +
# Observed
geom_line(
data = dplyr::filter(comp, name == "observed"),
aes(y = value, color = "Observed"), linewidth = 0.9
) +
# Trend
geom_line(
data = dplyr::filter(comp, name == "trend"),
aes(y = value, color = "Trend"), linewidth = 1.25
) +
# Remainder (điểm + đường mảnh)
geom_line(
data = dplyr::filter(comp, name == "remainder"),
aes(y = value, color = "Remainder"), linewidth = 0.7, alpha = 0.8
) +
geom_point(
data = dplyr::filter(comp, name == "remainder"),
aes(y = value, color = "Remainder"), size = 1.2, alpha = 0.9
) +
# nhãn Trend
geom_label(
data = last_trend,
aes(x = Date + 20, y = value, label = lbl),
inherit.aes = FALSE, size = 3.1, label.size = 0,
fill = "white", color = col_trd, fontface = "bold"
) +
facet_wrap(~ panel, ncol = 1, scales = "free_y") +
scale_fill_manual(values = c("Dương" = fill_pos, "Âm" = fill_neg), guide = "none") +
scale_color_manual(values = c("Observed" = col_obs, "Trend" = col_trd, "Remainder" = col_rem), name = NULL) +
scale_y_continuous(labels = num_fmt, breaks = scales::breaks_pretty(n = 3),
expand = expansion(mult = c(0.08, 0.12))) +
scale_x_date(date_breaks = "1 year", date_labels = "%Y",
expand = expansion(mult = c(0.02, 0.04))) +
coord_cartesian(clip = "off") +
labs(
title = paste0("Phân rã Xu hướng – Mùa vụ – Phần dư của: ", toupper(series_name)),
x = NULL, y = NULL, caption = cap_default
) +
theme_pro() +
theme(
legend.position = "top",
panel.grid.major.x = element_blank(),
axis.text.x = element_text(angle = 45, hjust = 1),
plot.margin = margin(10, 24, 10, 10)
)
print(p23) # hoặc maybe_plotly(p23) nếu muốn tương tác
maybe_save(p23, "23_stl_decomposition.png", w = 9, h = 8)
}
}
Phân tích Code:
ts) với tần suất 4 kỳ mỗi năm. Đây là yêu cầu của hàm stl.stl để phân rã chuỗi thời gian thành ba thành phần: xu hướng, mùa vụ và phần dư.facet_wrap để vẽ mỗi thành phần trên một biểu đồ nhỏ riêng biệt.scales = "free_y". Điều này là cần thiết vì mỗi thành phần có một thang đo khác nhau.Kết quả:
cols <- intersect(c("revenue","gp","ebit","ni"), names(df_plot))
if (length(cols) >= 3) {
# Dữ liệu & nhãn cột
df_pairs <- df_plot[, cols, drop = FALSE]
vi_labels <- c(revenue = "Doanh thu", gp = "Ln gộp", ebit = "EBIT", ni = "Ln ròng")
colnames(df_pairs) <- vi_labels[cols]
# Panel trên: hệ số tương quan có nền theo độ mạnh (đỏ âm, xanh dương)
upper_fun <- function(data, mapping, ...){
x <- as.numeric(rlang::eval_tidy(mapping$x, data))
y <- as.numeric(rlang::eval_tidy(mapping$y, data))
c <- suppressWarnings(stats::cor(x, y, use = "pairwise.complete.obs"))
c <- ifelse(is.finite(c), c, NA_real_)
fill_fun <- scales::col_numeric(
palette = c("#EF4444", "#F8FAFC", "#22C55E"),
domain = c(-1, 1),
na.color = "#E5E7EB"
)
bg <- fill_fun(c)
txt <- if (!is.na(c) && abs(c) >= 0.5) "white" else "#111827"
ggplot() +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = Inf, fill = bg) +
annotate("text", x = 0.5, y = 0.5,
label = scales::number(c, accuracy = 0.01, decimal.mark = ","),
colour = txt, size = 5, fontface = "bold") +
theme_void()
}
# Panel dưới: scatter + đường hồi quy
lower_fun <- function(data, mapping, ...){
ggplot(data = data, mapping = mapping) +
geom_point(alpha = 0.6, size = 1.6, color = "#0EA5E9") +
geom_smooth(method = "lm", se = FALSE, linewidth = 0.9, color = "#F59E0B") +
theme_minimal(base_size = 10) +
theme(panel.grid.minor = element_blank(),
panel.grid.major = element_line(color = "#ECEFF4"))
}
# Đường chéo: mật độ (density)
diag_fun <- GGally::wrap("densityDiag", alpha = 0.6, fill = "#8B5CF6", adjust = 1)
p24 <- GGally::ggpairs(
df_pairs,
upper = list(continuous = upper_fun),
lower = list(continuous = lower_fun),
diag = list(continuous = diag_fun)
) +
theme_pro() +
theme(
strip.text = element_text(face = "bold", size = 10),
panel.spacing = unit(0.6, "lines"),
axis.text.x = element_text(size = 8, angle = 45, hjust = 1),
axis.text.y = element_text(size = 8),
plot.title = element_text(size = 14),
plot.caption = element_text(size = 9),
panel.grid.major = element_blank()
) +
labs(
title = "Ma trận phân tán ",
caption = cap_default )
print(p24)
if (save_plots) ggsave(file.path('figs', '24_pairs_plot.png'), p24, width = 9, height = 9, dpi = 300)
}
Phân tích Code:
GGally::ggpairs để tạo một lưới biểu đồ N x N.Kết quả:
if (all(c("Date","revenue") %in% names(df_plot))) {
df_roll <- df_plot |>
dplyr::mutate(
revenue_4Q = zoo::rollsum(revenue, k = 4, fill = NA, align = "right"),
sign_4Q = dplyr::case_when(
is.na(revenue_4Q) ~ NA_character_,
revenue_4Q >= 0 ~ "≥ 0",
TRUE ~ "< 0"
)
)
if (sum(!is.na(df_roll$revenue_4Q)) >= 2) {
# bước thời gian để canh nhãn
uq <- sort(unique(df_roll$Date))
step_days <- if (length(uq) > 1) median(diff(uq)) else 90
last_pt <- df_roll |>
dplyr::filter(!is.na(revenue_4Q)) |>
dplyr::slice_tail(n = 1) |>
dplyr::mutate(
x_lbl = Date + step_days*0.7,
lbl = paste0("4Q: ", scales::number(revenue_4Q, accuracy = 0.1, big.mark=".", decimal.mark=","))
)
col_line <- "#0EA5E9" # cyan-600
col_loess <- "#F59E0B" # amber-500
p26 <- ggplot(df_roll, aes(x = Date)) +
# ribbon phân cực theo dấu của rolling 4Q
geom_ribbon(
aes(ymin = pmin(revenue_4Q, 0), ymax = pmax(revenue_4Q, 0), fill = sign_4Q),
alpha = 0.55, color = NA
) +
# đường zero mảnh
geom_hline(yintercept = 0, linetype = "dotted", color = "#9CA3AF") +
# đường rolling 4Q + điểm
geom_line(aes(y = revenue_4Q), color = col_line, linewidth = 1.4) +
geom_point(aes(y = revenue_4Q), color = col_line, size = 1.7) +
# xu hướng loess
geom_smooth(aes(y = revenue_4Q), method = "loess", se = FALSE, color = col_loess, linewidth = 1) +
# nhãn kỳ mới nhất
geom_label(
data = last_pt,
aes(x = x_lbl, y = revenue_4Q, label = lbl),
inherit.aes = FALSE, size = 3.1, label.size = 0,
fill = "white", color = col_line, fontface = "bold"
) +
scale_fill_manual(values = c("≥ 0" = "#DCFCE7", "< 0" = "#EF4444"), guide = "none") +
scale_y_continuous(labels = num_fmt, expand = expansion(mult = c(0.08, 0.12))) +
scale_x_date(date_breaks = "1 year", date_labels = "%Y",
expand = expansion(mult = c(0.02, 0.04))) +
coord_cartesian(clip = "off") +
labs(
title = "Doanh thu Rolling 4Q (TTM)",
subtitle = "Ribbon xanh/đỏ theo dấu|Đường vàng là LOESS xu hướng",
x = NULL, y = "Ngàn Tỷ", caption = cap_default
) +
theme_pro() +
theme(
panel.grid.major.x = element_blank(),
axis.text.x = element_text(angle = 45, hjust = 1),
plot.margin = margin(10, 24, 10, 10)
)
print(maybe_plotly(p26)) # hoặc print(p26) nếu cần tĩnh tuyệt đối
maybe_save(p26, "26_revenue_rolling4Q.png")
}
}
Phân tích Code:
zoo::rollsum(..., k = 4, ...) để tính tổng doanh thu của 4 quý gần nhất. Thao tác này tạo ra dữ liệu Trailing Twelve Months (TTM).geom_ribbon để tô màu vùng dưới đường doanh thu TTM. Kỹ thuật này tạo cảm giác về quy mô và làm cho sự tăng trưởng trở nên ấn tượng hơn.geom_line (dữ liệu TTM) và geom_smooth (xu hướng của TTM). geom_smooth hoạt động như một lớp làm mịn thứ hai, giúp xác định sự thay đổi trong tốc độ tăng trưởng.Kết quả:
cols <- intersect(c("gross_margin","operating_margin","net_margin"), names(df_plot))
if (length(cols) >= 2) {
dfm <- df_plot |>
dplyr::select(Date, dplyr::all_of(cols)) |>
tidyr::pivot_longer(-Date, names_to = "Metric", values_to = "Value") |>
dplyr::mutate(
Metric = dplyr::recode(Metric,
gross_margin = "Biên gộp",
operating_margin = "Biên EBIT",
net_margin = "Biên ròng"
)
)
uq <- sort(unique(dfm$Date))
step_days <- if (length(uq) > 1) stats::median(diff(uq)) else 90
last_pts <- dfm |>
dplyr::filter(!is.na(Value)) |>
dplyr::group_by(Metric) |>
dplyr::slice_tail(n = 1) |>
dplyr::ungroup() |>
dplyr::mutate(
x_lbl = Date + step_days*0.9,
lbl = paste0(Metric, ": ",
scales::percent(Value, accuracy = 0.1, big.mark=".", decimal.mark=","))
)
pal_margin <- c(
"Biên ròng" = "#8B5CF6",
"Biên EBIT" = "#10B981",
"Biên gộp" = "#0EA5E9"
)
base <- ggplot(dfm, aes(x = Date, y = Value, color = Metric, group = Metric)) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = 0, ymax = Inf,
fill = "#EEFDF3", alpha = 0.30) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = 0,
fill = "#F5EEFF", alpha = 0.30) +
geom_line(linewidth = 2.2, alpha = 0.10, lineend = "round") +
geom_line(linewidth = 1.25, lineend = "round") +
# ==== LOESS ====
geom_smooth(se = FALSE, method = "loess", linewidth = 1, linetype = "dashed") +
# ===============
scale_color_manual(values = pal_margin) +
scale_y_continuous(labels = pct_fmt, expand = expansion(mult = c(0.08, 0.12))) +
scale_x_date(date_breaks = "1 year", date_labels = "%Y",
expand = expansion(mult = c(0.02, 0.18))) +
labs(
title = "Biên lợi nhuận (LOESS)",
subtitle = "Nét đứt là đường Xu Hướng",
x = NULL, y = "Tỷ lệ", caption = cap_default
) +
theme_pro() +
theme(
panel.grid.major.x = element_blank(),
axis.text.x = element_text(angle = 45, hjust = 1),
plot.margin = margin(10, 28, 10, 10)
)
if (requireNamespace("ggrepel", quietly = TRUE)) {
p27 <- base +
ggrepel::geom_label_repel(
data = last_pts,
aes(x = x_lbl, y = Value, label = lbl, color = Metric),
inherit.aes = FALSE,
size = 3.1, label.size = 0, fill = "white", fontface = "bold",
nudge_x = 0, nudge_y = 0,
min.segment.length = 0, segment.alpha = 0.4,
direction = "y", box.padding = 0.25, point.padding = 0.3,
max.overlaps = Inf
) +
coord_cartesian(clip = "off")
} else {
message("[INFO] ggrepel chưa cài đặt: dùng geom_label thông thường.")
p27 <- base +
geom_label(
data = last_pts,
aes(x = x_lbl, y = Value, label = lbl, color = Metric),
inherit.aes = FALSE, size = 3.1, label.size = 0,
fill = "white", fontface = "bold"
) +
coord_cartesian(clip = "off")
}
print(maybe_plotly(p27))
maybe_save(p27, "27_margins_loess.png")
}
Phân tích Code:
pivot_longer để chuyển dữ liệu sang dạng dài, giúp ggplot có thể tự động vẽ và tô màu ba đường riêng biệt.geom_line (dữ liệu gốc) và geom_smooth (xu hướng). Việc dùng đường nét đứt cho xu hướng giúp phân biệt rõ ràng hai loại thông tin.ggrepel để dán nhãn trực tiếp lên cuối mỗi đường. Kỹ thuật này hiệu quả hơn một chú thích riêng biệt vì người xem không cần phải tra cứu.Kết quả:
Kết luận: Vì Biên gộp ổn định, sự biến động của các biên lợi nhuận thấp hơn phải đến từ chi phí hoạt động. Các đường xu hướng cho thấy về dài hạn, các biên lợi nhuận tương đối ổn định.
req <- c("Date","net_margin","ccc")
if (all(req %in% names(df_plot))) {
# ===== Data & TTM for size =====
df_b3 <- df_plot |>
dplyr::select(Date, net_margin, ccc, revenue) |>
dplyr::arrange(Date) |>
dplyr::mutate(
revenue_ttm = if ("revenue" %in% names(df_plot))
zoo::rollsum(revenue, k = 4, fill = NA, align = "right") else NA_real_,
Qtr = zoo::as.yearqtr(Date),
label_q = format(Qtr, "Q%q %Y")
)
# ===== Medians for regimes =====
med_nm <- stats::median(df_b3$net_margin, na.rm = TRUE)
med_ccc <- stats::median(df_b3$ccc, na.rm = TRUE)
# ===== Start/End points for labels =====
start_pt <- df_b3 |>
dplyr::filter(!is.na(net_margin), !is.na(ccc)) |>
dplyr::slice_head(n = 1) |>
dplyr::mutate(lbl = paste0("Bắt đầu\n", label_q))
end_pt <- df_b3 |>
dplyr::filter(!is.na(net_margin), !is.na(ccc)) |>
dplyr::slice_tail(n = 1) |>
dplyr::mutate(lbl = paste0("Hiện tại\n", label_q))
# ===== Spans for smart nudges (x reversed) =====
rng_x <- range(df_b3$ccc, na.rm = TRUE); x_span <- diff(rng_x); if (!is.finite(x_span) || x_span == 0) x_span <- 1
rng_y <- range(df_b3$net_margin, na.rm = TRUE); y_span <- diff(rng_y); if (!is.finite(y_span) || y_span == 0) y_span <- 0.1
nudge_right <- -0.10 * x_span # x reversed => right = negative nudge
nudge_left <- 0.10 * x_span
time_cols <- c("#2563EB", "#8B5CF6", "#F59E0B", "#EF4444")
pB3 <- ggplot(df_b3, aes(x = ccc, y = net_margin)) +
# Regime background (4 quadrants)
annotate("rect", xmin = -Inf, xmax = med_ccc, ymin = med_nm, ymax = Inf, fill = "#E8F5E9", alpha = .65) +
annotate("rect", xmin = med_ccc, xmax = Inf, ymin = med_nm, ymax = Inf, fill = "#FFF8E1", alpha = .65) +
annotate("rect", xmin = -Inf, xmax = med_ccc, ymin = -Inf, ymax = med_nm, fill = "#E3F2FD", alpha = .65) +
annotate("rect", xmin = med_ccc, xmax = Inf, ymin = -Inf, ymax = med_nm, fill = "#FDE2E2", alpha = .65) +
geom_vline(xintercept = med_ccc, linetype = "dotted", color = "#94A3B8") +
geom_hline(yintercept = med_nm, linetype = "dotted", color = "#94A3B8") +
# Trajectory with arrow
geom_path(linewidth = .9, color = "#374151",
arrow = grid::arrow(length = grid::unit(3, "mm"), type = "closed"), na.rm = TRUE) +
# Halo + points
geom_point(color = "white", size = 4.0, alpha = 0.8, na.rm = TRUE) +
geom_point(aes(color = as.numeric(Date), size = revenue_ttm), alpha = 0.95, na.rm = TRUE) +
# ===== Non-overlapping labels (ggrepel) =====
ggrepel::geom_label_repel(
data = start_pt, aes(label = lbl), seed = 2,
nudge_x = nudge_left, nudge_y = -0.06 * y_span,
force = 2, max.time = 1.5, box.padding = 0.35, point.padding = 0.35,
min.segment.length = 0, direction = "both",
segment.color = "#CBD5E1", segment.size = 0.3,
fill = "white", color = "#111827", size = 3.2, label.size = 0, show.legend = FALSE
) +
ggrepel::geom_label_repel(
data = end_pt, aes(label = lbl), seed = 3,
nudge_x = nudge_right, nudge_y = 0.06 * y_span,
force = 2, max.time = 1.5, box.padding = 0.35, point.padding = 0.35,
min.segment.length = 0, direction = "both",
segment.color = "#CBD5E1", segment.size = 0.3,
fill = "white", color = "#111827", size = 3.2, label.size = 0, show.legend = FALSE
) +
# Scales
scale_size_continuous(range = c(1.4, 5.5), name = "Doanh thu (TTM)",
labels = scales::label_number(big.mark = ".", decimal.mark = ",")) +
scale_color_gradientn(colours = time_cols, name = "Thời gian",
breaks = as.numeric(unique(as.Date(cut(df_b3$Date, "year")))),
labels = format(unique(as.Date(cut(df_b3$Date, "year"))), "%Y")) +
# Extra right padding for labels (x reversed: pad on second mult)
scale_x_reverse(labels = scales::label_number(accuracy = 1, big.mark = ".", decimal.mark = ","),
expand = expansion(mult = c(0.06, 0.32)),
name = "CCC (ngày)") +
scale_y_continuous(labels = pct_fmt, expand = expansion(mult = c(0.06, 0.16)),
name = "Net margin") +
labs(title = "Phân rã quỹ đạo Margin và CCC (theo thời gian)",
caption = cap_default) +
theme_pro() +
theme(legend.position = "right", plot.title.position = "plot") +
coord_cartesian(clip = "off")
print(pB3)
maybe_save(pB3, "B3_regime_map_margin_ccc_nolabeloverlap.png", w = 9, h = 6)
}
Phân tích Code:
Kết quả:
need <- c("Date","roe","net_margin","asset_turnover","financial_leverage")
if (all(need %in% names(df_plot))) {
# === 1) Tính đóng góp DuPont (xấp xỉ tuyến tính quanh t-1) ===
d <- df_plot |>
dplyr::select(Date, roe, net_margin, asset_turnover, financial_leverage) |>
dplyr::arrange(Date) |>
dplyr::mutate(
nm_lag = dplyr::lag(net_margin),
at_lag = dplyr::lag(asset_turnover),
fl_lag = dplyr::lag(financial_leverage),
roe_lag= dplyr::lag(roe),
d_nm = net_margin - nm_lag,
d_at = asset_turnover - at_lag,
d_fl = financial_leverage - fl_lag,
contrib_nm = at_lag * fl_lag * d_nm,
contrib_at = nm_lag * fl_lag * d_at,
contrib_fl = nm_lag * at_lag * d_fl,
delta_roe = roe - roe_lag,
approx_delta = contrib_nm + contrib_at + contrib_fl,
resid = delta_roe - approx_delta
)
# === 2) Dài + thang điểm phần trăm (điểm %) ===
d_long <- d |>
dplyr::select(Date, contrib_nm, contrib_at, contrib_fl) |>
tidyr::pivot_longer(-Date, names_to = "component", values_to = "value") |>
dplyr::mutate(
component = dplyr::recode(component,
contrib_nm = "Net margin",
contrib_at = "Asset turnover",
contrib_fl = "Financial leverage"
),
value_pp = value * 100,
Date_f = factor(Date, levels = unique(Date))
) |>
dplyr::filter(!is.na(value_pp))
# Tổng (≈ ΔROE, bỏ phần dư) để chấm một vòng mảnh phía ngoài
total_pp <- d_long |>
dplyr::group_by(Date, Date_f) |>
dplyr::summarise(total_pp = sum(value_pp, na.rm = TRUE), .groups = "drop")
# Bảng màu
pal_bridge <- c(
"Net margin" = "#0EA5E9", # sky
"Asset turnover" = "#F59E0B", # amber
"Financial leverage" = "#8B5CF6" # violet
)
# === 3) Polar stream (hiếm gặp): geom_col xếp lớp + coord_polar ===
pB4_radial <- ggplot(d_long, aes(x = Date_f, y = value_pp, fill = component)) +
# Cột xếp lớp (cả dương lẫn âm) — độ rộng vừa đủ để tạo dòng chảy mượt
geom_col(width = 0.98, alpha = 0.95, color = NA, position = "stack") +
# Điểm mảnh biểu thị tổng (≈ ΔROE) mỗi kỳ
geom_point(data = total_pp, aes(x = Date_f, y = total_pp, color = "Tổng (≈ΔROE)"),
size = 1.6, inherit.aes = FALSE) +
scale_fill_manual(values = pal_bridge, name = "Đóng góp ΔROE") +
scale_color_manual(values = c("Tổng (≈ΔROE)" = "#111827"), name = NULL) +
# Vòng tròn: theo trục thời gian (theta = x). Bắt đầu ở đỉnh, quay thuận chiều kim đồng hồ.
coord_polar(theta = "x", start = pi/2, direction = -1, clip = "off") +
labs(
title = "Phân rã ROE dạng tròn",
subtitle = "Mỗi nan quạt là một quý",
x = NULL, y = NULL,
caption = "Đơn vị: điểm %. ΔROE xấp xỉ tổng đóng góp (bỏ phần dư nhỏ)."
) +
theme_pro() +
theme(
legend.position = "top",
axis.text = element_blank(),
panel.grid = element_blank(),
plot.margin = margin(20, 20, 20, 20)
)
print(pB4_radial)
maybe_save(pB4_radial, "B4_radial_roe_bridge.png", w = 9, h = 9)
}
Phân tích Code:
pivot_longer để chuẩn bị dữ liệu cho việc vẽ biểu đồ.coord_polar để biến đổi biểu đồ này thành một dạng tròn. Trục thời gian trở thành góc, và giá trị đóng góp trở thành bán kính.Kết quả: