finance-dashboard/lib/fundamentals.js

421 lines
20 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);
const parseNum = (s) => {
if (s == null) return null;
const n = Number(String(s).replace(/[$,%\s,]/g, ''));
return Number.isFinite(n) ? n : null;
};
function parseRange(s) {
if (!s) return {};
const parts = String(s).replace(/\$/g, '').split(/\s*(?:[-]|\/)\s*/).map(parseNum).filter(v => v != null);
return parts.length >= 2 ? { low: Math.min(parts[0], parts[1]), high: Math.max(parts[0], parts[1]) } : {};
}
// 用結束年月當季標籤(避免不同公司會計年度造成的「第幾季」混淆)
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) {
let out = {
price: null, name: null, currency: null, exchange: null, marketCap: null, sharesOutstanding: null,
peTrailing: null, targetPrice: null, dividendYield: null, change: null, changePercent: null,
marketTime: null, previousClose: null, dayHigh: null, dayLow: null, volume: null,
avgVolume: null, fiftyTwoWeekHigh: null, fiftyTwoWeekLow: null, fiftyDayAverage: null,
twoHundredDayAverage: null, source: null,
};
try {
const n = await jget(`https://api.nasdaq.com/api/quote/${encodeURIComponent(symbol)}/summary?assetclass=stocks`, {
Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/',
});
const s = n?.data?.summaryData || {};
const dayRange = parseRange(s.TodayHighLow?.value);
const yearRange = parseRange(s.FiftTwoWeekHighLow?.value || s['52WeekHighLow']?.value);
out.marketCap = parseNum(s.MarketCap?.value);
out.targetPrice = parseNum(s.OneYrTarget?.value);
out.dividendYield = parseNum(s.Yield?.value);
out.previousClose = parseNum(s.PreviousClose?.value);
out.price = out.previousClose;
out.dayHigh = dayRange.high ?? out.dayHigh;
out.dayLow = dayRange.low ?? out.dayLow;
out.volume = parseNum(s.ShareVolume?.value);
out.avgVolume = parseNum(s.AverageVolume?.value);
out.fiftyTwoWeekHigh = yearRange.high ?? out.fiftyTwoWeekHigh;
out.fiftyTwoWeekLow = yearRange.low ?? out.fiftyTwoWeekLow;
out.source = 'Nasdaq';
} catch { /* Yahoo fallback below */ }
try {
const n = await jget(`https://api.nasdaq.com/api/quote/${encodeURIComponent(symbol)}/info?assetclass=stocks`, {
Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/',
});
const d = n?.data || {};
const p = d.primaryData || {};
const dayRange = parseRange(d.keyStats?.dayrange?.value);
const yearRange = parseRange(d.keyStats?.fiftyTwoWeekHighLow?.value);
out = {
...out,
price: parseNum(p.lastSalePrice) ?? out.price,
name: d.companyName || out.name,
exchange: d.exchange || out.exchange,
change: parseNum(p.netChange) ?? out.change,
changePercent: parseNum(p.percentageChange) ?? out.changePercent,
marketTime: p.lastTradeTimestamp || out.marketTime,
dayHigh: dayRange.high ?? out.dayHigh,
dayLow: dayRange.low ?? out.dayLow,
volume: parseNum(p.volume) ?? out.volume,
fiftyTwoWeekHigh: yearRange.high ?? out.fiftyTwoWeekHigh,
fiftyTwoWeekLow: yearRange.low ?? out.fiftyTwoWeekLow,
bidPrice: parseNum(p.bidPrice),
askPrice: parseNum(p.askPrice),
marketStatus: d.marketStatus || null,
isRealTime: p.isRealTime ?? null,
notifications: d.notifications || [],
source: out.source ? `${out.source} + Nasdaq Info` : 'Nasdaq Info',
};
} catch { /* Yahoo fallback below */ }
try {
const q = await jget(`https://query1.finance.yahoo.com/v7/finance/quote?symbols=${encodeURIComponent(symbol)}`);
const r = q?.quoteResponse?.result?.[0];
if (r) out = {
...out,
price: r.regularMarketPrice ?? null,
name: r.shortName || r.longName || null,
currency: r.currency || null,
exchange: r.fullExchangeName || r.exchange || null,
marketCap: r.marketCap ?? out.marketCap,
sharesOutstanding: r.sharesOutstanding ?? null,
peTrailing: r.trailingPE ?? null,
change: r.regularMarketChange ?? null,
changePercent: r.regularMarketChangePercent ?? null,
marketTime: r.regularMarketTime ? new Date(r.regularMarketTime * 1000).toISOString() : null,
previousClose: r.regularMarketPreviousClose ?? out.previousClose,
dayHigh: r.regularMarketDayHigh ?? out.dayHigh,
dayLow: r.regularMarketDayLow ?? out.dayLow,
volume: r.regularMarketVolume ?? out.volume,
avgVolume: r.averageDailyVolume3Month ?? r.averageDailyVolume10Day ?? out.avgVolume,
fiftyTwoWeekHigh: r.fiftyTwoWeekHigh ?? out.fiftyTwoWeekHigh,
fiftyTwoWeekLow: r.fiftyTwoWeekLow ?? out.fiftyTwoWeekLow,
fiftyDayAverage: r.fiftyDayAverage ?? null,
twoHundredDayAverage: r.twoHundredDayAverage ?? null,
source: out.source ? `${out.source} + Yahoo` : 'Yahoo Finance',
};
} catch { /* chart fallback below */ }
if (out.price != null) return out;
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 {
...out,
price: p != null ? p : null,
name: out.name || r.meta?.shortName || r.meta?.longName || null,
currency: out.currency || r.meta?.currency || null,
exchange: out.exchange || r.meta?.exchangeName || null,
marketTime: out.marketTime || (r.meta?.regularMarketTime ? new Date(r.meta.regularMarketTime * 1000).toISOString() : null),
source: out.source || 'Yahoo Chart',
};
} catch { /* try next host */ }
}
return out;
}
export async function getQuote(symbol) {
return getPrice(String(symbol || '').trim().toUpperCase());
}
export async function getCompanyProfile(symbol) {
symbol = String(symbol || '').trim().toUpperCase();
const [quote, profile] = await Promise.all([
getQuote(symbol).catch(() => ({})),
jget(`https://api.nasdaq.com/api/company/${encodeURIComponent(symbol)}/company-profile?assetclass=stocks`, {
Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/',
}).catch(() => null),
]);
const p = profile?.data || {};
const val = (k) => p[k]?.value ?? null;
return {
symbol,
name: val('CompanyName') || quote.name || symbol,
description: val('CompanyDescription'),
sector: val('Sector'),
industry: val('Industry'),
region: val('Region'),
address: val('Address'),
phone: val('Phone'),
website: val('CompanyUrl'),
exchange: quote.exchange || null,
marketStatus: quote.marketStatus || null,
bidPrice: quote.bidPrice ?? null,
askPrice: quote.askPrice ?? null,
isRealTime: quote.isRealTime ?? null,
notifications: quote.notifications || [],
quote,
source: profile?.data ? 'Nasdaq profile' : quote.source || 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,
totalEquity: num(bs.totalStockholderEquity),
currentAssets: num(bs.totalCurrentAssets),
currentLiabilities: num(bs.totalCurrentLiabilities),
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),
sharesOutstanding: shares,
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 equityE = instantLatest(mergeUnits(g, ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest']));
const curAssetsE = instantLatest(mergeUnits(g, ['AssetsCurrent']));
const curLiabE = instantLatest(mergeUnits(g, ['LiabilitiesCurrent']));
const cashE = instantLatest(mergeUnits(g, ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents']));
const sharesE = instantLatest(pickShares(g, ['EntityCommonStockSharesOutstanding', 'CommonStocksIncludingAdditionalPaidInCapital']));
const balance = {
end: assetsE?.end || null,
totalAssets: assetsE?.val ?? null,
totalLiabilities: liabE?.val ?? null,
totalEquity: equityE?.val ?? ((assetsE?.val != null && liabE?.val != null) ? assetsE.val - liabE.val : null),
currentAssets: curAssetsE?.val ?? null,
currentLiabilities: curLiabE?.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, sharesOutstanding: sharesE?.val ?? 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: priceInfo.peTrailing ?? data.peTrailing ?? null,
marketCap: priceInfo.marketCap ?? data.marketCap ?? null,
sharesOutstanding: priceInfo.sharesOutstanding ?? data.sharesOutstanding ?? ((priceInfo.marketCap && priceInfo.price) ? priceInfo.marketCap / priceInfo.price : null),
targetPrice: priceInfo.targetPrice ?? null,
dividendYield: priceInfo.dividendYield ?? null,
quarters: data.quarters || [],
annual: data.annual || [],
balance: data.balance || {},
};
}