MetaTrader time looks like UTC. It's the broker's clock.
If you pull bars from MetaTrader 5 in Python, the time column looks like a Unix timestamp. It isn't one. It's the broker's server clock, written as seconds since 1970 on that clock. Pass it to pd.to_datetime(unit="s") and your bars land two or three hours away from where they really happened.
The confusion starts in the docs. The official page for copy_rates_from says the data the terminal returns has UTC time (MQL5 docs (External website)). In a 2021 forum thread titled "python api does not return UTC times", a moderator agreed the Python docs conflict with the MQL5 ones and said the times are broker server time (MQL5 forum, 2021 (External website)). The question came back in October 2026, with raw tick times measured about 10,799 seconds ahead of UTC, and the same answer from a moderator (MQL5 forum, 2026 (External website)).
I published a small Python library for this, brokerclock (External website). Before the API, here's why the usual one-line fix doesn't hold.
The clock you can't see
Many forex brokers set their server so that midnight falls at 17:00 in New York, the conventional end of the forex trading day. That puts them at GMT+2 in the New York winter and GMT+3 in the New York summer, so they switch on US daylight-saving dates, not European ones (broker server time explainer (External website)).
MetaTrader's Python package has no call that returns the broker's offset or the rule behind it. So people hardcode one.
A fixed +3 is wrong all winter. A fixed +2 is wrong all summer. A European daylight-saving rule looks right most of the year, then breaks twice, because the US and Europe change their clocks on different Sundays: for about three weeks in March, after the US switches and before Europe does, and for one week around the start of November. In 2025 the US moved on March 9 and Europe on March 30. Every bar in that window is an hour off, and nothing raises an error.
It works in the other direction too. The date arguments of copy_rates_range and copy_ticks_range, and an order's expiration, are read as server time. A naive datetime passes through the PC's local time zone on the way in, which adds a third clock to the problem.
A clock is a rule, not a number
The fix is to stop storing an offset and store the rule. brokerclock models the server clock as an IANA time zone plus a shift. The usual broker clock, GMT+2/+3 on US dates, is New York's wall clock plus seven hours:
from brokerclock import ServerZone
zone = ServerZone.us() # GMT+2/+3 (US daylight saving)
zone.to_utc(1751371200) # 2025-07-01 12:00 on the server clock
# datetime(2025, 7, 1, 9, 0, tzinfo=timezone.utc)Every conversion asks the time zone database for the offset in force at that moment, so the March gap and the November week come out right without special cases. ServerZone.eu(), ServerZone.au() and ServerZone.fixed(0) cover the other common setups. Any other clock is an IANA key plus a shift in 15-minute steps.
The hour that repeats when daylight saving ends is a real ambiguity, so to_utc raises AmbiguousTimeError unless you pass ambiguous="earlier" or "later". The hour skipped in spring raises NonexistentTimeError. I'd rather get an exception than a bar that silently moves.
Finding the rule from price history
Knowing you need a rule doesn't tell you which one your broker uses. You can ask support. The price history is a better witness.
The forex week ends on Friday at 17:00 New York. In bar history, that close is the last bar before a gap of 40 hours or more. Its end time on the server clock, compared with the real Friday 17:00, gives the server's offset for that week. A year of weekly closes is a year of offsets. Each candidate clock (fixed, or US, EU or Australian daylight saving at any base offset) predicts some of them, and the best predictor wins.
import MetaTrader5 as mt5
import brokerclock
mt5.initialize()
rates = mt5.copy_rates_from_pos("EURUSD", mt5.TIMEFRAME_H1, 0, 6500)
result = brokerclock.detect(rates["time"], timeframe=3600)
print(result.zone) # GMT+2/+3 (US daylight saving)
print(f"{result.matched} of {result.total} weeks agree")detect returns its evidence along with the answer. Weeks that don't fit the winner, usually early holiday closes, come back as outliers. When the data can't separate two clocks, for example summer-only bars where the US and EU rules agree, zone is None and candidates lists the tie. That's the honest answer, and the fix is a longer span of data.
Brokers close at or before 17:00, never after, so each weekly offset is rounded up to the next 15 minutes. H1 bars are the safest input, because the last H1 bar of the week still ends at 17:00.
Checking it against live ticks
History tells you the rule. A live tick tells you whether it still holds today:
tick = mt5.symbol_info_tick("EURUSD")
brokerclock.check(zone, [tick.time_msc / 1000])check converts the tick under the zone and compares it with the PC clock. A tick from the future raises ZoneMismatchError. A fresh tick that agrees returns True. A stale one returns False, because a closed market can't confirm anything. Without a zone, measure_offset returns the current offset, and raises StaleTickError instead of guessing when the tick is old.
Whole columns, and the way back
With the pandas extra, frame_to_utc takes the structured array from copy_rates_* and returns a DataFrame whose time column is aware UTC. For queries and expirations, zone.server_timestamp(instant) turns an aware UTC datetime into the server-clock integer MetaTrader expects, which also keeps the PC's time zone out of the request.
pip install "brokerclock[pandas]"The library never imports MetaTrader5. It needs only the standard library, plus tzdata on Windows, so it runs and tests on Linux and macOS, where the terminal package doesn't install. The suite has 260 tests on Python 3.10 to 3.14.
Other ways in
Inside MQL5, this has been worked on before. Amr Ali's TimeGMT library estimates the server's offset from the first H1 bar after each weekend, mainly to fix TimeGMT() in the strategy tester (TimeGMT (External website)). Kabelo Frans Mampa's article detects the daylight-saving schedule from Non-Farm Payrolls volatility in M15 bars, and it can't run in the strategy tester because the economic calendar returns no data there (Monochronic Trading, Part 1 (External website)).
Both live inside the terminal. brokerclock answers the same question in Python, returns the rule itself so future times convert too, and handles the way back into MetaTrader.
What it doesn't solve
When a broker switches the clock on 24/7 crypto CFDs is up to the broker, so bars right at a switch can still be an hour off. Metals and indices follow their exchange's hours, so detect from a forex major. A broker's demo and live servers can run different clocks. And a broker that changes its clock may rewrite its history in the new one, so detect again after a change.
If you already store MT5 data, I'd check one week first: late March. Line your bars up against another source. If they're an hour off, the offset was saved as a number when it needed to be a rule.
Sources
- MQL5 docs: copy_rates_from (External website)
- MQL5 forum: python api does not return UTC times (2021) (External website)
- MQL5 forum: MetaTrader 5 timestamp semantics (2026) (External website)
- Broker server time, GMT, and why your MT5 sessions are an hour out (External website)
- TimeGMT library by Amr Ali (External website)
- Monochronic Trading (Part 1): How to Detect Broker Timezone and DST in MQL5 (External website)
- brokerclock on GitHub (External website)