finance-dashboard/lib/sector-flow.js

372 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 美股 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日' },
};
}