// 美股 11 大板塊(SPDR 行業 ETF)— 熱力圖、輪動、資金流向、ETF 規模(機構被動配置 proxy) import { getHistory } from './marketdata.js'; import { yahooQuoteSummary, resetYahooAuth, sleep as yahooSleep } from './yahoo-session.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'; export const SECTOR_ETFS = [ { etf: 'XLK', nameZh: '科技', nameEn: 'Technology', group: 'growth' }, { etf: 'XLC', nameZh: '通訊服務', nameEn: 'Communication', group: 'growth' }, { etf: 'XLY', nameZh: '非必需消費', nameEn: 'Cons. Discretionary', group: 'cyclical' }, { etf: 'XLP', nameZh: '必需消費', nameEn: 'Cons. Staples', group: 'defensive' }, { etf: 'XLE', nameZh: '能源', nameEn: 'Energy', group: 'cyclical' }, { etf: 'XLF', nameZh: '金融', nameEn: 'Financials', group: 'cyclical' }, { etf: 'XLV', nameZh: '醫療保健', nameEn: 'Health Care', group: 'defensive' }, { etf: 'XLI', nameZh: '工業', nameEn: 'Industrials', group: 'cyclical' }, { etf: 'XLB', nameZh: '原物料', nameEn: 'Materials', group: 'cyclical' }, { etf: 'XLRE', nameZh: '房地產', nameEn: 'Real Estate', group: 'rate_sensitive' }, { etf: 'XLU', nameZh: '公用事業', nameEn: 'Utilities', group: 'defensive' }, ]; const BENCHMARK = 'SPY'; /** Yahoo 限流時的示意持股(僅 SPY;板塊 ETF 仍嘗試即時抓取) */ const TOP_HOLDINGS_FALLBACK = { SPY: [ { symbol: 'NVDA', name: 'NVIDIA Corp', pct: 7.85, pctFmt: '7.85%' }, { symbol: 'AAPL', name: 'Apple Inc', pct: 6.45, pctFmt: '6.45%' }, { symbol: 'MSFT', name: 'Microsoft Corp', pct: 4.9, pctFmt: '4.90%' }, { symbol: 'AMZN', name: 'Amazon.com Inc', pct: 3.8, pctFmt: '3.80%' }, { symbol: 'META', name: 'Meta Platforms Inc', pct: 3.2, pctFmt: '3.20%' }, { symbol: 'GOOGL', name: 'Alphabet Inc Class A', pct: 2.9, pctFmt: '2.90%' }, { symbol: 'GOOG', name: 'Alphabet Inc Class C', pct: 2.5, pctFmt: '2.50%' }, { symbol: 'BRK-B', name: 'Berkshire Hathaway Inc Class B', pct: 2.4, pctFmt: '2.40%' }, { symbol: 'AVGO', name: 'Broadcom Inc', pct: 2.3, pctFmt: '2.30%' }, { symbol: 'TSLA', name: 'Tesla Inc', pct: 2.1, pctFmt: '2.10%' }, ], }; function closeAt(points, idx) { if (!points?.length) return null; const i = idx < 0 ? points.length + idx : idx; const p = points[i]; if (!p) return null; return p.adjclose ?? p.close; } function returnOver(points, days) { if (!points?.length || points.length <= days) return null; const last = closeAt(points, -1); const prev = closeAt(points, -1 - days); if (last == null || prev == null || !prev) return null; return ((last / prev) - 1) * 100; } function avgVolume(points, days) { const slice = points.slice(-days).map(p => p.volume).filter(v => v != null && v > 0); if (!slice.length) return null; return slice.reduce((a, b) => a + b, 0) / slice.length; } async function fetchEtfTopHoldings(symbols, limit = 10) { const out = {}; for (const sym of symbols) { try { const r = await yahooQuoteSummary(sym, 'topHoldings'); const list = r?.topHoldings?.holdings || []; out[sym] = list.slice(0, limit).map(h => ({ symbol: (h.symbol || '').toUpperCase(), name: h.holdingName || h.symbol, pct: h.holdingPercent?.raw != null ? h.holdingPercent.raw * 100 : null, pctFmt: h.holdingPercent?.fmt || null, })).filter(h => h.symbol); } catch { /* skip */ } await new Promise(r => setTimeout(r, 120)); } return out; } function mergeHoldingsWithFallback(fetched, symbols) { const out = { ...fetched }; let usedFallback = false; for (const sym of symbols) { if (out[sym]?.length) continue; if (TOP_HOLDINGS_FALLBACK[sym]) { out[sym] = TOP_HOLDINGS_FALLBACK[sym]; usedFallback = true; } } return { out, usedFallback }; } async function fetchEtfAum(symbols) { const out = {}; for (const sym of symbols) { try { const r = await yahooQuoteSummary(sym, 'defaultKeyStatistics'); const raw = r?.defaultKeyStatistics?.totalAssets?.raw; if (raw != null) out[sym] = raw; } catch { /* skip symbol */ } await new Promise(r => setTimeout(r, 100)); } return out; } function rotationQuadrant(rs20, momentum) { // 類 RRG:X=相對大盤強度,Y=短期動能(5日減20日) if (rs20 >= 0 && momentum >= 0) return { key: 'leading', labelZh: '領漲', tone: 'good' }; if (rs20 >= 0 && momentum < 0) return { key: 'weakening', labelZh: '轉弱', tone: 'warn' }; if (rs20 < 0 && momentum >= 0) return { key: 'improving', labelZh: '改善', tone: 'good' }; return { key: 'lagging', labelZh: '落後', tone: 'bad' }; } function buildSectorRow(meta, points, spyPoints, aum) { const ret1d = returnOver(points, 1); const ret5d = returnOver(points, 5); const ret20d = returnOver(points, 20); const ret60d = returnOver(points, 60); const spy20 = returnOver(spyPoints, 20); const spy5 = returnOver(spyPoints, 5); const rs20 = ret20d != null && spy20 != null ? ret20d - spy20 : null; const rs5 = ret5d != null && spy5 != null ? ret5d - spy5 : null; const momentum = rs5 != null && rs20 != null ? rs5 - rs20 : null; const volToday = points.at(-1)?.volume; const avgVol = avgVolume(points, 20); const volRatio = volToday && avgVol ? volToday / avgVol : null; const flowScore = volRatio != null && ret5d != null ? volRatio * (ret5d >= 0 ? 1 : -1) * Math.min(Math.abs(ret5d), 8) : null; const quad = rs20 != null && momentum != null ? rotationQuadrant(rs20, momentum) : null; const price = closeAt(points, -1); return { ...meta, price, ret1d, ret5d, ret20d, ret60d, rs20, rs5, momentum, volRatio, flowScore, aum, aumB: aum != null ? aum / 1e9 : null, quadrant: quad, }; } function summarizeRotation(rows) { const ranked = [...rows].filter(r => r.rs20 != null).sort((a, b) => b.rs20 - a.rs20); const leader = ranked[0]; const laggard = ranked[ranked.length - 1]; const byQuad = { leading: [], weakening: [], improving: [], lagging: [] }; for (const r of rows) { if (r.quadrant?.key) byQuad[r.quadrant.key].push(r.etf); } const cyclicalAvg = avgOf(rows.filter(r => r.group === 'cyclical' || r.group === 'growth'), 'rs20'); const defensiveAvg = avgOf(rows.filter(r => r.group === 'defensive' || r.group === 'rate_sensitive'), 'rs20'); let regime = '均衡輪動'; let regimeNote = '景氣敏感與防禦板塊表現接近,資金未明顯單邊押注。'; if (cyclicalAvg != null && defensiveAvg != null) { const spread = cyclicalAvg - defensiveAvg; if (spread > 1.5) { regime = '偏景氣/成長'; regimeNote = `循環型板塊 20 日相對強度平均較防禦型高 ${spread.toFixed(1)} 個百分點,資金偏向風險與景氣復甦敘事。`; } else if (spread < -1.5) { regime = '偏防禦'; regimeNote = `防禦型板塊相對較強(差距約 ${Math.abs(spread).toFixed(1)} pct),市場偏避險或降風險偏好。`; } } return { leader: leader ? { etf: leader.etf, nameZh: leader.nameZh, rs20: leader.rs20 } : null, laggard: laggard ? { etf: laggard.etf, nameZh: laggard.nameZh, rs20: laggard.rs20 } : null, ranked: ranked.map(r => ({ etf: r.etf, nameZh: r.nameZh, rs20: r.rs20, quadrant: r.quadrant?.labelZh })), byQuadrant: byQuad, regime, regimeNote, cyclicalAvg, defensiveAvg, }; } function avgOf(rows, field) { const vals = rows.map(r => r[field]).filter(v => v != null); if (!vals.length) return null; return vals.reduce((a, b) => a + b, 0) / vals.length; } function institutionalView(rows) { let withAum = rows.filter(r => r.aumB != null).sort((a, b) => b.aumB - a.aumB); let aumProxy = false; if (!withAum.length) { aumProxy = true; withAum = [...rows] .filter(r => r.price != null) .sort((a, b) => (b.flowScore || 0) - (a.flowScore || 0)) .map((r, i, arr) => { const w = Math.max(1, Math.abs(r.flowScore || 0) + Math.abs(r.rs20 || 0) + 1); return { etf: r.etf, nameZh: r.nameZh, aumB: w, sharePct: null, _rank: i }; }); const sum = withAum.reduce((s, r) => s + r.aumB, 0); withAum = withAum.map(r => ({ ...r, sharePct: sum ? (r.aumB / sum) * 100 : null })); } const totalAum = withAum.reduce((s, r) => s + (r.aumB || 0), 0); const byFlow = [...rows].filter(r => r.flowScore != null).sort((a, b) => b.flowScore - a.flowScore); return { totalAumB: totalAum || null, aumProxy, byAum: withAum.map(r => ({ etf: r.etf, nameZh: r.nameZh, aumB: r.aumB, sharePct: r.sharePct ?? (totalAum ? (r.aumB / totalAum) * 100 : null), })), flowLeaders: byFlow.slice(0, 5).map(r => ({ etf: r.etf, nameZh: r.nameZh, flowScore: r.flowScore, ret5d: r.ret5d, volRatio: r.volRatio, note: flowNote(r), })), flowLaggards: byFlow.slice(-3).reverse().map(r => ({ etf: r.etf, nameZh: r.nameZh, flowScore: r.flowScore, ret5d: r.ret5d, volRatio: r.volRatio, note: flowNote(r), })), disclaimer: aumProxy ? 'ETF 總資產暫時無法連線取得,下表改以「流向動能分數」相對占比示意機構資金關注度(非實際 AUM)。流向分數=量能異常×5日報酬方向。' : 'ETF 總資產為被動/機構配置規模 proxy(Yahoo totalAssets);流向分數結合近 5 日報酬與成交量異常,非官方申報流向。', }; } function flowNote(r) { const parts = []; if (r.ret5d != null) parts.push(`5日 ${r.ret5d >= 0 ? '+' : ''}${r.ret5d.toFixed(1)}%`); if (r.volRatio != null) parts.push(`量能 ${r.volRatio.toFixed(2)}× 均量`); return parts.join(' · '); } function buildStockExposure(holdingsByEtf, rotation, rows) { const packs = []; const spy = holdingsByEtf[BENCHMARK]; if (spy?.length) { packs.push({ etf: BENCHMARK, nameZh: 'S&P 500 大盤', reason: '指數與被動基金的核心配置,代表整體機構底倉。', holdings: spy, }); } const ranked = (rotation?.ranked || []).slice(0, 3); for (const r of ranked) { const list = holdingsByEtf[r.etf]; if (!list?.length) continue; const meta = rows.find(x => x.etf === r.etf); packs.push({ etf: r.etf, nameZh: meta?.nameZh || r.nameZh, reason: `20 日相對大盤 RS ${r.rs20 != null ? (r.rs20 >= 0 ? '+' : '') + r.rs20.toFixed(1) + '%' : '—'},資金輪動偏強的板塊。`, holdings: list, }); } const composite = {}; for (const p of packs) { const w = p.etf === BENCHMARK ? 1 : 0.65; for (const h of p.holdings) { if (!composite[h.symbol]) composite[h.symbol] = { symbol: h.symbol, name: h.name, score: 0, refs: [] }; composite[h.symbol].score += (h.pct || 0) * w; composite[h.symbol].refs.push(p.etf); } } const topStocks = Object.values(composite) .sort((a, b) => b.score - a.score) .slice(0, 12) .map(s => ({ ...s, refs: [...new Set(s.refs)] })); return { packs, topStocks, howToRead: '下方為各 ETF 最新公布的前十大持股(非即時買賣紀錄)。機構「買什麼」在實務上常透過 ETF 與指數基金間接持有;要看單一對沖基金最新建倉,需查 13F(季報、約延遲 45 天)。', disclaimer: '持股來自 Yahoo ETF topHoldings,更新頻率通常為每月;與 13F、Dark pool 即時流向不同。', usedFallback: false, }; } export async function buildSectorFlowPayload() { const symbols = [...SECTOR_ETFS.map(s => s.etf), BENCHMARK]; // 最先抓大盤持股(避免後續 Yahoo 請求過多被限流) let earlyHoldings = {}; try { resetYahooAuth(); earlyHoldings = await fetchEtfTopHoldings([BENCHMARK], 10); } catch { /* optional */ } const histories = await Promise.all(symbols.map(async sym => { try { const h = await getHistory(sym, '6mo', '1d'); return [sym, h.points]; } catch { return [sym, null]; } })); const pts = Object.fromEntries(histories); const spyPoints = pts[BENCHMARK]; if (!spyPoints?.length) throw new Error('無法取得 SPY 基準'); // 先算輪動,優先抓 ETF 持股(僅 4 檔、避免在 11 次 AUM 之後被 Yahoo 限流) const prelimRows = SECTOR_ETFS.map(meta => { const points = pts[meta.etf]; if (!points?.length) return { ...meta, error: 'no_data' }; return buildSectorRow(meta, points, spyPoints, null); }).filter(Boolean); const rotationPre = summarizeRotation(prelimRows.filter(s => !s.error)); const holdingEtfs = [BENCHMARK, ...(rotationPre.ranked || []).slice(0, 3).map(r => r.etf)]; const uniqueHoldEtfs = [...new Set(holdingEtfs)]; let stockExposure = null; let holdingsUsedFallback = false; try { let holdingsByEtf = { ...earlyHoldings }; const needFetch = uniqueHoldEtfs.filter(s => !holdingsByEtf[s]); if (needFetch.length) { await yahooSleep(300); const more = await fetchEtfTopHoldings(needFetch, 10); holdingsByEtf = { ...holdingsByEtf, ...more }; } const merged = mergeHoldingsWithFallback(holdingsByEtf, uniqueHoldEtfs); holdingsByEtf = merged.out; holdingsUsedFallback = merged.usedFallback; if (Object.keys(holdingsByEtf).length) { stockExposure = buildStockExposure(holdingsByEtf, rotationPre, prelimRows.filter(s => !s.error)); if (stockExposure && holdingsUsedFallback) { stockExposure.disclaimer += ' SPY 持股在資料源限流時暫用近期常見權重示意。'; stockExposure.usedFallback = true; } } } catch (err) { console.warn('[sector-flow] topHoldings:', err?.message || err); } let aumMap = {}; try { await yahooSleep(400); aumMap = await fetchEtfAum(SECTOR_ETFS.map(s => s.etf)); } catch { /* AUM 可缺 */ } const sectors = SECTOR_ETFS.map(meta => { const points = pts[meta.etf]; if (!points?.length) return { ...meta, error: 'no_data' }; return buildSectorRow(meta, points, spyPoints, aumMap[meta.etf] ?? null); }).filter(Boolean); const rotation = summarizeRotation(sectors.filter(s => !s.error)); const okRows = sectors.filter(s => !s.error); const institutional = institutionalView(okRows); if (stockExposure && rotation?.leader) { stockExposure = buildStockExposure( Object.fromEntries((stockExposure.packs || []).map(p => [p.etf, p.holdings])), rotation, okRows, ); } return { updatedAt: new Date().toISOString(), benchmark: BENCHMARK, source: 'Yahoo Finance · SPDR 行業 ETF', sectors, rotation, institutional, stockExposure, heatmapWindow: { d1: '1日', d5: '5日', d20: '20日', d60: '約60日' }, }; }