chore: 初始提交 - 移除所有硬编码凭据,统一从环境变量读取

This commit is contained in:
yangxiangyuan
2026-06-11 17:10:58 +08:00
commit c223c79b36
1699 changed files with 313940 additions and 0 deletions
@@ -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 }
}
}