// ═══════════════════════════════════════════════════════════ // 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 = //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(/]*selected[^>]*>\s*(20\d{2})\s*]*>\s*]*>(20\d{2}) FOMC Meetings<\/a><\/h4>([\s\S]*?)(?=]*>\s*]*>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(/(?=]*fomc-meeting__month)/i).slice(1); for (const row of chunks) { const month = row.match(/fomc-meeting__month[\s\S]*?([^<]+)<\/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, })), }; }