Why a Retail Trader Should Care About TWAP
You've got a 10-lot position to enter on EURUSD. You click buy, the broker fills you in three chunks, and suddenly price is 8 pips against you. That's market impact — the cost of being bigger than the liquidity at the touch. Institutions have dealt with this for decades, and their answer isn't a smarter indicator. It's a smarter way to submit orders.
TWAP — Time-Weighted Average Price — is the simplest of the institutional execution algorithms. You split a large order into equal-sized child orders and fire them at regular intervals over a fixed window. The goal isn't to catch the bottom. It's to get an average fill close to the average price over that period, without tipping off the market or paying spread on a giant market order.
Most retail algo traders have never written an execution algorithm. We build EAs that generate signals, then fire a market order like it's going out of style. That's fine for small size. But if you're scaling up, or if you're testing a strategy that genuinely needs to be filled across a session, TWAP is the missing piece. In this post, I'll walk you through a complete MQL5 TWAP EA — the scheduling logic, the order splitting, and the honest limitations you'll hit in the Strategy Tester.
What TWAP Actually Does (and Doesn't Do)
TWAP divides your total order size by the number of time slices. If you want to buy 10 lots over 60 minutes with a slice every 5 minutes, that's 12 child orders of roughly 0.83 lots each. The EA schedules a timer, and on each tick of that timer, it checks if the market is open, submits a market order for the slice size, and moves on.
The key insight is that TWAP is not a directional strategy. It's an execution strategy. You use it when the decision to trade is already made — you're exiting a large position, or you're scaling into something you've analyzed. TWAP won't save you from a bad entry thesis. It will save you from paying the spread on 10 lots at once.
There's a common confusion between TWAP and VWAP. VWAP (Volume-Weighted Average Price) weights slices by historical volume profiles, so you trade more when the market is typically more liquid. TWAP ignores volume entirely — equal slices, equal time. For most retail purposes, TWAP is simpler to code and more predictable to backtest. You can add volume weighting later as a refinement; start with the time-based version.
Designing the TWAP EA Architecture in MQL5
Before we touch code, let's map the components. A TWAP EA in MQL5 needs four pieces:
- Input parameters — total volume, duration, slice interval, start time, and safety limits.
- Schedule manager — a way to track how many slices have fired and when the next one is due.
- Order executor — the logic that submits market orders and handles partial fills or requotes.
- Progress tracker — a dashboard on the chart showing filled volume, remaining volume, and average fill price.
MQL5's EventSetTimer() is your friend here. It fires a timer event every N seconds, which is far more reliable than trying to count bars or poll TimeCurrent() in OnTick(). A 5-minute slice interval means a 300-second timer. But here's a subtlety: if the terminal is busy, timer events can be delayed. For a TWAP EA, that's usually acceptable — a 2-second delay on a 5-minute slice doesn't change execution quality. If you're slicing every 10 seconds, you need a different approach, and I'll touch on that in the edge cases.
MQL5 Code: The Core TWAP Logic
Let's write the actual implementation. I'll keep it focused on the execution engine, not the full EA boilerplate. Here's the essential structure:
//--- Input parameters
input double InpTotalVolume = 2.0; // Total volume to execute
input int InpDurationMin = 60; // Execution window (minutes)
input int InpSliceInterval = 300; // Seconds between slices
input int InpStartHour = 9; // Start hour (server time)
input int InpStartMinute = 30; // Start minute
input int InpMaxSlippage = 20; // Max slippage in points
input bool InpUseTimer = true; // Use timer events
//--- Global state
double g_remainingVolume;
double g_sliceVolume;
int g_slicesFired;
int g_totalSlices;
bool g_executionActive;
datetime g_nextSliceTime;
//--- Calculate slice parameters on init
int OnInit()
{
g_totalSlices = (int)MathCeil(InpDurationMin * 60.0 / InpSliceInterval);
g_sliceVolume = NormalizeDouble(InpTotalVolume / g_totalSlices, 2);
g_remainingVolume = InpTotalVolume;
g_slicesFired = 0;
g_executionActive = false;
if(InpUseTimer)
EventSetTimer(1); // Check every second for slice timing
return(INIT_SUCCEEDED);
}
//--- Timer handler: fire slices when due
void OnTimer()
{
if(!g_executionActive)
{
//--- Check if we've reached the start time
MqlDateTime dt;
TimeToStruct(TimeCurrent(), dt);
int currentMinutes = dt.hour * 60 + dt.min;
int startMinutes = InpStartHour * 60 + InpStartMinute;
if(currentMinutes >= startMinutes)
{
g_executionActive = true;
g_nextSliceTime = TimeCurrent() + InpSliceInterval;
ExecuteSlice();
}
return;
}
//--- Active execution: check if it's time for the next slice
if(TimeCurrent() >= g_nextSliceTime && g_remainingVolume > 0)
{
ExecuteSlice();
g_nextSliceTime = TimeCurrent() + InpSliceInterval;
}
}
//--- Submit one child order
void ExecuteSlice()
{
if(g_remainingVolume <= 0)
{
g_executionActive = false;
return;
}
double volume = MathMin(g_sliceVolume, g_remainingVolume);
volume = NormalizeDouble(volume, 2);
//--- Build and send the market order request
MqlTradeRequest request = {};
MqlTradeResult result = {};
request.action = TRADE_ACTION_DEAL;
request.symbol = _Symbol;
request.volume = volume;
request.type = ORDER_TYPE_BUY; // Assume buy for simplicity
request.deviation = InpMaxSlippage;
request.type_filling = ORDER_FILLING_IOC;
request.comment = "TWAP slice " + IntegerToString(g_slicesFired + 1);
if(OrderSend(request, result))
{
if(result.retcode == TRADE_RETCODE_DONE)
{
g_remainingVolume -= volume;
g_slicesFired++;
Print("TWAP slice filled: ", volume, " remaining: ", g_remainingVolume);
}
else
{
Print("TWAP slice rejected: ", result.retcode, " comment: ", result.comment);
}
}
else
{
Print("OrderSend failed, error: ", GetLastError());
}
}This is the skeleton. A few things I want to call out:
Filling mode matters. I used ORDER_FILLING_IOC (Immediate or Cancel). For a market order, you want IOC so that if the broker can't fill the full volume at the requested price, it fills what it can and cancels the rest. The alternative, ORDER_FILLING_FOK (Fill or Kill), is dangerous for TWAP — a single thin moment and your entire slice gets killed, leaving you behind schedule. You can also use ORDER_FILLING_RETURN for market orders in some brokers, but IOC is the safest default.
Volume normalization is critical. Brokers have minimum and maximum lot sizes, and a step size (often 0.01). If your slice volume computes to 0.833 lots, you need to round it sensibly. The code above uses NormalizeDouble with 2 decimals, but that's not enough — you need to check against SYMBOL_VOLUME_MIN, SYMBOL_VOLUME_MAX, and SYMBOL_VOLUME_STEP. I'll show that in the edge cases section.
The timer runs every second. I set EventSetTimer(1) and compare against g_nextSliceTime. This is more robust than setting the timer directly to the slice interval, because it handles the case where the terminal was busy and skipped a timer event. If you're 10 seconds late, you still fire the slice — you just check if you've passed the due time.
Handling the Real-World Edge Cases
The happy path is easy. Real execution is not. Here are the edge cases you'll hit within your first week of running this live:
Partial Fills
IOC orders can return a partial fill. The result.volume field tells you how much was actually filled. In my code above, I subtract the requested volume regardless, which is wrong. Here's the fix:
if(result.retcode == TRADE_RETCODE_DONE || result.retcode == TRADE_RETCODE_PARTIAL)
{
double filledVolume = result.volume;
g_remainingVolume -= filledVolume;
g_slicesFired++;
Print("TWAP fill: ", filledVolume, " of ", volume, " requested");
}If you get a partial fill, you have a choice: fire a make-up slice immediately, or let the next scheduled slice absorb the difference. I usually let the schedule absorb it — that's the whole point of TWAP. If you start firing immediate make-up orders, you're turning into a market-on-close algorithm and defeating the purpose.
Weekend and Session Gaps
The timer doesn't care that it's Saturday. If your execution window spans a market close, you'll fire an order into a closed market and get an error. You need a market-open check. The simplest version:
bool IsMarketOpen()
{
return (bool)SeriesInfoInteger(_Symbol, PERIOD_M1, SERIES_SERVER_TIME);
//--- Better: check trade session
MqlDateTime dt;
TimeToStruct(TimeCurrent(), dt);
//--- Check if Friday after 22:00 or Sunday before 23:00 (server time)
if(dt.day_of_week == 5 && dt.hour >= 22) return(false);
if(dt.day_of_week == 6) return(false);
if(dt.day_of_week == 0 && dt.hour < 23) return(false);
return(true);
}That's a crude check. For a production EA, you'd want to query the symbol's trade sessions via SymbolInfoSessionTrade(), which gives you the exact session start and end times. It's more code, but it's the correct way.
Broker Lot Size Constraints
If your slice volume is below the broker's minimum, the order will be rejected. If it's not a multiple of the step size, it'll be rejected or rounded. Here's a proper volume normalizer:
double NormalizeVolume(double volume)
{
double minVol = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN);
double maxVol = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX);
double stepVol = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
volume = MathFloor(volume / stepVol) * stepVol;
if(volume < minVol) volume = 0; // Can't execute this slice
if(volume > maxVol) volume = maxVol;
return(NormalizeDouble(volume, 8));
}Notice I return 0 if the volume is below minimum. That slice gets skipped, and the volume rolls into the next slice. This is a pragmatic decision — you'd rather have slightly uneven slices than a rejected order.
Testing Your TWAP EA in the Strategy Tester
Here's where most people get fooled. The Strategy Tester in MT5 does not simulate market impact. When you backtest a TWAP EA, every slice fills at the exact bid/ask with zero slippage. That's fine for testing the scheduling logic — you can verify that the right number of slices fire at the right times. But it tells you nothing about the real-world improvement TWAP provides over a single market order.
To test realistically, you need to run the EA on a demo account with a tick data replay tool, or use the tester's "Every tick based on real ticks" mode with a good tick data provider. Even then, the simulation won't model how your order affects the spread.
What you can test in the built-in tester:
- Slice timing accuracy — did 12 slices fire over 60 minutes?
- Volume accounting — did the total filled volume match the target?
- Error handling — did the EA recover from simulated requotes?
I run my TWAP EAs in the tester with "Every tick based on real ticks" and a 1-minute chart, then validate the timing on a demo account for a week before considering live use. The tester is a logic checker, not a performance simulator, for execution algorithms.
Input Parameters and a Practical Configuration
Here's the full input set I use in production, with realistic defaults:
| Parameter | Type | Default | Description |
|---|---|---|---|
| InpTotalVolume | double | 2.0 | Total volume to execute across all slices. |
| InpDurationMin | int | 60 |






