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