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,39 @@
{
"COMEX黄金:ratio_max": "2026-02-19-am",
"COMEX白银:白银价格过低": "2026-02-18-pm",
"COMEX白银:白银跌幅预警": "2026-03-04-am",
"COMEX黄金:drop_2": "2026-03-03-pm",
"COMEX黄金:drop_3": "2026-03-04-am",
"COMEX白银:白银涨幅预警": "2026-03-04-pm",
"印度ETF:drop_2": "2026-03-03-pm",
"印度ETF:rise_2": "2026-02-23-pm",
"COMEX黄金:ratio_min": "2026-03-03-pm",
"恒生指数:rise_2": "2026-02-24-am",
"COMEX黄金:rise_3": "2026-03-02-pm",
"创业板指:rise_2": "2026-02-24-am",
"恒生指数:drop_2": "2026-03-04-pm",
"日经ETF(HK):rise_2": "2026-03-02-am",
"黄金白银净值跟踪:黄金_premium_normal": "2026-03-04-pm",
"COMEX白银:白银价格过高": "2026-03-02-pm",
"北证50:drop_2": "2026-03-03-pm",
"北证50:drop_3": "2026-03-03-pm",
"日经ETF(HK):drop_2": "2026-03-03-pm",
"北证50:drop_4": "2026-03-03-pm",
"日经ETF(HK):drop_3": "2026-03-03-pm",
"中国神华-299法:trend_bull": "2026-03-03-am",
"德国ETF:drop_2": "2026-03-03-am",
"德国ETF:drop_3": "2026-03-03-pm",
"科创50:drop_2": "2026-03-03-am",
"科创50:drop_3": "2026-03-03-pm",
"中国银行-299法:above_ma20": "2026-03-04-am",
"科创50:drop_4": "2026-03-03-pm",
"深证成指:drop_2": "2026-03-03-pm",
"创业板指:drop_2": "2026-03-04-am",
"科创50:drop_5": "2026-03-04-am",
"深证成指:drop_3": "2026-03-04-am",
"COMEX黄金:drop_4": "2026-03-04-am",
"COMEX黄金:drop_5": "2026-03-03-pm",
"纳斯达克:drop_2": "2026-03-04-am",
"标普500:drop_2": "2026-03-04-am",
"恒生指数:drop_3": "2026-03-04-pm"
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "巴西IBOVESPA",
"card_order": 18,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usEWZ",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWZ",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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/Sao_Paulo",
"sessions": [
{
"start": "10:00",
"end": "17:30"
}
],
"display_label": "巴西IBOV"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "北证50",
"card_order": 6,
"card_type": "domestic",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": false,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "bj899050",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/bj899050",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "Asia/Kolkata",
"sessions": [
{
"start": "09:15",
"end": "15:30"
}
],
"display_label": "印度孟买"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.find(q => q.symbol === config.symbol)
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,90 @@
{
"name_comment": "显示的名",
"name": "巴菲特指",
"card_order_comment": "卡片排序序号,越大越靠后(当前最大",
"card_order": 88,
"card_type_comment": "卡片类型:domestic(国内)/ overseas(海外)/ enhanced(增强)/ defensive(防御)",
"card_type": "overseas",
"card_span_comment": "卡片横向占位列数",
"card_span": 2,
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "1h",
"symbol_comment": "监控的代码(技能内部定义)",
"symbol": "us_buffett_index",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://fred.stlouisfed.org/series/SP500",
"source_comment": "数据来源",
"source": "fred",
"alerts_comment": "预警阈值配置(基于S&P500/GDP比率,非Wilshire5000原值)",
"alerts": {
"ratio_high_warning_comment": "S&P500/GDP 比率高于此值触发估值偏高预",
"ratio_high_warning": 0.19,
"ratio_critical_comment": "S&P500/GDP 比率高于此值触发极端高估严重告",
"ratio_critical": 0.21,
"change_percent_down_comment": "单日跌幅告警阈值(比率变化百分比)",
"change_percent_down": -5,
"change_percent_up_comment": "单日涨幅告警阈值(比率变化百分比)",
"change_percent_up": 5,
"urgent_abs_change_percent_comment": "严重告警阈值(绝对波动百分比,日变动超过此值触发紧急推送)",
"urgent_abs_change_percent": 8,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 240
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": false,
"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": false,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": false,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": false,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": false
}
],
"market_status_comment": "市场时区与交易时段定义,用于前端显示开休市状",
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "00:00",
"end": "23:59"
}
],
"display_label": "美股宏观"
}
}
@@ -0,0 +1,270 @@
const { fetchFredCurl } = require('../fred_curl')
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 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 fmt = (v, d = 2) => Number.isFinite(v) ? v.toFixed(d) : '—'
const trendArrow = (delta, threshold = 0.001) => {
const d = toNum(delta)
if (!Number.isFinite(d)) return '→'
if (Math.abs(d) < threshold) return '→'
return d > 0 ? '↑' : '↓'
}
const getValuationZone = ratio => {
if (!Number.isFinite(ratio)) return { zone: '未知', emoji: '⚪', color: '#8c8c8c', level: 'unknown' }
if (ratio < 0.10) return { zone: '显著低估', emoji: '🟢', color: '#15803d', level: 'low' }
if (ratio < 0.13) return { zone: '合理偏低', emoji: '🟢', color: '#16a34a', level: 'moderate_low' }
if (ratio < 0.16) return { zone: '合理估值', emoji: '🟡', color: '#ca8a04', level: 'fair' }
if (ratio < 0.19) return { zone: '偏高', emoji: '🟠', color: '#ea580c', level: 'elevated' }
if (ratio < 0.21) return { zone: '显著高估', emoji: '🔴', color: '#dc2626', level: 'high' }
return { zone: '极端高估', emoji: '🔴', color: '#b91c1c', level: 'extreme' }
}
const calcRatioSeries = (sp500Series, gdpSeries) => {
const out = []
if (!Array.isArray(sp500Series) || !sp500Series.length) return out
if (!Array.isArray(gdpSeries) || !gdpSeries.length) return out
const sp500Map = new Map()
for (const w of sp500Series) {
sp500Map.set(w.date, w.value)
}
const sp500Dates = Array.from(sp500Map.keys()).sort()
for (const g of gdpSeries) {
let bestDate = ''
for (const d of sp500Dates) {
if (d <= g.date) bestDate = d
else break
}
if (!bestDate) continue
const sp500Val = sp500Map.get(bestDate)
if (!Number.isFinite(sp500Val) || sp500Val <= 0) continue
const gdpVal = toNum(g.value)
if (!Number.isFinite(gdpVal) || gdpVal <= 0) continue
out.push({
date: g.date,
ratio: round(sp500Val / gdpVal, 4),
sp500: sp500Val,
gdp: gdpVal,
sp500Date: bestDate
})
}
return out
}
const getRatioPercentile = (ratioSeries, currentRatio) => {
if (!Array.isArray(ratioSeries) || ratioSeries.length < 4) return 50
const ratios = ratioSeries.map(r => r.ratio).filter(Number.isFinite).sort((a, b) => a - b)
let below = 0
for (const r of ratios) {
if (r <= currentRatio) below++
}
return round((below / ratios.length) * 100, 1)
}
const buildAlerts = (currentRatio, displayPrice, zoneLevel, config) => {
const alerts = []
const cfg = config && config.alerts ? config.alerts : {}
const ratioHighWarning = toNum(cfg.ratio_high_warning) || 0.19
const ratioCritical = toNum(cfg.ratio_critical) || 0.21
const displayWarning = round(ratioHighWarning * 100, 2)
const displayCritical = round(ratioCritical * 100, 2)
if (Number.isFinite(currentRatio) && currentRatio >= ratioCritical) {
alerts.push({
id: 'ratio_critical',
title: `${config.name}极端高估预警`,
message: `当前 S&P500/GDP 估值比率 ${fmt(displayPrice, 2)}(原始 ${fmt(currentRatio, 4)}),超过严重阈值 ${displayCritical},市场估值处于极端高位`,
level: 'critical'
})
} else if (Number.isFinite(currentRatio) && currentRatio >= ratioHighWarning) {
alerts.push({
id: 'ratio_warning',
title: `${config.name}估值偏高预警`,
message: `当前 S&P500/GDP 估值比率 ${fmt(displayPrice, 2)}(原始 ${fmt(currentRatio, 4)}),超过警告阈值 ${displayWarning},市场估值偏高`,
level: 'warning'
})
}
if (zoneLevel === 'extreme') {
const hasCritical = alerts.some(a => a.id === 'ratio_critical')
if (!hasCritical) {
alerts.push({
id: 'zone_extreme',
title: `${config.name}进入极端估值区间`,
message: `S&P500/GDP 估值比率 ${fmt(displayPrice, 2)} 进入极端高估区间,建议关注回调风险`,
level: 'critical'
})
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { logJSON } = context
try {
const [sp500Series, gdpSeries] = await Promise.all([
fetchFredSeries('SP500'),
fetchFredSeries('GDP')
])
if (!sp500Series.length) {
logJSON('skill.buffett.fetch.empty', { sp500: sp500Series.length, gdp: gdpSeries.length }, 'market_skills')
return null
}
if (!gdpSeries.length) {
logJSON('skill.buffett.fetch.empty', { sp500: sp500Series.length, gdp: gdpSeries.length }, 'market_skills')
return null
}
const ratioSeries = calcRatioSeries(sp500Series, gdpSeries)
if (ratioSeries.length < 2) {
logJSON('skill.buffett.ratio.empty', { points: ratioSeries.length }, 'market_skills')
return null
}
const latestSp500 = tail(sp500Series, 1)[0]
const prevSp500 = sp500Series.length > 1 ? sp500Series[sp500Series.length - 2] : null
const latestGdp = tail(gdpSeries, 1)[0]
const currentRatio = round(latestSp500.value / latestGdp.value, 4)
const prevRatio = prevSp500
? round(prevSp500.value / latestGdp.value, 4)
: currentRatio
const dailyChange = round(currentRatio - prevRatio, 4)
const dailyChangePct = prevRatio ? round((dailyChange / prevRatio) * 100, 2) : 0
const latestRatioPoint = tail(ratioSeries, 1)[0]
const prevRatioPoint = ratioSeries.length > 1 ? ratioSeries[ratioSeries.length - 2] : null
const qoqChange = prevRatioPoint
? round(latestRatioPoint.ratio - prevRatioPoint.ratio, 4)
: NaN
const qoqArrow = trendArrow(qoqChange, 0.001)
const percentile = getRatioPercentile(ratioSeries, currentRatio)
const zone = getValuationZone(currentRatio)
const historyMin = Math.min(...ratioSeries.map(r => r.ratio))
const historyMax = Math.max(...ratioSeries.map(r => r.ratio))
const capturedAt = new Date().toISOString().replace('T', ' ').slice(0, 19)
const displayPrice = round(currentRatio * 100, 2)
const displayPrev = round(prevRatio * 100, 2)
const displayChange = round(displayPrice - displayPrev, 2)
const html = `
<div style="font-size:16px;font-weight:700;line-height:1.7">
${zone.emoji} ${zone.zone}(历史 ${fmt(percentile, 0)}% 分位)
</div>
<div style="font-size:14px;color:#1f1f1f;line-height:1.7;margin-top:4px">
估值比率:<span style="font-weight:700;color:${zone.color}">${fmt(displayPrice, 2)}</span>
<span style="font-size:13px;color:#595959;margin-left:4px">S&P500/GDP×100</span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px;margin-top:8px">
<div style="border:1px solid rgba(0,0,0,0.08);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">
S&P 500:${fmt(latestSp500.value, 2)}(${latestSp500.date})<br/>
GDP:${fmt(latestGdp.value, 2)} 十亿美元(${latestGdp.date})<br/>
日变动:${displayChange >= 0 ? '+' : ''}${fmt(displayChange, 2)}(${dailyChangePct >= 0 ? '+' : ''}${fmt(dailyChangePct, 2)}%)
</div>
</div>
<div style="border:1px solid rgba(0,0,0,0.08);border-radius:10px;padding:10px 12px;background:rgba(255,255,255,0.78)">
<div style="font-size:13px;font-weight:700;color:#262626">📈 区间参考(×100)</div>
<div style="font-size:13px;color:#262626;line-height:1.7;margin-top:4px">
最低:${fmt(historyMin * 100, 2)} | 最高:${fmt(historyMax * 100, 2)}<br/>
当前分位:${fmt(percentile, 0)}% | 较上季 ${qoqArrow}<br/>
低估<10|合理偏低13|合理16|偏高19|高估21
</div>
</div>
</div>
<div style="font-size:13px;color:#595959;line-height:1.7;margin-top:8px">
数据时间:${capturedAt}(FRED)| 基于S&P500与GDP比率×100,作为巴菲特指数代理
</div>
`
const explain = [
`巴菲特指数代理(S&P500/GDP×100):${fmt(displayPrice, 2)},处于${zone.zone}区间。`,
`历史${fmt(percentile, 0)}%分位,区间[${fmt(historyMin * 100, 2)}, ${fmt(historyMax * 100, 2)}]。`,
`S&P 500 指数 ${fmt(latestSp500.value, 2)},GDP ${fmt(latestGdp.value, 2)} 十亿美元。`,
`较上季度${Number.isFinite(qoqChange) ? (qoqChange > 0 ? '上升' : '下降') + fmt(Math.abs(qoqChange), 4) : '无变化'}。`,
`估值区间(×100):<10显著低估|10-13合理偏低|13-16合理|16-19偏高|19-21显著高估|>21极端高估。`,
`注:原巴菲特指数用Wilshire5000,FRED 2024年6月已下架该数据,现用S&P500/GDP×100代理。`
].join(' ')
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source || 'fred',
name: `${zone.emoji} ${zone.zone}`,
price: displayPrice,
prevClose: displayPrev,
change: displayChange,
change_percent: dailyChangePct,
captured_at: capturedAt,
render_type: 'html',
html,
explain,
market_status: config.market_status,
buffett_ratio: currentRatio,
buffett_display: displayPrice,
buffett_zone: zone.zone,
buffett_zone_emoji: zone.emoji,
buffett_percentile: percentile,
sp500_value: latestSp500.value,
sp500_date: latestSp500.date,
gdp_value: latestGdp.value,
gdp_date: latestGdp.date,
history_min: historyMin,
history_max: historyMax,
qoq_change: qoqChange
}]
const alerts = buildAlerts(currentRatio, displayPrice, zone.level, config)
return { data, alerts }
} catch (e) {
logJSON('skill.buffett.run.error', { error: String(e.message || e) }, 'market_skills')
return null
}
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "中韩半导体指",
"card_order": 19,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sh000688",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000688",
"source_comment": "数据来源",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "Asia/Seoul",
"sessions": [
{
"start": "09:00",
"end": "15:30"
}
],
"display_label": "中韩半导"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "创业板指",
"card_order": 5,
"card_type": "domestic",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sz399006",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sz399006",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "创业"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.find(q => q.symbol === config.symbol)
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,92 @@
{
"name_comment": "显示的名",
"name": "COMEX黄金",
"card_order": 15,
"card_type": "defensive",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "hf_GC",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/globalfuture/GC00Y.html",
"silver_symbol_comment": "白银代码 (用于计算金银",
"silver_symbol": "hf_SI",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"price_min_comment": "最低价格预",
"price_min": 4800,
"price_max_comment": "最高价格预",
"price_max": 5500,
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 3,
"gold_silver_ratio_min_comment": "金银比最低预",
"gold_silver_ratio_min": 60,
"gold_silver_ratio_max_comment": "金银比最高预",
"gold_silver_ratio_max": 65
},
"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": "18:00",
"end": "17:00"
}
],
"display_label": "纽约"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,160 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const ALERT_COOLDOWN = 30 * 60 * 1000 // 30 minutes cooldown for same alert
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
if (symbol.startsWith('hf_')) {
// Futures format
const name = parts[13] || symbol
const price = parseFloat(parts[0] || '0')
const prevClose = parseFloat(parts[7] || '0')
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
const captured_at = (parts[12] && parts[6]) ? (parts[12] + ' ' + parts[6]) : ''
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
return null
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (gold, silver, config) => {
const alerts = []
const now = Date.now()
// Helper to add alert with slot-based cooldown (AM/PM logic like global markets)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Price Alerts
if (config.alerts.price_min && gold.price < config.alerts.price_min) {
addAlert('price_min', '黄金价格过低', `当前价格 ${gold.price} 低于预警值 ${config.alerts.price_min}`)
}
if (config.alerts.price_max && gold.price > config.alerts.price_max) {
addAlert('price_max', '黄金价格过高', `当前价格 ${gold.price} 高于预警值 ${config.alerts.price_max}`)
}
// 2. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && gold.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(gold.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, '黄金跌幅预警', `当前跌幅 ${gold.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 3. Rise Alerts (Integer steps: 3%, 4%...)
if (config.alerts.change_percent_up && gold.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(gold.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, '黄金涨幅预警', `当前涨幅 ${gold.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
// 4. Gold/Silver Ratio
if (silver && silver.price > 0) {
const ratio = gold.price / silver.price
if (config.alerts.gold_silver_ratio_min && ratio < config.alerts.gold_silver_ratio_min) {
addAlert('ratio_min', '金银比过低', `当前金银比 ${ratio.toFixed(2)} 低于 ${config.alerts.gold_silver_ratio_min} (黄金:${gold.price}, 白银:${silver.price}) (金银比 越高 :黄金偏强、白银偏弱,倾向 做多白银 / 做空黄金 。 金银比 越低 :白银偏强、黄金偏弱,倾向 观望或等待回落再布局)`)
}
if (config.alerts.gold_silver_ratio_max && ratio > config.alerts.gold_silver_ratio_max) {
addAlert('ratio_max', '金银比过高', `当前金银比 ${ratio.toFixed(2)} 高于 ${config.alerts.gold_silver_ratio_max} (黄金:${gold.price}, 白银:${silver.price}) (金银比 越高 :黄金偏强、白银偏弱,倾向 做多白银 / 做空黄金 。 金银比 越低 :白银偏强、黄金偏弱,倾向 观望或等待回落再布局)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbols = [config.symbol]
if (config.silver_symbol) symbols.push(config.silver_symbol)
const quotes = await fetchTencent(axios, symbols)
const gold = quotes.find(q => q.symbol === config.symbol)
const silver = quotes.find(q => q.symbol === config.silver_symbol)
if (!gold) {
logJSON('skill.gold.fetch.error', { message: 'Gold data not found' }, 'market_skills')
return null
}
// Prepare data for DB
// We only explicitly return Gold data to avoid duplication with Silver skill
// and to ensure the URL in config applies correctly to the main symbol
const data = quotes
.filter(q => q.symbol === config.symbol)
.map(q => ({
label: config.name,
symbol: q.symbol,
source: config.source,
name: q.name,
price: q.price,
prevClose: q.prevClose,
change: q.change,
change_percent: q.change_percent,
captured_at: q.captured_at,
market_status: config.market_status
}))
// Check alerts
const alerts = checkAlerts(gold, silver, config)
return { data, alerts }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "COMEX白银",
"card_order": 15,
"card_type": "defensive",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "hf_SI",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/globalfuture/SI00Y.html",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"price_min_comment": "最低价格预",
"price_min": 75,
"price_max_comment": "最高价格预",
"price_max": 95,
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 3
},
"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": "18:00",
"end": "17:00"
}
],
"display_label": "纽约"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,143 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const ALERT_COOLDOWN = 30 * 60 * 1000 // 30 minutes cooldown for same alert
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
if (symbol.startsWith('hf_')) {
// Futures format
const name = parts[13] || symbol
const price = parseFloat(parts[0] || '0')
const prevClose = parseFloat(parts[7] || '0')
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
const captured_at = (parts[12] && parts[6]) ? (parts[12] + ' ' + parts[6]) : ''
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
return null
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (silver, config) => {
const alerts = []
const now = Date.now()
// Helper to add alert with slot-based cooldown (AM/PM logic like global markets)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Price Alerts
if (config.alerts.price_min && silver.price < config.alerts.price_min) {
addAlert('price_min', '白银价格过低', `当前价格 ${silver.price} 低于预警值 ${config.alerts.price_min}`)
}
if (config.alerts.price_max && silver.price > config.alerts.price_max) {
addAlert('price_max', '白银价格过高', `当前价格 ${silver.price} 高于预警值 ${config.alerts.price_max}`)
}
// 2. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && silver.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(silver.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, '白银跌幅预警', `当前跌幅 ${silver.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 3. Rise Alerts (Integer steps: 3%, 4%...)
if (config.alerts.change_percent_up && silver.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(silver.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, '白银涨幅预警', `当前涨幅 ${silver.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbols = [config.symbol]
const quotes = await fetchTencent(axios, symbols)
const silver = quotes.find(q => q.symbol === config.symbol)
if (!silver) {
logJSON('skill.silver.fetch.error', { message: 'Silver data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = quotes.map(q => ({
label: q.symbol === config.symbol ? config.name : (q.name || q.symbol),
symbol: q.symbol,
source: config.source,
name: q.name,
price: q.price,
prevClose: q.prevClose,
change: q.change,
change_percent: q.change_percent,
captured_at: q.captured_at,
market_status: config.market_status
}))
// Check alerts
const alerts = checkAlerts(silver, config)
return { data, alerts }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "沪深300",
"card_order": 3,
"card_type": "domestic",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sh000300",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000300",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "沪深300"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.find(q => q.symbol === config.symbol)
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,21 @@
{
"enabled": true,
"display_comment": "是否显示该卡片 (false 则不拉取数据也不封存)",
"display": true,
"schedule": "1h",
"symbol": "sh000300_ma",
"source_symbol": "sh000300",
"name": "沪深300增强版",
"card_order": 7,
"card_type": "enhanced",
"source": "tencent",
"email_reminder_enabled_comment": "是否发送邮件提醒",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"alerts": {
"ma_cross": true,
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes": 120
}
}
+260
View File
@@ -0,0 +1,260 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
// Helper to calculate Simple Moving Average
const calculateMA = (data, period) => {
if (!data || data.length < period) return null
const subset = data.slice(-period)
const sum = subset.reduce((a, b) => a + b, 0)
return sum / period
}
// Helper to fetch K-line data from Tencent
const fetchKLine = async (axios, symbol) => {
// format: sh000300
// url: http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=sh000300,day,,,300,qfq
// fetch 300 days to calculate MA250
const url = `http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${symbol},day,,,300,qfq`
try {
const res = await axios.get(url, { timeout: 10000 })
if (!res || !res.data || res.data.code !== 0) return null
// Response structure:
// { code: 0, msg: "", data: { [symbol]: { day: [ ["2023-01-01", "3800.00", "3850.00", ...], ... ], qfqday: [...] } } }
// If qfq (adjusted) exists, prefer it. Usually in `qfqday`.
const d = res.data.data[symbol]
if (!d) return null
// Use qfqday if available (adjusted prices), else day
const rawData = d.qfqday || d.day
if (!rawData || !Array.isArray(rawData)) return null
// Extract closing prices (index 2 in Tencent array: [date, open, close, high, low, vol])
// Verify index:
// Usually: [date, open, close, high, low, volume]
// Example: ["2023-01-01", "10.00", "10.50", "10.60", "9.90", "1000"]
// Close is index 2.
return rawData.map(item => ({
date: item[0],
close: parseFloat(item[2])
}))
} catch (e) {
console.error('Fetch KLine Error:', e.message)
return null
}
}
const checkAlerts = (ma120, ma250, prevMA120, prevMA250, dateStr) => {
const alerts = []
// Helper to add alert with slot-based cooldown
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// Logic:
// 1. Cross Down (Bearish): 120 跌破 250
// Condition: Prev(MA120 >= MA250) AND Curr(MA120 < MA250)
// Or simply: MA120 < MA250
// User wants alert when it happens.
// "如果120 跌破 250。发出警告...请减仓/卖出"
// To avoid noise, we check if the state *changed* from yesterday (or previous calculation).
// But K-line data includes "today" (if market open).
// So index N is today, N-1 is yesterday.
const isCrossDown = (prevMA120 >= prevMA250) && (ma120 < ma250)
const isCrossUp = (prevMA120 <= prevMA250) && (ma120 > ma250)
const vals = `(MA120: ${ma120.toFixed(2)}, MA250: ${ma250.toFixed(2)})`
// 1. Cross Down (Bearish): 120 跌破 250
if (isCrossDown) {
addAlert('cross_down', '120日线跌破250日线', `120 跌破 250。今天的两个数值分别是 ${vals},请减仓/卖出`)
}
// 2. Cross Up (Bullish): 120 上穿 250
if (isCrossUp) {
addAlert('cross_up', '120日线上穿250日线', `120 重新上穿 250。今天的两个数值分别是 ${vals},请重新买回`)
}
// 3. Convergence/Divergence Logic (Gap Analysis)
// Calculate percentage difference relative to MA250
const diffPercent = ((ma120 - ma250) / ma250) * 100
const absDiff = Math.abs(diffPercent)
// Thresholds
const CONVERGENCE_THRESHOLD = 1.0 // < 1% means very close (about to cross)
const DIVERGENCE_THRESHOLD = 3.0 // > 3% means trend confirmed
// (A) Approaching (Convergence) Alert - Warning Phase
// Trigger if gap is small (< 1%) AND wasn't already small yesterday (to avoid spamming every day in sticky market)
// OR simply remind if it enters this zone.
// Strategy: Alert if current gap < 1%.
// To avoid spam: relying on slot-based cooldown (once per AM/PM slot).
// Refinement: Only alert if it's "Approaching". i.e., Gap is shrinking?
// User said: "快要接近" -> Gap is small.
if (absDiff < CONVERGENCE_THRESHOLD) {
const direction = diffPercent > 0 ? '向下逼近' : '向上逼近'
addAlert('convergence', '均线高度粘合预警', `MA120 与 MA250 差距仅 ${absDiff.toFixed(2)}% (${direction}),即将面临方向选择,请密切关注! ${vals}`)
}
// (B) Diverging (Trend Confirmation) Alert - Safe Phase
// Trigger when gap widens beyond 3% AFTER being smaller.
// This implies we entered a trend.
// We check if *yesterday* was < 3% and *today* >= 3%.
const prevDiffPercent = ((prevMA120 - prevMA250) / prevMA250) * 100
const prevAbsDiff = Math.abs(prevDiffPercent)
if (absDiff >= DIVERGENCE_THRESHOLD && prevAbsDiff < DIVERGENCE_THRESHOLD) {
if (diffPercent > 0) {
// Bullish divergence
addAlert('divergence_bull', '多头趋势确认', `金叉后开口扩大 (>${DIVERGENCE_THRESHOLD}%),多头趋势稳固,建议坚定持有。 ${vals}`)
} else {
// Bearish divergence
addAlert('divergence_bear', '空头趋势确认', `死叉后开口扩大 (>${DIVERGENCE_THRESHOLD}%),下跌趋势加速,建议空仓观望。 ${vals}`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbol = config.symbol // sh000300_ma (for DB/Uniqueness)
const sourceSymbol = config.source_symbol || 'sh000300' // Real symbol for API
const klineData = await fetchKLine(axios, sourceSymbol)
if (!klineData || klineData.length < 250) {
logJSON('skill.csi300_ma.error', { message: 'Insufficient data', length: klineData ? klineData.length : 0 }, 'market_skills')
return null
}
// Calculate MAs
// We need MA for today (latest) and yesterday (latest - 1)
const closes = klineData.map(d => d.close)
// Today
const ma120 = calculateMA(closes, 120)
const ma250 = calculateMA(closes, 250)
// Yesterday (remove last element)
const closesPrev = closes.slice(0, -1)
const prevMA120 = calculateMA(closesPrev, 120)
const prevMA250 = calculateMA(closesPrev, 250)
if (!ma120 || !ma250 || !prevMA120 || !prevMA250) {
logJSON('skill.csi300_ma.error', { message: 'MA calculation failed' }, 'market_skills')
return null
}
// Determine Status Text
let statusText = ''
if (ma120 > ma250) {
statusText = '继续定投、拿着'
} else if (ma120 < ma250) {
statusText = '减仓/卖出' // Or '空仓观望'
} else {
statusText = '均线粘合'
}
// Format for Card
// We want to show:
// Header: 沪深300增强版 · MA120: xxx | MA250: xxx (sh000300)
// Body: MA120 > MA250 :结论为“ 继续定投、拿着 ”
const latestClose = closes[closes.length - 1]
const prevClose = closes[closes.length - 2]
const change = latestClose - prevClose
const changePercent = (change / prevClose) * 100
const latestDate = klineData[klineData.length - 1].date
// Calculate Bias Ratio (乖离率)
// (Price - MA250) / MA250 * 100
const biasRatio = ((latestClose - ma250) / ma250) * 100
// Suggestion Logic
// If alerts exist, use the first one. Else, generic based on trend.
const alerts = checkAlerts(ma120, ma250, prevMA120, prevMA250, latestDate)
let suggestion = ''
if (alerts.length > 0) {
suggestion = alerts[0].message
} else {
// No active alert today, give general advice
if (ma120 > ma250) {
if (biasRatio > 10) suggestion = '乖离率过大 (>10%),注意短期回调风险'
else suggestion = '趋势向上,乖离率合理,可继续持有/定投'
} else {
if (biasRatio < -10) suggestion = '乖离率过大 (<-10%),可能存在超跌反弹'
else suggestion = '趋势向下,建议保持空仓或谨慎操作'
}
}
// Server-Driven UI (HTML Generation)
const isBull = ma120 > ma250
const conclusionColor = isBull ? '#f5222d' : '#52c41a'
const biasColor = biasRatio > 0 ? '#f5222d' : '#52c41a' // Red for positive bias (standard CN market color logic?) Or just neutral?
// Using standard CN colors: Red = Up/Bull, Green = Down/Bear
const htmlPayload = `
<div style="font-size: 14px; margin-top: 8px;">
MA120 ${isBull ? '>' : '<'} MA250 :结论为“ <span style="font-weight:bold; color:${conclusionColor}">${statusText}</span> ”
</div>
<div style="margin: 8px 0; border-top: 1px solid #eee; width: 100%;"></div>
<div style="font-size: 13px; color: #666; line-height: 1.5;">
乖离率:<span style="color:${biasColor}; font-weight:bold">${biasRatio.toFixed(2)}%</span> <br/>
提示:${suggestion}
</div>
`
const data = [{
label: config.name,
symbol: symbol,
displaySymbol: 'sh000300', // Show original symbol in UI
isMaCard: true,
render_type: 'html', // New SDUI flag
html: htmlPayload, // The payload
ma120: ma120.toFixed(2),
ma250: ma250.toFixed(2),
conclusion: statusText,
source: 'tencent',
name: statusText, // Fallback
price: latestClose,
prevClose: prevClose,
change: change,
change_percent: changePercent,
market_status: `${statusText} (${latestDate})`
}]
return { data, alerts }
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "道琼斯工业指",
"card_order": 13.5,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usDJI",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usDJI",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "09:30",
"end": "16:00"
}
],
"display_label": "道琼"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+121
View File
@@ -0,0 +1,121 @@
const iconv = require('iconv-lite')
const { fetchYahoo } = require('../../marketsClient')
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) parts = body.split(',')
const name = parts[1]
const price = parseFloat(parts[3])
const prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
const addAlert = (key, title, message, level = 'warning') => {
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
const slot = hour >= 0 && hour < 12 ? 'am' : 'pm'
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
let item = null
if (String(config.source || '').toLowerCase() === 'yahoo_probe') {
const quotes = await fetchYahoo([config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
} else {
const quotes = await fetchTencent(axios, [config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
}
// Fallback: try Tencent if Yahoo failed, or vice versa
if (!item) {
if (String(config.source || '').toLowerCase() === 'yahoo_probe') {
const fb = await fetchTencent(axios, [config.symbol])
item = fb.find(q => q.symbol === config.symbol)
} else {
const fb = await fetchYahoo([config.symbol])
item = fb.find(q => q.symbol === config.symbol)
}
}
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* FRED (Federal Reserve Economic Data) CSV fetcher via curl.exe
*
* Why curl.exe instead of axios:
* Node.js axios → FRED's Akamai CDN times out on SP500, DGS2, DGS10 series.
* curl.exe → same URL returns data in ~1s.
*
* Why date range params (cosd/coed):
* Full DGS2/DGS10 datasets (daily from 1962) are huge → Akamai times out.
* Limiting to recent years solves this. All calculations only need ≤3 years.
*
* Root cause: Akamai CDN differentiates by TLS fingerprint AND URL pattern.
*/
const { execFile } = require('child_process')
const FRED_BASE = 'https://fred.stlouisfed.org/graph/fredgraph.csv'
/**
* Fetch FRED CSV data using curl.exe with a date range limit
* @param {string} seriesId - FRED series ID (e.g. 'SP500', 'FEDFUNDS')
* @param {object} [opts]
* @param {number} [opts.timeout=20] - curl --max-time seconds
* @param {number} [opts.lookbackYears=3] - years to look back from today
* @param {string} [opts.coed] - custom observation end date YYYY-MM-DD (default today)
* @param {string} [opts.cosd] - custom observation start date YYYY-MM-DD (default coed - lookbackYears)
* @returns {Promise<string>} raw CSV text
*/
const fetchFredCurl = (seriesId, opts = {}) => {
const timeout = opts.timeout || 20
const lookbackYears = opts.lookbackYears || 3
const now = new Date()
const coed = opts.coed || now.toISOString().slice(0, 10)
const cosd = opts.cosd || (new Date(now.getTime() - lookbackYears * 365 * 24 * 60 * 60 * 1000)).toISOString().slice(0, 10)
const url = `${FRED_BASE}?id=${encodeURIComponent(seriesId)}&cosd=${cosd}&coed=${coed}`
return new Promise((resolve, reject) => {
execFile('curl.exe', ['-s', '--max-time', String(timeout), url], {
encoding: 'utf8',
timeout: (timeout + 10) * 1000,
maxBuffer: 10 * 1024 * 1024,
windowsHide: true
}, (err, stdout, stderr) => {
if (err) {
const msg = stderr ? stderr.trim() : err.message
return reject(new Error(`curl failed for ${seriesId}: ${msg}`))
}
resolve(stdout)
})
})
}
module.exports = { fetchFredCurl }
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "德国DAX30",
"card_order": 10,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usEWG",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWG",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "Europe/Berlin",
"sessions": [
{
"start": "09:00",
"end": "17:30"
}
],
"display_label": "德国DAX"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,208 @@
const iconv = require('iconv-lite')
const { fetchYahoo } = require('../../marketsClient')
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
const parts = body.split('~')
if (parts.length < 3 && body.includes(',')) return null
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencentQuote = async (axios, symbol) => {
const url = `http://qt.gtimg.cn/q=${symbol}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return null
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
for (const line of lines) {
const r = parseTencentLine(line)
if (r) return { ...r, source: 'tencent' }
}
return null
} catch (e) {
return null
}
}
const fetchEastmoneyQuote = async (axios, url, symbol, nameFallback) => {
const secidMap = {
zsn225: '100.N225',
zsgdaxi: '100.GDAXI',
usnfty: '105.NFTY',
usflsa: '107.FLSA',
zsbvsp: '100.BVSP',
zh931790: '2.931790',
zssti: '100.STI',
zsks11: '100.KS11',
zskospi: '100.KS11',
zstwii: '100.TWII'
}
let secid = secidMap[String(symbol || '').toLowerCase()] || null
if (!secid && url) {
let html = ''
try {
const res = await axios.get(url, {
timeout: 10000,
validateStatus: () => true,
headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://quote.eastmoney.com/' }
})
if (!res || res.status >= 400) return null
html = String(res.data || '')
} catch (e) {
return null
}
let m = html.match(/"secid"\s*:\s*"([0-9]+\.[^"]+)"/) || html.match(/secid\s*=\s*["']([0-9]+\.[^"']+)["']/)
if (!secid) {
const m2 = html.match(/quoteId\s*[:=]\s*["']([0-9]+\.[^"']+)["']/i) || html.match(/securityId\s*[:=]\s*["']([0-9]+\.[^"']+)["']/i)
if (m2) secid = m2[1]
}
if (!secid) {
const mMarket = html.match(/"market"\s*:\s*(\d+)/) || html.match(/market\s*=\s*(\d+)/)
const mCode = html.match(/"code"\s*:\s*"([^"]+)"/) || html.match(/code\s*=\s*["']([^"']+)["']/)
if (mMarket && mCode) secid = `${mMarket[1]}.${mCode[1]}`
}
}
if (!secid) return null
try {
const api = `https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=${encodeURIComponent(secid)}&fields1=f1,f2,f3&fields2=f51,f52,f53,f54,f55,f56,f57,f58&klt=101&fqt=0&end=20500101&lmt=2`
const resp = await axios.get(api, {
timeout: 10000,
validateStatus: () => true,
headers: { 'User-Agent': 'Mozilla/5.0', 'Referer': 'https://quote.eastmoney.com/' }
})
const data = resp && resp.data && resp.data.data
const klines = data && Array.isArray(data.klines) ? data.klines : []
if (!klines.length) return null
const parseLine = (line) => String(line || '').split(',')
const last = parseLine(klines[klines.length - 1])
const prev = klines.length > 1 ? parseLine(klines[klines.length - 2]) : null
const price = Number(last[2])
const prevClose = prev ? Number(prev[2]) : NaN
if (!Number.isFinite(price) || price <= 0) return null
const hasPrev = Number.isFinite(prevClose) && prevClose > 0
const change = hasPrev ? price - prevClose : 0
const change_percent = hasPrev ? (change / prevClose) * 100 : 0
const captured_at = new Date().toISOString().replace('T', ' ').slice(0, 19)
return {
symbol,
name: data.name || nameFallback || symbol,
price,
prevClose: hasPrev ? prevClose : price,
change,
change_percent,
captured_at,
source: 'eastmoney'
}
} catch (e) {
return null
}
}
const checkAlerts = (data, config) => {
const alerts = []
const addAlert = (key, title, message, level = 'warning') => {
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm'
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const source = String(config.source || '').toLowerCase()
let item = null
if (source === 'yahoo_probe') {
const quotes = await fetchYahoo([config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
} else if (source === 'tencent') {
item = await fetchTencentQuote(axios, config.symbol)
} else {
item = await fetchEastmoneyQuote(axios, config.url, config.symbol, config.name)
}
if (!item && source !== 'yahoo_probe' && String(config.card_type || '').toLowerCase() === 'overseas') {
const quotes = await fetchYahoo([config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
}
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,43 @@
{
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule": "5m",
"name": "黄金白银净值跟",
"card_order": 16,
"card_type": "defensive",
"gold_fund_code": "518880",
"silver_fund_code": "161226",
"gold_symbol": "sh518880",
"silver_symbol": "sz161226",
"source": "tencent",
"email": {
"enabled": true,
"from": "yangdafe@gmail.com",
"to": [
"yangxiangyuan@umer.com.cn"
],
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
"secure": true,
"auth": {
"user": "yangdafe@gmail.com",
"pass": "{{GMAIL_SMTP_PASS}}"
}
}
},
"email_reminder_enabled": true,
"calendar_reminder_enabled": true,
"push_targets": [
{
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled": true,
"key": ""
},
{
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled": true
}
]
}
@@ -0,0 +1,244 @@
const iconv = require('iconv-lite')
const toBJDateTime = () => {
const d = new Date()
const utc = d.getTime() + d.getTimezoneOffset() * 60000
const bj = new Date(utc + 8 * 3600000)
const y = bj.getFullYear()
const m = String(bj.getMonth() + 1).padStart(2, '0')
const day = String(bj.getDate()).padStart(2, '0')
const hh = String(bj.getHours()).padStart(2, '0')
const mm = String(bj.getMinutes()).padStart(2, '0')
const ss = String(bj.getSeconds()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
}
const fetchFundGz = async (axios, code) => {
const url = `http://fundgz.1234567.com.cn/js/${code}.js?rt=${Date.now()}`
try {
const res = await axios.get(url, { timeout: 10000, validateStatus: () => true, responseType: 'text' })
if (!res || res.status >= 400 || !res.data) return null
const m = String(res.data).match(/jsonpgz\((\{.*\})\)/)
if (!m) return null
const obj = JSON.parse(m[1])
const navStr = obj.gsz || obj.dwjz
const time = obj.gstime || obj.gztime || ''
const nav = parseFloat(navStr)
if (!Number.isFinite(nav)) return null
return { code, nav, time }
} catch { return null }
}
const fetchFundLastNav = async (axios, code) => {
const url = `https://api.fund.eastmoney.com/f10/lsjz?fundCode=${encodeURIComponent(code)}&pageIndex=1&pageSize=1`
try {
const res = await axios.get(url, {
timeout: 10000,
validateStatus: () => true,
headers: {
'User-Agent': 'Mozilla/5.0',
'Referer': 'https://fundf10.eastmoney.com/'
}
})
if (!res || res.status >= 400 || !res.data) return null
const payload = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
const list = payload && payload.Data && Array.isArray(payload.Data.LSJZList) ? payload.Data.LSJZList : []
if (list.length === 0) return null
const row = list[0] || {}
const nav = parseFloat(row.DWJZ)
if (!Number.isFinite(nav)) return null
const time = String(row.FSRQ || '')
return { code, nav, time }
} catch { return null }
}
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) parts = body.split(',')
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (!Number.isFinite(price) || !Number.isFinite(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const toEastmoneySecid = (symbol) => {
const s = String(symbol || '').toLowerCase()
if (s.startsWith('sh') && s.length > 2) return `1.${s.slice(2)}`
if (s.startsWith('sz') && s.length > 2) return `0.${s.slice(2)}`
return null
}
const scaleEastmoneyPrice = v => {
const n = Number(v)
if (!Number.isFinite(n) || n <= 0) return null
return n / 1000
}
const fetchEastmoneyQuote = async (axios, symbol) => {
const secid = toEastmoneySecid(symbol)
if (!secid) return null
const url = `https://push2.eastmoney.com/api/qt/stock/get?secid=${encodeURIComponent(secid)}&fields=f43,f57,f58,f60`
try {
const res = await axios.get(url, {
timeout: 10000,
validateStatus: () => true,
headers: {
'User-Agent': 'Mozilla/5.0',
'Referer': 'https://quote.eastmoney.com/'
}
})
if (!res || res.status >= 400 || !res.data || !res.data.data) return null
const d = res.data.data
const last = scaleEastmoneyPrice(d.f43)
const prev = scaleEastmoneyPrice(d.f60)
const price = Number.isFinite(last) ? last : prev
if (!Number.isFinite(price)) return null
return {
symbol,
name: String(d.f58 || symbol),
price,
prevClose: Number.isFinite(prev) ? prev : price,
change: Number.isFinite(prev) ? (price - prev) : 0,
change_percent: Number.isFinite(prev) && prev > 0 ? ((price - prev) / prev) * 100 : 0,
captured_at: toBJDateTime()
}
} catch { return null }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push(r)
}
return out
} catch { return [] }
}
const pct = (price, nav) => {
if (!Number.isFinite(price) || !Number.isFinite(nav) || nav === 0) return null
return ((price - nav) / nav) * 100
}
const tip = v => {
if (!Number.isFinite(v)) return '—'
if (Math.abs(v) <= 10) return '正常、安全、可交易区间'
if (v > 50) return '极端泡沫、必须警告'
if (v < -10) return '折价机会,可抄底信号'
return '—'
}
const buildPremiumAlerts = (label, premium) => {
if (!Number.isFinite(premium)) return []
const fmt = premium.toFixed(2)
if (premium > 50) {
return [{
id: `${label}_premium_bubble`,
title: `${label}溢价率预警`,
message: `当前溢价率 ${fmt}% (> 50%),极端泡沫、必须警告`,
level: 'critical'
}]
}
if (premium < -10) {
return [{
id: `${label}_premium_discount`,
title: `${label}溢价率预警`,
message: `当前溢价率 ${fmt}% (< -10%),折价机会,可抄底信号`,
level: 'warning'
}]
}
return []
}
module.exports = {
run: async (context, config) => {
const { axios } = context
const goldFund = String(config.gold_fund_code || '518880')
const silverFund = String(config.silver_fund_code || '161226')
const goldSymbol = String(config.gold_symbol || 'sh518880')
const silverSymbol = String(config.silver_symbol || 'sz161226')
const [goldGzRaw, silverGzRaw] = await Promise.all([
fetchFundGz(axios, goldFund),
fetchFundGz(axios, silverFund)
])
const [goldNavFallback, silverNavFallback] = await Promise.all([
goldGzRaw ? Promise.resolve(null) : fetchFundLastNav(axios, goldFund),
silverGzRaw ? Promise.resolve(null) : fetchFundLastNav(axios, silverFund)
])
const goldGz = goldGzRaw || goldNavFallback
const silverGz = silverGzRaw || silverNavFallback
const quotes = await fetchTencent(axios, [goldSymbol, silverSymbol])
let goldQ = quotes.find(q => q.symbol === goldSymbol)
let silverQ = quotes.find(q => q.symbol === silverSymbol)
const [goldQuoteFallback, silverQuoteFallback] = await Promise.all([
(goldQ && Number.isFinite(goldQ.price)) ? Promise.resolve(null) : fetchEastmoneyQuote(axios, goldSymbol),
(silverQ && Number.isFinite(silverQ.price)) ? Promise.resolve(null) : fetchEastmoneyQuote(axios, silverSymbol)
])
if ((!goldQ || !Number.isFinite(goldQ.price)) && goldQuoteFallback) goldQ = goldQuoteFallback
if ((!silverQ || !Number.isFinite(silverQ.price)) && silverQuoteFallback) silverQ = silverQuoteFallback
const goldNav = goldGz ? goldGz.nav : null
const silverNav = silverGz ? silverGz.nav : null
const goldPrice = goldQ ? goldQ.price : null
const silverPrice = silverQ ? silverQ.price : null
const goldPremium = pct(goldPrice, goldNav)
const silverPremium = pct(silverPrice, silverNav)
const captured_at = toBJDateTime()
const data = [{
label: config.name || '黄金白银净值跟踪',
symbol: 'skill_gold_silver_nav',
source: 'fundgz+tencent+eastmoney_fallback',
name: 'ETF/LOF',
displaySymbol: `${goldFund}/${silverFund}`,
price: Number.isFinite(goldPrice) ? goldPrice : null,
prevClose: null,
change: null,
change_percent: null,
render_type: 'gold_silver_nav',
gold_nav: goldNav,
gold_price: goldPrice,
gold_premium: goldPremium,
gold_tip: tip(goldPremium),
silver_nav: silverNav,
silver_price: silverPrice,
silver_premium: silverPremium,
silver_tip: tip(silverPremium),
url: `https://fund.eastmoney.com/${goldFund}.html`,
captured_at,
market_status: config.market_status || null
}]
const alerts = [
...buildPremiumAlerts('黄金', goldPremium),
...buildPremiumAlerts('白银', silverPremium)
]
return { data, alerts }
}
}
+86
View File
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "恒生指数",
"card_order": 8,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "hkHSI",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/hkHSI",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "Asia/Hong_Kong",
"sessions": [
{
"start": "09:30",
"end": "12:00"
},
{
"start": "13:00",
"end": "16:00"
}
],
"display_label": "恒生"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.find(q => q.symbol === config.symbol)
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "印度NFTY(",
"card_order": 11,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usNFTY",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usNFTY",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "Asia/Kolkata",
"sessions": [
{
"start": "09:15",
"end": "15:30"
}
],
"display_label": "印度"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "韩国综合指数",
"card_order": 86,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usEWY",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWY",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "Asia/Seoul",
"sessions": [
{
"start": "09:00",
"end": "15:30"
}
],
"display_label": "韩国"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -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
}
}
}
+879
View File
@@ -0,0 +1,879 @@
const fs = require('fs')
const path = require('path')
const axios = require('axios')
const nodemailer = require('nodemailer')
const { logJSON } = require('../logger')
const { db } = require('../db')
const securityCalendar = require('../security_calendar')
const SKILLS_DIR = __dirname
const HISTORY_FILE = path.join(SKILLS_DIR, 'alert_history.json')
// 从 Toolbox_local_creds.env.local 加载本地凭证,用于解析 {{VAR}} 占位符
const localCreds = (() => {
try {
const userProfile = process.env.USERPROFILE || process.env.HOME || ''
const p = path.join(userProfile, 'Toolbox_local_creds.env.local')
if (!fs.existsSync(p)) return {}
const content = fs.readFileSync(p, 'utf8')
const creds = {}
content.split(/\r?\n/).forEach(line => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) return
const idx = trimmed.indexOf('=')
if (idx > 0) creds[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim()
})
return creds
} catch { return {} }
})()
// 将配置中的 {{VAR}} 占位符替换为实际值
const resolvePlaceholders = (val) => {
if (typeof val !== 'string') return val
return val.replace(/\{\{(.+?)\}\}/g, (_, name) => localCreds[name] || process.env[name] || `{{${name}}}`)
}
const skills = new Map()
const lastDispatchSignature = new Map()
const retryQueue = []
let retryTimer = null
const TITLE_INLINE_HAN_LIMIT = 50
const TITLE_POLICY_MARKER = 'FULL_TITLE_50CN_V1'
const chinaTradingCalendarCache = new Map()
const getAlertSlot = db.prepare(
`SELECT slot_key FROM alert_history WHERE skill = ? AND alert_id = ?`
)
const upsertAlertSlot = db.prepare(
`INSERT INTO alert_history (skill, alert_id, slot_key, updated_at)
VALUES (@skill, @alert_id, @slot_key, datetime('now'))
ON CONFLICT(skill, alert_id) DO UPDATE SET
slot_key = excluded.slot_key,
updated_at = datetime('now')`
)
const getMarketState = db.prepare(`SELECT value FROM market_state WHERE key=?`)
const upsertMarketState = db.prepare(
`INSERT INTO market_state (key, value, updated_at)
VALUES (@key, @value, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`
)
const insertEmailLog = db.prepare(
`INSERT OR IGNORE INTO alert_email_log (skill, alert_id, subject, status, error_message, created_at)
VALUES (@skill, @alert_id, @subject, @status, @error_message, datetime('now'))`
)
const updateEmailLogStatus = db.prepare(
`UPDATE alert_email_log SET status = @status, error_message = @error_message
WHERE id = @id`
)
const migrateHistoryFromFile = () => {
if (!fs.existsSync(HISTORY_FILE)) return
let data = null
try {
data = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'))
} catch (e) {
logJSON('skills.history.error', { error: String(e.message) }, 'market_skills')
return
}
const entries = Object.entries(data || {})
if (entries.length === 0) return
const tx = db.transaction(list => {
for (const [key, slot] of list) {
if (!slot) continue
const idx = key.indexOf(':')
if (idx <= 0) continue
const skill = key.slice(0, idx)
const alert_id = key.slice(idx + 1)
if (!skill || !alert_id) continue
upsertAlertSlot.run({ skill, alert_id, slot_key: String(slot) })
}
})
tx(entries)
}
migrateHistoryFromFile()
const DEFAULT_POLICY = {
urgentAbsChangePercent: 5,
urgentCooldownMinutes: 120,
nonChinaDailyCap: 3,
nonChinaPerSkillDailyCap: 1,
chinaAmReminderTime: '11:25',
chinaPmReminderTime: '14:55',
dailySummaryTime: '21:30'
}
const readStateJson = (key, fallback) => {
try {
const row = getMarketState.get(key)
if (!row || typeof row.value !== 'string' || !row.value) return fallback
return JSON.parse(row.value)
} catch {
return fallback
}
}
const writeStateJson = (key, value) => {
try {
upsertMarketState.run({ key, value: JSON.stringify(value || {}) })
} catch (e) {
logJSON('skills.state.write.error', { key, error: String(e.message || e) }, 'market_skills')
}
}
const toBJDate = (d = new Date()) => {
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
return new Date(utc + (3600000 * 8))
}
const pad2 = n => String(n).padStart(2, '0')
const bjParts = (d = new Date()) => {
const bj = toBJDate(d)
const year = bj.getFullYear()
const month = pad2(bj.getMonth() + 1)
const day = pad2(bj.getDate())
const hour = bj.getHours()
const minute = bj.getMinutes()
const second = bj.getSeconds()
return {
year,
month,
day,
hour,
minute,
second,
dateStr: `${year}-${month}-${day}`,
timeStr: `${pad2(hour)}:${pad2(minute)}:${pad2(second)}`
}
}
const bjNowDateTime = () => {
const p = bjParts()
return `${p.dateStr} ${p.timeStr}`
}
const parseHHMM = hhmm => {
const s = String(hhmm || '').trim()
const m = s.match(/^(\d{1,2}):(\d{2})$/)
if (!m) return { hour: 0, minute: 0 }
return { hour: Math.max(0, Math.min(23, parseInt(m[1], 10))), minute: Math.max(0, Math.min(59, parseInt(m[2], 10))) }
}
const localTs = (dateStr, hhmm) => `${dateStr} ${hhmm}`
const nextDateStr = (dateStr) => {
const d = new Date(`${dateStr}T00:00:00.000Z`)
d.setUTCDate(d.getUTCDate() + 1)
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
}
const isChinaTradingDate = async dateStr => {
const safeDate = String(dateStr || '').trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(safeDate)) return true
const monthKey = safeDate.slice(0, 7)
if (!chinaTradingCalendarCache.has(monthKey)) {
try {
const payload = await securityCalendar.getMonthCalendar(monthKey, { force_refresh: false })
chinaTradingCalendarCache.set(monthKey, payload)
} catch {
chinaTradingCalendarCache.set(monthKey, null)
}
}
const payload = chinaTradingCalendarCache.get(monthKey)
const days = payload && Array.isArray(payload.days) ? payload.days : []
const day = days.find(item => String((item && item.date) || '') === safeDate)
if (!day) return true
return day.is_trading_day !== false
}
const safeSkillId = (id = '') => String(id).replace(/[^a-zA-Z0-9_-]/g, '_')
const getPolicy = config => {
const p = (config && config.alert_policy) || {}
const a = (config && config.alerts) || {}
return {
urgentAbsChangePercent: Number.isFinite(Number(a.urgent_abs_change_percent)) ? Math.abs(Number(a.urgent_abs_change_percent)) : (Number.isFinite(Number(p.urgent_abs_change_percent)) ? Math.abs(Number(p.urgent_abs_change_percent)) : DEFAULT_POLICY.urgentAbsChangePercent),
urgentCooldownMinutes: Number.isFinite(Number(a.urgent_cooldown_minutes)) ? Math.max(1, Number(a.urgent_cooldown_minutes)) : (Number.isFinite(Number(p.urgent_cooldown_minutes)) ? Math.max(1, Number(p.urgent_cooldown_minutes)) : DEFAULT_POLICY.urgentCooldownMinutes),
nonChinaDailyCap: Number.isFinite(Number(p.non_china_daily_cap)) ? Math.max(1, Number(p.non_china_daily_cap)) : DEFAULT_POLICY.nonChinaDailyCap,
nonChinaPerSkillDailyCap: Number.isFinite(Number(p.non_china_per_skill_daily_cap)) ? Math.max(1, Number(p.non_china_per_skill_daily_cap)) : DEFAULT_POLICY.nonChinaPerSkillDailyCap,
chinaAmReminderTime: typeof p.china_am_reminder_time === 'string' ? p.china_am_reminder_time : DEFAULT_POLICY.chinaAmReminderTime,
chinaPmReminderTime: typeof p.china_pm_reminder_time === 'string' ? p.china_pm_reminder_time : DEFAULT_POLICY.chinaPmReminderTime,
dailySummaryTime: typeof p.daily_summary_time === 'string' ? p.daily_summary_time : DEFAULT_POLICY.dailySummaryTime
}
}
const isChinaSecuritySkill = config => {
const timezone = String(((config || {}).market_status || {}).timezone || '')
const cardType = String((config || {}).card_type || '')
const name = String((config || {}).name || '')
if (timezone !== 'Asia/Shanghai') return false
if (cardType === 'domestic' || cardType === 'enhanced') return true
return /上证|深证|创业板|科创|沪深|北证|A股/.test(name)
}
const getDayState = dateStr => {
const key = `skills.day_state.${dateStr}`
const v = readStateJson(key, null)
if (v && typeof v === 'object') return v
return { nonChinaTotal: 0, nonChinaSkills: {}, dailySummarySent: false, nonChinaFirstSent: false }
}
const setDayState = (dateStr, state) => {
writeStateJson(`skills.day_state.${dateStr}`, state)
}
const getDeferredList = dateStr => readStateJson(`skills.deferred.${dateStr}`, [])
const setDeferredList = (dateStr, list) => writeStateJson(`skills.deferred.${dateStr}`, Array.isArray(list) ? list : [])
const getLatestSkillDataState = skillId => {
const rows = readStateJson(`skills.latest.${safeSkillId(skillId)}`, [])
return Array.isArray(rows) ? rows : []
}
const setLatestSkillDataState = (skillId, rows) => {
writeStateJson(`skills.latest.${safeSkillId(skillId)}`, Array.isArray(rows) ? rows : [])
}
const appendDeferred = (dateStr, rows) => {
if (!Array.isArray(rows) || rows.length === 0) return
const old = getDeferredList(dateStr)
const merged = old.concat(rows).slice(-500)
setDeferredList(dateStr, merged)
}
const getSummaryPool = dateStr => {
const key = `skills.summary_pool.${dateStr}`
const v = readStateJson(key, null)
if (v && typeof v === 'object') return v
return { regular: [], urgent: [] }
}
const setSummaryPool = (dateStr, pool) => {
writeStateJson(`skills.summary_pool.${dateStr}`, pool || { regular: [], urgent: [] })
}
const addSummaryRows = (dateStr, rows, urgent = false) => {
if (!Array.isArray(rows) || rows.length === 0) return
const pool = getSummaryPool(dateStr)
const keySet = new Set()
const normalize = r => {
const skill = String(r.skill || '')
const alertId = String(r.alertId || '')
const title = String(r.title || '')
const message = String(r.message || '')
const ts = String(r.ts || '')
return { skill, alertId, title, message, ts }
}
const load = arr => {
for (const x of (Array.isArray(arr) ? arr : [])) {
const n = normalize(x)
const k = `${n.skill}|${n.alertId}|${n.title}|${n.message}`
if (keySet.has(k)) continue
keySet.add(k)
}
}
load(pool.regular)
load(pool.urgent)
const target = urgent ? (Array.isArray(pool.urgent) ? pool.urgent : []) : (Array.isArray(pool.regular) ? pool.regular : [])
for (const row of rows) {
const n = normalize(row)
const k = `${n.skill}|${n.alertId}|${n.title}|${n.message}`
if (keySet.has(k)) continue
keySet.add(k)
target.push(n)
}
if (urgent) pool.urgent = target
else pool.regular = target
setSummaryPool(dateStr, pool)
}
const dedupeByAlert = list => {
const map = new Map()
for (const it of list || []) {
const k = `${it.skill || ''}::${it.alertId || it.title || ''}`
if (!map.has(k)) map.set(k, it)
}
return Array.from(map.values())
}
const resolveUrl = (url) => {
const port = process.env.PORT || 8976
return String(url || '').replace(/{{PORT}}/g, port)
}
const queueRetry = task => {
const next = { attempts: 0, maxAttempts: 12, nextAt: Date.now() + 2 * 60 * 1000, ...task }
retryQueue.push(next)
try {
insertEmailLog.run({
skill: task.skillName || 'unknown',
alert_id: task.externalId || 'unknown',
subject: task.title || '',
status: 'retry_pending',
error_message: ''
})
} catch {}
}
const sendEmail = async (config, subject, text, skillName, alertId) => {
const emailReminderEnabled = config.email_reminder_enabled !== false
if (!emailReminderEnabled || !config.email || !config.email.enabled || !config.email.smtp) return true
// 解析 SMTP 配置中的 {{VAR}} 占位符(如 {{GMAIL_SMTP_PASS}})
const smtpCfg = JSON.parse(JSON.stringify(config.email.smtp))
if (smtpCfg.auth && smtpCfg.auth.pass) smtpCfg.auth.pass = resolvePlaceholders(smtpCfg.auth.pass)
let logId = 0
try {
const info = insertEmailLog.run({ skill: skillName || 'unknown', alert_id: alertId || 'unknown', subject, status: 'sending', error_message: '' })
logId = Number(info.lastInsertRowid || 0)
} catch {}
try {
const transporter = nodemailer.createTransport(smtpCfg)
await transporter.sendMail({
from: config.email.from,
to: Array.isArray(config.email.to) ? config.email.to.join(',') : config.email.to,
subject,
text
})
if (logId) {
try { updateEmailLogStatus.run({ id: logId, status: 'sent', error_message: '' }) } catch {}
}
} catch (e) {
const errMsg = String(e.message || e)
if (logId) {
try { updateEmailLogStatus.run({ id: logId, status: 'failed', error_message: errMsg }) } catch {}
} else {
try { insertEmailLog.run({ skill: skillName || 'unknown', alert_id: alertId || 'unknown', subject, status: 'failed', error_message: errMsg }) } catch {}
}
throw e
}
return true
}
const normalizeInlineText = text => String(text || '').replace(/\s+/g, ' ').trim()
const normalizeBodyForTitle = content => normalizeInlineText(String(content || '').replace(/\r?\n+/g, ';'))
const countHanChars = text => (String(text || '').match(/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g) || []).length
const countDisplayChars = text => Array.from(String(text || '')).length
const takePrefixByHanLimit = (text, hanLimit) => {
const chars = Array.from(String(text || ''))
let han = 0
let out = ''
for (const ch of chars) {
const isHan = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/.test(ch)
if (isHan) {
if (han >= hanLimit) break
han += 1
}
out += ch
}
return out.trim()
}
const applyTitlePolicy = (baseTitle, content) => {
const title = normalizeInlineText(baseTitle)
const detail = normalizeBodyForTitle(content)
if (!detail) return { title, marker: TITLE_POLICY_MARKER }
const hanCount = countHanChars(detail)
const effectiveCount = hanCount > 0 ? hanCount : countDisplayChars(detail)
if (effectiveCount <= TITLE_INLINE_HAN_LIMIT) {
return { title: normalizeInlineText(`${title} ${detail}`), marker: TITLE_POLICY_MARKER }
}
const prefix = hanCount > 0 ? takePrefixByHanLimit(detail, TITLE_INLINE_HAN_LIMIT) : Array.from(detail).slice(0, TITLE_INLINE_HAN_LIMIT).join('')
const remaining = Math.max(0, effectiveCount - TITLE_INLINE_HAN_LIMIT)
return { title: normalizeInlineText(`${title} ${prefix}【剩余${remaining}汉字,详见正文】`), marker: TITLE_POLICY_MARKER }
}
const buildWeeklyPayload = ({ target, title, content, externalId, dateStr, titlePolicy }) => ({
key: target.key || process.env.SYSTEM_AUTH_PASS_93220 || '',
source: 'market_skills',
external_id: externalId,
title,
content,
title_policy: titlePolicy || TITLE_POLICY_MARKER,
status_code: 'new',
status_label: '📅本周新增',
date: dateStr
})
const buildCalendarPayload = ({ title, content, eventId, tsLocal }) => ({
id: eventId,
title,
content,
start: tsLocal,
end: tsLocal,
remindAt: tsLocal,
allDay: 0
})
const pushTargets = async (config, payloadBuilder) => {
const calendarReminderEnabled = config.calendar_reminder_enabled !== false
const targets = Array.isArray(config.push_targets) ? [...config.push_targets] : []
if (config.push_api && config.push_api.enabled && config.push_api.url) targets.push(config.push_api)
for (const target of targets) {
if (!target.enabled) continue
const url = resolveUrl(target.url)
if (!url) continue
let payload = null
if (url.includes('/api/weekly/ext/event')) {
payload = payloadBuilder('weekly', target)
} else if (url.includes('/api/calendar/events')) {
if (!calendarReminderEnabled) continue
payload = payloadBuilder('calendar', target)
} else {
payload = payloadBuilder('generic', target)
}
if (!payload) continue
await axios.post(url, payload, { timeout: 15000 })
}
}
const sendDispatch = async ({ skillId, skillName, config, title, lines, externalId, tsLocal, sendEmailNow = false, enqueueOnFail = true }) => {
const content = lines.join('\n')
const titlePack = applyTitlePolicy(title, content)
const finalTitle = titlePack.title
const signature = `${finalTitle}\n${content}\n${externalId}\n${tsLocal}\n${sendEmailNow ? '1' : '0'}`
const prev = lastDispatchSignature.get(externalId)
if (prev === signature) return true
lastDispatchSignature.set(externalId, signature)
try {
if (sendEmailNow) {
await sendEmail(config, finalTitle, content, skillName, externalId)
logJSON('skills.alert.email.sent', { skill: skillName, externalId }, 'market_skills')
}
await pushTargets(config, (type, target) => {
const dateStr = tsLocal.slice(0, 10)
if (type === 'weekly') return buildWeeklyPayload({ target, title: finalTitle, content, externalId, dateStr, titlePolicy: titlePack.marker })
if (type === 'calendar') return buildCalendarPayload({ title: finalTitle, content, eventId: externalId, tsLocal })
return {
source: 'market_skills',
skill: skillName,
external_id: externalId,
title: finalTitle,
content,
title_policy: titlePack.marker,
ts: Date.now()
}
})
logJSON('skills.alert.dispatch.sent', { skill: skillName, externalId, tsLocal, title: finalTitle, title_policy: titlePack.marker }, 'market_skills')
return true
} catch (e) {
logJSON('skills.alert.dispatch.error', { skill: skillName, externalId, error: String(e.message || e) }, 'market_skills')
if (enqueueOnFail) queueRetry({ skillId, skillName, config, title, lines, externalId, tsLocal, sendEmailNow })
return false
}
}
const processRetryQueue = async () => {
if (retryQueue.length === 0) return
const nowMs = Date.now()
const pending = []
for (const job of retryQueue.splice(0, retryQueue.length)) {
if (job.nextAt > nowMs) {
pending.push(job)
continue
}
const ok = await sendDispatch({ ...job, enqueueOnFail: false })
if (!ok) {
job.attempts = Number(job.attempts || 0) + 1
if (job.attempts < Number(job.maxAttempts || 12)) {
const delay = Math.min(60 * 60 * 1000, Math.pow(2, job.attempts) * 60 * 1000)
job.nextAt = Date.now() + delay
pending.push(job)
try {
insertEmailLog.run({
skill: job.skillName || 'unknown',
alert_id: job.externalId || 'unknown',
subject: job.title || '',
status: 'retry_attempt',
error_message: `attempt ${job.attempts}/${job.maxAttempts}`
})
} catch {}
} else {
try {
insertEmailLog.run({
skill: job.skillName || 'unknown',
alert_id: job.externalId || 'unknown',
subject: job.title || '',
status: 'retry_exhausted',
error_message: `gave up after ${job.attempts} attempts`
})
} catch {}
logJSON('skills.alert.retry.giveup', { skill: job.skillName, externalId: job.externalId }, 'market_skills')
}
}
}
retryQueue.push(...pending)
}
// Helper to parse time string to ms
const parseInterval = (str) => {
if (typeof str === 'number') return str
if (!str) return 60000 // Default 1 min
const m = str.match(/^(\d+)([smh])$/)
if (!m) return 60000
const val = parseInt(m[1])
const unit = m[2]
if (unit === 's') return val * 1000
if (unit === 'm') return val * 60000
if (unit === 'h') return val * 3600000
return 60000
}
const insertQuote = db.prepare(
`INSERT INTO market_quotes (label, symbol, source, name, price, prev_close, change, change_percent)
VALUES (@label, @symbol, @source, @name, @price, @prevClose, @change, @change_percent)`
)
const saveQuotes = (quotes) => {
if (!Array.isArray(quotes)) return
const tx = db.transaction((list) => {
for (const q of list) {
try {
insertQuote.run(q)
} catch (e) {
logJSON('skills.db.error', { error: String(e.message), symbol: q.symbol }, 'market_skills')
}
}
})
tx(quotes)
}
const persistRegularSlotDedup = (skillName, alerts) => {
const p = bjParts()
const slot = p.hour < 12 ? 'am' : 'pm'
const currentSlotKey = `${p.dateStr}-${slot}`
const filtered = []
const updates = []
for (const alert of (alerts || [])) {
const alertId = alert.id || alert.title
const row = getAlertSlot.get(skillName, alertId)
if (row && row.slot_key === currentSlotKey) continue
filtered.push(alert)
updates.push({ skill: skillName, alert_id: alertId, slot_key: currentSlotKey })
}
if (updates.length > 0) {
const tx = db.transaction(list => {
for (const row of list) upsertAlertSlot.run(row)
})
tx(updates)
}
return filtered
}
const buildAlertRows = (skillName, alerts, dataTime) => {
const lineTs = String(dataTime || bjNowDateTime())
return (alerts || []).map(a => ({
skill: skillName,
alertId: a.id || a.title || '',
title: String(a.title || ''),
message: String(a.message || ''),
ts: lineTs
}))
}
const getChinaSlotAndTime = (policy, parts) => {
const slot = parts.hour < 12 ? 'am' : 'pm'
const hhmm = slot === 'am' ? policy.chinaAmReminderTime : policy.chinaPmReminderTime
return { slot, hhmm }
}
const makeBucketLines = rows => dedupeByAlert(rows).map(r => `【${String(r.ts || bjNowDateTime())}】 [${r.skill}] ${r.title}: ${r.message}`)
const handleChinaRegular = async ({ skillId, skillName, config, alerts, policy, dateStr, parts, dataTime }) => {
const isTradingDate = await isChinaTradingDate(dateStr)
if (!isTradingDate) {
logJSON('skills.cn.summary.skip.closed_day', { skill: skillName, date: dateStr }, 'market_skills')
return
}
const rows = buildAlertRows(skillName, alerts, dataTime)
addSummaryRows(dateStr, rows, false)
const { slot, hhmm } = getChinaSlotAndTime(policy, parts)
const key = `skills.bucket.cn.${dateStr}.${slot}`
const prev = readStateJson(key, [])
const merged = dedupeByAlert([...(Array.isArray(prev) ? prev : []), ...rows])
writeStateJson(key, merged)
const lines = makeBucketLines(merged)
if (lines.length === 0) return
const title = `中国证券${slot === 'am' ? '上午' : '下午'}汇总 ${dateStr}`
const externalId = `skill-summary-cn-${dateStr}-${slot}`
const tsLocal = localTs(dateStr, hhmm)
await sendDispatch({ skillId, skillName, config, title, lines, externalId, tsLocal, sendEmailNow: false })
}
const handleNonChinaRegular = async ({ skillId, skillName, config, alerts, policy, dateStr, dataTime }) => {
const rows = buildAlertRows(skillName, alerts, dataTime)
addSummaryRows(dateStr, rows, false)
const dayState = getDayState(dateStr)
const sid = safeSkillId(skillId)
const skillSentCount = Number(dayState.nonChinaSkills[sid] || 0)
if (skillSentCount < policy.nonChinaPerSkillDailyCap && dayState.nonChinaTotal >= policy.nonChinaDailyCap) {
const nextDate = nextDateStr(dateStr)
appendDeferred(nextDate, rows)
logJSON('skills.non_cn.deferred', { skill: skillName, date: dateStr, nextDate, count: rows.length }, 'market_skills')
return
}
if (skillSentCount < policy.nonChinaPerSkillDailyCap) {
dayState.nonChinaSkills[sid] = skillSentCount + 1
dayState.nonChinaTotal = Number(dayState.nonChinaTotal || 0) + 1
if (!dayState.nonChinaFirstSent) dayState.nonChinaFirstSent = true
setDayState(dateStr, dayState)
}
const key = `skills.bucket.non_cn.${dateStr}.${sid}`
const prev = readStateJson(key, [])
let merged = dedupeByAlert([...(Array.isArray(prev) ? prev : []), ...rows])
if (dayState.nonChinaFirstSent && Number(dayState.nonChinaTotal || 0) === 1) {
const deferred = getDeferredList(dateStr)
if (Array.isArray(deferred) && deferred.length > 0) {
merged = dedupeByAlert([...deferred, ...merged])
setDeferredList(dateStr, [])
}
}
writeStateJson(key, merged)
const lines = makeBucketLines(merged)
if (lines.length === 0) return
const title = `非中国证券汇总 ${dateStr} ${skillName}`
const externalId = `skill-summary-noncn-${dateStr}-${sid}`
const tsLocal = localTs(dateStr, '21:30')
await sendDispatch({ skillId, skillName, config, title, lines, externalId, tsLocal, sendEmailNow: false })
}
const handleUrgentAlerts = async ({ skillId, skillName, config, alerts, policy, dateStr, parts, absChange, dataTime }) => {
if (!Number.isFinite(absChange) || absChange < policy.urgentAbsChangePercent) return []
const cooldownState = readStateJson('skills.urgent_cooldown', {})
const nowMs = Date.now()
const cooldownMs = policy.urgentCooldownMinutes * 60 * 1000
const sendList = []
for (const a of alerts) {
const aid = a.id || a.title || 'unknown'
const k = `${skillName}::${aid}`
const last = Number(cooldownState[k] || 0)
if (nowMs - last < cooldownMs) continue
cooldownState[k] = nowMs
sendList.push(a)
}
writeStateJson('skills.urgent_cooldown', cooldownState)
if (sendList.length === 0) return []
const rows = buildAlertRows(skillName, sendList, dataTime)
addSummaryRows(dateStr, rows, true)
const lines = makeBucketLines(rows)
const windowBase = Math.floor(nowMs / cooldownMs)
const ext = `skill-urgent-${safeSkillId(skillId)}-${dateStr}-${windowBase}`
const tsLocal = localTs(dateStr, `${pad2(parts.hour)}:${pad2(parts.minute)}`)
const title = `严重告警 ${skillName} | 波动 ${absChange.toFixed(2)}%`
await sendDispatch({ skillId, skillName, config, title, lines, externalId: ext, tsLocal, sendEmailNow: true })
return sendList
}
const maybeSendDailySummary = async ({ skillName, config, policy, dateStr, parts }) => {
const dayState = getDayState(dateStr)
if (dayState.dailySummarySent) return
const cut = parseHHMM(policy.dailySummaryTime)
if (parts.hour < cut.hour || (parts.hour === cut.hour && parts.minute < cut.minute)) return
const pool = getSummaryPool(dateStr)
const regular = dedupeByAlert(pool.regular || [])
const urgent = dedupeByAlert(pool.urgent || [])
if (regular.length === 0 && urgent.length === 0) {
dayState.dailySummarySent = true
setDayState(dateStr, dayState)
return
}
const lines = []
if (urgent.length > 0) {
lines.push('【严重告警】')
lines.push(...makeBucketLines(urgent))
}
if (regular.length > 0) {
if (lines.length > 0) lines.push('')
lines.push('【日终汇总】')
lines.push(...makeBucketLines(regular))
}
const title = `市场日终总览 ${dateStr}`
const externalId = `skill-daily-summary-${dateStr}`
const tsLocal = localTs(dateStr, policy.dailySummaryTime)
await sendDispatch({ skillId: 'daily_summary', skillName: '市场日终总览', config, title, lines, externalId, tsLocal, sendEmailNow: true })
dayState.dailySummarySent = true
setDayState(dateStr, dayState)
}
const EXCLUDED_FROM_PUSH_SKILLS = new Set([
'中国银行-299法',
'中国神华-299法',
'巴菲特指数',
'美国战争指数',
'流动性状态机'
])
const sendAlerts = async (skillId, skillName, config, rawAlerts, dataRows) => {
if (!rawAlerts || rawAlerts.length === 0) return
if (EXCLUDED_FROM_PUSH_SKILLS.has(skillName)) return
const p = bjParts()
const dateStr = p.dateStr
const policy = getPolicy(config)
const quote = Array.isArray(dataRows) && dataRows.length > 0 ? dataRows[0] : null
const explain = quote && typeof quote.explain === 'string' ? quote.explain.trim() : ''
const alertsWithExplain = explain
? rawAlerts.map(a => ({ ...a, message: `${String(a.message || '').trim()}\n说明:${explain}` }))
: rawAlerts
const absChange = quote && Number.isFinite(Number(quote.change_percent)) ? Math.abs(Number(quote.change_percent)) : NaN
const dataTime = bjNowDateTime()
const urgentDispatched = await handleUrgentAlerts({ skillId, skillName, config, alerts: alertsWithExplain, policy, dateStr, parts: p, absChange, dataTime })
const urgentKeySet = new Set(urgentDispatched.map(a => a.id || a.title || ''))
const regularCandidates = alertsWithExplain.filter(a => !urgentKeySet.has(a.id || a.title || ''))
const regularAlerts = persistRegularSlotDedup(skillName, regularCandidates)
if (regularAlerts.length > 0) {
if (isChinaSecuritySkill(config)) {
await handleChinaRegular({ skillId, skillName, config, alerts: regularAlerts, policy, dateStr, parts: p, dataTime })
} else {
await handleNonChinaRegular({ skillId, skillName, config, alerts: regularAlerts, policy, dateStr, dataTime })
}
}
await maybeSendDailySummary({ skillName, config, policy, dateStr, parts: p })
}
const runSkill = async (id, skillModule, config) => {
logJSON('skills.run.start', { skill: id }, 'market_skills')
try {
if (config && config.display === false) return
const context = {
logJSON: (evt, data) => logJSON(evt, data, `skill:${id}`),
axios,
db
}
const result = await skillModule.run(context, config)
if (!result) {
// Placeholder card when skill returns nothing (all data sources failed)
if (config) {
const placeholderData = [{
label: config.name || id,
symbol: config.symbol || id,
source: 'error',
name: config.name || id,
price: null,
change: null,
change_percent: null,
captured_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
market_status: config.market_status || null,
card_order: Number.isFinite(config.card_order) ? config.card_order : 9999,
card_type: config.card_type || null,
url: config.url || null,
_error: '⚠ 数据源不可用(所有源均无响应)'
}]
const skillEntry = skills.get(id)
if (skillEntry) skillEntry.latestData = placeholderData
setLatestSkillDataState(id, placeholderData)
}
return
}
let { data, alerts } = result
if (data && Array.isArray(data)) {
const extra = {}
if (config.url) extra.url = config.url
if (Number.isFinite(config.card_order)) extra.card_order = config.card_order
if (config.card_type) extra.card_type = config.card_type
if (Number.isFinite(config.card_span) && config.card_span >= 1) extra.card_span = Math.floor(config.card_span)
if (Object.keys(extra).length > 0) {
data = data.map(item => ({ ...item, ...extra }))
}
}
if (data && data.length > 0) {
const skillEntry = skills.get(id)
if (skillEntry) {
skillEntry.latestData = data
}
setLatestSkillDataState(id, data)
saveQuotes(data)
logJSON('skills.data.saved', { skill: id, count: data.length }, 'market_skills')
}
if (alerts && alerts.length > 0) await sendAlerts(id, config.name || id, config, alerts, data)
const p = bjParts()
await maybeSendDailySummary({ skillName: config.name || id, config, policy: getPolicy(config), dateStr: p.dateStr, parts: p })
await processRetryQueue()
} catch (e) {
logJSON('skills.run.error', { skill: id, error: String(e.message || e) }, 'market_skills')
// Placeholder card when skill throws
if (config) {
const placeholderData = [{
label: config.name || id,
symbol: config.symbol || id,
source: 'error',
name: config.name || id,
price: null,
change: null,
change_percent: null,
captured_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
market_status: config.market_status || null,
card_order: Number.isFinite(config.card_order) ? config.card_order : 9999,
card_type: config.card_type || null,
url: config.url || null,
_error: `⚠ 数据异常:${String(e.message || e).slice(0, 80)}`
}]
const skillEntry = skills.get(id)
if (skillEntry) skillEntry.latestData = placeholderData
setLatestSkillDataState(id, placeholderData)
}
}
}
const loadSkills = () => {
if (!fs.existsSync(SKILLS_DIR)) return
const dirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
for (const dirent of dirs) {
if (!dirent.isDirectory()) continue
const id = dirent.name
const skillPath = path.join(SKILLS_DIR, id)
const indexFile = path.join(skillPath, 'index.js')
const configFile = path.join(skillPath, 'config.json')
if (fs.existsSync(indexFile) && fs.existsSync(configFile)) {
try {
const config = JSON.parse(fs.readFileSync(configFile, 'utf-8'))
if (config.enabled === false) continue
if (config.display === false) continue
const skillModule = require(indexFile)
const interval = parseInterval(config.schedule)
logJSON('skills.loaded', { skill: id, schedule: config.schedule, interval }, 'market_skills')
const entry = { timer: null, config, module: skillModule, latestData: getLatestSkillDataState(id) }
skills.set(id, entry)
runSkill(id, skillModule, config)
const timer = setInterval(() => {
runSkill(id, skillModule, config)
}, interval)
entry.timer = timer
} catch (e) {
logJSON('skills.load.error', { skill: id, error: String(e.message) }, 'market_skills')
}
}
}
}
const getLatestData = () => {
const all = []
for (const [id, entry] of skills) {
if (entry.latestData && Array.isArray(entry.latestData)) {
all.push(...entry.latestData)
}
}
return all
}
module.exports = {
start: () => {
loadSkills()
if (!retryTimer) {
retryTimer = setInterval(() => { processRetryQueue().catch(() => {}) }, 60 * 1000)
}
logJSON('skills.manager.started', { count: skills.size }, 'market_skills')
},
getLatestData
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "纳斯达克",
"card_order": 13,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usIXIC",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usIXIC",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "09:30",
"end": "16:00"
}
],
"display_label": "纳指"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+156
View File
@@ -0,0 +1,156 @@
const iconv = require('iconv-lite')
const { fetchYahoo } = require('../../marketsClient')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
let item = null
if (String(config.source || '').toLowerCase() === 'yahoo_probe') {
const quotes = await fetchYahoo([config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
} else {
const quotes = await fetchTencent(axios, [config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
}
// Fallback: try Tencent if Yahoo failed, or vice versa
if (!item) {
if (String(config.source || '').toLowerCase() === 'yahoo_probe') {
const fb = await fetchTencent(axios, [config.symbol])
item = fb.find(q => q.symbol === config.symbol)
} else {
const fb = await fetchYahoo([config.symbol])
item = fb.find(q => q.symbol === config.symbol)
}
}
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "日经225指数",
"card_order": 9,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usEWJ",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWJ",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "Asia/Tokyo",
"sessions": [
{
"start": "09:00",
"end": "11:30"
},
{
"start": "12:30",
"end": "15:00"
}
],
"display_label": "日经225"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,42 @@
{
"name_comment": "显示的名称",
"name": "MVREMX 稀土指数(REMX)",
"card_order": 18,
"card_type": "overseas",
"enabled_comment": "是否启用该监控 (true/false)",
"enabled": true,
"display_comment": "是否显示该卡片 (false 则不拉取数据也不封存)",
"display": true,
"schedule_comment": "刷新频率 (如 10m 表示10分钟)",
"schedule": "10m",
"symbol_comment": "监控的代码",
"symbol": "usREMX",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usREMX",
"source_comment": "数据来源",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配置",
"alerts": {
"change_percent_down_comment": "跌幅预警阈值",
"change_percent_down": -2,
"change_percent_up_comment": "涨幅预警阈值",
"change_percent_up": 2
},
"email_reminder_enabled_comment": "是否发送邮件提醒",
"email_reminder_enabled": false,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": false,
"push_targets_comment": "推送目标列表",
"push_targets": [],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "09:30",
"end": "16:00"
}
],
"display_label": "美股REMX"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "沙特ETF(富时沙特)",
"card_order": 17,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usFLSA",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usFLSA",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "09:30",
"end": "16:00"
}
],
"display_label": "富时沙特"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "上证指数",
"card_order": 1,
"card_type": "domestic",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sh000001",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000001",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "上证"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.find(q => q.symbol === config.symbol)
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,77 @@
{
"name_comment": "显示的名",
"name": "中国神华-299",
"card_order": 1,
"card_type": "299",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": false,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sh601088_299",
"source_symbol_comment": "用于拉取K线的真实代码",
"source_symbol": "sh601088",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/sh601088.html",
"source_comment": "数据来源 (",
"source": "tencent",
"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": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "中国A"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,201 @@
const calculateMA = (data, period) => {
if (!data || data.length < period) return null
const subset = data.slice(-period)
const sum = subset.reduce((a, b) => a + b, 0)
return sum / period
}
const fetchKLine = async (axios, symbol) => {
const url = `http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${symbol},day,,,300,qfq`
try {
const res = await axios.get(url, { timeout: 10000 })
if (!res || !res.data || res.data.code !== 0) return null
const d = res.data.data[symbol]
if (!d) return null
const rawData = d.qfqday || d.day
if (!rawData || !Array.isArray(rawData)) return null
return rawData.map(item => ({
date: item[0],
close: parseFloat(item[2])
}))
} catch (e) {
return null
}
}
const buildAlerts = ({
latestClose,
prevClose,
ma10,
ma20,
ma60,
ma90,
ma250,
prevMA20,
prevMA60,
prevMA90,
prevMA250
}) => {
const alerts = []
const addAlert = (id, title, message, level = 'warning') => {
alerts.push({ id, title, message, level })
}
const isCrossDown = (prevC, prevMa, currC, currMa) => prevC >= prevMa && currC < currMa
const isCrossUp = (prevC, prevMa, currC, currMa) => prevC <= prevMa && currC > currMa
const isBelowMA250 = latestClose < ma250
const isCrossDownMA250 = isCrossDown(prevClose, prevMA250, latestClose, ma250)
const isCrossUpMA250 = isCrossUp(prevClose, prevMA250, latestClose, ma250)
if (isCrossDownMA250) {
addAlert('below_ma250', '中国神华跌破年线!立即停止加仓,建议减仓', '中国神华跌破年线!立即停止加仓,建议减仓', 'error')
}
if (isCrossUpMA250) {
addAlert('above_ma250', '中国神华回到年线之上,可恢复加仓策略', '中国神华回到年线之上,可恢复加仓策略')
}
if (!isBelowMA250) {
if (isCrossDown(prevClose, prevMA20, latestClose, ma20)) {
addAlert('below_ma20', '中国神华-跌破20日线,需加仓 20%', '中国神华-跌破20日线,需加仓 20%')
}
if (isCrossDown(prevClose, prevMA60, latestClose, ma60)) {
addAlert('below_ma60', '中国神华-跌破60日线,需加仓 40%', '中国神华-跌破60日线,需加仓 40%')
}
if (isCrossDown(prevClose, prevMA90, latestClose, ma90)) {
addAlert('below_ma90', '中国神华-跌破90日线,需加仓剩余 40%', '中国神华-跌破90日线,需加仓剩余 40%')
}
}
if (isCrossUp(prevClose, prevMA20, latestClose, ma20)) {
addAlert('above_ma20', '中国神华-站上20日线,需卖出 20%', '中国神华-站上20日线,需卖出 20%')
}
if (isCrossUp(prevClose, prevMA60, latestClose, ma60)) {
addAlert('above_ma60', '中国神华-站上60日线,需卖出 40%', '中国神华-站上60日线,需卖出 40%')
}
if (isCrossUp(prevClose, prevMA90, latestClose, ma90)) {
addAlert('above_ma90', '中国神华-站上90日线,需卖出剩余 40%', '中国神华-站上90日线,需卖出剩余 40%')
}
if (ma10 > ma20) {
addAlert('trend_bull', '趋势提示', '中国神华处于上涨趋势,建议持有底仓 + 已建波段仓,不做 T')
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbol = config.symbol
const sourceSymbol = config.source_symbol || 'sh601088'
const klineData = await fetchKLine(axios, sourceSymbol)
if (!klineData || klineData.length < 250) {
logJSON('skill.shenhua_299.error', { message: 'Insufficient data', length: klineData ? klineData.length : 0 }, 'market_skills')
return null
}
const closes = klineData.map(d => d.close)
const latestClose = closes[closes.length - 1]
const prevClose = closes[closes.length - 2]
const latestDate = klineData[klineData.length - 1].date
const ma10 = calculateMA(closes, 10)
const ma20 = calculateMA(closes, 20)
const ma60 = calculateMA(closes, 60)
const ma90 = calculateMA(closes, 90)
const ma250 = calculateMA(closes, 250)
const closesPrev = closes.slice(0, -1)
const prevMA20 = calculateMA(closesPrev, 20)
const prevMA60 = calculateMA(closesPrev, 60)
const prevMA90 = calculateMA(closesPrev, 90)
const prevMA250 = calculateMA(closesPrev, 250)
if (!ma10 || !ma20 || !ma60 || !ma90 || !ma250 || !prevMA20 || !prevMA60 || !prevMA90 || !prevMA250) {
logJSON('skill.shenhua_299.error', { message: 'MA calculation failed' }, 'market_skills')
return null
}
const change = latestClose - prevClose
const changePercent = (change / prevClose) * 100
const trendText = ma10 > ma20 ? '多头排列' : '非多头'
const yearLineText = latestClose < ma250 ? '年线下方' : '年线之上'
const isCrossDown = (prevC, prevMa, currC, currMa) => prevC >= prevMa && currC < currMa
const isCrossUp = (prevC, prevMa, currC, currMa) => prevC <= prevMa && currC > currMa
const isBelowMA250 = latestClose < ma250
const crossDownMA20 = isCrossDown(prevClose, prevMA20, latestClose, ma20)
const crossDownMA60 = isCrossDown(prevClose, prevMA60, latestClose, ma60)
const crossDownMA90 = isCrossDown(prevClose, prevMA90, latestClose, ma90)
const crossUpMA20 = isCrossUp(prevClose, prevMA20, latestClose, ma20)
const crossUpMA60 = isCrossUp(prevClose, prevMA60, latestClose, ma60)
const crossUpMA90 = isCrossUp(prevClose, prevMA90, latestClose, ma90)
const crossUpMA250 = isCrossUp(prevClose, prevMA250, latestClose, ma250)
const statusDownMA20 = isBelowMA250 ? '暂停(年线下方)' : (crossDownMA20 ? '已触发(加仓20%)' : '未触发')
const statusDownMA60 = isBelowMA250 ? '暂停(年线下方)' : (crossDownMA60 ? '已触发(加仓40%)' : '未触发')
const statusDownMA90 = isBelowMA250 ? '暂停(年线下方)' : (crossDownMA90 ? '已触发(加仓剩余40%)' : '未触发')
const statusUpMA20 = crossUpMA20 ? '已触发(卖出20%)' : '未触发'
const statusUpMA60 = crossUpMA60 ? '已触发(卖出40%)' : '未触发'
const statusUpMA90 = crossUpMA90 ? '已触发(卖出剩余40%)' : '未触发'
const statusTrend = ma10 > ma20 ? '已满足(上涨趋势)' : '未满足'
const statusMA250 = isBelowMA250 ? '已触发(停止加仓/建议减仓)' : (crossUpMA250 ? '已触发(恢复加仓)' : '未触发')
const htmlPayload = `
<div style="font-size: 14px; margin-top: 8px;">
价格:${latestClose.toFixed(2)} (${change >= 0 ? '+' : ''}${change.toFixed(2)} / ${changePercent.toFixed(2)}%)
</div>
<div style="margin: 8px 0; border-top: 1px solid #eee; width: 100%;"></div>
<div style="font-size: 13px; color: #666; line-height: 1.6;">
MA10:${ma10.toFixed(2)} | MA20:${ma20.toFixed(2)} | MA60:${ma60.toFixed(2)}<br/>
MA90:${ma90.toFixed(2)} | MA250:${ma250.toFixed(2)}<br/>
趋势:${trendText} | 年线状态:${yearLineText}
</div>
<div style="margin: 8px 0; border-top: 1px solid #eee; width: 100%;"></div>
<div style="font-size: 13px; color: #666; line-height: 1.6;">
20日线跌破:${statusDownMA20} | 站上:${statusUpMA20}<br/>
60日线跌破:${statusDownMA60} | 站上:${statusUpMA60}<br/>
90日线跌破:${statusDownMA90} | 站上:${statusUpMA90}<br/>
MA10>MA20:${statusTrend} | MA250:${statusMA250}
</div>
`
const data = [{
label: config.name,
symbol: symbol,
displaySymbol: sourceSymbol,
isMaCard: true,
render_type: 'html',
html: htmlPayload,
ma10: ma10.toFixed(2),
ma20: ma20.toFixed(2),
ma60: ma60.toFixed(2),
ma90: ma90.toFixed(2),
ma250: ma250.toFixed(2),
source: config.source || 'tencent',
name: config.name,
price: latestClose,
prevClose: prevClose,
change: change,
change_percent: changePercent,
market_status: `${trendText} (${latestDate})`
}]
const alerts = buildAlerts({
latestClose,
prevClose,
ma10,
ma20,
ma60,
ma90,
ma250,
prevMA20,
prevMA60,
prevMA90,
prevMA250
})
return { data, alerts }
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "东南亚科技(海峡时报)",
"card_order": 20,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usASEA",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usASEA",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "Asia/Singapore",
"sessions": [
{
"start": "09:00",
"end": "17:00"
}
],
"display_label": "东南亚STI"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "标普500",
"card_order": 12,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usINX",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usINX",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "09:30",
"end": "16:00"
}
],
"display_label": "标普500"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+156
View File
@@ -0,0 +1,156 @@
const iconv = require('iconv-lite')
const { fetchYahoo } = require('../../marketsClient')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
let item = null
if (String(config.source || '').toLowerCase() === 'yahoo_probe') {
const quotes = await fetchYahoo([config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
} else {
const quotes = await fetchTencent(axios, [config.symbol])
item = quotes.find(q => q.symbol === config.symbol)
}
// Fallback: try Tencent if Yahoo failed, or vice versa
if (!item) {
if (String(config.source || '').toLowerCase() === 'yahoo_probe') {
const fb = await fetchTencent(axios, [config.symbol])
item = fb.find(q => q.symbol === config.symbol)
} else {
const fb = await fetchYahoo([config.symbol])
item = fb.find(q => q.symbol === config.symbol)
}
}
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "科创50",
"card_order": 4,
"card_type": "domestic",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sh000688",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000688",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "科创50"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.find(q => q.symbol === config.symbol)
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,86 @@
{
"name_comment": "显示的名",
"name": "深证成指",
"card_order": 2,
"card_type": "domestic",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sz399001",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sz399001",
"source_comment": "数据来源 (",
"source": "tencent",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "深证"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
+138
View File
@@ -0,0 +1,138 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
const parseTencentLine = (line) => {
const m = line.match(/^v_(\w+)="([^"]*)/)
if (!m) return null
const symbol = m[1]
const body = m[2]
let parts = body.split('~')
if (parts.length < 3 && body.includes(',')) {
parts = body.split(',')
}
// Standard Tencent Format (verified for sh/sz/bj/us/hk):
// 1: Name
// 3: Current Price
// 4: Prev Close
let name = parts[1]
let price = parseFloat(parts[3])
let prevClose = parseFloat(parts[4])
if (isNaN(price) || isNaN(prevClose)) return null
const change = price - prevClose
const pct = prevClose ? (change / prevClose) * 100 : 0
let captured_at = parts[30] || ''
if (captured_at.length === 14 && !captured_at.includes('-')) {
captured_at = captured_at.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, '$1-$2-$3 $4:$5:$6')
}
return { symbol, name, price, prevClose, change, change_percent: pct, captured_at }
}
const fetchTencent = async (axios, symbols) => {
if (!symbols || symbols.length === 0) return []
const url = `http://qt.gtimg.cn/q=${symbols.join(',')}`
try {
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: 10000, validateStatus: () => true })
if (!res || res.status >= 400) return []
const text = iconv.decode(Buffer.from(res.data), 'gb18030')
const lines = String(text || '').split(/;\s*/).filter(Boolean)
const out = []
for (const line of lines) {
const r = parseTencentLine(line)
if (r) out.push({ ...r, source: 'tencent' })
}
return out
} catch (e) {
return []
}
}
const checkAlerts = (data, config) => {
const alerts = []
// Helper to add alert with slot-based cooldown (AM/PM logic)
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// 1. Drop Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_down && data.change_percent <= config.alerts.change_percent_down) {
// Check integer steps
const currentDrop = Math.floor(Math.abs(data.change_percent))
const key = `drop_${currentDrop}`
if (currentDrop >= Math.abs(config.alerts.change_percent_down)) {
addAlert(key, `${config.name}跌幅预警`, `当前跌幅 ${data.change_percent.toFixed(2)}% (>= ${currentDrop}%)`)
}
}
// 2. Rise Alerts (Integer steps: 2%, 3%, 4%...)
if (config.alerts.change_percent_up && data.change_percent >= config.alerts.change_percent_up) {
const currentRise = Math.floor(data.change_percent)
const key = `rise_${currentRise}`
if (currentRise >= config.alerts.change_percent_up) {
addAlert(key, `${config.name}涨幅预警`, `当前涨幅 ${data.change_percent.toFixed(2)}% (>= ${currentRise}%)`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const quotes = await fetchTencent(axios, [config.symbol])
const item = quotes.find(q => q.symbol === config.symbol)
if (!item) {
logJSON('skill.fetch.error', { skill: config.name, message: 'Data not found' }, 'market_skills')
return null
}
// Prepare data for DB
const data = [{
label: config.name,
symbol: config.symbol,
source: config.source,
name: item.name,
price: item.price,
prevClose: item.prevClose,
change: item.change,
change_percent: item.change_percent,
captured_at: item.captured_at,
market_status: config.market_status
}]
// Check alerts
const alerts = checkAlerts(item, config)
return { data, alerts }
}
}
@@ -0,0 +1,82 @@
{
"name_comment": "显示的名",
"name": "宝岛加权指数",
"card_order": 87,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "usEWT",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWT",
"source_comment": "数据来源 (",
"source": "yahoo_probe",
"alerts_comment": "预警阈值配",
"alerts": {
"change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
"change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"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": "Asia/Taipei",
"sessions": [
{
"start": "09:00",
"end": "13:30"
}
],
"display_label": "台湾"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1 @@
module.exports = require('../germany_etf/index.js')
@@ -0,0 +1,94 @@
{
"name_comment": "显示的名",
"name": "美国战争指数",
"card_order": 14,
"card_type": "overseas",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "us_war_pizzint",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://fred.stlouisfed.org/series/VIXCLS",
"stable_url_comment": "稳定数据源地址(优先)",
"stable_url": "https://fred.stlouisfed.org/graph/fredgraph.csv?id=VIXCLS",
"pizzint_url_comment": "备用数据源地址(兜底)",
"pizzint_url": "https://www.pizzint.watch/",
"fail_alert_consecutive_comment": "连续失败达到该次数才触发异常告警",
"fail_alert_consecutive": 3,
"source_comment": "数据来源",
"source": "fred_vix",
"alerts_comment": "预警阈值配",
"alerts": {
"doughcon_critical_max_comment": "DOUGHCON 小于等于该值触发高风险预警",
"doughcon_critical_max": 2,
"doughcon_warning_max_comment": "DOUGHCON 小于等于该值触发风险抬升预",
"doughcon_warning_max": 3,
"vix_warning_min_comment": "VIX 大于等于该值触发风险抬升预",
"vix_warning_min": 22,
"vix_critical_min_comment": "VIX 大于等于该值触发高风险预警",
"vix_critical_min": 30,
"index_jump_threshold_comment": "战争指数单次上跳达到该值触发快速升温预",
"index_jump_threshold": 20,
"urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": false,
"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": false,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": false,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": false,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": false
}
],
"market_status": {
"timezone": "America/New_York",
"sessions": [
{
"start": "00:00",
"end": "23:59"
}
],
"display_label": "FRED VIX"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,382 @@
const fetchText = async (axios, url, headers = {}) => {
try {
const res = await axios.get(url, {
timeout: 10000,
validateStatus: () => true,
headers
})
if (!res || res.status >= 400) return null
return String(res.data || '')
} catch {
return null
}
}
const fetchPizzint = (axios, url) => fetchText(axios, url, {
'User-Agent': 'Mozilla/5.0',
'Referer': 'https://www.pizzint.watch/',
'Accept-Language': 'en-US,en;q=0.9'
})
const fetchFredVix = (axios, url) => fetchText(axios, url, {
'User-Agent': 'Mozilla/5.0',
'Accept': 'text/csv,text/plain;q=0.9,*/*;q=0.8'
})
const buildCandidateUrls = (url) => {
const primary = String(url || '').trim() || 'https://www.pizzint.watch/'
const list = [primary]
try {
const u = new URL(primary)
const host = String(u.hostname || '').toLowerCase()
if (host === 'www.pizzint.watch') {
list.push('https://pizzint.watch/')
} else if (host === 'pizzint.watch') {
list.push('https://www.pizzint.watch/')
}
} catch {}
list.push('https://r.jina.ai/http://www.pizzint.watch/')
list.push('https://r.jina.ai/http://pizzint.watch/')
return [...new Set(list)]
}
const stripHtmlToText = (raw) => String(raw || '')
.replace(/<script[\s\S]*?<\/script>/ig, ' ')
.replace(/<style[\s\S]*?<\/style>/ig, ' ')
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/\s+/g, ' ')
.trim()
const parsePizzint = (raw) => {
const html = String(raw || '')
const text = stripHtmlToText(html)
const doughconMatch =
text.match(/DOUGHCON\s*([1-5])/i) ||
html.match(/DOUGHCON\s*([1-5])/i) ||
text.match(/"doughcon"\s*:\s*([1-5])/i) ||
text.match(/"level"\s*:\s*([1-5])/i)
if (!doughconMatch) return null
const doughcon = Number(doughconMatch[1])
if (!Number.isFinite(doughcon)) return null
const statusMatch =
text.match(/STATUS:\s*([A-Z_]+)/i) ||
text.match(/\bSTATUS\b\s*:?\s*([A-Z][A-Z _-]{2,24})\b/i) ||
html.match(/STATUS:\s*(?:<\/[^>]+>\s*<[^>]+>\s*)*([A-Z][A-Z _-]{2,24})\s*</i) ||
text.match(/"status"\s*:\s*"([A-Z_]+)"/i)
const status = statusMatch ? String(statusMatch[1]).trim().replace(/\s+/g, '_').toUpperCase() : 'UNKNOWN'
const locationsMatch =
text.match(/(\d+)\s*LOCATIONS?\s*MONITORED/i) ||
text.match(/"locationsMonitored"\s*:\s*(\d+)/i) ||
text.match(/"locations"\s*:\s*(\d+)/i)
const locations = locationsMatch ? Number(locationsMatch[1]) : null
const timeMatch =
text.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/) ||
text.match(/"timestamp"\s*:\s*"([0-9T:\-.Z]+)"/i)
const capturedAt = timeMatch
? String(timeMatch[1]).replace('T', ' ').replace('Z', '').replace(/\s+/, ' ')
: new Date().toISOString().replace('T', ' ').slice(0, 19)
const feedFailed = /CONNECTION FAILED|Data fetch error|ERROR:\s*Failed to fetch|Failed to fetch/i.test(text)
const warIndex = Math.max(0, Math.min(100, (6 - doughcon) * 20))
return { doughcon, status, locations, capturedAt, feedFailed, warIndex }
}
const clamp = (n, min, max) => Math.max(min, Math.min(max, n))
const vixToWarIndex = (vix) => {
const base = ((vix - 12) / 38) * 100
return clamp(base, 0, 100)
}
const classifyVixStatus = (vix) => {
if (vix >= 30) return 'STRESSED'
if (vix >= 22) return 'ELEVATED'
return 'CALM'
}
const parseFredVix = (raw) => {
const lines = String(raw || '').split(/\r?\n/).map(x => x.trim()).filter(Boolean)
if (lines.length < 2) return null
const points = []
for (let i = 1; i < lines.length; i += 1) {
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()
const value = Number(valueRaw)
if (!date || !Number.isFinite(value)) continue
points.push({ date, value })
}
if (!points.length) return null
const last = points[points.length - 1]
const prev = points.length > 1 ? points[points.length - 2] : null
const warIndex = vixToWarIndex(last.value)
const prevWarIndex = prev ? vixToWarIndex(prev.value) : warIndex
return {
provider: 'fred_vix',
vix: last.value,
prevVix: prev ? prev.value : last.value,
warIndex,
prevWarIndex,
status: classifyVixStatus(last.value),
capturedAt: `${last.date} 00:00:00`
}
}
const getWarRiskBand = (warIndex) => {
if (!Number.isFinite(warIndex)) return '未知'
if (warIndex < 30) return '低风险'
if (warIndex < 60) return '中风险'
return '高风险'
}
const buildExplainText = (snapshot, sourceLabel) => {
const warIndex = Number(snapshot && snapshot.warIndex)
const band = getWarRiskBand(warIndex)
if (snapshot && snapshot.provider === 'fred_vix') {
const vix = Number(snapshot.vix)
return `指数说明:0-100 越高风险越高;当前 ${warIndex.toFixed(2)}(${band})。数据源 ${sourceLabel},VIX ${vix.toFixed(2)} 代表市场波动预期。`
}
const doughcon = Number(snapshot && snapshot.doughcon)
const status = String((snapshot && snapshot.status) || 'UNKNOWN')
return `指数说明:0-100 越高风险越高;当前 ${warIndex.toFixed(2)}(${band})。数据源 ${sourceLabel},DOUGHCON ${doughcon} / 状态 ${status}。`
}
const buildAlerts = (snapshot, prevIndex, config) => {
const alerts = []
const cfg = config && config.alerts ? config.alerts : {}
const jumpThreshold = Number.isFinite(cfg.index_jump_threshold) ? cfg.index_jump_threshold : 20
const warIndex = snapshot.warIndex
const delta = Number.isFinite(prevIndex) ? warIndex - prevIndex : 0
if (snapshot.provider === 'fred_vix') {
const vixWarningMin = Number.isFinite(cfg.vix_warning_min) ? cfg.vix_warning_min : 22
const vixCriticalMin = Number.isFinite(cfg.vix_critical_min) ? cfg.vix_critical_min : 30
if (snapshot.vix >= vixCriticalMin) {
alerts.push({
id: `vix_critical_${Math.floor(snapshot.vix)}`,
title: `${config.name}高风险预警`,
message: `VIX ${snapshot.vix.toFixed(2)},战争指数 ${warIndex.toFixed(2)},进入高警戒区`,
level: 'critical'
})
} else if (snapshot.vix >= vixWarningMin) {
alerts.push({
id: `vix_warning_${Math.floor(snapshot.vix)}`,
title: `${config.name}风险抬升预警`,
message: `VIX ${snapshot.vix.toFixed(2)},战争指数 ${warIndex.toFixed(2)},建议关注地缘冲突动态`,
level: 'warning'
})
}
if (Number.isFinite(prevIndex) && delta >= jumpThreshold) {
alerts.push({
id: `jump_${Math.floor(delta)}`,
title: `${config.name}快速升温预警`,
message: `指数单次上跳 ${delta.toFixed(2)},当前 ${warIndex.toFixed(2)},上一值 ${prevIndex.toFixed(2)}`,
level: 'critical'
})
}
return alerts
}
const criticalMax = Number.isFinite(cfg.doughcon_critical_max) ? cfg.doughcon_critical_max : 2
const warningMax = Number.isFinite(cfg.doughcon_warning_max) ? cfg.doughcon_warning_max : 3
const doughcon = snapshot.doughcon
const status = snapshot.status
if (snapshot.feedFailed) {
alerts.push({
id: 'feed_failed',
title: `${config.name}数据源异常`,
message: `PizzINT 页面出现数据抓取失败信号,请人工复核。当前 DOUGHCON ${doughcon}`,
level: 'warning'
})
}
if (status && status !== 'OPERATIONAL' && status !== 'UNKNOWN') {
alerts.push({
id: `status_${status.toLowerCase()}`,
title: `${config.name}运行状态异常`,
message: `当前状态 ${status},DOUGHCON ${doughcon},指数 ${warIndex.toFixed(2)}`,
level: 'warning'
})
}
if (doughcon <= criticalMax) {
alerts.push({
id: `doughcon_critical_${doughcon}`,
title: `${config.name}高风险预警`,
message: `DOUGHCON ${doughcon},战争指数 ${warIndex.toFixed(2)},进入高警戒区`,
level: 'critical'
})
} else if (doughcon <= warningMax) {
alerts.push({
id: `doughcon_warning_${doughcon}`,
title: `${config.name}风险抬升预警`,
message: `DOUGHCON ${doughcon},战争指数 ${warIndex.toFixed(2)},建议关注地缘冲突动态`,
level: 'warning'
})
}
if (Number.isFinite(prevIndex) && delta >= jumpThreshold) {
alerts.push({
id: `jump_${Math.floor(delta)}`,
title: `${config.name}快速升温预警`,
message: `指数单次上跳 ${delta.toFixed(2)},当前 ${warIndex.toFixed(2)},上一值 ${prevIndex.toFixed(2)}`,
level: 'critical'
})
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, db, logJSON } = context
const stableUrl = config.stable_url || 'https://fred.stlouisfed.org/graph/fredgraph.csv?id=VIXCLS'
const pizzintUrl = config.pizzint_url || 'https://www.pizzint.watch/'
const stateKey = `skill_state:${String(config.symbol || 'us_war_pizzint')}:war_index`
const failCountKey = `${stateKey}:fail_count`
const failAlertThreshold = Number.isFinite(Number(config && config.fail_alert_consecutive))
? Math.max(1, Number(config.fail_alert_consecutive))
: 3
const getState = db.prepare(`SELECT value FROM market_state WHERE key = ?`)
const upsertState = db.prepare(
`INSERT INTO market_state (key, value, updated_at)
VALUES (@key, @value, datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = datetime('now')`
)
const stateRow = getState.get(stateKey)
const failCountRow = getState.get(failCountKey)
const prevIndex = stateRow && Number.isFinite(Number(stateRow.value)) ? Number(stateRow.value) : NaN
const failCount = failCountRow && Number.isFinite(Number(failCountRow.value)) ? Math.max(0, Number(failCountRow.value)) : 0
const fallbackPrice = Number.isFinite(prevIndex) ? prevIndex : 20
const buildFallback = (status, message) => {
const nextFailCount = failCount + 1
upsertState.run({ key: failCountKey, value: String(nextFailCount) })
const explain = `指数说明:0-100 越高风险越高;当前为兜底值 ${fallbackPrice.toFixed(2)}。稳定源与备用源连续失败时会回退。`
const data = [{
label: config.name,
symbol: config.symbol,
source: 'war_index_fallback',
name: `DOUGHCON N/A · ${status}`,
price: fallbackPrice,
prevClose: fallbackPrice,
change: 0,
change_percent: 0,
captured_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
market_status: config.market_status,
pizzint_doughcon: null,
pizzint_status: status,
pizzint_locations: null,
pizzint_feed_failed: 1,
explain
}]
const alerts = nextFailCount >= failAlertThreshold
? [{
id: status.toLowerCase(),
title: `${config.name}数据异常`,
message: `${message}(连续失败 ${nextFailCount} 次)。${explain}`,
level: 'warning'
}]
: []
return { data, alerts }
}
const stableRaw = await fetchFredVix(axios, stableUrl)
const stableSnapshot = stableRaw ? parseFredVix(stableRaw) : null
if (stableSnapshot) {
if (failCount > 0) upsertState.run({ key: failCountKey, value: '0' })
const warIndex = stableSnapshot.warIndex
const prevClose = Number.isFinite(prevIndex) ? prevIndex : stableSnapshot.prevWarIndex
const change = warIndex - prevClose
const changePercent = prevClose ? (change / prevClose) * 100 : 0
const explain = buildExplainText(stableSnapshot, 'FRED VIX')
upsertState.run({ key: stateKey, value: String(warIndex) })
const data = [{
label: config.name,
symbol: config.symbol,
source: 'fred_vix',
name: `VIX ${stableSnapshot.vix.toFixed(2)} · ${stableSnapshot.status}`,
price: warIndex,
prevClose,
change,
change_percent: changePercent,
captured_at: stableSnapshot.capturedAt,
market_status: config.market_status,
pizzint_doughcon: null,
pizzint_status: null,
pizzint_locations: null,
pizzint_feed_failed: 0,
explain
}]
const alerts = buildAlerts(stableSnapshot, Number.isFinite(prevIndex) ? prevIndex : null, config)
return { data, alerts }
}
const candidates = buildCandidateUrls(pizzintUrl)
let snapshot = null
let fetchedCount = 0
let parseFailedCount = 0
let resolvedFrom = ''
for (const candidate of candidates) {
const html = await fetchPizzint(axios, candidate)
if (!html) continue
fetchedCount += 1
const parsed = parsePizzint(html)
if (parsed) {
snapshot = parsed
resolvedFrom = candidate
break
}
parseFailedCount += 1
}
if (!snapshot && fetchedCount === 0) {
logJSON('skill.fetch.error', { skill: config.name, message: 'All sources fetch failed', candidates: candidates.length, stableUrl }, 'market_skills')
return buildFallback('FETCH_FAILED', '稳定源 FRED VIX 与备用源 PizzINT 均抓取失败,已回退到兜底数值')
}
if (!snapshot) {
logJSON('skill.parse.error', { skill: config.name, message: 'PizzINT parse failed after stable source failure', fetchedCount, parseFailedCount, stableUrl }, 'market_skills')
return buildFallback('PARSE_FAILED', '稳定源不可用且备用源解析失败,已回退到兜底数值')
}
if (resolvedFrom && String(resolvedFrom) !== String(pizzintUrl)) {
logJSON('skill.fetch.fallback', { skill: config.name, resolvedFrom }, 'market_skills')
}
if (failCount > 0) upsertState.run({ key: failCountKey, value: '0' })
const warIndex = snapshot.warIndex
const prevClose = Number.isFinite(prevIndex) ? prevIndex : warIndex
const change = warIndex - prevClose
const changePercent = prevClose ? (change / prevClose) * 100 : 0
const explain = buildExplainText(snapshot, 'PizzINT')
upsertState.run({ key: stateKey, value: String(warIndex) })
const data = [{
label: config.name,
symbol: config.symbol,
source: 'pizzint',
name: `DOUGHCON ${snapshot.doughcon} · ${snapshot.status}`,
price: warIndex,
prevClose,
change,
change_percent: changePercent,
captured_at: snapshot.capturedAt,
market_status: config.market_status,
pizzint_doughcon: snapshot.doughcon,
pizzint_status: snapshot.status,
pizzint_locations: snapshot.locations,
pizzint_feed_failed: snapshot.feedFailed ? 1 : 0,
explain
}]
const alerts = buildAlerts(snapshot, Number.isFinite(prevIndex) ? prevIndex : null, config)
return { data, alerts }
}
}
@@ -0,0 +1,89 @@
{
"name_comment": "显示的名",
"name": "中国银行-299",
"card_order": 2,
"card_type": "299",
"card_span_comment": "卡片横向占位列数",
"card_span": 2,
"card_custom_class_comment": "中国银行卡片专属样式类名,仅用于前端独立展示",
"card_custom_class": "card-zhonghang-299",
"enabled_comment": "是否启用该监",
"enabled": true,
"display_comment": "是否显示该卡",
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
"symbol_comment": "监控的代",
"symbol": "sh601988_299",
"source_symbol_comment": "用于拉取K线的真实代码",
"source_symbol": "sh601988",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/sh601988.html",
"source_comment": "数据来源 (",
"source": "tencent",
"box_window_comment": "收盘价箱体窗口,仅支",
"box_window": 30,
"box_confirm_days_comment": "箱体突破确认天数",
"box_confirm_days": 2,
"box_volume_ma_window_comment": "突破日成交量均量窗口",
"box_volume_ma_window": 5,
"box_volume_expand_ratio_comment": "突破日放量倍数阈",
"box_volume_expand_ratio": 1.2,
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": false,
"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": false,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": false,
"push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": false,
"key_comment": "Weekly 工具",
"key": ""
},
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/calendar/events",
"enabled_comment": "是否启用",
"enabled": false
}
],
"market_status": {
"timezone": "Asia/Shanghai",
"sessions": [
{
"start": "09:30",
"end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
}
],
"display_label": "中国A"
},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
@@ -0,0 +1,289 @@
const calculateMA = (data, period) => {
if (!data || data.length < period) return null
const subset = data.slice(-period)
const sum = subset.reduce((a, b) => a + b, 0)
return sum / period
}
const calculateTrailingMA = (data, endExclusive, period) => {
if (!Array.isArray(data) || period <= 0) return null
if (!Number.isInteger(endExclusive) || endExclusive < period) return null
const subset = data.slice(endExclusive - period, endExclusive)
if (subset.length < period) return null
const sum = subset.reduce((a, b) => a + b, 0)
return sum / period
}
const fetchKLine = async (axios, symbol) => {
const url = `http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${symbol},day,,,300,qfq`
try {
const res = await axios.get(url, { timeout: 10000 })
if (!res || !res.data || res.data.code !== 0) return null
const d = res.data.data[symbol]
if (!d) return null
const rawData = d.qfqday || d.day
if (!rawData || !Array.isArray(rawData)) return null
return rawData.map(item => {
const volume = parseFloat(item[5])
return {
date: item[0],
close: parseFloat(item[2]),
volume: Number.isFinite(volume) ? volume : 0
}
})
} catch (e) {
return null
}
}
const buildAlerts = ({
latestClose,
prevClose,
ma10,
ma20,
ma60,
ma90,
ma250,
prevMA20,
prevMA60,
prevMA90,
prevMA250,
isTrendGateOpen,
boxBreakoutConfirmed,
boxBreakdownConfirmed
}) => {
const alerts = []
const addAlert = (id, title, message, level = 'warning') => {
alerts.push({ id, title, message, level })
}
const isCrossDown = (prevC, prevMa, currC, currMa) => prevC >= prevMa && currC < currMa
const isCrossUp = (prevC, prevMa, currC, currMa) => prevC <= prevMa && currC > currMa
const isBelowMA250 = latestClose < ma250
const isCrossDownMA250 = isCrossDown(prevClose, prevMA250, latestClose, ma250)
const isCrossUpMA250 = isCrossUp(prevClose, prevMA250, latestClose, ma250)
if (isCrossDownMA250) {
addAlert('below_ma250', '中国银行跌破年线!立即停止加仓,建议减仓', '中国银行跌破年线!立即停止加仓,建议减仓', 'error')
}
if (isCrossUpMA250) {
addAlert('above_ma250', '中国银行回到年线之上,可恢复加仓策略', '中国银行回到年线之上,可恢复加仓策略')
}
if (boxBreakoutConfirmed) {
addAlert('box_breakout_confirmed', '箱体上沿突破已确认', '中国银行30日收盘价箱体上沿突破已连续2日确认,且突破日放量')
}
if (boxBreakdownConfirmed) {
addAlert('box_breakdown_confirmed', '箱体下沿跌破已确认', '中国银行30日收盘价箱体下沿跌破已连续2日确认,建议防守', 'error')
}
if (isTrendGateOpen) {
if (isCrossDown(prevClose, prevMA20, latestClose, ma20)) {
addAlert('below_ma20', '中国银行-跌破20日线,需加仓 20%', '中国银行-跌破20日线,需加仓 20%')
}
if (isCrossDown(prevClose, prevMA60, latestClose, ma60)) {
addAlert('below_ma60', '中国银行-跌破60日线,需加仓 40%', '中国银行-跌破60日线,需加仓 40%')
}
if (isCrossDown(prevClose, prevMA90, latestClose, ma90)) {
addAlert('below_ma90', '中国银行-跌破90日线,需加仓剩余 40%', '中国银行-跌破90日线,需加仓剩余 40%')
}
}
if (isCrossUp(prevClose, prevMA20, latestClose, ma20)) {
addAlert('above_ma20', '中国银行-站上20日线,需卖出 20%', '中国银行-站上20日线,需卖出 20%')
}
if (isCrossUp(prevClose, prevMA60, latestClose, ma60)) {
addAlert('above_ma60', '中国银行-站上60日线,需卖出 40%', '中国银行-站上60日线,需卖出 40%')
}
if (isCrossUp(prevClose, prevMA90, latestClose, ma90)) {
addAlert('above_ma90', '中国银行-站上90日线,需卖出剩余 40%', '中国银行-站上90日线,需卖出剩余 40%')
}
if (ma10 > ma20) {
addAlert('trend_bull', '趋势提示', '中国银行处于上涨趋势,建议持有底仓 + 已建波段仓,不做 T')
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbol = config.symbol
const sourceSymbol = config.source_symbol || 'sh601988'
const klineData = await fetchKLine(axios, sourceSymbol)
if (!klineData || klineData.length < 250) {
logJSON('skill.zhonghang_299.error', { message: 'Insufficient data', length: klineData ? klineData.length : 0 }, 'market_skills')
return null
}
const closes = klineData.map(d => d.close)
const volumes = klineData.map(d => d.volume)
const latestClose = closes[closes.length - 1]
const prevClose = closes[closes.length - 2]
const prevPrevClose = closes[closes.length - 3]
const latestDate = klineData[klineData.length - 1].date
const ma10 = calculateMA(closes, 10)
const ma20 = calculateMA(closes, 20)
const ma60 = calculateMA(closes, 60)
const ma90 = calculateMA(closes, 90)
const ma250 = calculateMA(closes, 250)
const closesPrev = closes.slice(0, -1)
const prevMA20 = calculateMA(closesPrev, 20)
const prevMA60 = calculateMA(closesPrev, 60)
const prevMA90 = calculateMA(closesPrev, 90)
const prevMA250 = calculateMA(closesPrev, 250)
const boxWindowRaw = parseInt(String(config.box_window || 30), 10)
const boxWindow = [20, 30, 40].includes(boxWindowRaw) ? boxWindowRaw : 30
const confirmDaysRaw = parseInt(String(config.box_confirm_days || 2), 10)
const confirmDays = confirmDaysRaw >= 2 ? 2 : 1
const volumeMAWindowRaw = parseInt(String(config.box_volume_ma_window || 5), 10)
const volumeMAWindow = volumeMAWindowRaw > 0 ? volumeMAWindowRaw : 5
const volumeExpandRatioRaw = parseFloat(String(config.box_volume_expand_ratio || 1.2))
const volumeExpandRatio = Number.isFinite(volumeExpandRatioRaw) && volumeExpandRatioRaw > 0 ? volumeExpandRatioRaw : 1.2
if (!ma10 || !ma20 || !ma60 || !ma90 || !ma250 || !prevMA20 || !prevMA60 || !prevMA90 || !prevMA250) {
logJSON('skill.zhonghang_299.error', { message: 'MA calculation failed' }, 'market_skills')
return null
}
if (!Number.isFinite(prevPrevClose) || closes.length < boxWindow + 2) {
logJSON('skill.zhonghang_299.error', { message: 'Insufficient data for box', length: closes.length, boxWindow }, 'market_skills')
return null
}
const change = latestClose - prevClose
const changePercent = (change / prevClose) * 100
const trendText = ma10 > ma20 ? '多头排列' : '非多头'
const yearLineText = latestClose < ma250 ? '年线下方' : '年线之上'
const isCrossDown = (prevC, prevMa, currC, currMa) => prevC >= prevMa && currC < currMa
const isCrossUp = (prevC, prevMa, currC, currMa) => prevC <= prevMa && currC > currMa
const isBelowMA250 = latestClose < ma250
const isMA250SlopeUp = ma250 >= prevMA250
const isMA60SlopeUp = ma60 >= prevMA60
const latestIndex = closes.length - 1
const prevIndex = closes.length - 2
const getBoxUpper = idx => Math.max(...closes.slice(idx - boxWindow, idx))
const getBoxLower = idx => Math.min(...closes.slice(idx - boxWindow, idx))
const upperPrev = getBoxUpper(prevIndex)
const upperLatest = getBoxUpper(latestIndex)
const lowerPrev = getBoxLower(prevIndex)
const lowerLatest = getBoxLower(latestIndex)
const prevVolume = volumes[prevIndex]
const latestVolume = volumes[latestIndex]
const prevVolumeMA = calculateTrailingMA(volumes, prevIndex, volumeMAWindow)
const latestVolumeMA = calculateTrailingMA(volumes, latestIndex, volumeMAWindow)
const breakoutDayPrev = prevPrevClose <= upperPrev && prevClose > upperPrev
const breakdownDayPrev = prevPrevClose >= lowerPrev && prevClose < lowerPrev
const breakoutVolumeOkPrev = Number.isFinite(prevVolumeMA) ? prevVolume > prevVolumeMA * volumeExpandRatio : false
const breakoutDayLatest = prevClose <= upperLatest && latestClose > upperLatest
const breakoutVolumeOkLatest = Number.isFinite(latestVolumeMA) ? latestVolume > latestVolumeMA * volumeExpandRatio : false
const boxBreakoutConfirmed = confirmDays === 2
? breakoutDayPrev && latestClose > upperLatest && breakoutVolumeOkPrev
: breakoutDayLatest && breakoutVolumeOkLatest
const boxBreakdownConfirmed = confirmDays === 2
? breakdownDayPrev && latestClose < lowerLatest
: prevClose >= lowerLatest && latestClose < lowerLatest
const isTrendGateOpen = latestClose > ma250 && isMA250SlopeUp && isMA60SlopeUp && boxBreakoutConfirmed
const crossDownMA20 = isCrossDown(prevClose, prevMA20, latestClose, ma20)
const crossDownMA60 = isCrossDown(prevClose, prevMA60, latestClose, ma60)
const crossDownMA90 = isCrossDown(prevClose, prevMA90, latestClose, ma90)
const crossUpMA20 = isCrossUp(prevClose, prevMA20, latestClose, ma20)
const crossUpMA60 = isCrossUp(prevClose, prevMA60, latestClose, ma60)
const crossUpMA90 = isCrossUp(prevClose, prevMA90, latestClose, ma90)
const crossUpMA250 = isCrossUp(prevClose, prevMA250, latestClose, ma250)
const statusDownMA20 = isTrendGateOpen ? (crossDownMA20 ? '已触发(加仓20%)' : '未触发') : '暂停(未满足: 收盘>MA250 且 MA250/MA60斜率≥0 且 箱体上沿突破确认)'
const statusDownMA60 = isTrendGateOpen ? (crossDownMA60 ? '已触发(加仓40%)' : '未触发') : '暂停(未满足: 收盘>MA250 且 MA250/MA60斜率≥0 且 箱体上沿突破确认)'
const statusDownMA90 = isTrendGateOpen ? (crossDownMA90 ? '已触发(加仓剩余40%)' : '未触发') : '暂停(未满足: 收盘>MA250 且 MA250/MA60斜率≥0 且 箱体上沿突破确认)'
const statusUpMA20 = crossUpMA20 ? '已触发(卖出20%)' : '未触发'
const statusUpMA60 = crossUpMA60 ? '已触发(卖出40%)' : '未触发'
const statusUpMA90 = crossUpMA90 ? '已触发(卖出剩余40%)' : '未触发'
const statusTrend = ma10 > ma20 ? '已满足(上涨趋势)' : '未满足'
const statusMA250 = isBelowMA250 ? '已触发(停止加仓/建议减仓)' : (crossUpMA250 ? '已触发(恢复加仓)' : '未触发')
const statusBoxBreakout = boxBreakoutConfirmed ? '已确认(连续2日 + 突破日放量)' : '未确认'
const statusBoxBreakdown = boxBreakdownConfirmed ? '已确认(连续2日)' : '未确认'
const statusBoxRange = `上沿${upperLatest.toFixed(2)} / 下沿${lowerLatest.toFixed(2)} (N=${boxWindow})`
const breakoutDate = confirmDays === 2
? (breakoutDayPrev ? klineData[prevIndex].date : '—')
: (breakoutDayLatest ? klineData[latestIndex].date : '—')
const breakoutConfirmDate = boxBreakoutConfirmed ? klineData[latestIndex].date : '—'
const breakoutVolumeRatio = confirmDays === 2
? (Number.isFinite(prevVolumeMA) && prevVolumeMA > 0 ? prevVolume / prevVolumeMA : null)
: (Number.isFinite(latestVolumeMA) && latestVolumeMA > 0 ? latestVolume / latestVolumeMA : null)
const breakoutVolumeRatioText = Number.isFinite(breakoutVolumeRatio) ? `${breakoutVolumeRatio.toFixed(2)}x` : '—'
const breakoutVolumeGateText = `${volumeExpandRatio.toFixed(2)}x`
const htmlPayload = `
<div class="zh299-wrap">
<div class="zh299-price">价格:${latestClose.toFixed(2)} (${change >= 0 ? '+' : ''}${change.toFixed(2)} / ${changePercent.toFixed(2)}%)</div>
<div class="zh299-grid">
<div class="zh299-block">
<div class="zh299-title">均线快照</div>
<div class="zh299-row">MA10:${ma10.toFixed(2)} | MA20:${ma20.toFixed(2)} | MA60:${ma60.toFixed(2)}</div>
<div class="zh299-row">MA90:${ma90.toFixed(2)} | MA250:${ma250.toFixed(2)}</div>
<div class="zh299-row">趋势:${trendText} | 年线状态:${yearLineText}</div>
<div class="zh299-row">箱体:${statusBoxRange}</div>
</div>
<div class="zh299-block">
<div class="zh299-title">299执行面板</div>
<div class="zh299-row">20日线跌破:${statusDownMA20} | 站上:${statusUpMA20}</div>
<div class="zh299-row">60日线跌破:${statusDownMA60} | 站上:${statusUpMA60}</div>
<div class="zh299-row">90日线跌破:${statusDownMA90} | 站上:${statusUpMA90}</div>
<div class="zh299-row">MA10>MA20:${statusTrend} | MA250:${statusMA250}</div>
<div class="zh299-row">箱体上沿突破:${statusBoxBreakout}</div>
<div class="zh299-row">突破日:${breakoutDate} | 确认日:${breakoutConfirmDate}</div>
<div class="zh299-row">突破日量比:${breakoutVolumeRatioText} | 放量门槛:${breakoutVolumeGateText}</div>
<div class="zh299-row">箱体下沿跌破:${statusBoxBreakdown}</div>
</div>
</div>
</div>
`
const data = [{
label: config.name,
symbol: symbol,
displaySymbol: sourceSymbol,
isMaCard: true,
render_type: 'zhonghang_299_html',
html: htmlPayload,
card_custom_class: config.card_custom_class || 'card-zhonghang-299',
ma10: ma10.toFixed(2),
ma20: ma20.toFixed(2),
ma60: ma60.toFixed(2),
ma90: ma90.toFixed(2),
ma250: ma250.toFixed(2),
source: config.source || 'tencent',
name: config.name,
price: latestClose,
prevClose: prevClose,
change: change,
change_percent: changePercent,
market_status: `${trendText} (${latestDate})`
}]
const alerts = buildAlerts({
latestClose,
prevClose,
ma10,
ma20,
ma60,
ma90,
ma250,
prevMA20,
prevMA60,
prevMA90,
prevMA250,
isTrendGateOpen,
boxBreakoutConfirmed,
boxBreakdownConfirmed
})
return { data, alerts }
}
}