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

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
@@ -0,0 +1,21 @@
{
"enabled": true,
"display_comment": "是否显示该卡片 (false 则不拉取数据也不封存)",
"display": true,
"schedule": "1h",
"symbol": "sh000300_ma",
"source_symbol": "sh000300",
"name": "沪深300增强版",
"card_order": 7,
"card_type": "enhanced",
"source": "tencent",
"email_reminder_enabled_comment": "是否发送邮件提醒",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
"alerts": {
"ma_cross": true,
"urgent_abs_change_percent": 5,
"urgent_cooldown_minutes": 120
}
}
+260
View File
@@ -0,0 +1,260 @@
const iconv = require('iconv-lite')
// State to prevent alert spam
// Map<alertKey, lastAlertTimestamp>
const lastAlertTimes = new Map()
// Helper to calculate Simple Moving Average
const calculateMA = (data, period) => {
if (!data || data.length < period) return null
const subset = data.slice(-period)
const sum = subset.reduce((a, b) => a + b, 0)
return sum / period
}
// Helper to fetch K-line data from Tencent
const fetchKLine = async (axios, symbol) => {
// format: sh000300
// url: http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=sh000300,day,,,300,qfq
// fetch 300 days to calculate MA250
const url = `http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${symbol},day,,,300,qfq`
try {
const res = await axios.get(url, { timeout: 10000 })
if (!res || !res.data || res.data.code !== 0) return null
// Response structure:
// { code: 0, msg: "", data: { [symbol]: { day: [ ["2023-01-01", "3800.00", "3850.00", ...], ... ], qfqday: [...] } } }
// If qfq (adjusted) exists, prefer it. Usually in `qfqday`.
const d = res.data.data[symbol]
if (!d) return null
// Use qfqday if available (adjusted prices), else day
const rawData = d.qfqday || d.day
if (!rawData || !Array.isArray(rawData)) return null
// Extract closing prices (index 2 in Tencent array: [date, open, close, high, low, vol])
// Verify index:
// Usually: [date, open, close, high, low, volume]
// Example: ["2023-01-01", "10.00", "10.50", "10.60", "9.90", "1000"]
// Close is index 2.
return rawData.map(item => ({
date: item[0],
close: parseFloat(item[2])
}))
} catch (e) {
console.error('Fetch KLine Error:', e.message)
return null
}
}
const checkAlerts = (ma120, ma250, prevMA120, prevMA250, dateStr) => {
const alerts = []
// Helper to add alert with slot-based cooldown
const addAlert = (key, title, message, level = 'warning') => {
// Get current Beijing time
const d = new Date()
const utc = d.getTime() + (d.getTimezoneOffset() * 60000)
const bjDate = new Date(utc + (3600000 * 8))
const year = bjDate.getFullYear()
const month = String(bjDate.getMonth() + 1).padStart(2, '0')
const day = String(bjDate.getDate()).padStart(2, '0')
const hour = bjDate.getHours()
let slot = ''
if (hour >= 0 && hour < 12) slot = 'am'
else slot = 'pm' // 12:00 - 23:59
const currentSlotKey = `${year}-${month}-${day}-${slot}`
const lastSlotKey = lastAlertTimes.get(key)
// Only alert if we haven't alerted in this slot for this specific key
if (lastSlotKey !== currentSlotKey) {
alerts.push({ id: key, title, message, level })
lastAlertTimes.set(key, currentSlotKey)
}
}
// Logic:
// 1. Cross Down (Bearish): 120 跌破 250
// Condition: Prev(MA120 >= MA250) AND Curr(MA120 < MA250)
// Or simply: MA120 < MA250
// User wants alert when it happens.
// "如果120 跌破 250。发出警告...请减仓/卖出"
// To avoid noise, we check if the state *changed* from yesterday (or previous calculation).
// But K-line data includes "today" (if market open).
// So index N is today, N-1 is yesterday.
const isCrossDown = (prevMA120 >= prevMA250) && (ma120 < ma250)
const isCrossUp = (prevMA120 <= prevMA250) && (ma120 > ma250)
const vals = `(MA120: ${ma120.toFixed(2)}, MA250: ${ma250.toFixed(2)})`
// 1. Cross Down (Bearish): 120 跌破 250
if (isCrossDown) {
addAlert('cross_down', '120日线跌破250日线', `120 跌破 250。今天的两个数值分别是 ${vals},请减仓/卖出`)
}
// 2. Cross Up (Bullish): 120 上穿 250
if (isCrossUp) {
addAlert('cross_up', '120日线上穿250日线', `120 重新上穿 250。今天的两个数值分别是 ${vals},请重新买回`)
}
// 3. Convergence/Divergence Logic (Gap Analysis)
// Calculate percentage difference relative to MA250
const diffPercent = ((ma120 - ma250) / ma250) * 100
const absDiff = Math.abs(diffPercent)
// Thresholds
const CONVERGENCE_THRESHOLD = 1.0 // < 1% means very close (about to cross)
const DIVERGENCE_THRESHOLD = 3.0 // > 3% means trend confirmed
// (A) Approaching (Convergence) Alert - Warning Phase
// Trigger if gap is small (< 1%) AND wasn't already small yesterday (to avoid spamming every day in sticky market)
// OR simply remind if it enters this zone.
// Strategy: Alert if current gap < 1%.
// To avoid spam: relying on slot-based cooldown (once per AM/PM slot).
// Refinement: Only alert if it's "Approaching". i.e., Gap is shrinking?
// User said: "快要接近" -> Gap is small.
if (absDiff < CONVERGENCE_THRESHOLD) {
const direction = diffPercent > 0 ? '向下逼近' : '向上逼近'
addAlert('convergence', '均线高度粘合预警', `MA120 与 MA250 差距仅 ${absDiff.toFixed(2)}% (${direction}),即将面临方向选择,请密切关注! ${vals}`)
}
// (B) Diverging (Trend Confirmation) Alert - Safe Phase
// Trigger when gap widens beyond 3% AFTER being smaller.
// This implies we entered a trend.
// We check if *yesterday* was < 3% and *today* >= 3%.
const prevDiffPercent = ((prevMA120 - prevMA250) / prevMA250) * 100
const prevAbsDiff = Math.abs(prevDiffPercent)
if (absDiff >= DIVERGENCE_THRESHOLD && prevAbsDiff < DIVERGENCE_THRESHOLD) {
if (diffPercent > 0) {
// Bullish divergence
addAlert('divergence_bull', '多头趋势确认', `金叉后开口扩大 (>${DIVERGENCE_THRESHOLD}%),多头趋势稳固,建议坚定持有。 ${vals}`)
} else {
// Bearish divergence
addAlert('divergence_bear', '空头趋势确认', `死叉后开口扩大 (>${DIVERGENCE_THRESHOLD}%),下跌趋势加速,建议空仓观望。 ${vals}`)
}
}
return alerts
}
module.exports = {
run: async (context, config) => {
const { axios, logJSON } = context
const symbol = config.symbol // sh000300_ma (for DB/Uniqueness)
const sourceSymbol = config.source_symbol || 'sh000300' // Real symbol for API
const klineData = await fetchKLine(axios, sourceSymbol)
if (!klineData || klineData.length < 250) {
logJSON('skill.csi300_ma.error', { message: 'Insufficient data', length: klineData ? klineData.length : 0 }, 'market_skills')
return null
}
// Calculate MAs
// We need MA for today (latest) and yesterday (latest - 1)
const closes = klineData.map(d => d.close)
// Today
const ma120 = calculateMA(closes, 120)
const ma250 = calculateMA(closes, 250)
// Yesterday (remove last element)
const closesPrev = closes.slice(0, -1)
const prevMA120 = calculateMA(closesPrev, 120)
const prevMA250 = calculateMA(closesPrev, 250)
if (!ma120 || !ma250 || !prevMA120 || !prevMA250) {
logJSON('skill.csi300_ma.error', { message: 'MA calculation failed' }, 'market_skills')
return null
}
// Determine Status Text
let statusText = ''
if (ma120 > ma250) {
statusText = '继续定投、拿着'
} else if (ma120 < ma250) {
statusText = '减仓/卖出' // Or '空仓观望'
} else {
statusText = '均线粘合'
}
// Format for Card
// We want to show:
// Header: 沪深300增强版 · MA120: xxx | MA250: xxx (sh000300)
// Body: MA120 > MA250 :结论为“ 继续定投、拿着 ”
const latestClose = closes[closes.length - 1]
const prevClose = closes[closes.length - 2]
const change = latestClose - prevClose
const changePercent = (change / prevClose) * 100
const latestDate = klineData[klineData.length - 1].date
// Calculate Bias Ratio (乖离率)
// (Price - MA250) / MA250 * 100
const biasRatio = ((latestClose - ma250) / ma250) * 100
// Suggestion Logic
// If alerts exist, use the first one. Else, generic based on trend.
const alerts = checkAlerts(ma120, ma250, prevMA120, prevMA250, latestDate)
let suggestion = ''
if (alerts.length > 0) {
suggestion = alerts[0].message
} else {
// No active alert today, give general advice
if (ma120 > ma250) {
if (biasRatio > 10) suggestion = '乖离率过大 (>10%),注意短期回调风险'
else suggestion = '趋势向上,乖离率合理,可继续持有/定投'
} else {
if (biasRatio < -10) suggestion = '乖离率过大 (<-10%),可能存在超跌反弹'
else suggestion = '趋势向下,建议保持空仓或谨慎操作'
}
}
// Server-Driven UI (HTML Generation)
const isBull = ma120 > ma250
const conclusionColor = isBull ? '#f5222d' : '#52c41a'
const biasColor = biasRatio > 0 ? '#f5222d' : '#52c41a' // Red for positive bias (standard CN market color logic?) Or just neutral?
// Using standard CN colors: Red = Up/Bull, Green = Down/Bear
const htmlPayload = `
<div style="font-size: 14px; margin-top: 8px;">
MA120 ${isBull ? '>' : '<'} MA250 :结论为“ <span style="font-weight:bold; color:${conclusionColor}">${statusText}</span> ”
</div>
<div style="margin: 8px 0; border-top: 1px solid #eee; width: 100%;"></div>
<div style="font-size: 13px; color: #666; line-height: 1.5;">
乖离率:<span style="color:${biasColor}; font-weight:bold">${biasRatio.toFixed(2)}%</span> <br/>
提示:${suggestion}
</div>
`
const data = [{
label: config.name,
symbol: symbol,
displaySymbol: 'sh000300', // Show original symbol in UI
isMaCard: true,
render_type: 'html', // New SDUI flag
html: htmlPayload, // The payload
ma120: ma120.toFixed(2),
ma250: ma250.toFixed(2),
conclusion: statusText,
source: 'tencent',
name: statusText, // Fallback
price: latestClose,
prevClose: prevClose,
change: change,
change_percent: changePercent,
market_status: `${statusText} (${latestDate})`
}]
return { data, alerts }
}
}