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 = `
价格:${latestClose.toFixed(2)} (${change >= 0 ? '+' : ''}${change.toFixed(2)} / ${changePercent.toFixed(2)}%)
MA10:${ma10.toFixed(2)} | MA20:${ma20.toFixed(2)} | MA60:${ma60.toFixed(2)}
MA90:${ma90.toFixed(2)} | MA250:${ma250.toFixed(2)}
趋势:${trendText} | 年线状态:${yearLineText}
20日线跌破:${statusDownMA20} | 站上:${statusUpMA20}
60日线跌破:${statusDownMA60} | 站上:${statusUpMA60}
90日线跌破:${statusDownMA90} | 站上:${statusUpMA90}
MA10>MA20:${statusTrend} | MA250:${statusMA250}
` 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 } } }