Skip to content

Commit 68d6c88

Browse files
toreleonclaude
andcommitted
fix(web): stop reporting unreported CafeF ratio periods as zero
VCB and MBB showed EPS, BVPS, ROE and ROA as 0 on the stock page, and their Financials tab charted a plunge to zero for those years. The cause is in the source: CafeF returns a bucket for every period but fills unreported ones with zeros across every ratio. VCB's 2025 and 2023 buckets carry EPS, BVPS, ROE, ROA *and* P/E at 0 — impossible for a company with a share price. We read only the newest bucket and passed its zeros straight through, and zero is not null, so the `?? null` fallbacks never fired. A bucket whose ratios are all zero is now treated as "not reported". The stats grid asks for four buckets and uses the newest one CafeF actually filled in, and labels it with that year (`EPS ’22`) so a lagging figure is not read as current. The Financials tab drops those periods rather than charting them. The test is per bucket rather than per metric on purpose: a genuine zero, such as a debt-free company's Debt/Assets, still comes through. Verified against the raw source — VCB's 2025 and 2023 buckets are 0/8 non-zero and are the only ones dropped; FPT's every bucket is 8/8 non-zero and nothing is dropped (its missing 2022 is absent from CafeF's response to begin with). VCB now reads EPS 5,820 ₫, BVPS 28,660 ₫, ROE 22.04%, ROA 1.65% for 2022, and its annual series runs 2017–2022 with no zero columns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c14729c commit 68d6c88

4 files changed

Lines changed: 45 additions & 8 deletions

File tree

web/README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ Finance's information architecture and visual language. The UI defaults to the
7474
fails. CafeF substitutes a generic house placeholder for imageless articles; the server filters
7575
that out so those rows simply have no picture rather than a fake one.
7676

77+
### A note on CafeF's ratio periods
78+
79+
CafeF returns a bucket for every period but fills unreported ones with **zeros across every
80+
ratio** — VCB's 2025 and 2023 buckets carry EPS, BVPS, ROE, ROA *and* P/E at 0, which cannot be
81+
real for a company that has a share price. Banks lag like this routinely.
82+
83+
The server therefore treats a bucket whose ratios are *all* zero as "not reported": the stats
84+
grid falls back to the newest period CafeF has actually filled in (and labels it, e.g. `EPS ’22`,
85+
so a lagging figure isn't read as current), and the Financials tab omits those periods instead of
86+
charting a plunge to zero. The test is per bucket, not per metric, so a genuine zero — a
87+
debt-free company's Debt/Assets — still comes through.
88+
7789
Watchlists and portfolios are persisted in the shared Azoth SQLite database (`~/.azoth/azoth.db`)
7890
under `web_*` tables — no separate datastore, no broker credentials.
7991

@@ -135,7 +147,7 @@ pnpm typecheck # type-check the frontend
135147
| `GET /api/indicators/:ticker?range=` | SMA/EMA/Bollinger/RSI/MACD |
136148
| `GET /api/news/:ticker` | Ticker news |
137149
| `GET /api/about/:ticker` | Company profile + related stocks |
138-
| `GET /api/financials/:ticker?period=annual` | Annual key metrics (EPS, BVPS, ROE, ROA, margins, P/E) from CafeF |
150+
| `GET /api/financials/:ticker?period=annual` | Annual key metrics (EPS, BVPS, ROE, ROA, margins, P/E) from CafeF. Periods CafeF hasn't reported are omitted, not charted as zero |
139151

140152
### Persistent (SQLite-backed `web_*` tables)
141153

web/server/index.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
getTickerNews,
3232
getCompanyIntro,
3333
getFinancialRatios,
34+
type CafefRatioBucket,
3435
} from "../../src/data/sources/cafef.js";
3536
import { TICKER_UNIVERSES } from "../../src/tools/discover.js";
3637
import {
@@ -630,6 +631,7 @@ async function handleQuote(ticker: string) {
630631
bvps_thousand_vnd: fund.bvps,
631632
roe_pct: fund.roe,
632633
roa_pct: fund.roa,
634+
ratios_year: fund.ratiosYear,
633635
dividend_yield_pct: fund.divYield,
634636
shares_outstanding: fund.shares,
635637
foreign_ownership_pct: fund.foreignOwn,
@@ -681,10 +683,13 @@ async function fundamentalsBundle(ticker: string): Promise<FundBundle> {
681683
latestRatio(ticker, RATIOS.FOREIGN_OWNERSHIP),
682684
getCompanyProfile(ticker).catch(() => null),
683685
getCompanyIntro(ticker).catch(() => null),
684-
getFinancialRatios(ticker, "QUY", 1).catch(() => []),
686+
getFinancialRatios(ticker, "QUY", 4).catch(() => []),
685687
]);
688+
// Newest-first, so the first reported bucket is the latest one CafeF has
689+
// actually filled in. Banks routinely lag a year or two behind.
690+
const reported = cafef.find(isReportedPeriod);
686691
const latestCafef: Record<string, number> = {};
687-
for (const v of cafef[0]?.Value ?? []) latestCafef[v.Code] = v.Value;
692+
for (const v of reported?.Value ?? []) latestCafef[v.Code] = v.Value;
688693
return {
689694
company: {
690695
nameVi: profile?.vnName,
@@ -711,6 +716,8 @@ async function fundamentalsBundle(ticker: string): Promise<FundBundle> {
711716
bvps: latestCafef.BV ?? null,
712717
roe: latestCafef.ROE ?? null,
713718
roa: latestCafef.ROA ?? null,
719+
// Which year those four came from — they can lag, so the UI labels them.
720+
ratiosYear: reported?.Year ?? null,
714721
divYield: round(divYield, 2),
715722
shares: round(shares, 0),
716723
foreignOwn: round(foreignOwn, 2),
@@ -1008,6 +1015,17 @@ async function handleSearch(q: string) {
10081015
// Financials (quarterly / annual key metrics from CafeF)
10091016
// ---------------------------------------------------------------------------
10101017

1018+
/**
1019+
* Whether CafeF actually reported a ratio period. It returns a bucket for every
1020+
* period but fills unreported ones with zeros across the board — VCB's 2025 and
1021+
* 2023 buckets carry EPS, BV, ROE, ROA *and* P/E at 0, which cannot be real for
1022+
* a company with a share price. Judging this per bucket rather than per metric
1023+
* keeps a genuine zero (a debt-free company's Debt/Assets) intact.
1024+
*/
1025+
function isReportedPeriod(bucket: CafefRatioBucket): boolean {
1026+
return (bucket.Value ?? []).some((v) => Number.isFinite(v.Value) && v.Value !== 0);
1027+
}
1028+
10111029
const CAFEF_METRICS: { key: string; code: string; label: string; unit: "kVND" | "%" | "x" }[] = [
10121030
{ key: "eps", code: "EPS", label: "EPS", unit: "kVND" },
10131031
{ key: "bvps", code: "BV", label: "BVPS", unit: "kVND" },
@@ -1027,9 +1045,10 @@ async function handleFinancials(ticker: string, period: string) {
10271045
() => getFinancialRatios(ticker, reportType, 8).catch(() => []),
10281046
);
10291047
// CafeF returns newest-first; reverse to oldest→newest for charts/tables.
1048+
// Unreported periods are dropped rather than charted as a plunge to zero.
10301049
// NB: CafeF's ratios dataset is annual (Quater is 0), so we label by year and
10311050
// only prefix a quarter when CafeF actually reports one (1–4).
1032-
const ordered = [...buckets].reverse();
1051+
const ordered = buckets.filter(isReportedPeriod).reverse();
10331052
const hasQuarter = (q: number | undefined): q is number => q != null && q >= 1 && q <= 4;
10341053
const columns = ordered.map((b) => ({
10351054
label: hasQuarter(b.Quater) ? `Q${b.Quater} ${b.Year ?? ""}`.trim() : `${b.Year ?? ""}`,

web/src/components/StatsGrid.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ type Cell = {
1515
export default function StatsGrid({ quote }: StatsGridProps) {
1616
const s = quote.stats;
1717

18+
// CafeF's ratios lag for some issuers (banks especially), so name the year
19+
// these four came from rather than implying they're current.
20+
const fy = s.ratios_year ? ` ’${String(s.ratios_year).slice(-2)}` : "";
21+
1822
const cells: Cell[] = [
1923
{ label: "Open", value: fmtPriceVnd(s.open) },
2024
{ label: "High", value: fmtPriceVnd(s.high) },
@@ -28,10 +32,10 @@ export default function StatsGrid({ quote }: StatsGridProps) {
2832
{ label: "P/E ratio", value: fmtNum(s.pe) },
2933
{ label: "P/B ratio", value: fmtNum(s.pb) },
3034
{ label: "P/S ratio", value: fmtNum(s.ps) },
31-
{ label: "EPS", value: fmtPriceVnd(s.eps_thousand_vnd) },
32-
{ label: "BVPS", value: fmtPriceVnd(s.bvps_thousand_vnd) },
33-
{ label: "ROE", value: fmtPctPlain(s.roe_pct) },
34-
{ label: "ROA", value: fmtPctPlain(s.roa_pct) },
35+
{ label: `EPS${fy}`, value: fmtPriceVnd(s.eps_thousand_vnd) },
36+
{ label: `BVPS${fy}`, value: fmtPriceVnd(s.bvps_thousand_vnd) },
37+
{ label: `ROE${fy}`, value: fmtPctPlain(s.roe_pct) },
38+
{ label: `ROA${fy}`, value: fmtPctPlain(s.roa_pct) },
3539
{ label: "Div yield", value: fmtPctPlain(s.dividend_yield_pct) },
3640
{ label: "Foreign own", value: fmtPctPlain(s.foreign_ownership_pct) },
3741
{ label: "Shares out", value: fmtBigNum(s.shares_outstanding) },

web/src/lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ export interface QuoteStats {
151151
bvps_thousand_vnd: number | null;
152152
roe_pct: number | null;
153153
roa_pct: number | null;
154+
/** Year the EPS/BVPS/ROE/ROA above were reported for — CafeF's ratios can lag. */
155+
ratios_year: number | null;
154156
dividend_yield_pct: number | null;
155157
shares_outstanding: number | null;
156158
foreign_ownership_pct: number | null;

0 commit comments

Comments
 (0)