本策略是一套运行在币安永续合约上的均线穿叉多空量化系统。核心思路是每小时自动从全市场成交量最大的合约中,通过历史K线回测筛选出「均线策略适用性最强」的币种构成白名单,再对白名单币种实时监控均线穿叉信号进行开平仓,同时配备完整的止损/移动止盈/黑名单熔断体系。
① 标的池筛选 + 市场状态检测(每小时运行)
自动拉取全市场 USDT 永续合约行情,按美元成交额排序筛选前 N 个主流币种构建候选标的池。同时分析 BTC H4 波动率分位数,将市场划分为四种状态:正常(normal)、高波动(high_vol)、低波动(low_vol)、突变(volatile),影响后续仓位系数。
② 均线回测筛选(核心)
对每个候选币种拉取 H1 K线,遍历预设 MA 参数组合(如 MA5/20、MA10/30、MA20/60),执行完整的穿叉回测,计算胜率、盈亏比、最大回撤、信号数和波动率分位,综合评分取 Top N 写入白名单。防过拟合设计:只允许固定参数组合集,不做无限网格搜索。
③ 实时均线穿叉信号
对白名单币种用其回测最优 MA 参数,实时计算穿叉信号:金叉做多、死叉做空(volatile 状态自动禁止做空)。可选量能确认过滤假突破。
④ 执行调仓
按 equity × positionRatio / 信号数 均分仓位,不在信号列表的旧持仓自动平仓,市场状态异常时仓位系数自动缩减。
⑤ 实时持仓监控 + 风控(每3秒)
三重退出机制:固定止损、固定止盈、动态移动止盈(浮盈越高允许回撤越宽)。止损触发后币种进入黑名单冷却期,到期自动解除。
⑥ 可视化仪表板
三张可交互表格:账户概览、白名单评分排行、实时持仓监控,支持手动平仓/重置/清空黑名单/重新筛选。
| 参数 | 说明 | 默认值 |
|---|---|---|
| topN | 候选标的池大小 | 150 |
| maParams | MA参数组合(fast_slow逗号分隔) | 5_20,10_30,20_60 |
| klineLen | 回测K线数量 | 500 |
| topCoins | 白名单保留币种数 | 5 |
| minSignals | 最少信号数 | 8 |
| minWinRate | 最低胜率 | 0.40 |
| minProfitFactor | 最低盈亏比 | 1.20 |
| maxMDD | 最大回撤上限(%) | 25 |
| volPctBonus | 高弹性币加分权重 | 10 |
| positionRatio | 总仓位比例 | 0.80 |
| maxLeverage | 最大杠杆倍数 | 3 |
| allowShort | 是否允许做空 | true |
| volMultiplier | 量能确认倍数(1.0=关闭) | 1.0 |
| stopLossPct | 单仓止损比例(%) | 5 |
| takeProfitPct | 单仓固定止盈比例(%) | 10 |
| trailTrigger | 移动止盈启动浮盈阈值(%) | 3 |
| blacklistTTL | 黑名单冷却时长(毫秒) | 14400000 |
| btcChangePct | BTC突变判定阈值(%) | 5 |
{"type":"n8n","content":"{\"workflowData\":{\"nodes\":[{\"parameters\":{\"notice\":\"\",\"rule\":{\"interval\":[{\"field\":\"minutes\",\"minutesInterval\":1}]}},\"type\":\"n8n-nodes-base.scheduleTrigger\",\"typeVersion\":1.2,\"position\":[416,-224],\"id\":\"slow-trigger\",\"name\":\"慢触发器\"},{\"parameters\":{\"mode\":\"runOnceForAllItems\",\"language\":\"javaScript\",\"jsCode\":\"if (_G('ma_initialized') === null) {\\n _G('ma_initialized', true);\\n _G('ma_whitelist', JSON.stringify([]));\\n _G('ma_blacklist', JSON.stringify([]));\\n _G('ma_btScores', JSON.stringify({}));\\n _G('ma_symbolPool', JSON.stringify([]));\\n _G('ma_marketState', 'unknown');\\n _G('ma_btcVolPct', '0.5');\\n _G('ma_runCount', 0);\\n const account = exchange.GetAccount();\\n _G('ma_initmoney', account.Balance);\\n Log('=== 均线筛选量化策略 · 初始化完成 ===');\\n Log('初始资金:', account.Balance, 'USDT');\\n}\\nconst runCount = (_G('ma_runCount') || 0) + 1;\\n_G('ma_runCount', runCount);\\nconst account = exchange.GetAccount();\\nconst equity = account.Equity || account.Balance;\\nconst initMoney = _G('ma_initmoney');\\nconst totalReturn = ((equity - initMoney) / initMoney * 100).toFixed(2);\\nconst whitelist = JSON.parse(_G('ma_whitelist') || '[]');\\nLogProfit(equity - initMoney, '&');\\nLog('【第', runCount, '次运行】权益:', equity.toFixed(2),\\n '| 收益率:', totalReturn + '%',\\n '| 白名单:', whitelist.length, '个');\\nreturn [{ json: {\\n runCount,\\n equity: equity.toFixed(2),\\n availableCash: account.Balance.toFixed(2),\\n initMoney,\\n totalReturn: totalReturn + '%'\\n} }];\",\"notice\":\"\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[576,-224],\"id\":\"init-state\",\"name\":\"初始化状态\"},{\"parameters\":{\"mode\":\"runOnceForAllItems\",\"language\":\"javaScript\",\"jsCode\":\"const topN = $vars.topN || 150;\\nconst btcChangePct = $vars.btcChangePct || 5;\\nconst tickers = exchange.GetTickers();\\nif (!tickers || tickers.length === 0) { Log('GetTickers失败'); return []; }\\nconst filtered = tickers\\n .filter(t => t.Symbol.endsWith('USDT.swap'))\\n .map(t => ({ symbol: t.Symbol, quoteVolume: t.Last * t.Volume }))\\n .sort((a, b) => b.quoteVolume - a.quoteVolume)\\n .slice(0, topN)\\n .map(t => t.symbol);\\nLog('标的池更新:', filtered.length, '个币种(按美元成交量降序)');\\n_G('ma_symbolPool', JSON.stringify(filtered));\\n\\nlet marketState = 'normal';\\nlet btcVolPercentile = 0.5;\\ntry {\\n const btcR = exchange.GetRecords('BTC_USDT.swap', PERIOD_H4);\\n if (btcR && btcR.length >= 30) {\\n const n = btcR.length;\\n const latestChange = Math.abs((btcR[n-1].Close - btcR[n-2].Close) / btcR[n-2].Close * 100);\\n if (latestChange >= btcChangePct) { marketState = 'volatile'; Log('⚠️ BTC突变:', latestChange.toFixed(2) + '%'); }\\n const returns20 = [];\\n for (let i = n - 20; i < n; i++)\\n returns20.push(Math.abs((btcR[i].Close - btcR[i-1].Close) / btcR[i-1].Close));\\n const avgVol = returns20.reduce((a, b) => a + b, 0) / returns20.length;\\n const allVols = [];\\n for (let i = 1; i < n; i++)\\n allVols.push(Math.abs((btcR[i].Close - btcR[i-1].Close) / btcR[i-1].Close));\\n allVols.sort((a, b) => a - b);\\n btcVolPercentile = allVols.findIndex(v => v >= avgVol) / allVols.length;\\n if (marketState !== 'volatile') {\\n if (btcVolPercentile > 0.8) marketState = 'high_vol';\\n else if (btcVolPercentile < 0.3) marketState = 'low_vol';\\n }\\n }\\n} catch(e) { Log('BTC状态检测失败:', e.message); }\\n_G('ma_marketState', marketState);\\n_G('ma_btcVolPct', btcVolPercentile.toFixed(2));\\nLog('市场状态:', marketState, '| BTC波动率分位:', (btcVolPercentile * 100).toFixed(1) + '%');\\nreturn [{ json: { ...$input.first().json, symbols: filtered, symbolCount: filtered.length, marketState, btcVolPercentile } }];\",\"notice\":\"\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[736,-224],\"id\":\"get-symbol-pool\",\"name\":\"获取标的池\"},{\"parameters\":{\"mode\":\"runOnceForAllItems\",\"language\":\"javaScript\",\"jsCode\":\"const input = $input.first().json;\\nconst marketState = input.marketState || _G('ma_marketState') || 'normal';\\nconst maParamsStr = $vars.maParams || '5_20,10_30,20_60';\\nconst klineLen = $vars.klineLen || 500;\\nconst topCoins = $vars.topCoins || 5;\\nconst minSignals = $vars.minSignals || 8;\\nconst minWinRate = $vars.minWinRate || 0.40;\\nconst minProfitFactor = $vars.minProfitFactor || 1.20;\\nconst maxMDD = $vars.maxMDD || 25;\\nconst volPctBonus = $vars.volPctBonus || 10;\\nconst blacklistTTL = $vars.blacklistTTL || 14400000;\\n\\nconst maParamsList = maParamsStr.split(',').map(p => {\\n const parts = p.trim().split('_');\\n return { fast: parseInt(parts[0]), slow: parseInt(parts[1]) };\\n}).filter(p => !isNaN(p.fast) && !isNaN(p.slow) && p.fast > 0 && p.slow > p.fast);\\nLog('MA参数组合:', maParamsList.map(p => 'MA' + p.fast + '/' + p.slow).join(', '));\\n\\nlet blacklist = JSON.parse(_G('ma_blacklist') || '[]');\\nconst now = Date.now();\\nblacklist = blacklist.filter(coin => {\\n const t = _G('ma_blacklist_time_' + coin);\\n if (!t || (now - t) > blacklistTTL) { _G('ma_blacklist_time_' + coin, null); Log('✅ ' + coin + ' 黑名单到期,已解除'); return false; }\\n return true;\\n});\\n_G('ma_blacklist', JSON.stringify(blacklist));\\n\\nconst symbols = JSON.parse(_G('ma_symbolPool') || '[]');\\nconst allRecords = {};\\nLog('开始拉取K线 | 标的:', symbols.length, '| 每只取最近', klineLen, '根H1');\\nfor (let i = 0; i < symbols.length; i++) {\\n const coin = symbols[i].replace('_USDT.swap', '');\\n if (blacklist.includes(coin)) continue;\\n try {\\n const rec = exchange.GetRecords(symbols[i], PERIOD_H1);\\n if (rec && rec.length >= 60) allRecords[symbols[i]] = rec.slice(-Math.min(klineLen, rec.length));\\n } catch(e) { Log('K线失败:', symbols[i], e.message); }\\n if (i % 20 === 0) Sleep(300);\\n}\\nLog('K线就绪:', Object.keys(allRecords).length, '个币种');\\n\\nfunction backtest_MA(records, fast, slow) {\\n if (records.length < slow + 10) return null;\\n function calcMA(closes, period) {\\n const result = new Array(closes.length).fill(null);\\n let sum = 0;\\n for (let i = 0; i < closes.length; i++) {\\n sum += closes[i];\\n if (i >= period) sum -= closes[i - period];\\n if (i >= period - 1) result[i] = sum / period;\\n }\\n return result;\\n }\\n const closes = records.map(r => r.Close);\\n const fastMA = calcMA(closes, fast);\\n const slowMA = calcMA(closes, slow);\\n const n = closes.length;\\n const trades = []; let position = null; let equity = 1.0; let peak = 1.0; let maxDD = 0;\\n for (let i = slow; i < n; i++) {\\n if (fastMA[i]===null||slowMA[i]===null||fastMA[i-1]===null||slowMA[i-1]===null) continue;\\n const crossUp = fastMA[i-1] <= slowMA[i-1] && fastMA[i] > slowMA[i];\\n const crossDown = fastMA[i-1] >= slowMA[i-1] && fastMA[i] < slowMA[i];\\n if (position !== null) {\\n const shouldClose = (position.side==='long'&&crossDown)||(position.side==='short'&&crossUp);\\n if (shouldClose) {\\n const ret = (records[i].Close - position.entryPrice) / position.entryPrice * (position.side==='long'?1:-1);\\n equity *= (1+ret); peak = Math.max(peak,equity); maxDD = Math.max(maxDD,(peak-equity)/peak*100);\\n trades.push(ret); position = null;\\n }\\n }\\n if (position===null) {\\n if (crossUp) position = { side:'long', entryPrice: records[i].Close };\\n else if (crossDown) position = { side:'short', entryPrice: records[i].Close };\\n }\\n }\\n if (position !== null) {\\n const ret = (records[n-1].Close - position.entryPrice) / position.entryPrice * (position.side==='long'?1:-1);\\n equity *= (1+ret); peak = Math.max(peak,equity); maxDD = Math.max(maxDD,(peak-equity)/peak*100); trades.push(ret);\\n }\\n if (trades.length === 0) return null;\\n const wins = trades.filter(r => r > 0);\\n const losses = trades.filter(r => r <= 0);\\n const winRate = wins.length / trades.length;\\n const avgWin = wins.length > 0 ? wins.reduce((a,b)=>a+b,0) / wins.length : 0;\\n const avgLoss = losses.length > 0 ? Math.abs(losses.reduce((a,b)=>a+b,0) / losses.length) : 0;\\n const profitFactor = avgLoss > 0 ? avgWin / avgLoss : (avgWin > 0 ? 99 : 0);\\n return { winRate, profitFactor, maxDrawdown: maxDD, signalCount: trades.length, totalReturn: ((equity-1)*100).toFixed(2) };\\n}\\n\\nfunction calcVolPct(records) {\\n const n = records.length; if (n < 20) return 0.5;\\n const atrs = [];\\n for (let i = 1; i < n; i++)\\n atrs.push(Math.max(records[i].High-records[i].Low, Math.abs(records[i].High-records[i-1].Close), Math.abs(records[i].Low-records[i-1].Close)));\\n const recent14 = atrs.slice(-14);\\n const avgATR = recent14.reduce((a,b)=>a+b,0) / recent14.length;\\n const sorted = [...atrs].sort((a,b)=>a-b);\\n return sorted.findIndex(v => v >= avgATR) / sorted.length;\\n}\\n\\nconst results = []; const syms = Object.keys(allRecords);\\nfor (const sym of syms) {\\n const coin = sym.replace('_USDT.swap',''); const records = allRecords[sym]; const volPct = calcVolPct(records);\\n let bestScore=-Infinity; let bestResult=null; let bestParams=null;\\n for (const params of maParamsList) {\\n const bt = backtest_MA(records, params.fast, params.slow); if (!bt) continue;\\n if (bt.signalCount<minSignals||bt.winRate<minWinRate||bt.profitFactor<minProfitFactor||bt.maxDrawdown>maxMDD) continue;\\n const score = Math.min(bt.winRate*100,100)*0.30 + Math.min(bt.profitFactor*20,60)*0.30 +\\n Math.max(0,1-bt.maxDrawdown/maxMDD)*100*0.20 + volPct*volPctBonus;\\n if (score > bestScore) { bestScore=score; bestResult=bt; bestParams=params; }\\n }\\n if (bestResult) results.push({ coin, sym, score:bestScore, winRate:bestResult.winRate,\\n profitFactor:bestResult.profitFactor, maxDrawdown:bestResult.maxDrawdown,\\n signalCount:bestResult.signalCount, totalReturn:bestResult.totalReturn,\\n volPct, bestFast:bestParams.fast, bestSlow:bestParams.slow, updatedAt:new Date().toISOString() });\\n}\\nresults.sort((a,b) => b.score - a.score);\\nconst whitelist = results.slice(0,topCoins).map(r => r.coin);\\nconst btScores = {};\\nfor (const r of results) btScores[r.coin] = { score:r.score, winRate:r.winRate, profitFactor:r.profitFactor,\\n maxDrawdown:r.maxDrawdown, signalCount:r.signalCount, totalReturn:r.totalReturn,\\n volPct:r.volPct, bestFast:r.bestFast, bestSlow:r.bestSlow };\\n_G('ma_whitelist', JSON.stringify(whitelist));\\n_G('ma_btScores', JSON.stringify(btScores));\\n\\nLog('═══ 回测筛选完成 ═══');\\nLog('参与回测:', syms.length, '| 通过筛选:', results.length, '| 黑名单跳过:', blacklist.length);\\nLog('白名单 Top' + topCoins + ':');\\nfor (const r of results.slice(0, topCoins))\\n Log(' ', r.coin, '| 评分:', r.score.toFixed(1), '| 胜率:', (r.winRate*100).toFixed(1)+'%',\\n '| 盈亏比:', r.profitFactor.toFixed(2), '| MDD:', r.maxDrawdown.toFixed(2)+'%',\\n '| 最优参数: MA'+r.bestFast+'/'+r.bestSlow);\\n\\nlet positionScaleDown = 1.0;\\nif (marketState==='volatile') positionScaleDown = 0.5;\\nelse if (marketState==='high_vol') positionScaleDown = 0.8;\\nelse if (marketState==='low_vol') positionScaleDown = 0.7;\\nif (positionScaleDown < 1.0) Log('⚠️ 市场', marketState, '→ 仓位系数:', positionScaleDown);\\nreturn [{ json: { ...input, whitelist, btScores, candidateCount:results.length, positionScaleDown,\\n filterStats:{ total:syms.length, passed:results.length, blacklisted:blacklist.length } } }];\",\"notice\":\"\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[896,-224],\"id\":\"ma-backtest-filter\",\"name\":\"均线回测筛选\"},{\"parameters\":{\"mode\":\"runOnceForAllItems\",\"language\":\"javaScript\",\"jsCode\":\"const STOP_LOSS_PCT = $vars.stopLossPct || 5;\\nconst TAKE_PROFIT_PCT = $vars.takeProfitPct || 10;\\nconst TRAIL_TRIGGER = $vars.trailTrigger || 3;\\n\\nconst nowMin = new Date().getMinutes();\\nif (nowMin >= 59) {\\n Log('⏸️ 第' + nowMin + '分钟,等待慢触发器完成...');\\n return [{ json: { success: true, skipped: true, ts: new Date().toISOString() } }];\\n}\\n\\nfunction fmtPnl(pct) {\\n const v = parseFloat(pct);\\n if (v > 5) return '🚀 +' + v.toFixed(2) + '%';\\n if (v > 0) return '✅ +' + v.toFixed(2) + '%';\\n if (v > -3) return '🟡 ' + v.toFixed(2) + '%';\\n return '🔴 ' + v.toFixed(2) + '%';\\n}\\nfunction fmtProfit(p) {\\n return (p >= 0 ? '💰 +$' : '💸 -$') + Math.abs(p).toFixed(2);\\n}\\nfunction fmtMarket(s) {\\n const m = { normal:'🌤️ normal', high_vol:'⛈️ high_vol', low_vol:'😴 low_vol', volatile:'🌪️ volatile', unknown:'❓ unknown' };\\n return m[s] || s;\\n}\\nfunction fmtScore(score) {\\n if (score === null || score === undefined) return 'N/A';\\n const v = parseFloat(score);\\n if (v >= 70) return '🟢 ' + v.toFixed(1);\\n if (v >= 50) return '🟡 ' + v.toFixed(1);\\n return '🔴 ' + v.toFixed(1);\\n}\\nfunction getDynamicTrailDrawdown(maxPnl) {\\n if (maxPnl >= 7) return 3;\\n if (maxPnl >= 4) return 2;\\n return 1.5;\\n}\\n\\nfunction handleCommand() {\\n const cmd = GetCommand();\\n if (!cmd) return;\\n const parts = cmd.split(':');\\n switch (parts[0]) {\\n case '重置':\\n LogReset(); _G('ma_initmoney', null); Log('🔄 已重置账户基准'); break;\\n case '清空黑名单':\\n _G('ma_blacklist', JSON.stringify([])); Log('🗑️ 黑名单已清空'); break;\\n case '重新筛选':\\n _G('ma_whitelist', JSON.stringify([])); _G('ma_btScores', JSON.stringify({}));\\n Log('🔁 白名单已清空,下次慢触发器将重新筛选'); break;\\n case '手动平仓': {\\n const coin = parts[1]; if (!coin) break;\\n try {\\n const poss = exchange.GetPositions() || [];\\n for (const pos of poss) {\\n if (pos.Symbol.indexOf(coin) !== -1 && Math.abs(pos.Amount) > 0) {\\n const isLong = pos.Type === PD_LONG || pos.Type === 0;\\n exchange.CreateOrder(pos.Symbol, isLong ? 'closebuy' : 'closesell', -1, Math.abs(pos.Amount));\\n _G(coin + '_maxpnl', null); _G(coin + '_trail', null);\\n // 手动平仓后加入黑名单冷却1小时,防止同一根H1K线信号立刻重新开仓\\n let blacklist = JSON.parse(_G('ma_blacklist') || '[]');\\n if (!blacklist.includes(coin)) blacklist.push(coin);\\n _G('ma_blacklist', JSON.stringify(blacklist));\\n _G('ma_blacklist_time_' + coin, Date.now() - ($vars.blacklistTTL || 14400000) + 3600000);\\n Log('✅ ' + coin + ' 手动平仓成功,冷却1小时后恢复');\\n }\\n }\\n } catch(e) { Log('❌ ' + coin + ' 手动平仓失败:', e.message); }\\n break;\\n }\\n }\\n}\\n\\nfunction monitorTPSL(positions, tickers) {\\n let blacklist = JSON.parse(_G('ma_blacklist') || '[]');\\n for (const pos of (positions || [])) {\\n if (Math.abs(pos.Amount) === 0) continue;\\n const cm = pos.Symbol.match(/^(.+)_USDT/); if (!cm) continue;\\n const coin = cm[1]; const ticker = tickers[coin + '_USDT.swap']; if (!ticker) continue;\\n const isLong = pos.Type === PD_LONG || pos.Type === 0;\\n const cur = ticker.Last; const ent = pos.Price; const amt = Math.abs(pos.Amount);\\n const pnlPct = (cur - ent) * (isLong ? 1 : -1) / ent * 100;\\n const maxKey = coin + '_maxpnl'; const trailKey = coin + '_trail';\\n let maxPnl = _G(maxKey);\\n if (maxPnl === null) { maxPnl = pnlPct; _G(maxKey, maxPnl); }\\n else if (pnlPct > maxPnl) { maxPnl = pnlPct; _G(maxKey, maxPnl); }\\n if (!_G(trailKey) && maxPnl >= TRAIL_TRIGGER) {\\n _G(trailKey, true);\\n Log('🎯 ' + coin + ' 启动移动止盈,浮盈: +' + pnlPct.toFixed(2) + '%');\\n }\\n const trailDrawdown = getDynamicTrailDrawdown(maxPnl);\\n let reason = null;\\n if (_G(trailKey) && (maxPnl - pnlPct) >= trailDrawdown)\\n reason = '📉 移动止盈(回撤' + (maxPnl - pnlPct).toFixed(2) + '%, 阈值' + trailDrawdown + '%)';\\n if (!reason && pnlPct <= -STOP_LOSS_PCT) reason = '🛑 止损(' + pnlPct.toFixed(2) + '%)';\\n if (!reason && pnlPct >= TAKE_PROFIT_PCT) reason = '🎉 固定止盈(' + pnlPct.toFixed(2) + '%)';\\n if (reason) {\\n try {\\n exchange.CreateOrder(pos.Symbol, isLong ? 'closebuy' : 'closesell', -1, amt);\\n Log(coin, '平仓 →', reason);\\n _G(maxKey, null); _G(trailKey, null);\\n if (reason.includes('止损') && !blacklist.includes(coin)) {\\n blacklist.push(coin); _G('ma_blacklist', JSON.stringify(blacklist));\\n _G('ma_blacklist_time_' + coin, Date.now());\\n Log('🚫 ' + coin + ' 进入黑名单冷却');\\n }\\n } catch(e) { Log('❌ ' + coin + ' 自动平仓失败:', e.message); }\\n }\\n }\\n}\\n\\nhandleCommand();\\nconst account = exchange.GetAccount();\\nconst positions = exchange.GetPositions() || [];\\nconst tickers = {};\\ntry { const ts = exchange.GetTickers() || []; for (const t of ts) tickers[t.Symbol] = t; } catch(e) {}\\nmonitorTPSL(positions, tickers);\\n\\nconst initMoney = _G('ma_initmoney') || account.Balance;\\nconst equity = account.Equity || account.Balance;\\nconst profit = equity - initMoney;\\nconst pct = (profit / initMoney * 100).toFixed(2);\\nconst whitelist = JSON.parse(_G('ma_whitelist') || '[]');\\nconst blacklist = JSON.parse(_G('ma_blacklist') || '[]');\\nconst btScores = JSON.parse(_G('ma_btScores') || '{}');\\nconst marketState = _G('ma_marketState') || 'unknown';\\nconst runCount = _G('ma_runCount') || 0;\\nLogProfit(profit, '&');\\n\\nconst t1 = {\\n type:'table', title:'📊 账户概览',\\n cols:['💵 初始资金','💰 当前权益','📈 总收益','📊 收益率','🌤️ 市场状态','✅ 白名单','🚫 黑名单','🔄 运行次数','⚙️ 操作'],\\n rows:[[ '$'+initMoney.toFixed(2), '$'+equity.toFixed(2), fmtProfit(profit),\\n (parseFloat(pct)>=0?'🟢 +':'🔴 ')+pct+'%', fmtMarket(marketState),\\n '✅ '+whitelist.length+'个', '🚫 '+blacklist.length+'个', '🔄 '+runCount+'次',\\n [{type:'button',cmd:'重置',name:'🔄 重置基准'},\\n {type:'button',cmd:'清空黑名单',name:'🗑️ 清黑名单'},\\n {type:'button',cmd:'重新筛选',name:'🔁 重新筛选'}] ]]\\n};\\n\\nconst t2 = {\\n type:'table', title:'🏆 白名单币种 · 均线回测评分',\\n cols:['🪙 币种','🏅 综合评分','🎯 胜率','💹 盈亏比','📉 最大回撤','📊 信号数','⚡ 最优MA','🌡️ 波动率分位','状态'],\\n rows: whitelist.length===0\\n ? [['⏳ 等待慢触发器筛选...','-','-','-','-','-','-','-','-']]\\n : whitelist.map(coin => {\\n const s = btScores[coin] || {};\\n return ['🪙 '+coin, fmtScore(s.score),\\n s.winRate ? (s.winRate*100).toFixed(1)+'%' : 'N/A',\\n s.profitFactor ? s.profitFactor.toFixed(2) : 'N/A',\\n s.maxDrawdown ? s.maxDrawdown.toFixed(2)+'%' : 'N/A',\\n s.signalCount ? s.signalCount+'次' : 'N/A',\\n s.bestFast&&s.bestSlow ? 'MA'+s.bestFast+'/'+s.bestSlow : 'N/A',\\n s.volPct ? (s.volPct*100).toFixed(0)+'%' : 'N/A',\\n blacklist.includes(coin) ? '🚫 冷却中' : '✅ 活跃'];\\n })\\n};\\n\\nconst t3 = {\\n type:'table', title:'📋 实时持仓监控',\\n cols:['🪙 币种','📍 方向','🏷️ 入场价','💲 现价','📊 浮盈%','🏆 最高浮盈','🎯 移动止盈','⚙️ 操作'],\\n rows:[]\\n};\\nlet hasPos = false;\\nfor (const pos of positions) {\\n if (Math.abs(pos.Amount)===0) continue;\\n const cm = pos.Symbol.match(/^(.+)_USDT/); if (!cm) continue;\\n const coin = cm[1]; const tk = tickers[coin+'_USDT.swap']; if (!tk) continue;\\n hasPos = true;\\n const isLong = pos.Type===PD_LONG||pos.Type===0;\\n const pnlPct = (tk.Last-pos.Price)*(isLong?1:-1)/pos.Price*100;\\n const maxPnl = _G(coin+'_maxpnl')||pnlPct;\\n const td = getDynamicTrailDrawdown(maxPnl);\\n t3.rows.push(['🪙 '+coin, isLong?'🟢 多':'🔴 空',\\n pos.Price.toFixed(6), tk.Last.toFixed(6),\\n fmtPnl(pnlPct), fmtPnl(maxPnl),\\n _G(coin+'_trail')?('🎯 启用·回撤阈值'+td+'%'):('⏸️ 未启用·触发需+'+TRAIL_TRIGGER+'%'),\\n [{type:'button',cmd:'手动平仓:'+coin,name:'平仓'}]]);\\n}\\nif (!hasPos) t3.rows=[['😴 当前无持仓','-','-','-','-','-','等待信号触发','-']];\\n\\nLogStatus('`'+JSON.stringify(t1)+'`\\\\n\\\\n`'+JSON.stringify(t2)+'`\\\\n\\\\n`'+JSON.stringify(t3)+'`');\\nreturn [{ json: { success: true, ts: new Date().toISOString() } }];\",\"notice\":\"\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[592,112],\"id\":\"monitor-dashboard\",\"name\":\"持仓监控+仪表板\"},{\"parameters\":{\"notice\":\"\",\"rule\":{\"interval\":[{\"field\":\"seconds\",\"secondsInterval\":1}]}},\"type\":\"n8n-nodes-base.scheduleTrigger\",\"typeVersion\":1.2,\"position\":[416,112],\"id\":\"fast-trigger\",\"name\":\"快触发器\",\"logLevel\":0},{\"parameters\":{\"mode\":\"runOnceForAllItems\",\"language\":\"javaScript\",\"jsCode\":\"const input = $input.first().json;\\nconst whitelist = input.whitelist || JSON.parse(_G('ma_whitelist') || '[]');\\nconst btScores = input.btScores || JSON.parse(_G('ma_btScores') || '{}');\\nconst marketState = input.marketState || _G('ma_marketState') || 'normal';\\nif (whitelist.length === 0) { Log('白名单为空,跳过信号计算'); return [{ json: { ...input, longList:[], shortList:[] } }]; }\\n\\nconst allowShort = String($vars.allowShort) !== 'false' && marketState !== 'volatile';\\nconst volMultiplier = $vars.volMultiplier || 1.0;\\nLog('计算MA穿叉信号 | 白名单:', whitelist.length, '个 | 允许做空:', allowShort, '| 量能倍数:', volMultiplier);\\n\\nfunction calcMA(closes, period) {\\n if (closes.length < period) return null;\\n return closes.slice(-period).reduce((a,b)=>a+b,0) / period;\\n}\\n\\nconst longList = []; const shortList = []; const signalLog = [];\\n\\nfor (const coin of whitelist) {\\n const sym = coin + '_USDT.swap';\\n const scores = btScores[coin];\\n const tag = '[' + coin + ' MA' + (scores ? scores.bestFast+'/' + scores.bestSlow : '?/?') + ']';\\n\\n if (!scores) { Log(tag, '❌ 无回测评分,跳过'); continue; }\\n\\n const fast = scores.bestFast || 10;\\n const slow = scores.bestSlow || 30;\\n\\n try {\\n const rec = exchange.GetRecords(sym, PERIOD_H1);\\n if (!rec) { Log(tag, '❌ GetRecords返回null'); continue; }\\n if (rec.length < slow + 3) { Log(tag, '❌ K线不足 | 需要', slow+3, '根,实际', rec.length, '根'); continue; }\\n\\n const closes = rec.map(r => r.Close);\\n const n = closes.length;\\n const fastCur = calcMA(closes.slice(0, n), fast);\\n const fastPrev = calcMA(closes.slice(0, n-1), fast);\\n const slowCur = calcMA(closes.slice(0, n), slow);\\n const slowPrev = calcMA(closes.slice(0, n-1), slow);\\n\\n if ([fastCur, fastPrev, slowCur, slowPrev].some(v => v === null)) {\\n Log(tag, '❌ MA计算为null | fastCur:', fastCur, 'fastPrev:', fastPrev, 'slowCur:', slowCur, 'slowPrev:', slowPrev);\\n continue;\\n }\\n\\n const crossUp = fastPrev <= slowPrev && fastCur > slowCur;\\n const crossDown = fastPrev >= slowPrev && fastCur < slowCur;\\n const diff = ((fastCur - slowCur) / slowCur * 100).toFixed(4);\\n\\n let volConfirm = true;\\n let volInfo = '量能:关闭';\\n if (volMultiplier > 1.0) {\\n const vols = rec.map(r => r.Volume);\\n const avgVol = vols.slice(-20, -1).reduce((a,b) => a+b, 0) / 19;\\n volConfirm = rec[n-1].Volume >= avgVol * volMultiplier;\\n volInfo = '量能:' + (volConfirm ? '✅' : '❌未达' + volMultiplier + '倍')\\n + '(cur=' + rec[n-1].Volume.toFixed(0) + ' avg=' + avgVol.toFixed(0) + ')';\\n }\\n\\n const crossTag = crossUp ? '↑金叉' : crossDown ? '↓死叉' : '→无叉';\\n\\n if (crossUp && volConfirm) {\\n longList.push(sym);\\n signalLog.push('📈 ' + coin + ':金叉 MA' + fast + '/' + slow);\\n } else if (crossDown && allowShort && volConfirm) {\\n shortList.push(sym);\\n signalLog.push('📉 ' + coin + ':死叉 MA' + fast + '/' + slow);\\n } else {\\n // 打印每个币不满足开仓的具体原因\\n const reasons = [];\\n if (!crossUp && !crossDown) reasons.push('无穿叉(差值' + diff + '%)');\\n if (crossUp && !volConfirm) reasons.push('金叉但' + volInfo);\\n if (crossDown && !allowShort) reasons.push('死叉但做空已禁用(市场:' + marketState + ')');\\n if (crossDown && allowShort && !volConfirm) reasons.push('死叉但' + volInfo);\\n Log(tag, '⏸️', crossTag, '| 差值:', diff + '%', '|', reasons.join(' & '));\\n }\\n } catch(e) { Log('[' + coin + ']', '❌ 异常:', e.message); }\\n Sleep(100);\\n}\\n\\nLog('信号汇总 | 做多:', longList.length, '| 做空:', shortList.length);\\nif (signalLog.length > 0) Log('信号详情:', signalLog.join(' | '));\\nelse Log('本轮无穿叉信号');\\n_G('ma_lastSignalTime', Date.now());\\nreturn [{ json: { ...input, longList, shortList } }];\",\"notice\":\"\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[1056,-224],\"id\":\"calc-signal\",\"name\":\"计算信号\"},{\"parameters\":{\"mode\":\"runOnceForAllItems\",\"language\":\"javaScript\",\"jsCode\":\"const input = $input.first().json;\\nconst longList = input.longList || [];\\nconst shortList = input.shortList || [];\\nif (longList.length === 0 && shortList.length === 0) { Log('本轮无信号,跳过调仓'); return [{ json: { ...input, rebalanced:false } }]; }\\n\\nconst positionRatio = ($vars.positionRatio || 0.8) * (input.positionScaleDown || 1.0);\\nconst maxLeverage = $vars.maxLeverage || 3;\\nconst account = exchange.GetAccount();\\nconst equity = account.Equity || account.Balance;\\nconst balance = account.Balance;\\nconst perAmt = (equity * positionRatio) / (longList.length + shortList.length);\\nLog('开始调仓 | 权益:', equity.toFixed(2), '| 余额:', balance.toFixed(2),\\n '| 仓位系数:', positionRatio.toFixed(2), '| 单仓额:', perAmt.toFixed(2),\\n '| 多:', longList.length, '| 空:', shortList.length);\\n\\nlet positions = []; try { positions = exchange.GetPositions() || []; } catch(e) {}\\nconst currentHoldings = {};\\nfor (const pos of positions) { if (Math.abs(pos.Amount) > 0) currentHoldings[pos.Symbol] = pos; }\\n\\nconst targetSet = new Set([...longList, ...shortList]);\\nfor (const sym of Object.keys(currentHoldings)) {\\n if (!targetSet.has(sym)) {\\n try {\\n const pos = currentHoldings[sym]; const isLong = pos.Type===PD_LONG||pos.Type===0;\\n const id = exchange.CreateOrder(sym, isLong?'closebuy':'closesell', -1, Math.abs(pos.Amount));\\n Log('📤 平仓(信号退出):', sym, isLong?'多→平':'空→平', '| 订单:', id);\\n const cm = sym.match(/^(.+)_USDT/);\\n if (cm) { _G(cm[1]+'_maxpnl',null); _G(cm[1]+'_trail',null); }\\n Sleep(200);\\n } catch(e) { Log('❌ 平仓失败:', sym, e.message); }\\n }\\n}\\nSleep(500);\\n\\nlet allMarkets = {}; try { allMarkets = exchange.GetMarkets() || {}; } catch(e) {}\\nfunction openPos(sym, isLong) {\\n if (currentHoldings[sym]) return;\\n try {\\n const market = allMarkets[sym]; if (!market) { Log('⚠️ 跳过:', sym, '无市场信息'); return; }\\n exchange.SetMarginLevel(sym, maxLeverage);\\n const ticker = exchange.GetTicker(sym); if (!ticker) return;\\n const price = ticker.Last;\\n const ctVal = (market.CtVal &&market.CtVal >0)?market.CtVal :1;\\n const amtPrec = (market.AmountPrecision!==undefined)?market.AmountPrecision:0;\\n const minQty = (market.MinQty &&market.MinQty >0)?market.MinQty :1;\\n const maxQty = (market.MaxQty &&market.MaxQty >0)?market.MaxQty :999999;\\n let qty = _N(perAmt / price / ctVal, amtPrec);\\n qty = Math.min(qty, maxQty);\\n if (qty < minQty) { Log('⚠️ 下单量不足:', sym, qty, '<', minQty); return; }\\n const id = exchange.CreateOrder(sym, isLong?'buy':'sell', -1, qty);\\n Log(isLong?'📈 开多':'📉 开空', sym, '| 价:', price, '| 量:', qty, '| 订单:', id);\\n const cm = sym.match(/^(.+)_USDT/);\\n if (cm) { _G(cm[1]+'_maxpnl',null); _G(cm[1]+'_trail',null); }\\n Sleep(200);\\n } catch(e) { Log(isLong?'❌ 开多失败':'❌ 开空失败', sym, e.message); }\\n}\\nfor (const sym of longList) openPos(sym, true);\\nfor (const sym of shortList) openPos(sym, false);\\n_G('ma_lastRebalance', Date.now());\\nLog('调仓完成');\\nreturn [{ json: { ...input, rebalanced: true } }];\",\"notice\":\"\"},\"type\":\"n8n-nodes-base.code\",\"typeVersion\":2,\"position\":[1264,-224],\"id\":\"execute-rebalance\",\"name\":\"执行调仓\"}],\"pinData\":{},\"connections\":{\"慢触发器\":{\"main\":[[{\"node\":\"初始化状态\",\"type\":\"main\",\"index\":0}]]},\"初始化状态\":{\"main\":[[{\"node\":\"获取标的池\",\"type\":\"main\",\"index\":0}]]},\"获取标的池\":{\"main\":[[{\"node\":\"均线回测筛选\",\"type\":\"main\",\"index\":0}]]},\"均线回测筛选\":{\"main\":[[{\"node\":\"计算信号\",\"type\":\"main\",\"index\":0}]]},\"计算信号\":{\"main\":[[{\"node\":\"执行调仓\",\"type\":\"main\",\"index\":0}]]},\"快触发器\":{\"main\":[[{\"node\":\"持仓监控+仪表板\",\"type\":\"main\",\"index\":0}]]}},\"active\":false,\"settings\":{\"timezone\":\"Asia/Shanghai\",\"executionOrder\":\"v1\"},\"tags\":[],\"credentials\":{},\"id\":\"ma-strategy-v1\",\"plugins\":{},\"mcpClients\":{}},\"startNodes\":[],\"triggerToStartFrom\":{\"name\":\"慢触发器\"}}"}