Harness Engineer均线筛选量化策略


创建日期: 2026-03-27 19:05:30 最后修改: 2026-04-17 11:55:38
复制: 0 点击次数: 25
avatar of ianzeng123 ianzeng123
2
关注
447
关注者

策略简介

本策略是一套运行在币安永续合约上的均线穿叉多空量化系统。核心思路是每小时自动从全市场成交量最大的合约中,通过历史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

风险提示

  • 加密货币永续合约波动极大,均线策略在震荡市中容易被手续费磨损
  • 回测基于历史K线,过去表现好不代表未来持续有效,每小时重新筛选可部分缓解
  • 波动率加分使策略偏向高弹性币种,单次亏损幅度也更大,需合理设置止损
  • 杠杆成倍放大损失,强烈建议初期用低杠杆并控制总仓位比例
  • 强烈建议先在模拟盘运行数个周期,观察筛选质量和信号频率后再切换实盘
策略源码
{"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\":\"慢触发器\"}}"}