360 lines
15 KiB
JavaScript
360 lines
15 KiB
JavaScript
// ═══════════════════════════════════════════════════════════
|
||
// calendar.js — 重大事件日曆(免費/官方來源優先)
|
||
// 來源:
|
||
// - Federal Reserve FOMC calendar(利率決議、SEP/點陣圖、會議紀要)
|
||
// - BLS 官方 iCalendar(CPI、就業、PPI、JOLTS 等)
|
||
// - BEA release schedule(GDP、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,
|
||
})),
|
||
};
|
||
}
|