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