finance-dashboard/lib/calendar.js

360 lines
15 KiB
JavaScript
Raw Permalink 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.

// ═══════════════════════════════════════════════════════════
// 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,
})),
};
}