372 lines
14 KiB
JavaScript
372 lines
14 KiB
JavaScript
// 美股 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日' },
|
||
};
|
||
} |