finance-dashboard/lib/fundamentals.js

270 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ═══════════════════════════════════════════════════════════
// fundamentals.js — server 端抓取公司財報(金鑰不外洩、無需付費)
// 主來源Yahoo quoteSummary季/年 損益、現金流、資產負債 + 估值)
// 後備: SEC EDGAR companyfacts美股官方、免金鑰
// 皆正規化成同一形狀供 fincheck.js 使用。
// 沿用 fred.js 的 server 端 fetch + User-Agent 模式。
// ═══════════════════════════════════════════════════════════
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36';
const SEC_UA = 'EmmyInvestDashboard/1.0 (personal learning tool; contact@example.com)';
async function jget(url, headers = {}, ms = 9000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { headers: { 'User-Agent': UA, ...headers }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally { clearTimeout(timer); }
}
const num = (x) => (x && typeof x === 'object' && 'raw' in x) ? x.raw : (typeof x === 'number' ? x : null);
// 用結束年月當季標籤(避免不同公司會計年度造成的「第幾季」混淆)
function quarterLabel(endISO) {
const d = new Date(endISO); if (isNaN(d)) return String(endISO || '');
return `${d.getUTCFullYear()}/${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
}
const pct = (a, b) => (a != null && b) ? (a / b) * 100 : null;
function enrich(p) {
p.grossMargin = pct(p.grossProfit, p.revenue);
p.operatingMargin = pct(p.operatingIncome, p.revenue);
p.netMargin = pct(p.netIncome, p.revenue);
return p;
}
// ─── 現價Yahoo chart v8免 crumbquery1 失敗改 query2───
async function getPrice(symbol) {
for (const host of ['query1', 'query2']) {
try {
const d = await jget(`https://${host}.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?range=5d&interval=1d`);
const r = d?.chart?.result?.[0];
if (!r) continue;
let p = r.meta?.regularMarketPrice;
if (p == null) { const cl = (r.indicators?.quote?.[0]?.close || []).filter(x => x != null); p = cl.length ? cl[cl.length - 1] : null; }
return { price: p != null ? p : null, name: r.meta?.shortName || r.meta?.longName || null, currency: r.meta?.currency || null };
} catch { /* try next host */ }
}
return { price: null, name: null, currency: null };
}
// ─── Yahoo quoteSummary需 cookie + crumb───
let _auth = { cookie: null, crumb: null, at: 0 };
async function yahooAuth() {
if (_auth.crumb && Date.now() - _auth.at < 3600e3) return _auth;
const r1 = await fetch('https://fc.yahoo.com/', { headers: { 'User-Agent': UA } }).catch(() => null);
const cookie = (r1 && (r1.headers.get('set-cookie') || '')).split(';')[0] || '';
const r2 = await fetch('https://query2.finance.yahoo.com/v1/test/getcrumb', { headers: { 'User-Agent': UA, Cookie: cookie } });
const crumb = (await r2.text()).trim();
if (!crumb || crumb.includes('<')) throw new Error('無法取得 Yahoo crumb');
_auth = { cookie, crumb, at: Date.now() };
return _auth;
}
async function fetchYahoo(symbol) {
const { cookie, crumb } = await yahooAuth();
const mods = [
'price', 'summaryDetail', 'defaultKeyStatistics', 'financialData',
'incomeStatementHistory', 'incomeStatementHistoryQuarterly',
'cashflowStatementHistory', 'cashflowStatementHistoryQuarterly',
'balanceSheetHistoryQuarterly',
].join('%2C');
const url = `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?modules=${mods}&crumb=${encodeURIComponent(crumb)}`;
const d = await jget(url, { Cookie: cookie });
const r = d?.quoteSummary?.result?.[0];
if (!r) throw new Error('Yahoo 無資料');
const incQ = r.incomeStatementHistoryQuarterly?.incomeStatementHistory || [];
const cfQ = r.cashflowStatementHistoryQuarterly?.cashflowStatements || [];
const incY = r.incomeStatementHistory?.incomeStatementHistory || [];
const cfY = r.cashflowStatementHistory?.cashflowStatements || [];
const bsQ = r.balanceSheetHistoryQuarterly?.balanceSheetStatements || [];
const shares = num(r.defaultKeyStatistics?.sharesOutstanding);
const cfByDate = {};
for (const c of cfQ) cfByDate[num(c.endDate)] = c;
const quarters = incQ.map(s => {
const end = num(s.endDate);
const cf = cfByDate[end] || {};
const netIncome = num(s.netIncome);
return enrich({
end: end ? new Date(end * 1000).toISOString().slice(0, 10) : null,
label: end ? quarterLabel(end * 1000) : '',
revenue: num(s.totalRevenue),
grossProfit: num(s.grossProfit),
operatingIncome: num(s.operatingIncome),
netIncome,
eps: (netIncome != null && shares) ? netIncome / shares : null,
capex: num(cf.capitalExpenditures),
ocf: num(cf.totalCashFromOperatingActivities),
});
});
const cfYByDate = {};
for (const c of cfY) cfYByDate[num(c.endDate)] = c;
const annual = incY.map(s => {
const end = num(s.endDate);
const cf = cfYByDate[end] || {};
return enrich({
end: end ? new Date(end * 1000).toISOString().slice(0, 10) : null,
label: end ? String(new Date(end * 1000).getUTCFullYear()) : '',
revenue: num(s.totalRevenue),
grossProfit: num(s.grossProfit),
netIncome: num(s.netIncome),
capex: num(cf.capitalExpenditures),
ocf: num(cf.totalCashFromOperatingActivities),
});
});
const bs = bsQ[0] || {};
const totalAssets = num(bs.totalAssets);
const totalLiabilities = num(bs.totalLiab);
const balance = {
end: num(bs.endDate) ? new Date(num(bs.endDate) * 1000).toISOString().slice(0, 10) : null,
totalAssets, totalLiabilities,
cash: num(bs.cash),
totalDebt: (num(bs.shortLongTermDebt) || 0) + (num(bs.longTermDebt) || 0) || null,
debtToAssets: pct(totalLiabilities, totalAssets),
};
return {
source: 'Yahoo Finance',
name: num(r.price?.shortName) || r.price?.shortName || r.price?.longName || symbol,
currency: r.price?.currency || null,
peTrailing: num(r.summaryDetail?.trailingPE),
marketCap: num(r.price?.marketCap),
quarters, annual, balance,
};
}
// ─── SEC EDGAR companyfacts美股後備───
let _tickerMap = null;
async function tickerToCik(symbol) {
if (!_tickerMap) {
const d = await jget('https://www.sec.gov/files/company_tickers.json', { 'User-Agent': SEC_UA });
_tickerMap = {};
for (const k of Object.keys(d)) _tickerMap[String(d[k].ticker).toUpperCase()] = { cik: String(d[k].cik_str).padStart(10, '0'), name: d[k].title };
}
return _tickerMap[symbol] || null;
}
// 公司常會在不同年度換 XBRL 概念名(如營收 tag 變更),因此把所有候選概念
// 的序列合併成一條時間線,再用 durationSeries / instantLatest 去重取最新。
function mergeUnits(facts, names, unit = 'USD') {
let out = [];
for (const n of names) { const u = facts[n]?.units?.[unit]; if (u) out = out.concat(u); }
return out.length ? out : null;
}
function pickShares(facts, names) {
let out = [];
for (const n of names) { const u = facts[n]?.units?.['USD/shares'] || facts[n]?.units?.shares; if (u) out = out.concat(u); }
return out.length ? out : null;
}
// 取「期間型」概念的最近 N 期quarterly ≈ 80-100 天 / annual ≈ 350-380 天)
function durationSeries(units, kind) {
if (!units) return [];
const lo = kind === 'q' ? 80 : 330, hi = kind === 'q' ? 100 : 380;
const byEnd = {};
for (const e of units) {
if (!e.start || !e.end) continue;
const days = (new Date(e.end) - new Date(e.start)) / 86400000;
if (days < lo || days > hi) continue;
// 同一 end 取較新申報10-Q/10-K
if (!byEnd[e.end] || (e.filed || '') > (byEnd[e.end].filed || '')) byEnd[e.end] = e;
}
return Object.values(byEnd).sort((a, b) => (a.end < b.end ? 1 : -1));
}
function instantLatest(units) {
if (!units) return null;
const sorted = units.filter(e => e.end).sort((a, b) => (a.end < b.end ? 1 : -1));
return sorted[0] || null;
}
async function fetchEdgar(symbol) {
const hit = await tickerToCik(symbol);
if (!hit) throw new Error('SEC EDGAR 查無此美股代號');
const cf = await jget(`https://data.sec.gov/api/xbrl/companyfacts/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA });
const g = cf.facts?.['us-gaap'] || {};
const REV = ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'RevenueFromContractWithCustomerIncludingAssessedTax', 'SalesRevenueNet', 'SalesRevenueGoodsNet'];
const revU = mergeUnits(g, REV);
const gpU = mergeUnits(g, ['GrossProfit']);
const oiU = mergeUnits(g, ['OperatingIncomeLoss']);
const niU = mergeUnits(g, ['NetIncomeLoss']);
const epsU = pickShares(g, ['EarningsPerShareDiluted', 'EarningsPerShareBasic']);
const capU = mergeUnits(g, ['PaymentsToAcquirePropertyPlantAndEquipment', 'PaymentsToAcquireProductiveAssets']);
const ocfU = mergeUnits(g, ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations']);
const mapByEnd = (series) => { const m = {}; for (const e of series) m[e.end] = e.val; return m; };
const revQ = durationSeries(revU, 'q'), gpQ = durationSeries(gpU, 'q'), oiQ = durationSeries(oiU, 'q'), niQ = durationSeries(niU, 'q'), epsQ = durationSeries(epsU, 'q');
const gpM = mapByEnd(gpQ), oiM = mapByEnd(oiQ), niM = mapByEnd(niQ), epsM = mapByEnd(epsQ);
const quarters = revQ.slice(0, 8).map(e => enrich({
end: e.end, label: quarterLabel(e.end),
revenue: e.val, grossProfit: gpM[e.end] ?? null, operatingIncome: oiM[e.end] ?? null,
netIncome: niM[e.end] ?? null, eps: epsM[e.end] ?? null, capex: null, ocf: null,
}));
const revY = durationSeries(revU, 'y'), niY = durationSeries(niU, 'y'), capY = durationSeries(capU, 'y'), ocfY = durationSeries(ocfU, 'y'), gpY = durationSeries(gpU, 'y');
const niYM = mapByEnd(niY), capYM = mapByEnd(capY), ocfYM = mapByEnd(ocfY), gpYM = mapByEnd(gpY);
const annual = revY.slice(0, 5).map(e => enrich({
end: e.end, label: String(new Date(e.end).getUTCFullYear()),
revenue: e.val, grossProfit: gpYM[e.end] ?? null, netIncome: niYM[e.end] ?? null,
capex: capYM[e.end] != null ? -Math.abs(capYM[e.end]) : null, ocf: ocfYM[e.end] ?? null,
}));
const assetsE = instantLatest(mergeUnits(g, ['Assets']));
const liabE = instantLatest(mergeUnits(g, ['Liabilities']));
const cashE = instantLatest(mergeUnits(g, ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents']));
const balance = {
end: assetsE?.end || null,
totalAssets: assetsE?.val ?? null,
totalLiabilities: liabE?.val ?? null,
cash: cashE?.val ?? null,
totalDebt: null,
debtToAssets: pct(liabE?.val, assetsE?.val),
};
if (!quarters.length && !annual.length) throw new Error('EDGAR 無 us-gaap 財報資料(可能為以 IFRS 申報的外國發行人,建議改查其美股同業)');
return { source: 'SEC EDGAR', name: cf.entityName || hit.name || symbol, currency: 'USD', peTrailing: null, marketCap: null, quarters, annual, balance };
}
// ─── 輕量「是否有新財報」探針(美股;只抓 submissions比 companyfacts 小很多)───
// 回傳最近一筆財報類申報的識別碼,用來判斷自上次抓取後是否出現新財報。
export async function getLatestFilingInfo(symbol) {
const hit = await tickerToCik(symbol);
if (!hit) return null;
const d = await jget(`https://data.sec.gov/submissions/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA });
const f = d.filings?.recent;
if (!f || !Array.isArray(f.form)) return null;
const FORMS = new Set(['10-Q', '10-K', '20-F', '40-F', '6-K']);
for (let i = 0; i < f.form.length; i++) {
if (FORMS.has(f.form[i])) return { accn: f.accessionNumber?.[i] || null, form: f.form[i], filingDate: f.filingDate?.[i] || null };
}
return null;
}
// ─── 對外:取得正規化財報(價格 + 兩來源擇優)───
export async function getFundamentals(symbol) {
const priceInfo = await getPrice(symbol);
let data = null, errs = [];
try {
data = await fetchYahoo(symbol);
if (!data.quarters?.length && !data.annual?.length) { errs.push('Yahoo 無財報期間'); data = null; }
} catch (e) { errs.push('Yahoo: ' + (e?.message || e)); }
if (!data) {
try { data = await fetchEdgar(symbol); }
catch (e) { errs.push('EDGAR: ' + (e?.message || e)); }
}
if (!data) throw new Error('兩個來源都取不到財報(' + errs.join('') + '');
const asOf = data.quarters?.[0]?.label || data.annual?.[0]?.label || null;
return {
symbol,
name: data.name || priceInfo.name || symbol,
currency: data.currency || priceInfo.currency || 'USD',
source: data.source,
asOf,
price: priceInfo.price,
peTrailing: data.peTrailing ?? null,
marketCap: data.marketCap ?? null,
quarters: data.quarters || [],
annual: data.annual || [],
balance: data.balance || {},
};
}