finance-dashboard/lib/calendar.js

360 lines
15 KiB
JavaScript
Raw Normal View History

2026-06-03 16:42:07 +00:00
// ═══════════════════════════════════════════════════════════
// calendar.js — 重大事件日曆(免費/官方來源優先)
// 來源:
// - Federal Reserve FOMC calendar利率決議、SEP/點陣圖、會議紀要)
// - BLS 官方 iCalendarCPI、就業、PPI、JOLTS 等)
// - BEA release scheduleGDP、PCE、Personal Income/Outlays
// - Nasdaq earnings calendar追蹤股票財報日
// - 四巫日3/6/9/12 月第三個週五,衍生品結算)
// - FRED 發布日程零售、房市、ADP、初領失業金等
// - 市場結構日月選擇權結算、美股休市、Jackson Hole、ECB/BOJ/BOE
// ═══════════════════════════════════════════════════════════
import { fetchFredMacroEvents } from './calendar-fred.js';
import { fetchMarketStructureEvents } from './calendar-market.js';
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 MONTHS = {
january: 0, jan: 0, february: 1, feb: 1, march: 2, mar: 2, april: 3, apr: 3,
may: 4, june: 5, jun: 5, july: 6, jul: 6, august: 7, aug: 7,
september: 8, sep: 8, october: 9, oct: 9, november: 10, nov: 10, december: 11, dec: 11,
};
const IMPACT_WORDS = [
['FOMC', 'high'], ['Federal Funds', 'high'], ['Interest Rate', 'high'],
['CPI', 'high'], ['Consumer Price Index', 'high'], ['Employment Situation', 'high'],
['Nonfarm', 'high'], ['Payroll', 'high'], ['PCE', 'high'], ['GDP', 'high'],
['Producer Price', 'medium'], ['PPI', 'medium'], ['Job Openings', 'medium'],
['JOLTS', 'medium'], ['Retail Sales', 'medium'], ['Personal Income', 'medium'],
['Import and Export Price', 'medium'], ['Productivity and Costs', 'medium'],
['Employment Cost Index', 'medium'], ['Real Earnings', 'low'],
];
const BLS_SKIP = /Metropolitan Area|State Employment|State Job Openings|County Employment|American Time Use|Quarterly Data Series on Business Employment Dynamics|Usual Weekly Earnings|State Unemployment \(Monthly\)/i;
const TITLE_MAP = [
[/Consumer Price Index|CPI/i, 'CPI 通膨'],
[/Employment Situation|Nonfarm|Payroll/i, '非農就業 / 失業率'],
[/Producer Price|PPI/i, 'PPI 生產者物價'],
[/Job Openings|JOLTS/i, 'JOLTS 職缺'],
[/Import and Export Price/i, '進出口物價'],
[/Real Earnings/i, '實質薪資'],
[/GDP/i, 'GDP'],
[/Personal Income|Personal Outlays|PCE/i, 'PCE / 個人收入支出'],
[/Import and Export Price/i, '進出口物價'],
[/Productivity and Costs/i, '生產力 / 單位成本'],
[/Employment Cost Index/i, '就業成本指數 ECI'],
];
const DEDUP_GROUPS = [
[/cpi|消費者物價/i, 'cpi'],
[/非農|employment situation/i, 'nfp'],
[/ppi|生產者物價/i, 'ppi'],
[/jolts|職缺/i, 'jolts'],
[/gdp|國內生產/i, 'gdp'],
[/pce|個人收入支出/i, 'pce'],
[/fomc.*紀要|會議紀要|minutes/i, 'fomc_min'],
[/fomc|利率決議|sep|點陣/i, 'fomc'],
[/四巫/i, 'quad_witch'],
[/月選擇權結算/i, 'monthly_opex'],
[/就業成本|employment cost index|\beci\b/i, 'eci'],
[/生產力|productivity and costs/i, 'productivity'],
[/國際貿易|international trade/i, 'trade'],
[/新屋開工|營建許可|housing starts|building permits/i, 'housing'],
[/零售銷售|retail sales/i, 'retail'],
[/工業生產|industrial production/i, 'indpro'],
[/密西根|consumer sentiment|surveys of consumers/i, 'umich'],
[/初領失業|jobless claims/i, 'claims'],
[/adp/i, 'adp'],
[/ec\s*利率|ecb/i, 'ecb'],
[/日本央行|boj/i, 'boj'],
[/英央行|mpc/i, 'boe'],
];
const IMPACT_RANK = { high: 0, medium: 1, low: 2 };
async function text(url, headers = {}, ms = 12000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: '*/*', ...headers }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.text();
} finally { clearTimeout(timer); }
}
async function json(url, headers = {}, ms = 12000) {
return JSON.parse(await text(url, { Accept: 'application/json,text/plain,*/*', ...headers }, ms));
}
function iso(y, m, d) {
return new Date(Date.UTC(y, m, d)).toISOString().slice(0, 10);
}
function inRange(date, start, end) {
return date >= start && date <= end;
}
function impact(title) {
for (const [word, level] of IMPACT_WORDS) if (title.toLowerCase().includes(word.toLowerCase())) return level;
return 'low';
}
function shortTitle(title) {
for (const [re, label] of TITLE_MAP) if (re.test(title)) return label;
return String(title || '').replace(/^U\.S\.\s*/i, '').replace(/\s+News Release.*$/i, '').trim();
}
function addEvent(events, ev) {
if (!ev?.date || !ev?.title) return;
events.push({
time: ev.time || '',
impact: ev.impact || impact(ev.title),
category: ev.category || 'macro',
source: ev.source || '',
symbol: ev.symbol || null,
url: ev.url || null,
note: ev.note || '',
...ev,
});
}
function parseICalDate(raw) {
const m = String(raw || '').match(/(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2}))?/);
if (!m) return null;
return { date: `${m[1]}-${m[2]}-${m[3]}`, time: m[4] ? `${m[4]}:${m[5]}` : '' };
}
function unfoldICS(src) {
return src.replace(/\r\n[ \t]/g, '').replace(/\n[ \t]/g, '');
}
function parseICS(src) {
const blocks = unfoldICS(src).split('BEGIN:VEVENT').slice(1);
return blocks.map(block => {
const get = (name) => {
const re = new RegExp(`^${name}(?:;[^:]*)?:(.*)$`, 'mi');
return block.match(re)?.[1]?.trim() || '';
};
const dt = parseICalDate(get('DTSTART'));
return dt ? { ...dt, title: get('SUMMARY'), description: get('DESCRIPTION'), url: get('URL') } : null;
}).filter(Boolean);
}
export async function fetchBlsEvents(start, end) {
const events = [];
const ics = await text('https://www.bls.gov/schedule/news_release/bls.ics');
for (const item of parseICS(ics)) {
if (!inRange(item.date, start, end)) continue;
const title = item.title || '';
if (BLS_SKIP.test(title)) continue;
if (impact(title) === 'low' && !/Real Earnings|Import and Export Price/i.test(title)) continue;
addEvent(events, {
date: item.date,
time: item.time || '08:30',
title: shortTitle(title),
category: 'macro',
impact: impact(title),
source: 'BLS',
url: item.url || 'https://www.bls.gov/schedule/news_release/',
note: shortTitle(title),
});
}
return events;
}
export async function fetchBeaEvents(start, end) {
const events = [];
const html = await text('https://www.bea.gov/news/schedule/full');
const rowRe = /<tr[\s\S]*?<\/tr>/gi;
for (const row of html.match(rowRe) || []) {
const clean = row.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const m = clean.match(/\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2})\s+(\d{1,2}:\d{2}\s+[AP]M)?\s*(.*)/i);
if (!m) continue;
const year = Number((html.match(/<option[^>]*selected[^>]*>\s*(20\d{2})\s*</i) || [])[1]) || new Date().getUTCFullYear();
const date = iso(year, MONTHS[m[1].toLowerCase()], Number(m[2]));
if (!inRange(date, start, end)) continue;
const rawTitle = (m[4] || '').replace(/\bN\s*ews\b/i, '').trim();
if (!/GDP|Personal Income|Personal Outlays|PCE|International Trade|Corporate Profits/i.test(rawTitle)) continue;
addEvent(events, {
date,
time: m[3] || '08:30 AM',
title: shortTitle(rawTitle),
category: 'macro',
impact: impact(rawTitle),
source: 'BEA',
url: 'https://www.bea.gov/news/schedule',
note: shortTitle(rawTitle),
});
}
return events;
}
export async function fetchFomcEvents(start, end) {
const events = [];
const html = await text('https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm');
const yearRe = /<h4[^>]*>\s*<a[^>]*>(20\d{2}) FOMC Meetings<\/a><\/h4>([\s\S]*?)(?=<h4[^>]*>\s*<a[^>]*>20\d{2} FOMC Meetings|$)/gi;
let yMatch;
while ((yMatch = yearRe.exec(html))) {
const year = Number(yMatch[1]);
const section = yMatch[2];
const chunks = section.split(/(?=<div[^>]*fomc-meeting__month)/i).slice(1);
for (const row of chunks) {
const month = row.match(/fomc-meeting__month[\s\S]*?<strong>([^<]+)<\/strong>/i)?.[1]?.trim();
const dateText = row.match(/fomc-meeting__date[^>]*>([^<]+)<\/div>/i)?.[1]?.trim();
if (!month || !dateText) continue;
const monthIndex = MONTHS[month.toLowerCase()];
if (monthIndex == null) continue;
const dayNums = dateText.match(/\d{1,2}/g)?.map(Number) || [];
if (!dayNums.length) continue;
const date = iso(year, monthIndex, dayNums[dayNums.length - 1]);
if (inRange(date, start, end)) {
const hasSep = /\*/.test(dateText) || /Projection|projections|SEP/i.test(row);
addEvent(events, {
date,
time: '14:00 ET',
title: hasSep ? 'FOMC 利率決議 + SEP 點陣圖' : 'FOMC 利率決議',
category: 'fed',
impact: 'high',
source: 'Federal Reserve',
url: 'https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm',
note: hasSep ? '含 Summary of Economic Projections / dot plot' : '政策聲明與利率決議',
});
}
const min = row.match(/Released\s+([A-Za-z]+)\s+(\d{1,2}),\s+(20\d{2})/i);
if (min) {
const minDate = iso(Number(min[3]), MONTHS[min[1].toLowerCase()], Number(min[2]));
if (inRange(minDate, start, end)) addEvent(events, {
date: minDate,
time: '14:00 ET',
title: 'FOMC 會議紀要',
category: 'fed',
impact: 'medium',
source: 'Federal Reserve',
url: 'https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm',
note: `${year}${month} FOMC 會議紀要`,
});
}
}
}
return events;
}
function daysBetween(start, end) {
const out = [];
const d = new Date(start + 'T00:00:00Z');
const e = new Date(end + 'T00:00:00Z');
while (d <= e) {
out.push(d.toISOString().slice(0, 10));
d.setUTCDate(d.getUTCDate() + 1);
}
return out;
}
function nasdaqDate(isoDate) {
const d = new Date(isoDate + 'T00:00:00Z');
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
}
const QUAD_WITCH_MONTHS = [2, 5, 8, 11]; // 3、6、9、12 月
function thirdFridayISO(year, monthIndex) {
const dow = new Date(Date.UTC(year, monthIndex, 1)).getUTCDay();
const day = 1 + ((5 - dow + 7) % 7) + 14;
return iso(year, monthIndex, day);
}
export function fetchQuadrupleWitchingEvents(start, end) {
const events = [];
const y0 = Number(start.slice(0, 4));
const y1 = Number(end.slice(0, 4));
for (let y = y0; y <= y1; y++) {
for (const mo of QUAD_WITCH_MONTHS) {
const date = thirdFridayISO(y, mo);
if (!inRange(date, start, end)) continue;
addEvent(events, {
date,
time: '16:00 美東',
title: '四巫日(衍生品結算)',
category: 'derivatives',
impact: 'high',
source: 'Market calendar',
note: '每季第三個週五,期指、指數選擇權、個股選擇權等同日到期,成交與波動常明顯放大',
});
}
}
return events;
}
function dedupeKey(ev) {
const title = String(ev.title || '');
const text = `${title} ${ev.note || ''}`;
for (const [re, slug] of DEDUP_GROUPS) {
if (re.test(title)) return `${ev.date}|${slug}`;
}
for (const [re, slug] of DEDUP_GROUPS) {
if (re.test(text)) return `${ev.date}|${slug}`;
}
return `${ev.date}|${ev.time}|${ev.category}|${ev.symbol || ''}|${ev.title}`;
}
function pickBetterEvent(a, b) {
const ra = IMPACT_RANK[a.impact] ?? 2;
const rb = IMPACT_RANK[b.impact] ?? 2;
if (ra !== rb) return ra < rb ? a : b;
return (a.title?.length || 0) >= (b.title?.length || 0) ? a : b;
}
function dedupeEvents(events) {
const byKey = new Map();
for (const ev of events) {
const key = dedupeKey(ev);
const prev = byKey.get(key);
byKey.set(key, prev ? pickBetterEvent(prev, ev) : ev);
}
return [...byKey.values()];
}
export async function fetchEarningsEvents(start, end, symbols = []) {
const wanted = new Set((symbols || []).map(s => String(s).trim().toUpperCase()).filter(Boolean));
if (!wanted.size) return [];
const events = [];
const headers = { Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/market-activity/earnings' };
const dates = daysBetween(start, end);
for (let i = 0; i < dates.length; i += 4) {
const chunk = dates.slice(i, i + 4);
const results = await Promise.all(chunk.map(date => json(`https://api.nasdaq.com/api/calendar/earnings?date=${nasdaqDate(date)}`, headers).catch(() => null)));
results.forEach((j, idx) => {
const date = chunk[idx];
const rows = j?.data?.rows || [];
for (const r of rows) {
const sym = String(r.symbol || '').toUpperCase();
if (!wanted.has(sym)) continue;
const name = r.name ? String(r.name).trim() : sym;
const bits = [name !== sym ? name : ''];
if (r.epsForecast) bits.push(`EPS 預估 ${r.epsForecast}`);
if (r.fiscalQuarterEnding) bits.push(`財季 ${r.fiscalQuarterEnding}`);
addEvent(events, {
date,
time: r.time ? String(r.time).replace(/\bET\b/i, '美東').replace(/\bAM\b/i, '上午').replace(/\bPM\b/i, '下午') : '',
title: `${sym} 財報`,
symbol: sym,
category: 'earnings',
impact: 'high',
source: 'Nasdaq earnings',
url: `https://www.nasdaq.com/market-activity/stocks/${sym.toLowerCase()}/earnings`,
note: bits.filter(Boolean).join(' · ') || `${sym} 財報`,
});
}
});
}
return events;
}
export async function buildCalendar({ start, end, symbols }) {
const sourceResults = await Promise.allSettled([
fetchFomcEvents(start, end),
fetchBlsEvents(start, end),
fetchBeaEvents(start, end),
fetchEarningsEvents(start, end, symbols),
Promise.resolve(fetchQuadrupleWitchingEvents(start, end)),
fetchFredMacroEvents(start, end),
Promise.resolve(fetchMarketStructureEvents(start, end)),
]);
const events = dedupeEvents(sourceResults.flatMap(r => r.status === 'fulfilled' ? r.value : []))
.sort((a, b) => (a.date + a.time).localeCompare(b.date + b.time));
return {
updatedAt: new Date().toISOString(),
start, end, symbols,
events,
sources: sourceResults.map((r, i) => ({
name: ['Federal Reserve', 'BLS', 'BEA', 'Nasdaq earnings', 'Market calendar', 'FRED releases', 'Global markets'][i],
ok: r.status === 'fulfilled',
error: r.status === 'rejected' ? String(r.reason?.message || r.reason) : null,
})),
};
}