首页 脚本库 搜索
返回

OilSpreadAnalyst V2

MQL4 EA · 智能交易系统 v2.0
98video 👁 16 浏览 ⬇ 11 下载 📅 2026-11-09 00:31:32 更新时间: 2026-11-09 00:45:09
下载源码

详情

产品概述

OilSpreadAnalyst V2 是一套面向 MetaTrader 4 的 Brent / WTI 原油价差决策面板。在保留统计分析和成本测算的基础上,V2 将界面压缩为更直观的关键区域:当前建议、当前价差、两个品种报价、关键统计、做多净收益和做空净收益。

V2 改进

  • 当前建议放在最醒目的位置,直接显示做多价差、做空价差、观望或等待拐点。
  • 关键统计只保留均值、Z-Score、百分位和半衰期,减少信息干扰。
  • 同时展示做多价差与做空价差的毛利润、点差与佣金、净收益和保本收敛空间。
  • 新增“一键做多价差”和“一键做空价差”按钮,可同时建立 Brent / WTI 两条腿。
  • 默认限制同时只保留一组价差仓位;若其中一条腿成交失败,会尝试回补已成交的另一条腿。
  • 保留 M15 / H1 / H4 切换、动量过滤、提醒和手动刷新。

核心功能

  • 实时显示 UKOILm(Brent)与 USOILm(WTI)的中间价、BID / ASK 和日涨跌幅。
  • 计算 Brent-WTI 中间价差、Brent/WTI 比价、Brent 溢价和双向执行价差。
  • 基于最近 N 根 K 线计算均值、标准差、Z-Score、百分位、半衰期、动量和斜率。
  • 价差极端但动量仍在扩大时,面板提示“等待拐点”,避免盲目逆势进场。
  • 使用两侧实际点差和输入佣金估算净收益,帮助过滤理论空间不足的交易机会。

信号与执行

默认信号阈值为 Z-Score ±1.5σ。价差高于历史均值时偏向做空价差,即卖出 Brent、买入 WTI;价差低于历史均值时偏向做多价差,即买入 Brent、卖出 WTI;偏离不足时显示观望。

V2 的一键交易默认启用。使用前请确认 MT4 已开启自动交易权限,账户和经纪商允许相关品种交易,并检查手数、佣金、滑点、MagicNumber 和价差仓位限制。一键开仓不会改变市场风险,仍需自行确认保证金和流动性。

适用对象

  • 平台:MetaTrader 4。
  • 默认品种:UKOILm / USOILm,可按经纪商品种名称修改。
  • 默认周期:H1;默认统计样本:120 根 K 线。
  • 适合希望快速读取原油跨品种价差、并可按按钮执行双向仓位的交易者。

风险提醒

统计信号不是盈利保证。价差可能长期偏离甚至继续扩大;真实交易还会受到滑点、隔夜利息、点差变化、保证金、成交时间和两腿执行不同步的影响。请先在小手数或模拟账户中验证。

源代码

MQL4
//+------------------------------------------------------------------+
//|                                         OilSpreadAnalystV2.mq4 |
//|                             Brent / WTI spread analysis dashboard |
//+------------------------------------------------------------------+
#property copyright "Oil Spread Analyst"
#property link      "https://www.mql5.com"
#property version   "2.00"
#property strict
#property description "Simplified Brent/WTI spread dashboard: one clear recommendation, key stats, and after-cost P&L."

//--- Symbols and statistics
input string          BrentSymbol             = "UKOILm";
input string          WtiSymbol               = "USOILm";
input ENUM_TIMEFRAMES AnalysisTimeframe       = PERIOD_H1;
input int             LookbackBars             = 120;
input int             MomentumBars             = 12;
input double          SignalZScore             = 1.50;
input double          MomentumBlockZ           = 0.50;
input bool            AvoidWideningSpread      = true;

//--- Trade economics
input double          TradeLotsPerLeg          = 0.10;
input double          BrentCommissionPerLot   = 0.00;
input double          WtiCommissionPerLot     = 0.00;
input bool            EnableOneClickTrading   = true;
input int             MagicNumber             = 20261108;
input int             SlippagePoints          = 20;
input bool            OnlyOneSpreadPosition   = true;

//--- Alerts and refresh
input bool            EnableAlerts             = true;
input int             RefreshSeconds           = 1;

//--- Layout
#define PANEL_X       16
#define PANEL_Y       4
#define PANEL_W       860
#define PANEL_H       516
#define OBJECT_PREFIX "OSV2_"

const color CLR_PANEL       = C'14,19,24';
const color CLR_HEADER      = C'20,28,35';
const color CLR_CELL        = C'23,31,38';
const color CLR_CELL_ALT    = C'27,36,43';
const color CLR_BORDER      = C'54,67,76';
const color CLR_TEXT        = C'226,232,236';
const color CLR_MUTED       = C'145,158,168';
const color CLR_TEAL        = C'52,184,166';
const color CLR_TEAL_DARK   = C'25,90,84';
const color CLR_ORANGE      = C'226,132,64';
const color CLR_RED         = C'224,91,88';
const color CLR_GREEN       = C'73,196,132';
const color CLR_AMBER       = C'228,185,80';
const color CLR_GAUGE       = C'34,46,55';
const color CLR_ZERO        = C'92,108,119';

struct SpreadStats
  {
   bool              valid;
   int               samples;
   double            mean;
   double            stddev;
   double            minimum;
   double            maximum;
   double            zscore;
   double            percentile;
   double            halfLife;
   double            slope;
   double            momentum;
   datetime          lastBarTime;
  };

SpreadStats g_stats;
double      g_history[];
int         g_historyCount = 0;

int         g_analysisTf = PERIOD_H1;
int         g_signal = 0;              // -1 short spread, 0 wait, +1 long spread
int         g_lastAlertSignal = 0;
datetime    g_lastAlertBar = 0;
datetime    g_lastRefresh = 0;

double      g_bidBrent = 0.0;
double      g_askBrent = 0.0;
double      g_bidWti = 0.0;
double      g_askWti = 0.0;
double      g_midBrent = 0.0;
double      g_midWti = 0.0;
double      g_midSpread = 0.0;
double      g_ratio = 0.0;
double      g_premiumPct = 0.0;
double      g_valuePerPriceBrent = 0.0;
double      g_valuePerPriceWti = 0.0;
double      g_spreadUnitValue = 0.0;
double      g_signalThreshold = 1.50;
double      g_momentumBlock = 0.50;
double      g_spreadCostMoney = 0.0;
double      g_commissionMoney = 0.0;
double      g_totalCostMoney = 0.0;
double      g_breakEvenPrice = 0.0;
double      g_longGross = 0.0;
double      g_shortGross = 0.0;
double      g_longNet = 0.0;
double      g_shortNet = 0.0;
int         g_digitsBrent = 3;
int         g_digitsWti = 3;
bool        g_quotesReady = false;
bool        g_economicsReady = false;
bool        g_previousChartForeground = true;
bool        g_chartForegroundChanged = false;

//+------------------------------------------------------------------+
//| Expert initialization                                           |
//+------------------------------------------------------------------+
int OnInit()
  {
   g_analysisTf = (int)AnalysisTimeframe;
   g_previousChartForeground = (ChartGetInteger(0, CHART_FOREGROUND) != 0);
   g_chartForegroundChanged = false;
   if(g_previousChartForeground)
      g_chartForegroundChanged = ChartSetInteger(0, CHART_FOREGROUND, false);
   ResetStats();
   BuildDashboard();
   EventSetTimer(MathMax(1, RefreshSeconds));
   UpdateDashboard(true);
   return(INIT_SUCCEEDED);
  }

//+------------------------------------------------------------------+
//| Expert deinitialization                                         |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   EventKillTimer();
   DeleteDashboard();
   if(g_chartForegroundChanged)
      ChartSetInteger(0, CHART_FOREGROUND, g_previousChartForeground);
   ChartRedraw();
  }

//+------------------------------------------------------------------+
//| Tick handler                                                    |
//+------------------------------------------------------------------+
void OnTick()
  {
   if(RefreshSeconds <= 0 || TimeLocal() - g_lastRefresh >= RefreshSeconds)
      UpdateDashboard(false);
  }

//+------------------------------------------------------------------+
//| Timer handler                                                   |
//+------------------------------------------------------------------+
void OnTimer()
  {
   UpdateDashboard(false);
  }

//+------------------------------------------------------------------+
//| Chart event handler                                             |
//+------------------------------------------------------------------+
void OnChartEvent(const int id,
                  const long &lparam,
                  const double &dparam,
                  const string &sparam)
  {
   if(id != CHARTEVENT_OBJECT_CLICK)
      return;

   if(sparam == "OSV2_BTN_LONG")
     {
      ObjectSetInteger(0, sparam, OBJPROP_STATE, false);
      OpenSpreadTrade(1);
      return;
     }
   if(sparam == "OSV2_BTN_SHORT")
     {
      ObjectSetInteger(0, sparam, OBJPROP_STATE, false);
      OpenSpreadTrade(-1);
      return;
     }
   if(sparam == "OSV2_BTN_M15")
     {
      SetAnalysisTimeframe(PERIOD_M15);
      return;
     }
   if(sparam == "OSV2_BTN_H1")
     {
      SetAnalysisTimeframe(PERIOD_H1);
      return;
     }
   if(sparam == "OSV2_BTN_H4")
     {
      SetAnalysisTimeframe(PERIOD_H4);
      return;
     }
   if(sparam == "OSV2_BTN_REFRESH")
     {
      ObjectSetInteger(0, sparam, OBJPROP_STATE, false);
      g_lastRefresh = 0;
      UpdateDashboard(true);
      return;
     }
  }

//+------------------------------------------------------------------+
//| Reset calculated state                                          |
//+------------------------------------------------------------------+
void ResetStats()
  {
   g_stats.valid = false;
   g_stats.samples = 0;
   g_stats.mean = 0.0;
   g_stats.stddev = 0.0;
   g_stats.minimum = 0.0;
   g_stats.maximum = 0.0;
   g_stats.zscore = 0.0;
   g_stats.percentile = 0.0;
   g_stats.halfLife = 0.0;
   g_stats.slope = 0.0;
   g_stats.momentum = 0.0;
   g_stats.lastBarTime = 0;
   ArrayResize(g_history, 0);
   g_historyCount = 0;
  }

//+------------------------------------------------------------------+
//| Change analysis timeframe                                       |
//+------------------------------------------------------------------+
void SetAnalysisTimeframe(const int timeframe)
  {
   if(g_analysisTf == timeframe)
     {
      ObjectSetInteger(0, "OSV2_BTN_M15", OBJPROP_STATE, false);
      ObjectSetInteger(0, "OSV2_BTN_H1", OBJPROP_STATE, false);
      ObjectSetInteger(0, "OSV2_BTN_H4", OBJPROP_STATE, false);
      UpdateTimeframeButtons();
      ChartRedraw();
      return;
     }

   g_analysisTf = timeframe;
   g_lastAlertSignal = 0;
   g_lastAlertBar = 0;
   ResetStats();
   UpdateTimeframeButtons();
   g_lastRefresh = 0;
   UpdateDashboard(true);
  }

//+------------------------------------------------------------------+
//| Main dashboard refresh                                          |
//+------------------------------------------------------------------+
void UpdateDashboard(const bool force)
  {
   if(!force && RefreshSeconds > 0 && TimeLocal() - g_lastRefresh < RefreshSeconds)
      return;

   g_lastRefresh = TimeLocal();
   g_signalThreshold = MathMax(0.25, SignalZScore);
   g_momentumBlock = MathMax(0.0, MomentumBlockZ);
   g_quotesReady = ReadQuotes();

   if(!g_quotesReady)
     {
      UpdateUnavailableState();
      ChartRedraw();
      return;
     }

   g_midBrent = (g_bidBrent + g_askBrent) * 0.5;
   g_midWti = (g_bidWti + g_askWti) * 0.5;
   g_midSpread = g_midBrent - g_midWti;
   g_ratio = (g_midWti > 0.0 ? g_midBrent / g_midWti : 0.0);
   g_premiumPct = (g_ratio - 1.0) * 100.0;

   CalculateStatistics();
   CalculateEconomics();
   DetermineSignal();

   UpdateHeader();
   UpdateQuoteBlocks();
   UpdateSpreadBlock();
   UpdateStatisticsBlock();
   UpdateGauge();
   UpdateSignalBlock();
   UpdateTradeBlocks();
   UpdateFooterStatus();
   UpdateTimeframeButtons();
   CheckSignalAlert();

   ChartRedraw();
  }

//+------------------------------------------------------------------+
//| Read live quotes and symbol properties                          |
//+------------------------------------------------------------------+
bool ReadQuotes()
  {
   g_bidBrent = MarketInfo(BrentSymbol, MODE_BID);
   g_askBrent = MarketInfo(BrentSymbol, MODE_ASK);
   g_bidWti = MarketInfo(WtiSymbol, MODE_BID);
   g_askWti = MarketInfo(WtiSymbol, MODE_ASK);

   if(g_bidBrent <= 0.0 || g_askBrent <= 0.0 ||
      g_bidWti <= 0.0 || g_askWti <= 0.0)
      return false;

   if(g_askBrent < g_bidBrent || g_askWti < g_bidWti)
      return false;

   g_digitsBrent = (int)MarketInfo(BrentSymbol, MODE_DIGITS);
   g_digitsWti = (int)MarketInfo(WtiSymbol, MODE_DIGITS);

   double tickSizeBrent = MarketInfo(BrentSymbol, MODE_TICKSIZE);
   double tickSizeWti = MarketInfo(WtiSymbol, MODE_TICKSIZE);
   double pointBrent = MarketInfo(BrentSymbol, MODE_POINT);
   double pointWti = MarketInfo(WtiSymbol, MODE_POINT);
   double tickValueBrent = MarketInfo(BrentSymbol, MODE_TICKVALUE);
   double tickValueWti = MarketInfo(WtiSymbol, MODE_TICKVALUE);

   if(tickSizeBrent <= 0.0)
      tickSizeBrent = pointBrent;
   if(tickSizeWti <= 0.0)
      tickSizeWti = pointWti;

   g_valuePerPriceBrent = (tickSizeBrent > 0.0 ? tickValueBrent / tickSizeBrent : 0.0);
   g_valuePerPriceWti = (tickSizeWti > 0.0 ? tickValueWti / tickSizeWti : 0.0);
   g_spreadUnitValue = (g_valuePerPriceBrent + g_valuePerPriceWti) * 0.5;
   g_economicsReady = (g_valuePerPriceBrent > 0.0 && g_valuePerPriceWti > 0.0);
   return true;
  }

//+------------------------------------------------------------------+
//| Calculate rolling spread statistics                             |
//+------------------------------------------------------------------+
bool CalculateStatistics()
  {
   ResetStats();

   int barsBrent = iBars(BrentSymbol, g_analysisTf);
   int barsWti = iBars(WtiSymbol, g_analysisTf);
   int requested = MathMax(20, MathMin(LookbackBars, 1000));
   int available = MathMin(barsBrent - 2, barsWti - 2);
   if(available < 20)
      return false;

   int sampleCount = MathMin(requested, available);
   double values[];
   ArrayResize(values, sampleCount);
   ArrayInitialize(values, 0.0);

   int n = 0;
   int periodSeconds = PeriodSeconds(g_analysisTf);

   // Oldest completed bar first, newest completed bar last.
   for(int k = sampleCount; k >= 1; k--)
     {
      datetime brentTime = iTime(BrentSymbol, g_analysisTf, k);
      if(brentTime <= 0)
         continue;

      int wtiShift = iBarShift(WtiSymbol, g_analysisTf, brentTime, false);
      if(wtiShift < 0)
         continue;

      datetime wtiTime = iTime(WtiSymbol, g_analysisTf, wtiShift);
      if(wtiTime <= 0 || MathAbs((long)brentTime - (long)wtiTime) > periodSeconds)
         continue;

      double brentClose = iClose(BrentSymbol, g_analysisTf, k);
      double wtiClose = iClose(WtiSymbol, g_analysisTf, wtiShift);
      if(brentClose <= 0.0 || wtiClose <= 0.0)
         continue;

      values[n] = brentClose - wtiClose;
      n++;
     }

   if(n < 20)
      return false;

   ArrayResize(values, n);
   ArrayResize(g_history, n);
   g_historyCount = n;

   double sum = 0.0;
   double minimum = values[0];
   double maximum = values[0];
   for(int i = 0; i < n; i++)
     {
      g_history[i] = values[i];
      sum += values[i];
      if(values[i] < minimum)
         minimum = values[i];
      if(values[i] > maximum)
         maximum = values[i];
     }

   double mean = sum / n;
   double squared = 0.0;
   for(int j = 0; j < n; j++)
      squared += MathPow(values[j] - mean, 2.0);

   double stddev = (n > 1 ? MathSqrt(squared / (n - 1)) : 0.0);
   double zscore = (stddev > 0.0 ? (g_midSpread - mean) / stddev : 0.0);

   int belowOrEqual = 0;
   for(int p = 0; p < n; p++)
     {
      if(values[p] <= g_midSpread)
         belowOrEqual++;
     }
   double percentile = 100.0 * (double)belowOrEqual / (double)n;

   int momentumBars = MathMax(2, MathMin(MomentumBars, n - 1));
   double momentum = (stddev > 0.0 ?
                      (g_midSpread - values[n - 1 - momentumBars]) / stddev : 0.0);

   int slopeBars = MathMin(MathMax(MomentumBars, 5), n);
   double slope = CalculateSlope(values, n - slopeBars, slopeBars);
   double halfLife = CalculateHalfLife(values, n);

   g_stats.valid = true;
   g_stats.samples = n;
   g_stats.mean = mean;
   g_stats.stddev = stddev;
   g_stats.minimum = minimum;
   g_stats.maximum = maximum;
   g_stats.zscore = zscore;
   g_stats.percentile = percentile;
   g_stats.halfLife = halfLife;
   g_stats.slope = slope;
   g_stats.momentum = momentum;
   g_stats.lastBarTime = iTime(BrentSymbol, g_analysisTf, 1);
   return true;
  }

//+------------------------------------------------------------------+
//| Linear regression slope per bar                                 |
//+------------------------------------------------------------------+
double CalculateSlope(const double &values[], const int start, const int count)
  {
   if(count < 2 || start < 0 || start + count > ArraySize(values))
      return 0.0;

   double sumX = 0.0;
   double sumY = 0.0;
   double sumXY = 0.0;
   double sumXX = 0.0;

   for(int i = 0; i < count; i++)
     {
      double x = (double)i;
      double y = values[start + i];
      sumX += x;
      sumY += y;
      sumXY += x * y;
      sumXX += x * x;
     }

   double denominator = count * sumXX - sumX * sumX;
   if(MathAbs(denominator) < 0.0000001)
      return 0.0;

   return (count * sumXY - sumX * sumY) / denominator;
  }

//+------------------------------------------------------------------+
//| AR(1) half-life in bars                                         |
//+------------------------------------------------------------------+
double CalculateHalfLife(const double &values[], const int count)
  {
   if(count < 10)
      return 0.0;

   double sumX = 0.0;
   double sumY = 0.0;
   for(int i = 1; i < count; i++)
     {
      sumX += values[i - 1];
      sumY += values[i] - values[i - 1];
     }

   int observations = count - 1;
   double meanX = sumX / observations;
   double meanY = sumY / observations;
   double covariance = 0.0;
   double variance = 0.0;

   for(int j = 1; j < count; j++)
     {
      double x = values[j - 1] - meanX;
      double y = (values[j] - values[j - 1]) - meanY;
      covariance += x * y;
      variance += x * x;
     }

   if(variance <= 0.0)
      return 0.0;

   double beta = covariance / variance;
   if(beta >= 0.0 || beta <= -0.999999)
      return 0.0;

   double halfLife = -MathLog(2.0) / MathLog(1.0 + beta);
   if(halfLife <= 0.0)
      return 0.0;
   return halfLife;
  }

//+------------------------------------------------------------------+
//| Calculate after-cost economics for both spread directions       |
//+------------------------------------------------------------------+
void CalculateEconomics()
  {
   double spreadBrent = MathMax(0.0, g_askBrent - g_bidBrent);
   double spreadWti = MathMax(0.0, g_askWti - g_bidWti);
   double lots = MathMax(0.0, TradeLotsPerLeg);

   g_spreadCostMoney =
      (spreadBrent * g_valuePerPriceBrent +
       spreadWti * g_valuePerPriceWti) * lots;
   g_commissionMoney =
      (MathMax(0.0, BrentCommissionPerLot) +
       MathMax(0.0, WtiCommissionPerLot)) * lots;
   g_totalCostMoney = g_spreadCostMoney + g_commissionMoney;

   g_longGross = (g_stats.valid ?
                  (g_stats.mean - g_midSpread) * g_spreadUnitValue * lots : 0.0);
   g_shortGross = (g_stats.valid ?
                   (g_midSpread - g_stats.mean) * g_spreadUnitValue * lots : 0.0);
   g_longNet = g_longGross - g_totalCostMoney;
   g_shortNet = g_shortGross - g_totalCostMoney;

   double unitValueAtLots = g_spreadUnitValue * lots;
   g_breakEvenPrice = (unitValueAtLots > 0.0 ?
                       g_totalCostMoney / unitValueAtLots : 0.0);
  }

//+------------------------------------------------------------------+
//| Select statistical signal with a widening-spread filter         |
//+------------------------------------------------------------------+
void DetermineSignal()
  {
   g_signal = 0;
   if(!g_stats.valid)
      return;

   int rawSignal = 0;
   if(g_stats.zscore >= g_signalThreshold)
      rawSignal = -1;
   else if(g_stats.zscore <= -g_signalThreshold)
      rawSignal = 1;

   if(rawSignal == 0)
      return;

   bool blockedByMomentum = false;
   if(AvoidWideningSpread)
     {
      if(rawSignal == -1 && g_stats.momentum > g_momentumBlock)
         blockedByMomentum = true;
      if(rawSignal == 1 && g_stats.momentum < -g_momentumBlock)
         blockedByMomentum = true;
     }

   if(!blockedByMomentum)
      g_signal = rawSignal;
  }

//+------------------------------------------------------------------+
//| Normalize lot size for a symbol                                 |
//+------------------------------------------------------------------+
bool NormalizeLots(const string symbol, const double lots, double &normalizedLots)
  {
   double minimumLot = MarketInfo(symbol, MODE_MINLOT);
   double maximumLot = MarketInfo(symbol, MODE_MAXLOT);
   double lotStep = MarketInfo(symbol, MODE_LOTSTEP);
   if(lotStep <= 0.0)
      lotStep = 0.01;

   normalizedLots = MathFloor(lots / lotStep + 0.0000001) * lotStep;
   normalizedLots = MathMax(minimumLot, MathMin(maximumLot, normalizedLots));
   normalizedLots = NormalizeDouble(normalizedLots, 2);
   return normalizedLots > 0.0;
  }

//+------------------------------------------------------------------+
//| Check whether this EA already has an open spread position       |
//+------------------------------------------------------------------+
bool HasOpenSpreadPosition()
  {
   for(int i = OrdersTotal() - 1; i >= 0; i--)
     {
      if(!OrderSelect(i, SELECT_BY_POS, MODE_TRADES))
         continue;
      if(OrderMagicNumber() != MagicNumber)
         continue;
      if(OrderSymbol() != BrentSymbol && OrderSymbol() != WtiSymbol)
         continue;
      if(OrderType() == OP_BUY || OrderType() == OP_SELL)
         return true;
     }
   return false;
  }

//+------------------------------------------------------------------+
//| Close one leg if the paired order fails                         |
//+------------------------------------------------------------------+
bool CloseOrderTicket(const int ticket)
  {
   if(!OrderSelect(ticket, SELECT_BY_TICKET, MODE_TRADES))
      return false;
   if(OrderType() != OP_BUY && OrderType() != OP_SELL)
      return false;

   RefreshRates();
   double closePrice = (OrderType() == OP_BUY ?
                        MarketInfo(OrderSymbol(), MODE_BID) :
                        MarketInfo(OrderSymbol(), MODE_ASK));
   if(closePrice <= 0.0)
      return false;

   return OrderClose(ticket, OrderLots(), closePrice, SlippagePoints, clrNONE);
  }

//+------------------------------------------------------------------+
//| Open a two-leg spread trade                                     |
//+------------------------------------------------------------------+
void OpenSpreadTrade(const int direction)
  {
   if(!EnableOneClickTrading)
     {
      Alert("一键交易已关闭:请在 EA 参数中开启 EnableOneClickTrading。");
      return;
     }
   if(!IsTradeAllowed() || IsTradeContextBusy())
     {
      Alert("一键交易失败:请开启自动交易,或稍后重试。");
      return;
     }
   if(!g_quotesReady)
     {
      Alert("一键交易失败:行情不可用。");
      return;
     }
   if(OnlyOneSpreadPosition && HasOpenSpreadPosition())
     {
      Alert("一键交易失败:当前已存在本 EA 的价差仓位。");
      return;
     }

   double lotsBrent = 0.0;
   double lotsWti = 0.0;
   if(!NormalizeLots(BrentSymbol, TradeLotsPerLeg, lotsBrent) ||
      !NormalizeLots(WtiSymbol, TradeLotsPerLeg, lotsWti))
     {
      Alert("一键交易失败:手数不符合品种最小手数。");
      return;
     }

   int brentType = (direction > 0 ? OP_BUY : OP_SELL);
   int wtiType = (direction > 0 ? OP_SELL : OP_BUY);
   string comment = (direction > 0 ? "OSV2 LONG SPREAD" : "OSV2 SHORT SPREAD");

   ResetLastError();
   double brentMargin = AccountFreeMarginCheck(BrentSymbol, brentType, lotsBrent);
   double wtiMargin = AccountFreeMarginCheck(WtiSymbol, wtiType, lotsWti);
   if(brentMargin <= 0.0 || wtiMargin <= 0.0 || GetLastError() == 134)
     {
      Alert("一键交易失败:账户保证金不足。");
      return;
     }

   RefreshRates();
   double brentPrice = (brentType == OP_BUY ? g_askBrent : g_bidBrent);
   double wtiPrice = (wtiType == OP_BUY ? g_askWti : g_bidWti);
   color brentColor = (brentType == OP_BUY ? CLR_TEAL : CLR_ORANGE);
   color wtiColor = (wtiType == OP_BUY ? CLR_TEAL : CLR_ORANGE);

   int brentTicket = OrderSend(BrentSymbol, brentType, lotsBrent, brentPrice,
                               SlippagePoints, 0, 0, comment,
                               MagicNumber, 0, brentColor);
   if(brentTicket < 0)
     {
      Alert("一键交易失败:Brent 腿未成交,错误码 " + IntegerToString(GetLastError()));
      return;
     }

   RefreshRates();
   wtiPrice = (wtiType == OP_BUY ? MarketInfo(WtiSymbol, MODE_ASK) :
                                  MarketInfo(WtiSymbol, MODE_BID));
   int wtiTicket = OrderSend(WtiSymbol, wtiType, lotsWti, wtiPrice,
                             SlippagePoints, 0, 0, comment,
                             MagicNumber, 0, wtiColor);
   if(wtiTicket < 0)
     {
      int closeError = GetLastError();
      if(!CloseOrderTicket(brentTicket))
         Alert("警告:WTI 腿未成交,Brent 腿回补失败,请立即人工处理。");
      else
         Alert("一键交易失败:WTI 腿未成交,Brent 腿已回补。错误码 " +
               IntegerToString(closeError));
      return;
     }

   Alert((direction > 0 ? "已一键做多价差:" : "已一键做空价差:") +
         "买/卖 " + BrentSymbol + " " + DoubleToString(lotsBrent, 2) + "手," +
         "卖/买 " + WtiSymbol + " " + DoubleToString(lotsWti, 2) + "手。");
   g_lastRefresh = 0;
   UpdateDashboard(true);
  }

//+------------------------------------------------------------------+
//| Send a signal-transition alert on a new bar                     |
//+------------------------------------------------------------------+
void CheckSignalAlert()
  {
   if(!EnableAlerts || !g_stats.valid)
      return;

   datetime currentBar = iTime(BrentSymbol, g_analysisTf, 0);
   if(currentBar <= 0 || currentBar == g_lastAlertBar)
      return;

   g_lastAlertBar = currentBar;
   if(g_signal == 0)
     {
      g_lastAlertSignal = 0;
      return;
     }
   if(g_signal == g_lastAlertSignal)
      return;

   string action = (g_signal > 0 ? "做多价差" : "做空价差");
   string message = StringFormat(
      "%s %s/%s | %s | 价差 %.3f | z %.2f | 分位 %.1f%% | 预计净利 %s %.2f",
      BrentSymbol, WtiSymbol, TimeframeName(g_analysisTf), action,
      g_midSpread, g_stats.zscore, g_stats.percentile,
      AccountCurrency(), (g_signal > 0 ? g_longNet : g_shortNet));
   Alert(message);
   g_lastAlertSignal = g_signal;
  }

//+------------------------------------------------------------------+
//| Build all chart objects                                         |
//+------------------------------------------------------------------+
void BuildDashboard()
  {
   DeleteDashboard();

   CreateRect("OSV2_BG", 0, 0, PANEL_W, PANEL_H, CLR_PANEL, CLR_BORDER);
   CreateRect("OSV2_HEADER", 0, 0, PANEL_W, 46, CLR_HEADER, CLR_BORDER);
   CreateRect("OSV2_ACCENT", 0, 0, 5, 46, CLR_TEAL, CLR_TEAL);

   CreateLabel("OSV2_TITLE", 22, 9, "OIL SPREAD V2", 16, CLR_TEXT, "Segoe UI Semibold");
   CreateLabel("OSV2_SUBTITLE", 22, 29, "Brent / WTI 价差速览", 9, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_HEADER_SYMBOLS", 826, 10, BrentSymbol + " / " + WtiSymbol, 9, CLR_TEXT, "Consolas", ANCHOR_RIGHT_UPPER);
   CreateLabel("OSV2_HEADER_TIME", 826, 30, "等待行情", 8, CLR_MUTED, "Consolas", ANCHOR_RIGHT_UPPER);

   CreateRect("OSV2_SIGNAL_BG", 16, 54, 532, 132, CLR_CELL, CLR_BORDER);
   CreateLabel("OSV2_SIGNAL_CAPTION", 31, 62, "当前建议", 8, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_SIGNAL_ACTION", 31, 77, "等待数据", 24, CLR_AMBER, "Microsoft YaHei");
   CreateLabel("OSV2_SIGNAL_DETAIL", 31, 115, "--", 11, CLR_TEXT, "Microsoft YaHei");
   CreateLabel("OSV2_SIGNAL_REASON", 31, 139, "--", 8, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_SIGNAL_STRENGTH", 531, 62, "--", 8, CLR_MUTED, "Consolas", ANCHOR_RIGHT_UPPER);
   CreateRect("OSV2_GAUGE_TRACK", 31, 166, 470, 8, CLR_GAUGE, CLR_GAUGE);
   CreateRect("OSV2_GAUGE_LONG_ZONE", 31, 166, 117, 8, CLR_TEAL_DARK, CLR_TEAL_DARK);
   CreateRect("OSV2_GAUGE_SHORT_ZONE", 384, 166, 117, 8, C'84,50,35', C'84,50,35');
   CreateRect("OSV2_GAUGE_MID", 265, 164, 2, 12, CLR_ZERO, CLR_ZERO);
   CreateRect("OSV2_GAUGE_FILL", 265, 167, 2, 5, CLR_TEAL, CLR_TEAL);
   CreateRect("OSV2_GAUGE_MARKER", 263, 163, 6, 14, CLR_TEXT, CLR_TEXT);
   CreateLabel("OSV2_GAUGE_LEFT_VALUE", 31, 176, "左极值 --", 7, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_GAUGE_RIGHT_VALUE", 501, 176, "右极值 --", 7, CLR_MUTED, "Microsoft YaHei", ANCHOR_RIGHT_UPPER);

   CreateRect("OSV2_SPREAD_BG", 556, 54, 288, 132, CLR_CELL_ALT, CLR_BORDER);
   CreateLabel("OSV2_SPREAD_CAPTION", 571, 62, "当前价差", 8, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_SPREAD_VALUE", 571, 79, "--", 27, CLR_TEXT, "Consolas");
   CreateLabel("OSV2_SPREAD_UNIT", 571, 117, "USD / bbl", 8, CLR_MUTED, "Segoe UI");
   CreateLabel("OSV2_RATIO_VALUE", 571, 140, "比价 --  ·  溢价 --", 9, CLR_TEXT, "Consolas");
   CreateLabel("OSV2_EXEC_SPREADS", 571, 160, "执行  多 -- / 空 --", 8, CLR_MUTED, "Consolas");

   CreateRect("OSV2_SYM1_BG", 16, 194, 260, 108, CLR_CELL, CLR_BORDER);
   CreateLabel("OSV2_SYM1_NAME", 31, 202, BrentSymbol + " · BRENT", 9, CLR_TEAL, "Segoe UI Semibold");
   CreateLabel("OSV2_SYM1_PRICE", 31, 220, "--", 20, CLR_TEXT, "Consolas");
   CreateLabel("OSV2_SYM1_QUOTE", 31, 252, "BID -- / ASK --", 8, CLR_MUTED, "Consolas");
   CreateLabel("OSV2_SYM1_CHANGE", 31, 272, "日变动 --", 8, CLR_MUTED, "Consolas");

   CreateRect("OSV2_SYM2_BG", 284, 194, 260, 108, CLR_CELL, CLR_BORDER);
   CreateLabel("OSV2_SYM2_NAME", 299, 202, WtiSymbol + " · WTI", 9, CLR_ORANGE, "Segoe UI Semibold");
   CreateLabel("OSV2_SYM2_PRICE", 299, 220, "--", 20, CLR_TEXT, "Consolas");
   CreateLabel("OSV2_SYM2_QUOTE", 299, 252, "BID -- / ASK --", 8, CLR_MUTED, "Consolas");
   CreateLabel("OSV2_SYM2_CHANGE", 299, 272, "日变动 --", 8, CLR_MUTED, "Consolas");

   CreateRect("OSV2_STATS_BG", 556, 194, 288, 108, CLR_CELL, CLR_BORDER);
   CreateLabel("OSV2_STATS_CAPTION", 571, 202, "关键统计", 8, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_STAT_MEAN_CAPTION", 571, 223, "均值", 9, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_STAT_MEAN_VALUE", 826, 223, "--", 10, CLR_TEXT, "Consolas", ANCHOR_RIGHT_UPPER);
   CreateLabel("OSV2_STAT_Z_CAPTION", 571, 244, "Z-SCORE", 9, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_STAT_Z_VALUE", 826, 244, "--", 10, CLR_TEXT, "Consolas", ANCHOR_RIGHT_UPPER);
   CreateLabel("OSV2_STAT_PCT_CAPTION", 571, 265, "百分位", 9, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_STAT_PCT_VALUE", 826, 265, "--", 10, CLR_TEXT, "Consolas", ANCHOR_RIGHT_UPPER);
   CreateLabel("OSV2_STAT_HL_CAPTION", 571, 286, "半衰期", 9, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_STAT_HL_VALUE", 826, 286, "--", 10, CLR_TEXT, "Consolas", ANCHOR_RIGHT_UPPER);

   CreateRect("OSV2_LONG_BG", 16, 310, 414, 120, CLR_CELL, CLR_BORDER);
   CreateLabel("OSV2_LONG_TITLE", 31, 317, "做多价差净利", 10, CLR_TEAL, "Microsoft YaHei");
   CreateLabel("OSV2_LONG_NET", 31, 333, "--", 24, CLR_TEXT, "Consolas");
   CreateLabel("OSV2_LONG_DETAIL", 31, 368, "买 Brent / 卖 WTI", 9, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_LONG_GROSS", 31, 387, "回归毛利 --", 8, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_LONG_COST", 31, 403, "点差 + 佣金 --", 8, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_LONG_BE", 31, 419, "保本收敛 --", 8, CLR_MUTED, "Microsoft YaHei");

   CreateRect("OSV2_SHORT_BG", 438, 310, 414, 120, CLR_CELL, CLR_BORDER);
   CreateLabel("OSV2_SHORT_TITLE", 453, 317, "做空价差净利", 10, CLR_ORANGE, "Microsoft YaHei");
   CreateLabel("OSV2_SHORT_NET", 453, 333, "--", 24, CLR_TEXT, "Consolas");
   CreateLabel("OSV2_SHORT_DETAIL", 453, 368, "卖 Brent / 买 WTI", 9, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_SHORT_GROSS", 453, 387, "回归毛利 --", 8, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_SHORT_COST", 453, 403, "点差 + 佣金 --", 8, CLR_MUTED, "Microsoft YaHei");
   CreateLabel("OSV2_SHORT_BE", 453, 419, "保本收敛 --", 8, CLR_MUTED, "Microsoft YaHei");

   CreateButton("OSV2_BTN_LONG", 16, 438, 414, 36, "一键做多价差");
   CreateButton("OSV2_BTN_SHORT", 438, 438, 414, 36, "一键做空价差");
   ObjectSetInteger(0, "OSV2_BTN_LONG", OBJPROP_BGCOLOR, CLR_TEAL_DARK);
   ObjectSetInteger(0, "OSV2_BTN_LONG", OBJPROP_COLOR, CLR_TEXT);
   ObjectSetInteger(0, "OSV2_BTN_LONG", OBJPROP_BORDER_COLOR, CLR_TEAL);
   ObjectSetInteger(0, "OSV2_BTN_SHORT", OBJPROP_BGCOLOR, C'84,50,35');
   ObjectSetInteger(0, "OSV2_BTN_SHORT", OBJPROP_COLOR, CLR_TEXT);
   ObjectSetInteger(0, "OSV2_BTN_SHORT", OBJPROP_BORDER_COLOR, CLR_ORANGE);

   CreateRect("OSV2_FOOTER", 16, 482, 828, 30, CLR_HEADER, CLR_BORDER);
   CreateLabel("OSV2_FOOTER_STATUS", 31, 489, "正在读取历史行情...", 8, CLR_MUTED, "Microsoft YaHei");
   CreateButton("OSV2_BTN_M15", 680, 486, 38, 22, "M15");
   CreateButton("OSV2_BTN_H1", 723, 486, 38, 22, "H1");
   CreateButton("OSV2_BTN_H4", 766, 486, 38, 22, "H4");
   CreateButton("OSV2_BTN_REFRESH", 809, 486, 52, 22, "刷新");
  }

//+------------------------------------------------------------------+
//| Create one statistic block                                      |
//+------------------------------------------------------------------+
void CreateStatBlock(const string name, const int x, const int y,
                     const string cn, const string en)
  {
   CreateLabel(name + "_CAPTION", x, y, cn + "  ·  " + en, 8, CLR_MUTED, "Microsoft YaHei");
   CreateLabel(name + "_VALUE", x, y + 16, "--", 14, CLR_TEXT, "Consolas");
   CreateLabel(name + "_SUB", x, y + 43, "--", 8, CLR_MUTED, "Consolas");
  }

//+------------------------------------------------------------------+
//| Update header                                                   |
//+------------------------------------------------------------------+
void UpdateHeader()
  {
   SetLabelText("OSV2_HEADER_SYMBOLS", BrentSymbol + " / " + WtiSymbol);
   SetLabelText("OSV2_HEADER_TIME", "服务器 " + TimeToString(TimeCurrent(), TIME_DATE | TIME_SECONDS));
   SetLabelText("OSV2_SYM1_NAME", BrentSymbol + " · BRENT");
   SetLabelText("OSV2_SYM2_NAME", WtiSymbol + " · WTI");
  }

//+------------------------------------------------------------------+
//| Update live quote blocks                                        |
//+------------------------------------------------------------------+
void UpdateQuoteBlocks()
  {
   SetLabelText("OSV2_SYM1_PRICE", FormatPrice(g_midBrent, g_digitsBrent));
   SetLabelText("OSV2_SYM2_PRICE", FormatPrice(g_midWti, g_digitsWti));
   SetLabelText("OSV2_SYM1_QUOTE",
                "BID " + FormatPrice(g_bidBrent, g_digitsBrent) +
                " / ASK " + FormatPrice(g_askBrent, g_digitsBrent));
   SetLabelText("OSV2_SYM2_QUOTE",
                "BID " + FormatPrice(g_bidWti, g_digitsWti) +
                " / ASK " + FormatPrice(g_askWti, g_digitsWti));

   UpdateDailyChange("OSV2_SYM1_CHANGE", BrentSymbol, g_midBrent, g_digitsBrent);
   UpdateDailyChange("OSV2_SYM2_CHANGE", WtiSymbol, g_midWti, g_digitsWti);
  }

//+------------------------------------------------------------------+
//| Update daily change labels                                      |
//+------------------------------------------------------------------+
void UpdateDailyChange(const string name, const string symbol,
                       const double currentMid, const int digits)
  {
   double previousClose = iClose(symbol, PERIOD_D1, 1);
   if(previousClose <= 0.0 || currentMid <= 0.0)
     {
      SetLabelText(name, "日变动  --");
      SetLabelColor(name, CLR_MUTED);
      return;
     }

   double change = currentMid - previousClose;
   double percent = change / previousClose * 100.0;
   string sign = (change > 0.0 ? "+" : "");
   string text = "日变动  " + sign + FormatPrice(change, digits) +
                 "  (" + sign + DoubleToString(percent, 2) + "%)";
   SetLabelText(name, text);
   SetLabelColor(name, (change > 0.0 ? CLR_GREEN : (change < 0.0 ? CLR_RED : CLR_MUTED)));
  }

//+------------------------------------------------------------------+
//| Update spread and ratio block                                   |
//+------------------------------------------------------------------+
void UpdateSpreadBlock()
  {
   SetLabelText("OSV2_SPREAD_VALUE", DoubleToString(g_midSpread, 3));

   string premiumSign = (g_premiumPct > 0.0 ? "+" : "");
   SetLabelText("OSV2_RATIO_VALUE",
                "比价 " + DoubleToString(g_ratio, 4) +
                "  ·  溢价 " + premiumSign + DoubleToString(g_premiumPct, 2) + "%");

   double longExecutable = g_askBrent - g_bidWti;
   double shortExecutable = g_bidBrent - g_askWti;
   SetLabelText("OSV2_EXEC_SPREADS",
                "执行  多 " + DoubleToString(longExecutable, 3) +
                " / 空 " + DoubleToString(shortExecutable, 3));
  }

//+------------------------------------------------------------------+
//| Update statistics block                                         |
//+------------------------------------------------------------------+
void UpdateStatisticsBlock()
  {
   if(!g_stats.valid)
     {
      SetLabelText("OSV2_STAT_MEAN_VALUE", "--");
      SetLabelText("OSV2_STAT_Z_VALUE", "--");
      SetLabelText("OSV2_STAT_PCT_VALUE", "--");
      SetLabelText("OSV2_STAT_HL_VALUE", "--");
      return;
     }

   SetLabelText("OSV2_STAT_MEAN_VALUE", DoubleToString(g_stats.mean, 3));

   string zSign = (g_stats.zscore > 0.0 ? "+" : "");
   SetLabelText("OSV2_STAT_Z_VALUE", zSign + DoubleToString(g_stats.zscore, 2) + "σ");
   SetLabelColor("OSV2_STAT_Z_VALUE",
                  (g_stats.zscore >= g_signalThreshold ? CLR_ORANGE :
                   (g_stats.zscore <= -g_signalThreshold ? CLR_TEAL : CLR_TEXT)));

   SetLabelText("OSV2_STAT_PCT_VALUE", DoubleToString(g_stats.percentile, 1) + "%");

   if(g_stats.halfLife > 0.0)
      SetLabelText("OSV2_STAT_HL_VALUE", DoubleToString(g_stats.halfLife, 1) + "根");
   else
      SetLabelText("OSV2_STAT_HL_VALUE", "N/A");
  }

//+------------------------------------------------------------------+
//| Update z-score gauge                                            |
//+------------------------------------------------------------------+
void UpdateGauge()
  {
   double z = (g_stats.valid ? g_stats.zscore : 0.0);
   double clamped = MathMax(-3.0, MathMin(3.0, z));
   int trackX = 31;
   int centerX = trackX + 235;
   int markerX = trackX + (int)MathRound((clamped + 3.0) / 6.0 * 470.0);
   markerX = MathMax(trackX + 1, MathMin(trackX + 469, markerX));

   color activeColor = CLR_ZERO;
   if(z >= g_signalThreshold)
      activeColor = CLR_ORANGE;
   else if(z <= -g_signalThreshold)
      activeColor = CLR_TEAL;

   int fillX = MathMin(centerX, markerX);
   int fillWidth = MathMax(2, MathAbs(markerX - centerX));
   SetRectGeometry("OSV2_GAUGE_FILL", fillX, 167, fillWidth, 5);
   SetRectColor("OSV2_GAUGE_FILL", activeColor, activeColor);
   SetRectGeometry("OSV2_GAUGE_MARKER", markerX - 3, 163, 6, 14);
   SetRectColor("OSV2_GAUGE_MARKER", activeColor, activeColor);

   if(g_stats.valid)
     {
      double leftExtreme = g_stats.mean - 3.0 * g_stats.stddev;
      double rightExtreme = g_stats.mean + 3.0 * g_stats.stddev;
      SetLabelText("OSV2_GAUGE_LEFT_VALUE",
                   "左极值 " + DoubleToString(leftExtreme, 3));
      SetLabelText("OSV2_GAUGE_RIGHT_VALUE",
                   "右极值 " + DoubleToString(rightExtreme, 3));
     }
   else
     {
      SetLabelText("OSV2_GAUGE_LEFT_VALUE", "左极值 --");
      SetLabelText("OSV2_GAUGE_RIGHT_VALUE", "右极值 --");
     }
  }

//+------------------------------------------------------------------+
//| Update signal block                                             |
//+------------------------------------------------------------------+
void UpdateSignalBlock()
  {
   if(!g_stats.valid)
     {
      SetLabelText("OSV2_SIGNAL_ACTION", "等待数据");
      SetLabelColor("OSV2_SIGNAL_ACTION", CLR_AMBER);
      SetLabelText("OSV2_SIGNAL_DETAIL", "双品种历史尚未对齐");
      SetLabelText("OSV2_SIGNAL_REASON", "请检查品种名称与图表历史");
      SetLabelText("OSV2_SIGNAL_STRENGTH", "--");
      return;
     }

   string zSign = (g_stats.zscore > 0.0 ? "+" : "");
   string reason = "z " + zSign + DoubleToString(g_stats.zscore, 2) +
                   "σ  ·  " + DoubleToString(g_stats.percentile, 1) + "%分位";
   if(g_stats.halfLife > 0.0)
      reason += "  ·  半衰期 " + DoubleToString(g_stats.halfLife, 1) + "根";
   else
      reason += "  ·  均值回归证据弱";

   string action = "观望";
   string detail = "价差未达到统计极值";
   color actionColor = CLR_AMBER;

   if(g_signal > 0)
     {
      action = "做多价差";
      detail = "买 Brent / 卖 WTI";
      actionColor = CLR_TEAL;
     }
   else if(g_signal < 0)
     {
      action = "做空价差";
      detail = "卖 Brent / 买 WTI";
      actionColor = CLR_ORANGE;
     }
   else if(AvoidWideningSpread &&
            ((g_stats.zscore >= g_signalThreshold && g_stats.momentum > g_momentumBlock) ||
             (g_stats.zscore <= -g_signalThreshold && g_stats.momentum < -g_momentumBlock)))
     {
      action = "等待拐点";
      detail = "价差仍在扩散";
      actionColor = CLR_AMBER;
      reason += "  ·  动量过滤已触发";
     }

   double strength = MathMin(100.0, MathAbs(g_stats.zscore) / 3.0 * 100.0);
   string strengthText = "偏离强度 " + DoubleToString(strength, 0) + "/100";
   SetLabelText("OSV2_SIGNAL_ACTION", action);
   SetLabelColor("OSV2_SIGNAL_ACTION", actionColor);
   SetLabelText("OSV2_SIGNAL_DETAIL", detail);
   SetLabelColor("OSV2_SIGNAL_DETAIL", actionColor);
   SetLabelText("OSV2_SIGNAL_REASON", reason);
   SetLabelText("OSV2_SIGNAL_STRENGTH", strengthText);
  }

//+------------------------------------------------------------------+
//| Update both strategy economics blocks                           |
//+------------------------------------------------------------------+
void UpdateTradeBlocks()
  {
   if(!g_stats.valid || !g_economicsReady)
     {
      SetLabelText("OSV2_LONG_NET", "--");
      SetLabelText("OSV2_SHORT_NET", "--");
      SetLabelText("OSV2_LONG_GROSS", "回归毛利 --");
      SetLabelText("OSV2_SHORT_GROSS", "回归毛利 --");
      SetLabelText("OSV2_LONG_COST", "点差 + 佣金 --");
      SetLabelText("OSV2_SHORT_COST", "点差 + 佣金 --");
      SetLabelText("OSV2_LONG_BE", "保本收敛 --");
      SetLabelText("OSV2_SHORT_BE", "保本收敛 --");
      return;
     }

   string currency = AccountCurrency();
   SetLabelText("OSV2_LONG_NET", SignedMoney(g_longNet, currency));
   SetLabelColor("OSV2_LONG_NET", (g_longNet >= 0.0 ? CLR_GREEN : CLR_RED));
   SetLabelText("OSV2_SHORT_NET", SignedMoney(g_shortNet, currency));
   SetLabelColor("OSV2_SHORT_NET", (g_shortNet >= 0.0 ? CLR_GREEN : CLR_RED));

   SetLabelText("OSV2_LONG_GROSS", "回归毛利 " + SignedMoney(g_longGross, currency));
   SetLabelText("OSV2_SHORT_GROSS", "回归毛利 " + SignedMoney(g_shortGross, currency));
   SetLabelText("OSV2_LONG_COST",
                "点差 " + MoneyText(g_spreadCostMoney, currency) +
                " + 佣金 " + MoneyText(g_commissionMoney, currency));
   SetLabelText("OSV2_SHORT_COST",
                "点差 " + MoneyText(g_spreadCostMoney, currency) +
                " + 佣金 " + MoneyText(g_commissionMoney, currency));

   string lotsText = DoubleToString(MathMax(0.0, TradeLotsPerLeg), 2);
   SetLabelText("OSV2_LONG_BE",
                "保本收敛 " + DoubleToString(g_breakEvenPrice, 3) +
                " USD/bbl · 每腿 " + lotsText + "手");
   SetLabelText("OSV2_SHORT_BE",
                "保本收敛 " + DoubleToString(g_breakEvenPrice, 3) +
                " USD/bbl · 每腿 " + lotsText + "手");
  }

//+------------------------------------------------------------------+
//| Update timeframe tab buttons                                    |
//+------------------------------------------------------------------+
void UpdateTimeframeButtons()
  {
   StyleTimeframeButton("OSV2_BTN_M15", g_analysisTf == PERIOD_M15);
   StyleTimeframeButton("OSV2_BTN_H1", g_analysisTf == PERIOD_H1);
   StyleTimeframeButton("OSV2_BTN_H4", g_analysisTf == PERIOD_H4);
  }

//+------------------------------------------------------------------+
//| Style one timeframe button                                      |
//+------------------------------------------------------------------+
void StyleTimeframeButton(const string name, const bool selected)
  {
   ObjectSetInteger(0, name, OBJPROP_STATE, false);
   ObjectSetInteger(0, name, OBJPROP_BGCOLOR, (selected ? CLR_TEAL_DARK : CLR_CELL_ALT));
   ObjectSetInteger(0, name, OBJPROP_COLOR, (selected ? CLR_TEXT : CLR_MUTED));
   ObjectSetInteger(0, name, OBJPROP_BORDER_COLOR, (selected ? CLR_TEAL : CLR_BORDER));
  }

//+------------------------------------------------------------------+
//| Update unavailable symbols/data state                           |
//+------------------------------------------------------------------+
void UpdateUnavailableState()
  {
   g_stats.valid = false;
   g_historyCount = 0;
   SetLabelText("OSV2_HEADER_TIME", "行情不可用");
   SetLabelText("OSV2_SYM1_PRICE", "--");
   SetLabelText("OSV2_SYM2_PRICE", "--");
   SetLabelText("OSV2_SYM1_QUOTE", "BID -- / ASK --");
   SetLabelText("OSV2_SYM2_QUOTE", "BID -- / ASK --");
   SetLabelText("OSV2_SYM1_CHANGE", "日变动 --");
   SetLabelText("OSV2_SYM2_CHANGE", "日变动 --");
   SetLabelText("OSV2_SPREAD_VALUE", "--");
   SetLabelText("OSV2_RATIO_VALUE", "比价 -- · 溢价 --");
   SetLabelText("OSV2_EXEC_SPREADS", "执行 --");
   SetLabelText("OSV2_STAT_MEAN_VALUE", "--");
   SetLabelText("OSV2_STAT_Z_VALUE", "--");
   SetLabelText("OSV2_STAT_PCT_VALUE", "--");
   SetLabelText("OSV2_STAT_HL_VALUE", "--");
   SetLabelText("OSV2_GAUGE_LEFT_VALUE", "左极值 --");
   SetLabelText("OSV2_GAUGE_RIGHT_VALUE", "右极值 --");
   SetLabelText("OSV2_SIGNAL_ACTION", "等待行情");
   SetLabelColor("OSV2_SIGNAL_ACTION", CLR_AMBER);
   SetLabelText("OSV2_SIGNAL_DETAIL", "报价不完整");
   SetLabelColor("OSV2_SIGNAL_DETAIL", CLR_AMBER);
   SetLabelText("OSV2_SIGNAL_REASON", "请检查品种名称与市场报价");
   SetLabelText("OSV2_SIGNAL_STRENGTH", "--");
   SetLabelText("OSV2_FOOTER_STATUS", "品种不可用或报价不完整");
   SetLabelColor("OSV2_FOOTER_STATUS", CLR_RED);
   UpdateGauge();
   UpdateTradeBlocks();
   UpdateTimeframeButtons();
  }

//+------------------------------------------------------------------+
//| Refresh footer data-quality status                              |
//+------------------------------------------------------------------+
void UpdateFooterStatus()
  {
   string barTime = (g_stats.valid && g_stats.lastBarTime > 0 ?
                     TimeToString(g_stats.lastBarTime, TIME_MINUTES) : "--:--");
   string status = "样本 " + IntegerToString(g_stats.samples) +
                   " / " + IntegerToString(MathMax(20, MathMin(LookbackBars, 1000))) +
                   "  ·  " + TimeframeName(g_analysisTf) +
                   "  ·  K线至 " + barTime +
                   "  ·  更新 " + TimeToString(TimeCurrent(), TIME_MINUTES);
   SetLabelText("OSV2_FOOTER_STATUS", status);
   SetLabelColor("OSV2_FOOTER_STATUS", CLR_MUTED);
  }

//+------------------------------------------------------------------+
//| Create rectangle label object                                   |
//+------------------------------------------------------------------+
void CreateRect(const string name, const int x, const int y,
                const int width, const int height,
                const color background, const color border)
  {
   if(ObjectFind(0, name) < 0)
      ObjectCreate(0, name, OBJ_RECTANGLE_LABEL, 0, 0, 0);

   ObjectSetInteger(0, name, OBJPROP_CORNER, CORNER_LEFT_UPPER);
   ObjectSetInteger(0, name, OBJPROP_XDISTANCE, PANEL_X + x);
   ObjectSetInteger(0, name, OBJPROP_YDISTANCE, PANEL_Y + y);
   ObjectSetInteger(0, name, OBJPROP_XSIZE, width);
   ObjectSetInteger(0, name, OBJPROP_YSIZE, height);
   ObjectSetInteger(0, name, OBJPROP_BGCOLOR, background);
   ObjectSetInteger(0, name, OBJPROP_BORDER_TYPE, BORDER_FLAT);
   ObjectSetInteger(0, name, OBJPROP_COLOR, border);
   ObjectSetInteger(0, name, OBJPROP_BACK, false);
   ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false);
   ObjectSetInteger(0, name, OBJPROP_SELECTED, false);
   ObjectSetInteger(0, name, OBJPROP_HIDDEN, true);
  }

//+------------------------------------------------------------------+
//| Create text label                                               |
//+------------------------------------------------------------------+
void CreateLabel(const string name, const int x, const int y,
                 const string text, const int fontSize,
                 const color textColor, const string font,
                 const int anchor = ANCHOR_LEFT_UPPER)
  {
   if(ObjectFind(0, name) < 0)
      ObjectCreate(0, name, OBJ_LABEL, 0, 0, 0);

   ObjectSetInteger(0, name, OBJPROP_CORNER, CORNER_LEFT_UPPER);
   ObjectSetInteger(0, name, OBJPROP_XDISTANCE, PANEL_X + x);
   ObjectSetInteger(0, name, OBJPROP_YDISTANCE, PANEL_Y + y);
   ObjectSetInteger(0, name, OBJPROP_ANCHOR, anchor);
   ObjectSetInteger(0, name, OBJPROP_FONTSIZE, fontSize);
   ObjectSetInteger(0, name, OBJPROP_COLOR, textColor);
   ObjectSetString(0, name, OBJPROP_FONT, font);
   ObjectSetString(0, name, OBJPROP_TEXT, text);
   ObjectSetInteger(0, name, OBJPROP_BACK, false);
   ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false);
   ObjectSetInteger(0, name, OBJPROP_SELECTED, false);
   ObjectSetInteger(0, name, OBJPROP_HIDDEN, true);
  }

//+------------------------------------------------------------------+
//| Create button object                                            |
//+------------------------------------------------------------------+
void CreateButton(const string name, const int x, const int y,
                  const int width, const int height, const string text)
  {
   if(ObjectFind(0, name) < 0)
      ObjectCreate(0, name, OBJ_BUTTON, 0, 0, 0);

   ObjectSetInteger(0, name, OBJPROP_CORNER, CORNER_LEFT_UPPER);
   ObjectSetInteger(0, name, OBJPROP_XDISTANCE, PANEL_X + x);
   ObjectSetInteger(0, name, OBJPROP_YDISTANCE, PANEL_Y + y);
   ObjectSetInteger(0, name, OBJPROP_XSIZE, width);
   ObjectSetInteger(0, name, OBJPROP_YSIZE, height);
   ObjectSetInteger(0, name, OBJPROP_FONTSIZE, 9);
   ObjectSetString(0, name, OBJPROP_FONT, "Microsoft YaHei");
   ObjectSetString(0, name, OBJPROP_TEXT, text);
   ObjectSetInteger(0, name, OBJPROP_BGCOLOR, CLR_CELL_ALT);
   ObjectSetInteger(0, name, OBJPROP_COLOR, CLR_MUTED);
   ObjectSetInteger(0, name, OBJPROP_BORDER_COLOR, CLR_BORDER);
   ObjectSetInteger(0, name, OBJPROP_STATE, false);
   ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false);
   ObjectSetInteger(0, name, OBJPROP_SELECTED, false);
   ObjectSetInteger(0, name, OBJPROP_HIDDEN, true);
  }

//+------------------------------------------------------------------+
//| Update label text                                               |
//+------------------------------------------------------------------+
void SetLabelText(const string name, const string text)
  {
   ObjectSetString(0, name, OBJPROP_TEXT, text);
  }

//+------------------------------------------------------------------+
//| Update label color                                              |
//+------------------------------------------------------------------+
void SetLabelColor(const string name, const color textColor)
  {
   ObjectSetInteger(0, name, OBJPROP_COLOR, textColor);
  }

//+------------------------------------------------------------------+
//| Update rectangle geometry                                       |
//+------------------------------------------------------------------+
void SetRectGeometry(const string name, const int x, const int y,
                     const int width, const int height)
  {
   ObjectSetInteger(0, name, OBJPROP_XDISTANCE, PANEL_X + x);
   ObjectSetInteger(0, name, OBJPROP_YDISTANCE, PANEL_Y + y);
   ObjectSetInteger(0, name, OBJPROP_XSIZE, MathMax(1, width));
   ObjectSetInteger(0, name, OBJPROP_YSIZE, MathMax(1, height));
  }

//+------------------------------------------------------------------+
//| Update rectangle colors                                         |
//+------------------------------------------------------------------+
void SetRectColor(const string name, const color background, const color border)
  {
   ObjectSetInteger(0, name, OBJPROP_BGCOLOR, background);
   ObjectSetInteger(0, name, OBJPROP_COLOR, border);
  }

//+------------------------------------------------------------------+
//| Delete only this EA's chart objects                             |
//+------------------------------------------------------------------+
void DeleteDashboard()
  {
   for(int i = ObjectsTotal(0, -1, -1) - 1; i >= 0; i--)
     {
      string name = ObjectName(0, i, -1, -1);
      if(StringFind(name, OBJECT_PREFIX) == 0)
         ObjectDelete(0, name);
     }
  }

//+------------------------------------------------------------------+
//| Format price with symbol digits                                 |
//+------------------------------------------------------------------+
string FormatPrice(const double price, const int digits)
  {
   int useDigits = MathMax(0, MathMin(8, digits));
   return DoubleToString(price, useDigits);
  }

//+------------------------------------------------------------------+
//| Signed account-currency amount                                  |
//+------------------------------------------------------------------+
string SignedMoney(const double value, const string currency)
  {
   string sign = (value > 0.0 ? "+" : "");
   return sign + DoubleToString(value, 2) + " " + currency;
  }

//+------------------------------------------------------------------+
//| Unsigned account-currency amount                                |
//+------------------------------------------------------------------+
string MoneyText(const double value, const string currency)
  {
   return DoubleToString(value, 2) + " " + currency;
  }

//+------------------------------------------------------------------+
//| Timeframe display name                                          |
//+------------------------------------------------------------------+
string TimeframeName(const int timeframe)
  {
   switch(timeframe)
     {
      case PERIOD_M1:  return "M1";
      case PERIOD_M5:  return "M5";
      case PERIOD_M15: return "M15";
      case PERIOD_M30: return "M30";
      case PERIOD_H1:  return "H1";
      case PERIOD_H4:  return "H4";
      case PERIOD_D1:  return "D1";
      case PERIOD_W1:  return "W1";
      case PERIOD_MN1: return "MN1";
      default:         return "TF" + IntegerToString(timeframe);
     }
  }
//+------------------------------------------------------------------+