2026-06-02 09:40:21 +00:00
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// 資料引擎 — 負責「抓真實資料」並換算成卡片要顯示的數字
|
|
|
|
|
|
//
|
|
|
|
|
|
// 流程:FRED / Stooq 取得原始時間序列
|
|
|
|
|
|
// → computeMetric() 依設定做 YoY / MoM / 變動量等換算
|
|
|
|
|
|
// → 取最新值當顯示值、與前一期比較算「變動」
|
|
|
|
|
|
// → 取近期資料做成 sparkline 走勢圖(真實,不再是假的)
|
|
|
|
|
|
// → formatValue() 套上 % / bp / $ / K 等單位
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
import { INDICATORS } from './indicators.js';
|
2026-06-03 16:42:07 +00:00
|
|
|
|
import { buildHistoricalContext } from './context.js';
|
2026-06-02 09:40:21 +00:00
|
|
|
|
|
|
|
|
|
|
const FRED_BASE = 'https://api.stlouisfed.org/fred/series/observations';
|
|
|
|
|
|
const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart/';
|
|
|
|
|
|
|
|
|
|
|
|
// 自訂錯誤:金鑰未設定時用,讓 API 端點能回傳友善訊息
|
|
|
|
|
|
export class MissingKeyError extends Error {}
|
|
|
|
|
|
|
|
|
|
|
|
function getApiKey() {
|
|
|
|
|
|
const key = process.env.FRED_API_KEY;
|
|
|
|
|
|
if (!key || key === 'your_fred_api_key_here') {
|
|
|
|
|
|
throw new MissingKeyError('尚未設定 FRED_API_KEY');
|
|
|
|
|
|
}
|
|
|
|
|
|
return key;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isoDaysAgo(days) {
|
|
|
|
|
|
const d = new Date(Date.now() - days * 86400000);
|
|
|
|
|
|
return d.toISOString().slice(0, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
|
|
|
|
|
|
|
|
// 併發限制器:FRED 對「短時間內大量同時請求」會回 429,
|
|
|
|
|
|
// 因此一次最多只放行 5 個請求,其餘排隊。
|
|
|
|
|
|
function createLimiter(max) {
|
|
|
|
|
|
let active = 0;
|
|
|
|
|
|
const queue = [];
|
|
|
|
|
|
const next = () => {
|
|
|
|
|
|
if (active >= max || queue.length === 0) return;
|
|
|
|
|
|
active++;
|
|
|
|
|
|
const { fn, resolve, reject } = queue.shift();
|
|
|
|
|
|
fn().then(resolve, reject).finally(() => { active--; next(); });
|
|
|
|
|
|
};
|
|
|
|
|
|
return (fn) => new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); next(); });
|
|
|
|
|
|
}
|
|
|
|
|
|
const limit = createLimiter(2);
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 抓取 FRED 序列 → [{date, value:Number}](已濾掉缺值 '.')───
|
|
|
|
|
|
// 透過限制器排隊,並對 429(請求過多)自動退避重試。
|
|
|
|
|
|
async function fetchFredSeries(seriesId, startISO) {
|
|
|
|
|
|
const key = getApiKey();
|
|
|
|
|
|
const url = `${FRED_BASE}?series_id=${encodeURIComponent(seriesId)}` +
|
|
|
|
|
|
`&api_key=${key}&file_type=json&sort_order=asc&observation_start=${startISO}`;
|
|
|
|
|
|
return limit(async () => {
|
|
|
|
|
|
for (let attempt = 0; attempt < 9; attempt++) {
|
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
|
if (res.status === 429) { // 過多請求,退避後重試(含抖動避免同步重試)
|
|
|
|
|
|
await sleep(700 * (attempt + 1) + Math.random() * 400);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!res.ok) throw new Error(`FRED ${seriesId} 回應 ${res.status}`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
return (json.observations || [])
|
|
|
|
|
|
.filter((o) => o.value !== '.' && o.value !== '' && o.value != null)
|
|
|
|
|
|
.map((o) => ({ date: o.date, value: Number(o.value) }))
|
|
|
|
|
|
.filter((o) => Number.isFinite(o.value));
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error(`FRED ${seriesId} 持續回應 429(請稍後再試)`);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 抓取 Yahoo Finance 行情(日線)→ [{date, value:收盤}] ───
|
|
|
|
|
|
// 伺服器對伺服器呼叫,無 CORS 問題、免金鑰;用於 FRED 無法良好提供的黃金。
|
|
|
|
|
|
async function fetchYahooSeries(symbol, range = '1y') {
|
|
|
|
|
|
const url = `${YAHOO_BASE}${encodeURIComponent(symbol)}?range=${range}&interval=1d`;
|
|
|
|
|
|
const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
|
|
|
|
|
|
if (!res.ok) throw new Error(`Yahoo ${symbol} 回應 ${res.status}`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
const result = json?.chart?.result?.[0];
|
|
|
|
|
|
const ts = result?.timestamp;
|
|
|
|
|
|
const closes = result?.indicators?.quote?.[0]?.close;
|
|
|
|
|
|
if (!ts || !closes) throw new Error(`Yahoo ${symbol} 無資料`);
|
|
|
|
|
|
const out = [];
|
|
|
|
|
|
for (let i = 0; i < ts.length; i++) {
|
|
|
|
|
|
if (closes[i] == null || !Number.isFinite(closes[i])) continue;
|
|
|
|
|
|
out.push({ date: new Date(ts[i] * 1000).toISOString().slice(0, 10), value: closes[i] });
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 依 transform 把原始序列換算成「要顯示的指標序列」 ───
|
|
|
|
|
|
function computeMetric(points, transform, periodsPerYear) {
|
|
|
|
|
|
const v = points.map((p) => p.value);
|
|
|
|
|
|
const out = [];
|
|
|
|
|
|
switch (transform) {
|
|
|
|
|
|
case 'level': // 直接用原值
|
|
|
|
|
|
case 'usd':
|
|
|
|
|
|
for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] });
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'yoy': { // 年增率:與一年前比較
|
|
|
|
|
|
const n = periodsPerYear || 12;
|
|
|
|
|
|
for (let i = n; i < points.length; i++) {
|
|
|
|
|
|
if (v[i - n] === 0) continue;
|
|
|
|
|
|
out.push({ date: points[i].date, val: (v[i] / v[i - n] - 1) * 100 });
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'mom': { // 月增率:與上一期比較
|
|
|
|
|
|
for (let i = 1; i < points.length; i++) {
|
|
|
|
|
|
if (v[i - 1] === 0) continue;
|
|
|
|
|
|
out.push({ date: points[i].date, val: (v[i] / v[i - 1] - 1) * 100 });
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'payems_diff': { // 非農:本期減上期(PAYEMS 單位已是千人)
|
|
|
|
|
|
for (let i = 1; i < points.length; i++) {
|
|
|
|
|
|
out.push({ date: points[i].date, val: v[i] - v[i - 1] });
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'percent_to_bp': // 百分比 → 基點
|
|
|
|
|
|
for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] * 100 });
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'level_per_thousand': // 人數 → 千人
|
|
|
|
|
|
for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] / 1000 });
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'millions_to_trillions': // 百萬 → 兆
|
|
|
|
|
|
for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] / 1e6 });
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] });
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 取近期資料、平均取樣成 sparkline(最多 24 點)───
|
|
|
|
|
|
function buildSparkline(metric) {
|
|
|
|
|
|
if (metric.length === 0) return [];
|
|
|
|
|
|
// 推估資料頻率(相鄰兩點的中位數天數),決定要回看多久
|
|
|
|
|
|
const gaps = [];
|
|
|
|
|
|
for (let i = 1; i < metric.length; i++) {
|
|
|
|
|
|
gaps.push((new Date(metric[i].date) - new Date(metric[i - 1].date)) / 86400000);
|
|
|
|
|
|
}
|
|
|
|
|
|
gaps.sort((a, b) => a - b);
|
|
|
|
|
|
const medianGap = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 30;
|
|
|
|
|
|
let spanDays;
|
|
|
|
|
|
if (medianGap <= 4) spanDays = 365; // 每日
|
|
|
|
|
|
else if (medianGap <= 10) spanDays = 730; // 每週
|
|
|
|
|
|
else if (medianGap <= 45) spanDays = 1460; // 每月
|
|
|
|
|
|
else spanDays = 2920; // 每季
|
|
|
|
|
|
const cutoff = Date.now() - spanDays * 86400000;
|
|
|
|
|
|
let recent = metric.filter((m) => new Date(m.date).getTime() >= cutoff);
|
|
|
|
|
|
if (recent.length < 8) recent = metric.slice(-16); // 點太少就直接取最後 16 點
|
|
|
|
|
|
// 平均取樣到最多 24 點
|
|
|
|
|
|
const target = 24;
|
|
|
|
|
|
if (recent.length <= target) return recent.map((m) => m.val);
|
|
|
|
|
|
const step = (recent.length - 1) / (target - 1);
|
|
|
|
|
|
const sampled = [];
|
|
|
|
|
|
for (let i = 0; i < target; i++) sampled.push(recent[Math.round(i * step)].val);
|
|
|
|
|
|
return sampled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 數值格式化 ───
|
|
|
|
|
|
function fmtNum(n, d) {
|
|
|
|
|
|
return n.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
|
|
|
|
|
|
}
|
|
|
|
|
|
function signed(n, d) {
|
|
|
|
|
|
return (n >= 0 ? '+' : '') + n.toFixed(d);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatValue(val, format, decimals) {
|
|
|
|
|
|
const d = decimals ?? 2;
|
|
|
|
|
|
switch (format) {
|
|
|
|
|
|
case 'pct': return `${val.toFixed(d)}%`;
|
|
|
|
|
|
case 'pct_signed': return `${signed(val, d)}%`;
|
|
|
|
|
|
case 'bp': return `${Math.round(val)}bp`;
|
|
|
|
|
|
case 'num0': return fmtNum(val, 0);
|
|
|
|
|
|
case 'num1': return val.toFixed(1);
|
|
|
|
|
|
case 'num2': return val.toFixed(2);
|
|
|
|
|
|
case 'num2_signed': return signed(val, 2);
|
|
|
|
|
|
case 'k': return `${Math.round(val).toLocaleString('en-US')}K`;
|
|
|
|
|
|
case 'k_signed': return `${val >= 0 ? '+' : ''}${Math.round(val)}K`;
|
|
|
|
|
|
case 'trillions': return `$${val.toFixed(d)}T`;
|
|
|
|
|
|
case 'usd': return `$${val.toFixed(d)}`;
|
|
|
|
|
|
case 'usd0': return `$${fmtNum(val, 0)}`;
|
|
|
|
|
|
default: return val.toFixed(d);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatChange(delta, format, decimals) {
|
|
|
|
|
|
const d = decimals ?? 2;
|
|
|
|
|
|
if (!Number.isFinite(delta)) return '';
|
|
|
|
|
|
switch (format) {
|
|
|
|
|
|
case 'pct':
|
|
|
|
|
|
case 'pct_signed': return signed(delta, d); // 例:+0.03(單位同上方百分比)
|
|
|
|
|
|
case 'num0': return `${delta >= 0 ? '+' : ''}${Math.round(delta)}`;
|
|
|
|
|
|
case 'num1': return signed(delta, 1);
|
|
|
|
|
|
case 'num2':
|
|
|
|
|
|
case 'num2_signed': return signed(delta, 2);
|
|
|
|
|
|
case 'bp': return `${delta >= 0 ? '+' : ''}${Math.round(delta)}bp`;
|
|
|
|
|
|
case 'k':
|
|
|
|
|
|
case 'k_signed': return `${delta >= 0 ? '+' : ''}${Math.round(delta)}K`;
|
|
|
|
|
|
case 'trillions': return `${delta >= 0 ? '+' : '-'}$${Math.abs(delta * 1000).toFixed(0)}B`; // 兆→十億
|
|
|
|
|
|
case 'usd': return `${delta >= 0 ? '+' : '-'}$${Math.abs(delta).toFixed(1)}`;
|
|
|
|
|
|
case 'usd0': return `${delta >= 0 ? '+' : '-'}$${fmtNum(Math.abs(delta), 0)}`;
|
|
|
|
|
|
default: return signed(delta, d);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 顏色與徽章判定 ───
|
|
|
|
|
|
function classify(ind, dir) {
|
|
|
|
|
|
const meaningful = !ind.excludeFromScore;
|
|
|
|
|
|
const flat = dir === 'neutral';
|
|
|
|
|
|
// 反向指標:下降才是好;一般指標:上升才是好
|
|
|
|
|
|
const good = ind.inverted ? dir === 'down' : dir === 'up';
|
|
|
|
|
|
let valueColorKey, changeColorKey, badgeKind;
|
|
|
|
|
|
if (!meaningful) {
|
|
|
|
|
|
valueColorKey = 'blue'; // 中性指標用藍色,不暗示好壞
|
|
|
|
|
|
changeColorKey = 'text2';
|
|
|
|
|
|
badgeKind = 'neutral';
|
|
|
|
|
|
} else if (flat) {
|
|
|
|
|
|
valueColorKey = 'yellow';
|
|
|
|
|
|
changeColorKey = 'text2';
|
|
|
|
|
|
badgeKind = 'neutral';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
valueColorKey = good ? 'green' : 'red';
|
|
|
|
|
|
changeColorKey = good ? 'green' : 'red';
|
|
|
|
|
|
badgeKind = good ? 'good' : 'bad';
|
|
|
|
|
|
}
|
|
|
|
|
|
return { valueColorKey, changeColorKey, badgeKind, good, meaningful };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function dirOf(delta, scale) {
|
|
|
|
|
|
const eps = scale * 0.0005; // 極小變動視為持平
|
|
|
|
|
|
if (!Number.isFinite(delta)) return 'neutral';
|
|
|
|
|
|
if (delta > eps) return 'up';
|
|
|
|
|
|
if (delta < -eps) return 'down';
|
|
|
|
|
|
return 'neutral';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 抓取單一指標並組成卡片 ───
|
|
|
|
|
|
async function buildCard(ind) {
|
|
|
|
|
|
let points;
|
|
|
|
|
|
if (ind.source === 'yahoo') {
|
|
|
|
|
|
points = await fetchYahooSeries(ind.symbol, 'max');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 回看年數拉長到約 26 年,讓走勢大圖能涵蓋 2000 網路泡沫、2008 金融海嘯等
|
|
|
|
|
|
// 歷史事件(多數 FRED 序列起點更早,會自動回傳實際擁有的範圍)。
|
|
|
|
|
|
// yoy 需多一年前置資料;季資料再多抓幾年確保換算完整。
|
|
|
|
|
|
const yearsBack = ind.transform === 'yoy'
|
|
|
|
|
|
? (ind.periodsPerYear === 4 ? 30 : 27)
|
|
|
|
|
|
: 26;
|
|
|
|
|
|
points = await fetchFredSeries(ind.seriesId, isoDaysAgo(yearsBack * 365));
|
|
|
|
|
|
}
|
|
|
|
|
|
const metric = computeMetric(points, ind.transform, ind.periodsPerYear);
|
|
|
|
|
|
if (metric.length === 0) throw new Error(`${ind.key} 換算後無資料`);
|
|
|
|
|
|
|
|
|
|
|
|
const latest = metric[metric.length - 1];
|
|
|
|
|
|
const prev = metric.length > 1 ? metric[metric.length - 2] : null;
|
|
|
|
|
|
const value = latest.val;
|
|
|
|
|
|
const delta = prev ? value - prev.val : NaN;
|
|
|
|
|
|
const scale = Math.max(Math.abs(value), 1);
|
|
|
|
|
|
const dir = dirOf(delta, scale);
|
|
|
|
|
|
const cls = classify(ind, dir);
|
|
|
|
|
|
|
2026-06-03 16:42:07 +00:00
|
|
|
|
const context = buildHistoricalContext(metric, ind, value);
|
|
|
|
|
|
const tip = context
|
|
|
|
|
|
? { ...ind.tip, context }
|
|
|
|
|
|
: ind.tip;
|
|
|
|
|
|
|
2026-06-02 09:40:21 +00:00
|
|
|
|
const card = {
|
|
|
|
|
|
key: ind.key,
|
|
|
|
|
|
group: ind.group,
|
|
|
|
|
|
label: ind.label,
|
|
|
|
|
|
labelEn: ind.labelEn,
|
|
|
|
|
|
value: formatValue(value, ind.format, ind.decimals),
|
|
|
|
|
|
rawValue: value,
|
|
|
|
|
|
change: formatChange(delta, ind.format, ind.decimals),
|
|
|
|
|
|
dir,
|
|
|
|
|
|
badge: dir === 'up' ? '上升' : dir === 'down' ? '下降' : '持平',
|
|
|
|
|
|
badgeKind: cls.badgeKind,
|
|
|
|
|
|
valueColorKey: cls.valueColorKey,
|
|
|
|
|
|
changeColorKey: cls.changeColorKey,
|
|
|
|
|
|
inverted: !!ind.inverted,
|
|
|
|
|
|
good: cls.good,
|
|
|
|
|
|
meaningful: cls.meaningful,
|
|
|
|
|
|
spark: buildSparkline(metric),
|
|
|
|
|
|
substitute: ind.substitute || null,
|
2026-06-03 16:42:07 +00:00
|
|
|
|
tip,
|
|
|
|
|
|
context,
|
2026-06-02 09:40:21 +00:00
|
|
|
|
format: ind.format,
|
|
|
|
|
|
decimals: ind.decimals ?? 2,
|
|
|
|
|
|
asOf: latest.date,
|
|
|
|
|
|
};
|
|
|
|
|
|
return { card, metric };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 把單一指標的格式化函式對外公開(供 /api/series 用)
|
|
|
|
|
|
export { formatValue };
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 抓取全部指標(容錯:個別失敗不影響其他)───
|
|
|
|
|
|
export async function getIndicatorCards() {
|
|
|
|
|
|
const results = await Promise.allSettled(INDICATORS.map((ind) => buildCard(ind)));
|
|
|
|
|
|
const cards = {};
|
|
|
|
|
|
const seriesHistory = {}; // key → 完整歷史序列 [{date,val}]
|
|
|
|
|
|
const degraded = [];
|
|
|
|
|
|
let missingKey = false;
|
|
|
|
|
|
results.forEach((r, idx) => {
|
|
|
|
|
|
const ind = INDICATORS[idx];
|
|
|
|
|
|
if (r.status === 'fulfilled') {
|
|
|
|
|
|
cards[ind.key] = r.value.card;
|
|
|
|
|
|
seriesHistory[ind.key] = r.value.metric;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (r.reason instanceof MissingKeyError) missingKey = true;
|
|
|
|
|
|
degraded.push({ key: ind.key, label: ind.label, reason: String(r.reason?.message || r.reason) });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
// 只要偵測到金鑰未設定,就視為設定問題(畫面顯示設定教學),
|
|
|
|
|
|
// 不因為少數免金鑰來源(如黃金)成功就誤判為正常。
|
|
|
|
|
|
if (missingKey) {
|
|
|
|
|
|
throw new MissingKeyError('尚未設定 FRED_API_KEY');
|
|
|
|
|
|
}
|
|
|
|
|
|
return { cards, seriesHistory, degraded };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 殖利率曲線(真實,跨天期)───
|
|
|
|
|
|
const CURVE_SERIES = [
|
|
|
|
|
|
['3M', 'DGS3MO'], ['6M', 'DGS6MO'], ['1Y', 'DGS1'], ['2Y', 'DGS2'],
|
|
|
|
|
|
['3Y', 'DGS3'], ['5Y', 'DGS5'], ['7Y', 'DGS7'], ['10Y', 'DGS10'],
|
|
|
|
|
|
['20Y', 'DGS20'], ['30Y', 'DGS30'],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
export async function getYieldCurve() {
|
|
|
|
|
|
const results = await Promise.allSettled(
|
|
|
|
|
|
CURVE_SERIES.map(([, id]) => fetchFredSeries(id, isoDaysAgo(90)))
|
|
|
|
|
|
);
|
|
|
|
|
|
const maturities = [];
|
|
|
|
|
|
const yields = [];
|
|
|
|
|
|
const prevYields = [];
|
|
|
|
|
|
results.forEach((r, i) => {
|
|
|
|
|
|
if (r.status !== 'fulfilled' || r.value.length === 0) return;
|
|
|
|
|
|
const pts = r.value;
|
|
|
|
|
|
const last = pts[pts.length - 1].value;
|
|
|
|
|
|
// 約一個月前(21 個交易日)
|
|
|
|
|
|
const ago = pts[Math.max(0, pts.length - 22)].value;
|
|
|
|
|
|
maturities.push(CURVE_SERIES[i][0]);
|
|
|
|
|
|
yields.push(last);
|
|
|
|
|
|
prevYields.push(ago);
|
|
|
|
|
|
});
|
|
|
|
|
|
const inverted = yields.length >= 2 && yields[0] > yields[yields.length - 1];
|
|
|
|
|
|
return { maturities, yields, prevYields, inverted };
|
|
|
|
|
|
}
|