finance-dashboard/lib/sector-flow.js

372 lines
14 KiB
JavaScript
Raw Permalink Normal View History

2026-06-04 09:32:28 +00:00
// 美股 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) {
// 類 RRGX=相對大盤強度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 總資產為被動/機構配置規模 proxyYahoo 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日' },
};
}