feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user