// ═══════════════════════════════════════════════════════════ // 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 = /