Beyond Tick Volume: Measuring Market Activity from the Waits Between Quotes
Introduction
Every MetaTrader 5 chart carries a measure of market activity for free: tick volume, the number of price updates that arrived during a bar. It answers its question exactly. If a bar received 640 quotes, tick volume prints 640. The trouble starts when we ask it a different question: is the market unusually busy right now?
Three things stand between a per-bar count and that question. The first is the clock. On EURUSD, the average time between quotes during the quietest half hour of the day is roughly forty-seven times longer than during the busiest half hour, and the pattern repeats every day. Most of what a tick volume histogram shows is therefore the time of day. A tall bar at the London open usually means only that it is the London open. The second is timing. A bar's count is complete only when the bar closes, so in the middle of a bar it holds a partial number that cannot be compared with the finished bars beside it. The third is resolution. Two one-minute bars with 20 quotes each look identical, whether those quotes arrived evenly or as one burst in the final three seconds.
This article builds a gauge that answers the question directly. It uses the time between consecutive quotes rather than counts per bar, and divides out the normal rhythm of the trading day. What remains is modeled with the autoregressive conditional duration (ACD) model of Engle and Russell (1998). The result is a single number, updated on every quote, that reads 1 when the market is exactly as busy as it normally is at this hour, 2 when the model expects quotes at twice the usual pace, and 0.5 at half. It is a smoothed reading of the recent pace, not a count of the last few seconds.
We then test whether that number is useful for forecasting the volatility of the next few minutes. Recent price movement already predicts most of that, so the test that matters is whether activity adds anything once live volatility is in the forecast. On all six symbols it does, by a small but statistically significant margin for at least one way of measuring activity. The ACD gauge alone passes the test on five; on USDJPY its gain is too small to tell apart from noise. How the ACD gauge compares with a simpler live count depends on the horizon and the market, and we report both as they came out. Everything is written in pure MQL5: a five-file library, two scripts, and an indicator.
We will cover:
- What a Tick Is on This Feed
- The Market's Daily Heartbeat
- Busy Begets Busy: the Duration Model
- Fitting the Model and Checking What It Earned
- One Number, Updated on Every Quote
- Does Activity Tell Us Anything?
- The Gauge on a Chart
- What the Numbers Do Not Say
- Conclusion
What a Tick Is on This Feed
Before modeling anything we need to be precise about the raw material. On a retail forex or CFD feed, a tick is a change in the quote: the bid, the ask, or both moved. It is not a trade. The account used for this article delivered no last-price ticks at all on any of the twelve symbols examined (currency pairs, gold, oil, stock indices, and Bitcoin), and no ticks that repeated an unchanged bid and ask. Every wait measured below is therefore the time between two quote changes, and it would be wrong to describe it as the time between trades.
The timestamps themselves are good. Every symbol used all 1000 possible millisecond values, with no sign of rounding to whole seconds, and no tick ever arrived stamped earlier than the one before it. Over the thirty days used throughout this article, EURUSD produced 2,356,394 quote changes, an average wait of 737 milliseconds, and a coefficient of variation of 3.1. If quotes arrived as a Poisson process, independently at a constant rate, the waits would be exponential, with a standard deviation equal to their mean and a coefficient of 1. "Random arrivals" below means this benchmark. A value three times higher says the waits are far more uneven than random arrivals. Part of that is the daily rhythm, which the next section removes; what survives it is clustering.
Two situations require rules before those waits can be modeled:
- Ticks that share a millisecond. Duration models work on positive waits, and a wait of zero is not a very short wait. It is the feed delivering one burst as several records. We fold every tick stamped in the same millisecond as the previous one into a single event. On EURUSD this affected 0.09% of ticks. On gold it affected about 7%, which is why it is done by rule rather than left to chance.
- A closed market. A weekend, a daily break on an index, or a holiday produces waits of hours or days. These are not the market being slow, and a single sixty-hour wait fed into the model would teach it that the next quote is days away. Any wait longer than a threshold, five minutes by default, marks the next event as the start of a new session. The event is kept, its wait is not, and the model restarts its expectation from the long-run level.
Both rules live in CAcdEvents, which stores only the event time and the session flag. The wait is one subtraction away, and a month of gold quotes is more than thirteen million events, so nothing else is kept.
//+------------------------------------------------------------------+ //| Fold, flag and append; returns the number of events created. | //| An out-of-order tick is merged, so no wait is ever negative. | //+------------------------------------------------------------------+ int CAcdEvents::AddTicks(const MqlTick &ticks[],const int count) { if(count<=0 || !Reserve(m_total+count)) return 0; const int before=m_total; for(int i=0;i<count;i++) { const long t=(long)ticks[i].time_msc; m_ticks++; if(m_last_msc>=0 && t<=m_last_msc) { m_merged++; continue; } const bool start=(m_last_msc<0 || t-m_last_msc>m_gap_ms); m_time[m_total] =t; m_start[m_total]=(uchar)(start ? 1 : 0); if(start) m_sessions++; m_total++; m_last_msc=t; } return m_total-before; }
The loader requests history one day at a time with CopyTicksRange, so the peak memory is one day of tick records rather than a month of them, and it feeds each day through this function.

Fig. 1. From quotes to events: waits between quote changes, a burst, two ticks in one millisecond folded into one event, and a closed market that starts a new session
One more check belongs here, because it catches a feed that does not behave like a market. On the same account, BTCUSD quotes appeared to arrive on a clock: nearly every wait fell close to a quarter of a second, and the coefficient of variation was 0.36, far below the value of 1 that random arrivals would give. A low coefficient shows that the waits are regular, not why, so the threshold is a guard rather than a diagnosis. The calibration script measures the coefficient first and stops below 0.7, where a clustering model would mostly describe the regularity.
//+------------------------------------------------------------------+ //| Is this a market at all? Waits far more regular than random | //| arrivals suggest a timer-driven feed, not worth fitting. | //+------------------------------------------------------------------+ bool DescribeFeed(const CAcdEvents &ev,double &mean_wait_ms) { mean_wait_ms=0.0; double s=0.0,s2=0.0; long n=0; for(int i=1;i<ev.Total();i++) { if(ev.IsStart(i)) continue; const double w=ev.Wait(i); s +=w; s2+=w*w; n++; } if(n<2) return false; const double mean=s/n; const double cv =MathSqrt(MathMax(s2/n-mean*mean,0.0))/mean; mean_wait_ms=mean; PrintFormat(" ticks read : %I64d, %.2f%% folded into same-millisecond events", ev.TicksRead(),100.0*ev.TicksMerged()/MathMax(ev.TicksRead(),1)); PrintFormat(" events : %d in %I64d session(s)",ev.Total(),ev.Sessions()); PrintFormat(" from / to : %s / %s", TimeToString((datetime)(ev.Time(0)/1000),TIME_DATE|TIME_MINUTES), TimeToString((datetime)(ev.Time(ev.Total()-1)/1000),TIME_DATE|TIME_MINUTES)); PrintFormat(" mean wait : %.1f ms, coefficient of variation %.3f",mean,cv); if(cv<0.7) { Print(" WARNING: waits are far more regular than random arrivals. This feed"); Print(" looks timer-driven, and a clustering model would describe the timer."); return false; } return true; }
BTCUSD is therefore left out. The rest of the article uses six of the remaining symbols: three major currency pairs (EURUSD, GBPUSD and USDJPY), gold, and the US30 and SPX500 indices. They cover the three asset classes the gauge is meant for, and every result below is reported for all six, weak ones included.
The Market's Daily Heartbeat
With a clean series of waits, the first thing to remove is the part we already know. Fig. 2 shows the normal wait between quotes at each time of day for EURUSD and US30. On EURUSD the waits run from a few hundred milliseconds in the late European afternoon to around ten seconds near the server's midnight. That shape repeats every day. It says what is normal for the hour, not whether today is unusual, and a model that is not told about it will spend all of its memory rediscovering the London open.

Fig. 2. The normal wait between quote changes at each time of day, EURUSD and US30, on a logarithmic scale
CAcdDiurnal learns this profile as the average wait in fixed time-of-day bins, thirty minutes wide by default, optionally kept separately for each weekday. Three details decide whether the profile helps or quietly hurts.
First, each wait is filed under the time it started, not the time it ended. When a quote arrives, the question the model has to answer is how long the next wait will be, and the only time it knows at that moment is now.
Second, the profile is read back by interpolating between bin centers rather than as a staircase. With a staircase, a wait starting at 14:59:59 and one starting at 15:00:00 would be adjusted by different factors, and the model would see a jump in activity every half hour that the market never made.
//+------------------------------------------------------------------+ //| Interpolate between the bin centers either side of t; the ring | //| wraps at midnight within the same weekday. | //+------------------------------------------------------------------+ double CAcdDiurnal::Value(const long t_msc) const { const double pos =((double)((t_msc/1000)%86400)+(t_msc%1000)/1000.0)/m_bin_sec-0.5; const double fl =MathFloor(pos); const double frac=pos-fl; int b0=(int)fl; if(b0<0) b0+=m_bins; const int b1 =(b0+1)%m_bins; const int base=DayOf(t_msc)*m_bins; return (1.0-frac)*m_value[base+b0]+frac*m_value[base+b1]; }
Third, the profile is fitted on the training period only. Later we score the model on a held-out period, and if the profile had been measured on all of the data, the held-out waits would be adjusted by a rhythm partly learned from themselves.
After fitting, every wait is divided by its profile value, and the profile is rescaled so that these adjusted waits average exactly one over the training period. This is the step that lets the rest of the project call 1 "normal".
//+------------------------------------------------------------------+ //| Put adjusted waits on a mean of one, so 1 reads as "normal". | //+------------------------------------------------------------------+ double CAcdDiurnal::UnitMeanWaits(const CAcdEvents &ev,const int from,const int to) { if(!m_ready) return 0.0; const int a=(from<1 ? 1 : from); const int b=(to>=ev.Total() ? ev.Total()-1 : to); double sum=0.0; long cnt=0; for(int i=a;i<=b;i++) { if(ev.IsStart(i)) continue; sum+=ev.Wait(i)/Value(ev.Time(i-1)); cnt++; } if(cnt<1000 || sum<=0.0) return 0.0; const double factor=sum/cnt; Scale(factor); return factor; }
The adjusted waits are not free of structure. On EURUSD's test period their autocorrelation at a lag of one event is still 0.115, and at a lag of one hundred events it is still 0.066. A busy stretch is followed by more busy stretches, well beyond anything the clock explains. That leftover clustering is what the next section models.
Busy Begets Busy: the Duration Model
The ACD model keeps a single running number: the expected adjusted wait until the next quote, which we will call psi. Every time a quote arrives, psi is nudged toward the wait that just ended. If the last wait was shorter than expected, the expectation drops a little; if it was longer, it rises a little. How hard each wait pulls, and how long the old expectation survives, are the two things the model learns from data.
psi_i = omega + alpha * x_(i-1) + beta * psi_(i-1) x_i = psi_i * eps_i, mean(eps) = 1
Here x is the adjusted wait, alpha is the weight on the wait that just ended, and beta is the weight on the previous expectation. Their sum is the persistence: the share of a burst's influence that survives from one event to the next. Readers who know GARCH(1,1) will recognize the recursion exactly, with waits in place of squared returns. The surprise eps is whatever the expectation could not foresee, scaled so that it averages one.
Persistence is easier to read as a half-life: the number of events after which a burst's pull on the expectation has halved. A persistence of 0.9975 gives a half-life of 277 events, which on EURUSD at its average wait is a little over three minutes. During a burst the events come faster, so the same half-life passes sooner in clock time.

Fig. 3. The expected wait through a burst of quotes: each short wait pulls it down, and afterwards it decays back toward normal, halving the gap every half-life
The update is a single line of arithmetic, shared by the fitting code and the live gauge.
//--- the one-step update, shared by the model and the live state double AcdNextPsi(const SAcdParams &p,const double x_prev,const double psi_prev) { return p.omega+p.alpha*x_prev+p.beta*psi_prev; }
omega is not searched for. It is pinned so that the long-run expected wait equals the training mean of the adjusted waits, which is one by construction. This is called mean targeting. It removes one dimension from the search, and it means the activity ratio reads exactly 1 whenever the expectation sits at its long-run level.
The model also needs a distribution for the surprise, because the parameters are fitted by maximum likelihood. We provide two. The exponential is what purely random arrivals would produce, and it has no extra parameter. The Weibull adds a shape k: below one, it makes both very short and very long waits more common than the exponential allows, which is exactly what a bursty quote stream looks like. At k = 1 the Weibull reduces exactly to the exponential.
//--- log of the gamma-function term that makes a Weibull unit-mean double AcdLogScale(const double shape) { return MathGammaLog(1.0+1.0/shape); } //--- unit-mean Weibull log-density; psi-free terms are passed in for speed double AcdLogDensity(const double shape,const double log_shape,const double log_scale, const double log_x,const double psi) { const double log_z=log_scale+log_x-MathLog(psi); return log_shape+shape*log_z-log_x-MathExp(shape*log_z); }
The gamma-function term rescales the Weibull so that its mean is one for every shape. The likelihood in the next section still gives the exponential its own shorter branch, because it needs none of these terms.
Fitting the Model and Checking What It Earned
CAcdModel runs the pipeline in a fixed order, and the order is the method. The events are split chronologically, seventy percent for training and the rest held out. The rhythm is fitted on the training events and every wait is adjusted by it. The recursion is then fitted by maximum likelihood on the same training events, and the held-out events are touched once, to be scored. SetEvents carries out everything up to the fit.
//+------------------------------------------------------------------+ //| Build the adjusted waits. The profile is scaled first and each | //| wait divided once, so the live state matches bit for bit. | //+------------------------------------------------------------------+ bool CAcdModel::SetEvents(const CAcdEvents &ev) { m_total=ev.Total(); if(m_total<5000) { PrintFormat("CAcdModel: %d events is too few to fit a duration model",m_total); m_total=0; return false; } m_split=(int)MathFloor(m_train_frac*m_total); if(!m_diurnal.Setup(m_bin_minutes,m_by_weekday)) return false; if(!m_diurnal.FitWaits(ev,1,m_split-1)) return false; if(m_diurnal.UnitMeanWaits(ev,1,m_split-1)<=0.0) { Print("CAcdModel: the training range holds too few usable waits"); return false; } ArrayResize(m_x,m_total); ArrayResize(m_logx,m_total); ArrayResize(m_start,m_total); double sum=0.0; long cnt=0; for(int i=0;i<m_total;i++) { if(i==0 || ev.IsStart(i)) { m_x[i] =0.0; m_logx[i] =0.0; m_start[i]=1; continue; } m_x[i] =ev.Wait(i)/m_diurnal.Value(ev.Time(i-1)); m_logx[i] =MathLog(m_x[i]); m_start[i]=0; if(i<m_split) { sum+=m_x[i]; cnt++; } } m_mean=sum/cnt; ArrayFree(m_psi); return true; }
The search is a Nelder-Mead simplex over at most three unconstrained numbers. Persistence and the share of it carried by alpha pass through a logistic function, so they stay between zero and one; the Weibull shape passes through an exponential, so it stays positive. Every point the simplex can reach is therefore a valid, stationary model, and no penalty terms are needed.
//+------------------------------------------------------------------+ //| Search vector to parameters; persistence stays below one. | //+------------------------------------------------------------------+ void CAcdModel::Decode(const double &u[],SAcdParams &p) const { double q=1.0/(1.0+MathExp(-u[0])); if(q>0.99999) q=0.99999; const double s=1.0/(1.0+MathExp(-u[1])); p.dist =m_dist; p.alpha=q*s; p.beta =q*(1.0-s); p.mean =m_mean; p.omega=(1.0-q)*m_mean; p.shape=1.0; if(m_dist==ACD_DIST_WEIBULL) p.shape=MathMax(0.05,MathMin(10.0,MathExp(u[2]))); }
What the simplex maximizes is the mean log-likelihood per training event:
//+------------------------------------------------------------------+ //| Mean log-likelihood per usable event in [a,b]. The recursion runs| //| from event 0 and restarts at the mean after each session start. | //+------------------------------------------------------------------+ double CAcdModel::MeanLogLik(const SAcdParams &p,const int a,const int b,int &n) const { const bool expo =(p.dist==ACD_DIST_EXPONENTIAL); const double log_shape=MathLog(p.shape); const double log_scale=(expo ? 0.0 : AcdLogScale(p.shape)); double ll =0.0; double psi =p.mean; double x_prev=0.0; bool fresh =true; n=0; for(int i=0;i<=b;i++) { if(m_start[i]!=0) { fresh=true; continue; } psi =(fresh ? p.mean : AcdNextPsi(p,x_prev,psi)); fresh=false; x_prev=m_x[i]; if(i<a) continue; if(expo) ll+=-MathLog(psi)-m_x[i]/psi; else ll+=AcdLogDensity(p.shape,log_shape,log_scale,m_logx[i],psi); n++; } return (n>0 ? ll/n : 0.0); }
A likelihood on its own says little, so every fit is scored against a rhythm-only baseline: the same distribution, the same time-of-day profile, but no recursion at all, so the expected wait never moves from normal. The baseline gets its own best Weibull shape, so the recursion receives no credit for dispersion it did not explain. The difference in log-likelihood per event between the two is what clustering is worth on that symbol.
//+------------------------------------------------------------------+ //| Score a parameter set on both ranges, with its baseline. | //+------------------------------------------------------------------+ bool CAcdModel::Evaluate(const SAcdParams &p,SAcdFit &fit) { if(m_total==0) return false; fit.params =p; fit.ll_train=MeanLogLik(p,0,m_split-1,fit.n_train); fit.ll_test =MeanLogLik(p,m_split,m_total-1,fit.n_test); SAcdParams base=p; base.alpha=0.0; base.beta =0.0; base.omega=p.mean; base.shape=(p.dist==ACD_DIST_WEIBULL ? FitBaseShape() : 1.0); int n=0; fit.base_shape =base.shape; fit.base_ll_train=MeanLogLik(base,0,m_split-1,n); fit.base_ll_test =MeanLogLik(base,m_split,m_total-1,n); fit.evaluations =0; fit.converged =true; return true; }
The script ACD_Calibrate runs all of this for one symbol and reports the result. On EURUSD, over thirty days ending 14 September 2026, the training period held 1,648,063 events and the test period 706,313. The Weibull fit came out with alpha 0.0210, beta 0.9765 and shape 0.795, and it beat the exponential on held-out likelihood by 0.058 per event. Against its own rhythm-only baseline it gained 0.034 per event in training and 0.055 on the test period. The test gain is the larger of the two, most likely because the test period happened to be more clustered; what matters is that the gain did not collapse out of sample: over the 706,313 test events it sums to roughly 39,000 units of log-likelihood, the first sign that the clustering is real rather than fitted noise.
The second sign is what the fit leaves behind. If the recursion has captured the clustering, the surprises should look like independent draws. Table 1 compares the autocorrelation of the adjusted waits with that of the surprises, on the test period.
| Series | Lag 1 | Lag 2 | Lag 5 | Lag 20 | Lag 100 | Ljung-Box Q(100) |
|---|---|---|---|---|---|---|
| Adjusted waits | +0.115 | +0.129 | +0.086 | +0.079 | +0.066 | 398,240 |
| Surprises after the fit | +0.031 | +0.044 | +0.003 | +0.001 | -0.002 | 2,723 |
Table 1. Autocorrelation before and after the fit, EURUSD test period
From lag 5 onward the remaining correlations are within a few thousandths of zero, and the Ljung-Box statistic falls by 99.3%. The test still rejects the fitted model, as it would almost any model on a sample this large, so its p-value shows that the fit is imperfect but cannot rank one fit against another; the size of the fall does that. About 2,000 of the remaining 2,723 come from lags 1 and 2 alone. Those small correlations are real, and we come back to them in the last section.
Table 2 shows the same fit on all six symbols.
| Symbol | Events | Alpha | Beta | Persistence | Half-life, events | Weibull k | Gain per event, test |
|---|---|---|---|---|---|---|---|
| EURUSD | 2,354,376 | 0.0210 | 0.9765 | 0.99750 | 277 | 0.795 | +0.055 |
| GBPUSD | 3,191,539 | 0.0376 | 0.9586 | 0.99625 | 184 | 0.815 | +0.055 |
| USDJPY | 2,854,333 | 0.0443 | 0.9518 | 0.99615 | 180 | 0.818 | +0.095 |
| XAUUSD | 13,567,262 | 0.0357 | 0.9584 | 0.99402 | 116 | 0.788 | +0.062 |
| US30 | 4,349,394 | 0.0228 | 0.9764 | 0.99920 | 870 | 0.736 | +0.091 |
| SPX500 | 2,870,082 | 0.0200 | 0.9788 | 0.99885 | 604 | 0.573 | +0.074 |
Table 2. Weibull ACD fits, thirty days of quotes ending 14 September 2026
Two patterns stand out. Clustering beats the rhythm-only baseline on every symbol, out of sample. And on this feed the two US indices remember a burst for two to seven and a half times as many events as the currency pairs and gold do, with SPX500 also showing the most dispersed surprises of the six. Quote activity on those indices comes in longer, more uneven episodes.
One Number, Updated on Every Quote
A fitted model is only useful if it can run live. CAcdState holds everything the model knows about the recent past, which is a single number, and updates it in constant time as each quote arrives. There is no window, no stored history and no refitting.
//+------------------------------------------------------------------+ //| One event: adjust the ended wait and fold it into psi. A wait | //| over the gap resets psi to the long-run mean, as in the fit. | //+------------------------------------------------------------------+ bool CAcdState::OnEvent(const long t_msc) { if(!m_ready || (m_last>=0 && t_msc<=m_last)) return false; m_events++; if(m_last<0 || t_msc-m_last>m_gap_ms) { m_last =t_msc; m_psi =m_p.mean; m_x =0.0; m_surprise=0.0; return true; } m_x =(double)(t_msc-m_last)/m_diurnal.Value(m_last); m_surprise=m_x/m_psi; m_psi =AcdNextPsi(m_p,m_x,m_psi); m_last =t_msc; return true; }
Because the update calls the same AcdNextPsi as the likelihood, and divides by the same stored profile, a state replayed over the fitting history with the fitted profile reproduces the model's expectations exactly, not approximately. The indicator uses a fresher profile, explained in its section, so on a chart the match is by design rather than bit for bit.
Three readings come out of the state. The activity ratio is the long-run mean divided by the current expectation, and it is the gauge this article is about. The surprise is the wait that just ended divided by the wait expected for it; one quote is one draw, so it is kept for diagnostics rather than display. The silence ratio covers what the other two cannot: the expectation only moves when a quote arrives, so a market that suddenly goes quiet leaves it frozen at its last busy value. Dividing the time since the last quote by the expected wait, with the rhythm put back in, says between quotes that the next one is overdue.
//+------------------------------------------------------------------+ double CAcdState::ActivityRatio(void) const { return (m_psi>0.0 ? m_p.mean/m_psi : 0.0); } //+------------------------------------------------------------------+ double CAcdState::ExpectedWaitMs(void) const { if(m_last<0) return 0.0; return m_psi*m_diurnal.Value(m_last); } //+------------------------------------------------------------------+ double CAcdState::SilenceRatio(const long now_msc) const { const double expected=ExpectedWaitMs(); if(expected<=0.0 || now_msc<m_last) return 0.0; return (double)(now_msc-m_last)/expected; }
On EURUSD's test period the activity ratio had a median of 1.08, a 5th percentile of 0.54 and a 95th percentile of 2.24. The market ran at twice its normal pace or more on 7.6% of events, and at half or less on 3.5%. These figures are counted per event, and a busy minute contributes more events than a quiet one, which is why the median sits a little above 1.
Does Activity Tell Us Anything?
A gauge that describes activity well is still only interesting if activity says something about what comes next. The script ACD_Evidence asks the narrowest version of that question a trader would care about: does knowing how busy the market is improve a forecast of the volatility that follows?
The question has to be asked carefully, because volatility clusters. Almost anything correlated with recent volatility will appear to predict future volatility. So the script builds a ladder of regressions, each fitted on the training period and scored on the later test period, and adds one kind of information at a time. The first six rungs compare three ways of measuring activity against volatility from finished bars:
- A: the normal volatility for this time of day.
- B: A plus the realized volatility of the last fifteen finished one-minute bars, built from the same per-second prices. This is what any trader already has.
- C: B plus tick volume over those fifteen finished bars, divided by its normal level for the time of day. This is what an indicator recalculating on bar close can see.
- D: B plus two rolling tick counts ending at this exact second, over the last minute and the last fifteen minutes, each divided by its normal level. This is a simple live alternative.
- E: B plus the ACD activity ratio and the silence ratio.
- F: D and E together.
Mid-bar, D and E see the seconds of the forming bar, while B's volatility stops at the last finished one. Part of anything they gain could therefore come from fresher information rather than from activity. The last three rungs remove that advantage by giving price the same freshness:
- G: B plus the realized volatility of the last minute and the last fifteen minutes, sampled every five seconds up to this exact second. This is the strongest baseline in the ladder: price alone, as fresh as the live activity measures.
- H: G plus the ACD activity ratio and the silence ratio.
- I: G plus the two rolling tick counts.
E and H use two outputs of the ACD state, but the chart plots only the first, so two more rungs use it alone:
- J: B plus the ACD activity ratio only.
- K: G plus the ACD activity ratio only.
D, E and G each add two numbers to B, and H and I two to G, so the live measures always meet on equal terms; C, J and K add one. Every R-squared is also measured on the test period, where an uninformative number tends to lower the score, not raise it.
//--- the columns each rung regresses on int RungColumns(const int r,int &cols[]) { ArrayResize(cols,NCOL); cols[0]=COL_TOD; cols[1]=COL_RV; switch(r) { case 0: return 1; case 1: return 2; case 2: cols[2]=COL_BAR; return 3; case 3: cols[2]=COL_LIVE1; cols[3]=COL_LIVEW; return 4; case 4: cols[2]=COL_ACT; cols[3]=COL_SIL; return 4; case 5: cols[2]=COL_LIVE1; cols[3]=COL_LIVEW; cols[4]=COL_ACT; cols[5]=COL_SIL; return 6; case 6: cols[2]=COL_RV1; cols[3]=COL_RVW; return 4; case 7: cols[2]=COL_RV1; cols[3]=COL_RVW; cols[4]=COL_ACT; cols[5]=COL_SIL; return 6; case 8: cols[2]=COL_RV1; cols[3]=COL_RVW; cols[4]=COL_LIVE1; cols[5]=COL_LIVEW; return 6; case 9: cols[2]=COL_ACT; return 3; default: cols[2]=COL_RV1; cols[3]=COL_RVW; cols[4]=COL_ACT; return 5; } }
The target is the realized variance of the mid price over the horizon that follows, in logs. Everything comes from the same quote stream. The script loads the ticks once, then builds the events for the model and a per-second series of mid prices and tick counts for the regressions. No bar history is used at all. The test R-squared is taken against the test period's own mean, so a predictor has to beat someone who already knew the average volatility of the period being forecast. In what follows, one point is 0.01 of R-squared.
The regressions are ordinary least squares without regularization. Every normalization, the usual tick count for the time of day, the volatility level in rung A and the floors that keep the logarithms finite, is measured on training snapshots only. The second of each mid-bar snapshot is a fixed hash of the minute, so every run uses the same snapshots.
Snapshots are taken once per minute, and nothing at or after the snapshot is visible to any predictor. A snapshot is dropped if a closed market falls anywhere between the start of its history and the end of its target. A training snapshot is also dropped if its target would reach into the test period. The snapshot loop of the study is:
for(int s0=w_sec+60;s0+60+h_sec<sec.total;s0+=60) { const int s =s0+(st.mid_bar ? MidBarOffset(base+s0) : 0); const long t_ms=(base+s)*1000; while(e<ev.Total() && ev.Time(e)<t_ms) state.OnEvent(ev.Time(e++)); const long end_ms=t_ms+(long)h_sec*1000; if(t_ms<split_msc && end_ms>split_msc) continue; if(!state.InSession(t_ms) || sec.mid[s0-w_sec-1]<=0.0) continue; //--- no closed market anywhere from the history's start to the target's end const int ia=ev.FindTime((base+s0-w_sec-60)*1000); const int ib=ev.FindTime(end_ms); if(ib<=ia+1 || starts[ib]-starts[ia+1]>0 || ev.Time(ib-1)<end_ms-gap_ms) continue; double rv=0.0; for(int k=w_sec;k>0;k-=60) { const double r=MathLog(sec.mid[s0-k+59]/sec.mid[s0-k-1]); rv+=r*r; } //--- the same volatility, live: sampled like the target, up to this second double rv1=0.0,rvw=0.0; for(int j=InpStepSec;j<=w_sec;j+=InpStepSec) { const double r=MathLog(sec.mid[s-1-j+InpStepSec]/sec.mid[s-1-j]); rvw+=r*r; if(j<=60) rv1+=r*r; } double fut=0.0; for(int j=1;j<=n_steps;j++) { const double r=MathLog(sec.mid[s-1+j*st.step]/sec.mid[s-1+(j-1)*st.step]); fut+=r*r; } const long snap=base+s; const long m =base+s0; g_t[n] =t_ms; g_fut[n]=fut; g_rv[n] =rv; g_r1[n] =rv1; g_rw[n] =rvw; g_bar[n]=MathLog((sec.cum[s0]-sec.cum[s0-w_sec]+1.0)/(NormalTicks(per_minute,m-w_sec,m)+1.0)); g_l1[n] =MathLog((sec.cum[s]-sec.cum[s-60]+1.0)/(NormalTicks(per_minute,snap-60,snap)+1.0)); g_lw[n] =MathLog((sec.cum[s]-sec.cum[s-w_sec]+1.0)/(NormalTicks(per_minute,snap-w_sec,snap)+1.0)); g_act[n]=MathLog(MathMax(state.ActivityRatio(),1.0e-6)); g_sil[n]=MathLog(state.SilenceRatio(t_ms)+0.05); if(t_ms<split_msc) n_train++; n++; }
One more step is needed before these R-squared differences count as evidence. Snapshots are a minute apart but targets run up to fifteen minutes ahead, so neighboring forecast errors share most of their target, and the persistence of volatility stretches that dependence further. Each comparison that matters is therefore tested with a Diebold-Mariano test, which asks whether the mean difference between two rungs' squared errors is distinguishable from zero. Its variance uses Newey-West weights over 60 snapshots, about an hour, several times the longest overlap. A t-statistic above about 2 says the gain is unlikely to be chance. Setting the lag to the horizon alone, as is common for overlapping forecasts, gives larger statistics; we report the cautious ones.
//+------------------------------------------------------------------+ //| Diebold-Mariano t on squared test errors, positive when a beats | //| b. Newey-West variance, because the target windows overlap. | //+------------------------------------------------------------------+ double DmStat(const double &err[],const int n_test,const int a,const int b,const int lag) { double d[]; ArrayResize(d,n_test); double mean=0.0; for(int i=0;i<n_test;i++) { const double ea=err[a*n_test+i],eb=err[b*n_test+i]; d[i]=eb*eb-ea*ea; mean+=d[i]; } mean/=n_test; double var=0.0; for(int i=0;i<n_test;i++) var+=(d[i]-mean)*(d[i]-mean); for(int l=1;l<=lag && l<n_test;l++) { double g=0.0; for(int i=l;i<n_test;i++) g+=(d[i]-mean)*(d[i-l]-mean); var+=2.0*(1.0-l/(lag+1.0))*g; } var/=n_test; return (var>0.0 ? mean/MathSqrt(var/n_test) : 0.0); }
The same ladder is run three times. First, exactly at the start of each minute with a fifteen-minute horizon: the moment when a finished bar's tick count is complete and a per-bar measure is at its strongest. Then twice at a varying second inside each minute, with five-minute and one-minute horizons: the moments the live gauge is built for.
At the bar close, the activity measures tie.
| Symbol | A | B | C | D | E | F | G | H | I |
|---|---|---|---|---|---|---|---|---|---|
| EURUSD | 0.435 | 0.531 | 0.569 | 0.573 | 0.574 | 0.582 | 0.558 | 0.580 | 0.576 |
| GBPUSD | 0.465 | 0.558 | 0.586 | 0.590 | 0.586 | 0.597 | 0.586 | 0.600 | 0.602 |
| USDJPY | -0.846 | 0.461 | 0.468 | 0.470 | 0.469 | 0.470 | 0.503 | 0.502 | 0.503 |
| XAUUSD | 0.376 | 0.497 | 0.537 | 0.544 | 0.532 | 0.545 | 0.543 | 0.554 | 0.559 |
| US30 | 0.565 | 0.620 | 0.667 | 0.672 | 0.670 | 0.676 | 0.648 | 0.676 | 0.677 |
| SPX500 | 0.449 | 0.571 | 0.643 | 0.654 | 0.650 | 0.660 | 0.622 | 0.660 | 0.658 |
Table 3. Test R-squared at the start of each minute, next 15 minutes
Activity clearly matters: adding any of the three activity measures to B improves the forecast by three to eight points on every symbol except USDJPY, where the gain is under a point. But once a bar has closed, the three measures carry nearly the same information, and the differences between C, D and E stay within about a point. E against C, the pair tested, is not significant on any symbol, with a largest t-statistic of 1.7. This is the expected result, not a disappointing one. At the close of a bar, finished bars hold the complete count, so a per-bar measure is at its strongest. Live volatility has nothing fresh to offer at this moment either: on EURUSD and both indices, G does worse than any of the activity measures.
The negative A on USDJPY is not an error. The volatility level of the test period differed from the training period, and a time-of-day profile alone cannot follow a level shift; once recent volatility is included in B, the forecast recovers.
Inside the bar, the live measures pull ahead.
| Symbol | B | C | D | E | F | G | H | I |
|---|---|---|---|---|---|---|---|---|
| EURUSD | 0.552 | 0.585 | 0.593 | 0.599 | 0.601 | 0.619 | 0.632 | 0.625 |
| GBPUSD | 0.523 | 0.555 | 0.566 | 0.569 | 0.578 | 0.606 | 0.620 | 0.617 |
| USDJPY | 0.557 | 0.571 | 0.585 | 0.583 | 0.587 | 0.628 | 0.631 | 0.631 |
| XAUUSD | 0.550 | 0.570 | 0.629 | 0.614 | 0.631 | 0.636 | 0.651 | 0.658 |
| US30 | 0.661 | 0.702 | 0.737 | 0.746 | 0.747 | 0.756 | 0.778 | 0.771 |
| SPX500 | 0.545 | 0.587 | 0.639 | 0.664 | 0.660 | 0.677 | 0.701 | 0.685 |
Table 4. Test R-squared mid-bar, next 5 minutes
| Symbol | B | C | D | E | F | G | H | I |
|---|---|---|---|---|---|---|---|---|
| EURUSD | 0.413 | 0.447 | 0.468 | 0.457 | 0.466 | 0.473 | 0.485 | 0.490 |
| GBPUSD | 0.373 | 0.403 | 0.431 | 0.420 | 0.432 | 0.447 | 0.460 | 0.466 |
| USDJPY | 0.464 | 0.486 | 0.511 | 0.498 | 0.510 | 0.527 | 0.532 | 0.538 |
| XAUUSD | 0.439 | 0.441 | 0.523 | 0.506 | 0.529 | 0.540 | 0.554 | 0.564 |
| US30 | 0.579 | 0.606 | 0.658 | 0.664 | 0.666 | 0.681 | 0.699 | 0.696 |
| SPX500 | 0.424 | 0.446 | 0.512 | 0.535 | 0.526 | 0.551 | 0.571 | 0.563 |
Table 5. Test R-squared mid-bar, next 1 minute
Three results come out of Tables 4 and 5.
Every live measure beats tick volume from finished bars. Mid-bar, E beats C on all six symbols at both horizons, by between 1.0 and 8.8 points, most on gold and the indices. The difference is significant in 11 of the 12 cases, all but GBPUSD at five minutes (t-statistic 1.6).
Fresh prices carry most of that gain. Live volatility alone, rung G, beats the ACD gauge alone, rung E, on every symbol at both horizons, by one to four and a half points. Much of what a live activity measure offers over finished bars is simply that it is up to date, and a live price measure is up to date too.
Activity still adds something that prices do not. With live volatility already in the forecast, adding either activity measure raises the test R-squared on all six symbols at both horizons. Table 6 isolates those gains, each with its t-statistic.
| Symbol | ACD gauge, 5 min | Rolling counts, 5 min | ACD gauge, 1 min | Rolling counts, 1 min |
|---|---|---|---|---|
| EURUSD | +0.012 (2.6) | +0.005 (1.9) | +0.012 (2.5) | +0.016 (3.6) |
| GBPUSD | +0.013 (2.8) | +0.010 (1.8) | +0.012 (3.3) | +0.019 (3.4) |
| USDJPY | +0.003 (1.3) | +0.002 (1.4) | +0.006 (1.9) | +0.011 (3.0) |
| XAUUSD | +0.015 (3.5) | +0.021 (3.2) | +0.014 (4.6) | +0.023 (5.5) |
| US30 | +0.022 (4.6) | +0.015 (3.9) | +0.019 (5.1) | +0.015 (4.5) |
| SPX500 | +0.024 (6.3) | +0.008 (5.6) | +0.019 (6.0) | +0.011 (4.7) |
Table 6. Gain in test R-squared from adding activity to live volatility mid-bar, with the Diebold-Mariano t-statistic in brackets: H minus G for the ACD gauge, I minus G for the rolling counts

Fig. 4. What each activity measure adds to the forecast mid-bar once live volatility is known, for each symbol and horizon
The gains are small. For the ACD gauge they are a fraction of a point on USDJPY, one to one and a half points on the other currency pairs and gold, and around two points on the indices. They are significant on every symbol except USDJPY, with t-statistics of 1.3 at five minutes and 1.9 at one. The rolling counts' gains are significant on every symbol at one minute, USDJPY included, but at five minutes only on gold and the indices.
The gain belongs to the line on the chart. Rung K, which drops the silence ratio, scores within 0.001 of rung H on every symbol at both horizons, with t-statistics within 0.3 of H's. The silence ratio added nothing measurable.
Which activity measure adds more depends on the horizon and the market, and not every comparison is decisive. Testing H against I directly, at five minutes the ACD gauge adds significantly more than the counts on EURUSD and both indices and less on gold, with GBPUSD and USDJPY tied. At one minute the counts add significantly more on GBPUSD, USDJPY and gold, the gauge more on SPX500, and EURUSD and US30 are tied. E against D, without live volatility, ranks the two the same way, and combining them in rung F adds about two points at most over the counts alone.
The fair summary is that being live accounts for most of the improvement over finished bars, and fresh prices deliver more of it than activity does. Activity is still worth measuring: on every symbol tested, at least one activity measure carried significant information about the next few minutes that live volatility did not. The ACD gauge is a good way to measure it, strongest at five minutes and on the indices, but not the only way; for one-minute forecasts on currency pairs and gold, rhythm-adjusted rolling counts did as well or better. The ACD gauge needs no counting window to be chosen, and its memory is fitted from the data rather than set by hand.
The Gauge on a Chart
The indicator ACD_Activity puts the gauge in a subwindow under the price. It does no fitting on the chart. A calibration on a month of quotes takes tens of seconds, and an indicator runs on the chart's own thread, so the model's parameters come from ACD_Calibrate, which ends by printing them on one line:
SAcdFit best=fe; if(fw.ll_test>fe.ll_test) best=fw; PrintFormat(" held-out log-likelihood prefers the %s density", (best.params.dist==ACD_DIST_WEIBULL ? "Weibull" : "exponential")); ReportResiduals(model,best); Print("--- parameters for the live state ------------------"); PrintFormat(" %s gap %d s bins %d min%s",AcdParamsToString(best.params),InpGapSec, InpBinMinutes,(InpByWeekday ? " per weekday" : ""));
Only alpha and beta are pasted into the indicator's inputs. The Weibull shape is not needed live, because it enters the likelihood but not the expected wait.
What the indicator does learn on load is the rhythm. It fits the time-of-day profile on the seven trading days before the plotted range, warms the state through them, and then replays the last three trading days, so nothing drawn on the chart has been adjusted by a profile that saw it.
The rhythm and the parameters therefore come from different windows, on purpose. The rhythm drifts with the seasons and the news calendar, so the indicator learns it from the latest days. Alpha and beta describe how bursts persist once the rhythm is removed, and both profiles scale the adjusted waits to average one, so the parameters carry over. The indicator keeps one profile for all weekdays, since seven days would leave only one or two behind each weekday, so calibrate with InpByWeekday at its default, false.
//+------------------------------------------------------------------+ //| Learn the rhythm on the days before the plot, warm the state, | //| then replay the plotted days. | //+------------------------------------------------------------------+ bool Build(const datetime &time[],const long &tick_volume[],const int rates_total) { const datetime now =TimeCurrent(); const datetime plot_from =TradingDateStart(time,rates_total,InpPlotDays); const datetime rhythm_from=TradingDateStart(time,rates_total,InpPlotDays+InpRhythmDays); if(plot_from<=0 || rhythm_from>=plot_from) return false; CAcdEvents ev; ev.SetGapSeconds(InpGapSec); if(!FeedRange(rhythm_from,plot_from,GetPointer(ev),time,tick_volume,rates_total)) return false; if(ev.Total()<5000) { if(!g_waiting) PrintFormat("ACD_Activity: only %d quotes before %s, waiting for tick history", ev.Total(),TimeToString(plot_from,TIME_DATE)); return false; } if(!g_rhythm.Setup(InpBinMinutes,false) || !g_rhythm.FitWaits(ev,1,ev.Total()-1) || g_rhythm.UnitMeanWaits(ev,1,ev.Total()-1)<=0.0) return false; if(!g_state.Init(g_params,GetPointer(g_rhythm),InpGapSec)) return false; for(int i=0;i<ev.Total();i++) g_state.OnEvent(ev.Time(i)); g_last_msc=ev.Time(ev.Total()-1); double sum=0.0; int cnt=0; g_bar=-1; for(int i=0;i<rates_total;i++) { if(time[i]>=rhythm_from && time[i]<plot_from && tick_volume[i]>0) { sum+=(double)tick_volume[i]; cnt++; } if(g_bar<0 && time[i]>=plot_from) g_bar=i; } if(g_bar<0) return false; g_vol_mean=(cnt>0 ? sum/cnt : 0.0); ArrayInitialize(BufActivity,EMPTY_VALUE); ArrayInitialize(BufColor,0.0); ArrayInitialize(BufVolume,EMPTY_VALUE); ArrayInitialize(BufRatio,EMPTY_VALUE); ArrayInitialize(BufExpected,EMPTY_VALUE); PlotIndexSetInteger(0,PLOT_DRAW_BEGIN,g_bar); PlotIndexSetInteger(1,PLOT_DRAW_BEGIN,g_bar); return FeedRange(plot_from,now+60,NULL,time,tick_volume,rates_total); }
From then on each new quote is folded into the state once. When a quote belongs to a later bar than the previous one, the bars it has moved past are written first.
//+------------------------------------------------------------------+ //| Fold one tick in, closing every bar it has moved past. | //+------------------------------------------------------------------+ void FeedTick(const long t_msc,const datetime &time[],const long &tick_volume[],const int rates_total) { while(g_bar+1<rates_total && t_msc>=(long)time[g_bar+1]*1000) { WriteBar(g_bar,((long)time[g_bar]+PeriodSeconds())*1000,tick_volume); g_bar++; } g_state.OnEvent(t_msc); if(t_msc>g_last_msc) g_last_msc=t_msc; }
//+------------------------------------------------------------------+ //| Write the gauge for one bar; a closed market leaves a break. | //+------------------------------------------------------------------+ void WriteBar(const int i,const long now_msc,const long &tick_volume[]) { BufExpected[i]=EMPTY_VALUE; if(!g_state.InSession(now_msc)) { BufActivity[i]=EMPTY_VALUE; BufRatio[i] =EMPTY_VALUE; BufVolume[i] =EMPTY_VALUE; return; } const double ratio=g_state.ActivityRatio(); BufRatio[i] =ratio; BufActivity[i]=MathLog(MathMax(ratio,1.0e-6))/M_LN2; BufColor[i] =(ratio>=InpBusy ? 1.0 : (ratio<=InpQuiet ? 2.0 : 0.0)); BufVolume[i] =(InpShowVolume && g_vol_mean>0.0 && tick_volume[i]>0 ? MathLog(tick_volume[i]/g_vol_mean)/M_LN2 : EMPTY_VALUE); }
The main line is the logarithm, base 2, of the activity ratio, so doubling and halving are equal distances from normal. It is drawn grey around normal, orange-red at twice the normal pace or more, and blue at half or less, with labeled levels at each threshold. The dotted line is raw tick volume against its average over the rhythm period, on the same logarithmic scale. It is there as a contrast: it shows what the gauge removes.
The line shows the activity ratio alone, which is all the forecasts needed: rung K did as well as rung H. The silence ratio still matters for reading the chart. It is not drawn, so when a burst ends in sudden silence, the line stays at its last busy value until the next quote arrives.

Fig. 5. ACD_Activity on EURUSD M1 through the London open: tick volume rises with the session while the activity ratio stays near normal
Fig. 5 shows the difference that matters most in daily use. As London opens, tick volume climbs steeply, because the session always brings more quotes. The activity line barely moves, because that increase is exactly what is normal for the hour. Over the three trading days plotted on EURUSD M1, the hour of the day explains 72.8% of the variation in the tick volume line but only 29.7% of the variation in the activity line. It does not fall to zero because the rhythm was learned on the seven days before the plot, not on the plotted days themselves.

Fig. 6. A genuine burst: the activity ratio rises into the busy zone within seconds, before the bar has closed
Fig. 6 shows the other case. When quotes suddenly arrive far faster than usual for the hour, the activity line turns orange-red within seconds of the burst starting, while the bar is still forming and its tick volume is still a partial count.
Reading the gauge from an Expert Advisor. The buffer layout is fixed in the library so that an EA can rely on it through iCustom.
//--- indicator buffer map, fixed for EAs reading it through iCustom enum ENUM_ACD_BUFFER { ACD_BUF_ACTIVITY = 0, // log2 of the activity ratio, plotted ACD_BUF_COLOR = 1, // 0 normal, 1 busy, 2 quiet ACD_BUF_VOLUME = 2, // log2 of tick volume against its average ACD_BUF_RATIO = 3, // the activity ratio itself ACD_BUF_EXPECTED = 4 // forming bar only: expected wait in ms for the next quote };
An EA would normally read buffer 3, the plain activity ratio, rather than the plotted logarithm. Buffer 4 holds the expected wait in milliseconds for the next quote, set only on the forming bar at the end of each calculation:
const int edge=rates_total-1; while(g_bar<edge) { WriteBar(g_bar,((long)time[g_bar]+PeriodSeconds())*1000,tick_volume); g_bar++; } const long now_msc=MathMax((long)TimeCurrent()*1000,g_last_msc); WriteBar(edge,now_msc,tick_volume); if(BufRatio[edge]!=EMPTY_VALUE) BufExpected[edge]=g_state.ExpectedWaitMs(); return rates_total;
It is published instead of a ready-made silence ratio because timer events are not delivered to an indicator created through iCustom, so a value refreshed between quotes would not reach the EA. The EA computes the silence ratio itself. The server time advances in whole seconds, too coarse against an expected wait of a second or less, so the EA should note GetTickCount64 each time a new tick reaches it and, in its own timer, divide the milliseconds elapsed since then by buffer 4. A result well above 1 says the next quote is overdue.
A natural use is execution rather than direction: holding back a new order while the market is running at several times its normal pace, when spreads and slippage are often at their worst. This article did not measure spreads, so treat that use as something to test on your own account.
What the Numbers Do Not Say
Several limits should be kept in view before this gauge is used for anything.
These are quotes from one broker, not trades. Quote changes reflect the broker's aggregation and filtering as well as market activity. Parameters fitted on one feed will not transfer to another, and should be recalibrated on the account where the indicator runs.
Live volatility is measured one way. Rung G samples prices every five seconds, while B uses one-minute bars. Part of G's lead over the activity measures may come from that finer sampling rather than from freshness alone, and a different volatility estimator could leave activity with a smaller or larger share.
The Weibull fits the shape of the waits only approximately. On EURUSD the surprises had a coefficient of variation of 1.53, where the fitted shape implies 1.27: the real waits have heavier tails than a Weibull allows. The shape mainly affects the likelihood comparisons, but alpha and beta were fitted under it too, so a more flexible distribution could move them slightly.
A little structure remains at very short lags. After the fit, the surprises still correlate at 0.03 and 0.04 over one and two events. Quote streams have fine-grained patterns, such as bid and ask updates arriving in quick pairs, that a single persistence parameter cannot reach.
The evidence covers thirty days. Each test period is about a week and a half. The significance test allows for overlapping targets within that period but says nothing about another one, and the six symbols share the same days, so they are not six independent confirmations. The ranking between the ACD gauge and the rolling counts shifted with horizon and asset class within that window, and it may shift again in other conditions.
Activity is not direction. Nothing here says which way price will move. The gauge forecasts how much the market is likely to move and how busy it is, and should be read as a measure of conditions, not as a signal.
Conclusion
We started from a narrow question: is the market unusually busy right now? Tick volume answers a related but different question, how many quotes arrived in a bar, and most of its variation follows the time of day. We built a gauge that answers the first question directly, on every quote.
- Waits, not counts. Working on the time between quote changes, with same-millisecond ticks folded and closed markets marked, gives the model a clean series on any symbol whose feed passes the regularity check.
- Remove the rhythm first. A time-of-day profile fitted only on the training period, interpolated between bins and scaled so normal reads 1, takes out the part of activity that is already known.
- Model what remains. The ACD recursion captures the clustering left after the rhythm: it beat a rhythm-only baseline out of sample on all six symbols and removed 99.3% of the autocorrelation on EURUSD.
- Activity adds to fresh prices. Inside a bar, the live gauge improved one- to five-minute volatility forecasts over closed-bar tick volume on every symbol, though live volatility alone did more. With live volatility already in the forecast, the gauge still added a small, significant gain on five of six symbols, carried by the plotted ratio alone; on USDJPY it was too small to separate from noise. Against rolling live counts it led at five minutes on EURUSD and the indices and trailed on gold, while at one minute the counts led on currencies and gold, with several comparisons tied.
To use it on your own account:
- Run ACD_Calibrate on the symbol with thirty days of quotes.
- Check that the feed passed the regularity check (the script stops if it did not) and that the Weibull clustering gain on the test period is positive.
- Paste the printed alpha and beta into the inputs of ACD_Activity on that symbol.
- Recalibrate about once a month, and whenever you change symbol or broker. If the persistence moves noticeably between runs, the older parameters are stale.
Getting the Source Code via MQL5 Algo Forge
All source files are attached to this article below, but the full repository is also available on MQL5 Algo Forge, the community's Git-based platform for sharing and collaborating on trading projects.
| File name | Description |
|---|---|
| MQL5\Include\ACD\AcdTypes.mqh | Shared types: parameters, fit report, buffer map, the one-step update and the log-density |
| MQL5\Include\ACD\AcdEvents.mqh | Turns the tick stream into events: same-millisecond folding, session gaps, day-by-day loading |
| MQL5\Include\ACD\AcdDiurnal.mqh | Time-of-day profile with interpolation and unit-mean scaling |
| MQL5\Include\ACD\AcdModel.mqh | Maximum-likelihood fit, rhythm-only baseline and residual diagnostics |
| MQL5\Include\ACD\AcdState.mqh | Live constant-time state: activity ratio, surprise, expected wait and silence ratio |
| MQL5\Scripts\ACD\ACD_Calibrate.mq5 | Fits one symbol and reports the feed check, rhythm, both fits, residuals and live parameters |
| MQL5\Scripts\ACD\ACD_Evidence.mq5 | Multi-symbol test of activity as a volatility predictor, at the bar close and mid-bar |
| MQL5\Indicators\ACD\ACD_Activity.mq5 | Live activity gauge with tick volume contrast and buffers for Expert Advisors |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Price Action Analysis Toolkit Development (Part 87): Searching Chart History with Recorded Double-Bottom Samples
MetaTrader 5 Machine Learning Blueprint (Part 23): Feature Screening
Neural Networks in Trading: A Spiking-Semantic Approach to Spatiotemporal Identification (S3CE-Net)
How to Detect and Normalize Chart Objects in MQL5 (Part 6): Manipulating Chart Objects with MetaTrader 5 Agentic AI Assistant
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use