feat: 初始提交(仅核心代码,已排除大文件)

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
@@ -0,0 +1,84 @@
{
"name_comment": "显示的名",
"name": "流动性状态机",
"card_order": 6,
"card_type": "overseas",
"card_span_comment": "卡片横向占位列数",
"card_span": 2,
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "6h",
"symbol_comment": "监控的代",
"symbol": "us_liquidity_state",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://fred.stlouisfed.org/series/FEDFUNDS",
"source_comment": "数据来源 (",
"source": "fred",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -20,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 40,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 240,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 20
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
"from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
"to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": true
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "00:00",
"end": "23:59"
}
],
"display_label": "美国宏观"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,442 @@
const { fetchFredCurl } = require('../fred_curl')
const FRED_SERIES = {
ffr: 'FEDFUNDS',
us2y: 'DGS2',
us10y: 'DGS10',
cpi_index: 'CPIAUCSL',
unemployment: 'UNRATE'
}
const toNum = v => {
const n = Number(v)
return Number.isFinite(n) ? n : NaN
}
const round = (v, d = 2) => {
const n = Number(v)
if (!Number.isFinite(n)) return NaN
const p = 10 ** d
return Math.round(n * p) / p
}
const clamp = (v, min, max) => Math.min(max, Math.max(min, v))
const parseFredCsv = raw => {
const text = typeof raw === 'string' ? raw : ''
const lines = text.split(/\r?\n/).filter(Boolean)
const out = []
for (let i = 1; i < lines.length; i++) {
const row = lines[i]
const idx = row.indexOf(',')
if (idx <= 0) continue
const date = row.slice(0, idx).trim()
const valueRaw = row.slice(idx + 1).trim()
if (!date || valueRaw === '.' || valueRaw === '') continue
const value = toNum(valueRaw)
if (!Number.isFinite(value)) continue
out.push({ date, value })
}
return out
}
const fetchFredSeries = async (id) => {
const csv = await fetchFredCurl(id)
return parseFredCsv(csv)
}
const tail = (arr, n) => (Array.isArray(arr) ? arr.slice(Math.max(0, arr.length - n)) : [])
const avg = arr => {
if (!Array.isArray(arr) || arr.length === 0) return NaN
const nums = arr.map(x => toNum(x)).filter(Number.isFinite)
if (nums.length === 0) return NaN
return nums.reduce((a, b) => a + b, 0) / nums.length
}
const sma = (series, n) => {
if (!Array.isArray(series) || series.length === 0) return NaN
const values = tail(series.map(x => x.value), Math.max(1, n))
return avg(values)
}
const valueNBack = (series, n) => {
if (!Array.isArray(series) || series.length === 0) return NaN
const idx = Math.max(0, series.length - 1 - Math.max(0, n))
return toNum(series[idx] && series[idx].value)
}
const cpiYoYSeries = cpiSeries => {
const out = []
if (!Array.isArray(cpiSeries) || cpiSeries.length < 13) return out
for (let i = 12; i < cpiSeries.length; i++) {
const curr = toNum(cpiSeries[i].value)
const prev = toNum(cpiSeries[i - 12].value)
if (!Number.isFinite(curr) || !Number.isFinite(prev) || prev <= 0) continue
out.push({
date: cpiSeries[i].date,
value: ((curr / prev) - 1) * 100
})
}
return out
}
const scoreFromDelta = (delta, easingThreshold, tighteningThreshold) => {
const d = toNum(delta)
if (!Number.isFinite(d)) return 50
if (d <= easingThreshold) return 100
if (d >= tighteningThreshold) return 0
const mid = (easingThreshold + tighteningThreshold) / 2
const half = (tighteningThreshold - easingThreshold) / 2 || 1
const scaled = 50 - ((d - mid) / half) * 50
return clamp(scaled, 0, 100)
}
const scoreRateDirection = ffrSeries => {
const latest = valueNBack(ffrSeries, 0)
const prev3 = valueNBack(ffrSeries, 3)
const delta = latest - prev3
return { delta, score: scoreFromDelta(delta, -0.1, 0.1) }
}
const scoreYieldExpectation = us2ySeries => {
const latest = valueNBack(us2ySeries, 0)
const prev30 = valueNBack(us2ySeries, 30)
const delta = latest - prev30
return { delta, score: scoreFromDelta(delta, -0.3, 0.3) }
}
const scoreCpiTrend = cpiYoY => {
const recent = tail(cpiYoY, 4)
if (recent.length < 4) return { delta: NaN, score: 50 }
const m3 = toNum(recent[0].value)
const m2 = toNum(recent[1].value)
const m1 = toNum(recent[2].value)
const m0 = toNum(recent[3].value)
const delta = m0 - m3
const isThreeMonthDown = m0 < m1 && m1 < m2
const isThreeMonthUp = m0 > m1 && m1 > m2
if (isThreeMonthDown) return { delta, score: 100 }
if (isThreeMonthUp) return { delta, score: 0 }
return { delta, score: scoreFromDelta(delta, -0.3, 0.3) }
}
const scoreUnemployment = unrateSeries => {
const latest = valueNBack(unrateSeries, 0)
const prev3 = valueNBack(unrateSeries, 3)
const delta = latest - prev3
return { delta, score: scoreFromDelta(delta, 0.3, -0.2) }
}
const buildState = score => {
if (score >= 85) return { state: '宽松', emoji: '🟢', impact: '流动性充分改善,利好纳指与标普,纳指弹性更高' }
if (score >= 70) return { state: '宽松初期', emoji: '🟢+', impact: '宽松预期先行,通常是纳指表现最优的阶段' }
if (score >= 45) return { state: '中性', emoji: '🟡', impact: '标普与纳指以震荡为主,等待增量信号' }
if (score >= 30) return { state: '紧缩中', emoji: '🟠', impact: '估值受压但未到极端,纳指波动通常大于标普' }
return { state: '紧缩', emoji: '🔴', impact: '高利率压制风险资产,纳指承压最明显' }
}
const fmt = (v, d = 2, suffix = '') => Number.isFinite(v) ? `${round(v, d).toFixed(d)}${suffix}` : '—'
const trendArrow = (delta, weak, strong) => {
const d = toNum(delta)
if (!Number.isFinite(d)) return '→'
const ad = Math.abs(d)
if (ad < weak) return '→'
if (d > 0) return ad >= strong ? '↑↑↑' : '↑'
return ad >= strong ? '↓↓↓' : '↓'
}
const dominantFactorFromDeltas = ({ rateDelta, y2Delta, cpiDelta, unrateDelta }) => {
const candidates = [
{ key: 'FFR利率方向', short: 'FFR利率', delta: rateDelta, unitThreshold: 0.1, easingWhenNegative: true },
{ key: '2Y利率预期', short: '2Y预期', delta: y2Delta, unitThreshold: 0.3, easingWhenNegative: true },
{ key: 'CPI通胀趋势', short: 'CPI趋势', delta: cpiDelta, unitThreshold: 0.3, easingWhenNegative: true },
{ key: '失业率变化', short: '失业率', delta: unrateDelta, unitThreshold: 0.3, easingWhenNegative: false }
].map(x => ({
...x,
strength: Number.isFinite(x.delta) ? Math.abs(x.delta) / x.unitThreshold : -1
}))
candidates.sort((a, b) => b.strength - a.strength)
const top = candidates[0]
const delta = toNum(top && top.delta)
const easing = Number.isFinite(delta)
? (top.easingWhenNegative ? delta < 0 : delta > 0)
: false
return {
dominant_factor: top ? top.short : '综合因素',
dominant_factor_full: top ? top.key : '综合因素',
dominant_bias: easing ? '宽松预期' : '紧缩预期'
}
}
const buildMarketPhase = ({ score, ffrLevel, rateDelta, y2Delta }) => {
if (rateDelta > 0.1) return '紧缩期(加息)'
if (score >= 85 && rateDelta < 0 && y2Delta < -0.2) return '宽松释放期(大牛)'
if ((y2Delta <= -0.15 && rateDelta <= 0.05) || (score >= 65 && score < 85)) return '降息预期交易期(Pre-Easing)'
if (ffrLevel >= 4 || score < 65) return '高位压制期'
return '高位压制期'
}
const buildIndexView = ({ score, y2Delta, cpiDelta, unrateDelta }) => {
const nasdaqBias = (score >= 70 || y2Delta <= -0.15) ? '当前偏正面' : (score >= 45 ? '中性偏震荡' : '当前偏负面')
const sp500Bias = (score >= 85 && cpiDelta < 0 && unrateDelta >= 0)
? '偏正面'
: (score < 45 ? '偏谨慎' : '仍有分歧')
return {
nasdaq: `纳指驱动:流动性 + 利率预期(${nasdaqBias})`,
sp500: `标普驱动:盈利 + 宏观(${sp500Bias})`,
nasdaq_bias: nasdaqBias,
sp500_bias: sp500Bias
}
}
const buildSignalStrength = score => {
if (score >= 85) return '★★★★★'
if (score >= 70) return '★★★☆☆'
if (score >= 45) return '★★☆☆☆'
if (score >= 30) return '★☆☆☆☆'
return '☆☆☆☆☆'
}
const buildCoreConclusion = marketPhase => {
if (marketPhase === '降息预期交易期(Pre-Easing)') {
return '市场进入“降息预期交易期”,流动性方向转松,当前更利好纳指(科技股),而非全面牛市。'
}
if (marketPhase === '宽松释放期(大牛)') {
return '市场进入“宽松释放期”,流动性进入释放阶段,纳指与标普更容易共振上行。'
}
if (marketPhase === '紧缩期(加息)') {
return '市场处于“紧缩期”,流动性方向偏紧,纳指与标普整体承压。'
}
return '市场处于“高位压制期”,风险偏好恢复有限,结构性行情强于全面行情。'
}
const buildTrendNarrative = ({ rateDelta, y2Delta, cpiDelta, unrateDelta }) => ({
ffr: rateDelta < 0 ? '政策开始转向' : (rateDelta > 0 ? '政策偏紧延续' : '政策维持稳定'),
us2y: y2Delta < 0 ? '市场正在押注降息' : (y2Delta > 0 ? '市场预期反向' : '市场预期观望'),
cpi: cpiDelta < 0 ? '支持降息' : (cpiDelta > 0 ? '约束降息' : '通胀信号中性'),
unemployment: unrateDelta > 0 ? '经济走弱' : (unrateDelta < 0 ? '经济尚稳' : '就业信号中性')
})
const buildRiskHints = ({ rateDelta, y2Delta, cpiDelta, marketPhase }) => {
const divergence = (rateDelta < 0 && y2Delta > 0.1) || (rateDelta > 0 && y2Delta < -0.1)
return [
divergence ? '2Y与FFR出现分歧(预期 vs 现实)' : '2Y与FFR短期一致,需警惕后续再分歧',
cpiDelta > 0 ? 'CPI出现反弹迹象,降息预期存在回摆风险' : '若CPI反弹 → 降息预期失效',
marketPhase === '宽松释放期(大牛)' ? '宽松释放已启动,仍需跟踪盈利兑现节奏' : '当前仍未进入真实宽松(资金未释放)'
]
}
const buildDominantDisplay = ({ dominantFactorFull, arrows, trendNarrative }) => {
if (dominantFactorFull === 'FFR利率方向') return { arrow: arrows.ffr, period: '3个月', text: trendNarrative.ffr }
if (dominantFactorFull === 'CPI通胀趋势') return { arrow: arrows.cpi, period: '3个月', text: trendNarrative.cpi }
if (dominantFactorFull === '失业率变化') return { arrow: arrows.unemployment, period: '3个月', text: trendNarrative.unemployment }
return { arrow: arrows.us2y, period: '30日', text: trendNarrative.us2y }
}
module.exports = {
run: async (context, config) => {
const { logJSON } = context
try {
const [ffr, us2y, us10y, cpiIndex, unemployment] = await Promise.all([
fetchFredSeries(FRED_SERIES.ffr),
fetchFredSeries(FRED_SERIES.us2y),
fetchFredSeries(FRED_SERIES.us10y),
fetchFredSeries(FRED_SERIES.cpi_index),
fetchFredSeries(FRED_SERIES.unemployment)
])
if (!ffr.length || !us2y.length || !us10y.length || !cpiIndex.length || !unemployment.length) {
logJSON('skill.liquidity.fetch.empty', {
ffr: ffr.length,
us2y: us2y.length,
us10y: us10y.length,
cpiIndex: cpiIndex.length,
unemployment: unemployment.length
}, 'market_skills')
return null
}
const cpiYoY = cpiYoYSeries(cpiIndex)
const ratePart = scoreRateDirection(ffr)
const yieldPart = scoreYieldExpectation(us2y)
const cpiPart = scoreCpiTrend(cpiYoY)
const unratePart = scoreUnemployment(unemployment)
const totalScore = clamp(
ratePart.score * 0.4 +
yieldPart.score * 0.3 +
cpiPart.score * 0.2 +
unratePart.score * 0.1,
0,
100
)
const totalScoreRounded = round(totalScore, 2)
const state = buildState(totalScoreRounded)
const latestFfr = tail(ffr, 1)[0]
const latestUs2y = tail(us2y, 1)[0]
const latestUs10y = tail(us10y, 1)[0]
const latestCpiYoY = tail(cpiYoY, 1)[0]
const latestUnrate = tail(unemployment, 1)[0]
const spread = toNum(latestUs10y.value) - toNum(latestUs2y.value)
const capturedAt = new Date().toISOString().replace('T', ' ').slice(0, 19)
const ffrTrend = latestFfr.value - valueNBack(ffr, 3)
const us2yTrend = latestUs2y.value - valueNBack(us2y, 30)
const cpiTrend = (latestCpiYoY ? latestCpiYoY.value : NaN) - valueNBack(cpiYoY, 3)
const unrateTrend = latestUnrate.value - valueNBack(unemployment, 3)
const ffrArrow = trendArrow(ffrTrend, 0.03, 0.1)
const us2yArrow = trendArrow(us2yTrend, 0.05, 0.3)
const cpiArrow = trendArrow(cpiTrend, 0.05, 0.3)
const unrateArrow = trendArrow(unrateTrend, 0.05, 0.2)
const dominant = dominantFactorFromDeltas({
rateDelta: ratePart.delta,
y2Delta: yieldPart.delta,
cpiDelta: cpiPart.delta,
unrateDelta: unratePart.delta
})
const marketPhase = buildMarketPhase({
score: totalScoreRounded,
ffrLevel: latestFfr.value,
rateDelta: ratePart.delta,
y2Delta: yieldPart.delta
})
const indexView = buildIndexView({
score: totalScoreRounded,
y2Delta: yieldPart.delta,
cpiDelta: cpiPart.delta,
unrateDelta: unratePart.delta
})
const signalStrength = buildSignalStrength(totalScoreRounded)
const coreConclusion = buildCoreConclusion(marketPhase)
const trendNarrative = buildTrendNarrative({
rateDelta: ratePart.delta,
y2Delta: yieldPart.delta,
cpiDelta: cpiPart.delta,
unrateDelta: unratePart.delta
})
const riskHints = buildRiskHints({
rateDelta: ratePart.delta,
y2Delta: yieldPart.delta,
cpiDelta: cpiPart.delta,
marketPhase
})
const structureSummary = '科技优先上涨,而非全面上涨'
const dominantDisplay = buildDominantDisplay({
dominantFactorFull: dominant.dominant_factor_full,
arrows: {
ffr: ffrArrow,
us2y: us2yArrow,
cpi: cpiArrow,
unemployment: unrateArrow
},
trendNarrative
})
const html = `
<div style="font-size:16px;font-weight:700;line-height:1.7">
${state.emoji} ${state.state}(${marketPhase.includes('Pre-Easing') ? 'Pre-Easing' : state.state})<br/>
流动性评分:${fmt(totalScoreRounded, 0)} / 100 | 信号强度:${signalStrength}
</div>
<div style="font-size:13px;color:#1f1f1f;line-height:1.7;margin-top:8px">
👉 核心结论:${coreConclusion}
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px;margin-top:10px">
<div style="border:1px solid rgba(0,0,0,0.1);border-radius:10px;padding:10px 12px;background:rgba(255,255,255,0.78)">
<div style="font-size:13px;font-weight:700;color:#262626">🎯 主导因子</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
${dominant.dominant_factor_full} ${dominantDisplay.arrow}(${dominantDisplay.period})<br/>
→ ${dominantDisplay.text}
</div>
<div style="margin:8px 0;border-top:1px dashed rgba(0,0,0,0.15)"></div>
<div style="font-size:13px;font-weight:700;color:#262626">🧭 市场结构判断</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
纳指:✔ 长久期资产受益 | ✔ 估值扩张预期增强<br/>
标普:△ 盈利支撑仍在 | △ 宏观存在分歧<br/>
👉 当前结构:${structureSummary}
</div>
</div>
<div style="border:1px solid rgba(0,0,0,0.1);border-radius:10px;padding:10px 12px;background:rgba(255,255,255,0.78)">
<div style="font-size:13px;font-weight:700;color:#262626">⚠️ 风险提示</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
- ${riskHints[0]}<br/>
- ${riskHints[1]}<br/>
- ${riskHints[2]}
</div>
<div style="margin:8px 0;border-top:1px dashed rgba(0,0,0,0.15)"></div>
<div style="font-size:13px;font-weight:700;color:#262626">📊 关键趋势</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
FFR:${fmt(latestFfr.value, 2, '%')} ${ffrArrow}(${trendNarrative.ffr})<br/>
2Y:${fmt(latestUs2y.value, 2, '%')} ${us2yArrow}(${trendNarrative.us2y})<br/>
CPI:${fmt(latestCpiYoY && latestCpiYoY.value, 2, '%')} ${cpiArrow}(${trendNarrative.cpi})<br/>
失业率:${fmt(latestUnrate.value, 2, '%')} ${unrateArrow}(${trendNarrative.unemployment})
</div>
</div>
</div>
<div style="font-size:13px;color:#595959;line-height:1.7;margin-top:8px">
分项评分:利率方向 ${fmt(ratePart.score, 0)} | 2Y预期 ${fmt(yieldPart.score, 0)} | CPI趋势 ${fmt(cpiPart.score, 0)} | 失业率 ${fmt(unratePart.score, 0)}<br/>
数据时间:${capturedAt}(FRED)
</div>
`
const explain = [
`当前阶段:${marketPhase}。`,
`当前市场由【${dominant.dominant_factor_full}】主导,偏向${dominant.dominant_bias}。`,
`${indexView.nasdaq} ${indexView.sp500}。`,
`核心结论:${coreConclusion}`,
`当前结构:${structureSummary}。`,
`纳指影响:${state.state === '宽松' || state.state === '宽松初期' ? '长久期资产受益,估值扩张概率更高。' : state.state === '中性' ? '以交易驱动为主,业绩与预期差决定方向。' : '高利率压制估值,回撤风险先体现在纳指。'}`,
`标普影响:${state.state === '宽松' || state.state === '宽松初期' ? '风险偏好抬升,周期与成长轮动走强。' : state.state === '中性' ? '盈利韧性与宏观数据反复博弈。' : '防御板块相对占优,指数上行空间受限。'}`,
`风险提示:${riskHints.join(';')}。`,
`分项得分:利率方向 ${fmt(ratePart.score, 1)}|2Y预期 ${fmt(yieldPart.score, 1)}|CPI趋势 ${fmt(cpiPart.score, 1)}|失业率 ${fmt(unratePart.score, 1)}`
].join(' ')
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source || 'fred',
name: `${state.emoji} ${state.state}`,
price: totalScoreRounded,
prevClose: 50,
change: round(totalScoreRounded - 50, 2),
change_percent: round(((totalScoreRounded - 50) / 50) * 100, 2),
captured_at: capturedAt,
render_type: 'html',
html,
explain,
market_status: config.market_status,
liquidity_state: state.state,
liquidity_score: totalScoreRounded,
market_phase: marketPhase,
signal_strength: signalStrength,
core_conclusion: coreConclusion,
market_structure_summary: structureSummary,
dominant_factor: dominant.dominant_factor,
dominant_factor_full: dominant.dominant_factor_full,
dominant_bias: dominant.dominant_bias,
index_view: {
nasdaq: indexView.nasdaq,
sp500: indexView.sp500,
nasdaq_bias: indexView.nasdaq_bias,
sp500_bias: indexView.sp500_bias
},
risk_hints: riskHints,
date: capturedAt.slice(0, 10),
ffr: round(latestFfr.value, 4),
us2y: round(latestUs2y.value, 4),
us10y: round(latestUs10y.value, 4),
cpi: round(latestCpiYoY && latestCpiYoY.value, 4),
unemployment: round(latestUnrate.value, 4),
trend_arrow: {
ffr: ffrArrow,
us2y: us2yArrow,
cpi: cpiArrow,
unemployment: unrateArrow
},
series_date: {
ffr: latestFfr.date,
us2y: latestUs2y.date,
us10y: latestUs10y.date,
cpi: latestCpiYoY ? latestCpiYoY.date : null,
unemployment: latestUnrate.date
}
}]
return { data, alerts: [] }
} catch (e) {
logJSON('skill.liquidity.run.error', { error: String(e.message || e) }, 'market_skills')
return null
}
}
}