EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Linux / MT5 Python API Timezones Differ by Function, Causing a 3-Hour Shift
Linux

MT5 Python API Timezones Differ by Function, Causing a 3-Hour Shift

by Ton-Or ·28 Oct 2026·11 min read
MT5 Python API Timezones Differ by Function, Causing a 3-Hour Shift

The same library, connected to the same terminal, has one function that accepts server time and returns UTC, while another returns server time even though all the column names are identical. Without measuring each function, some of the data shifts by three hours, which is much harder to track down than a shift across the whole system.

1-minute summary

Symptom / problem
Data collected through the MT5 Python API has times off by 3 hours in some columns only.
Who should read
People collecting price data with the MT5 Python API.
Fix in short
Each function accepts and returns a different time zone, so prove them one by one by querying the same range twice, record an 8-function table, and add a time check to the program.
Jump to steps

I was building a system to collect data from a MetaTrader 5 terminal through its Python package, creating a data warehouse for historical analysis. Everything looked normal at first, until one day I compared minute bars against raw ticks from the same period and found that the bars were exactly three hours ahead of the ticks. No errors, no warnings, and every number looked like a normal unix timestamp. What was wrong was that they represented different timezones, despite coming from the same library and the same terminal.

I had always assumed this API used one timezone throughout: either everything was UTC or everything was server time. Both assumptions were wrong. In practice, it mixes them, even to the point where a single function accepts one kind of time and returns another. I have replaced all broker names, server names, and symbol names in this article with broker A, broker B, and symbol A. The tables and numbers, however, are actual measurements. This article is only about the timestamps in the data.

Symptoms: Some Columns Shifted, Not the Whole System

If every timestamp in a system shifts by the same amount, it rarely causes much trouble, because everything is still in the right order and can be compared internally. What breaks things is a shift in only some sources. Broker A's server uses a local clock with seasonal adjustments, running 2 hours ahead of UTC in winter and 3 hours ahead in summer. I encountered the problem in summer, so the number that kept appearing was 10800 seconds.

There were four symptoms, and at first I treated them as four unrelated issues.

Symptom What I saw
Second bars I built from ticks did not match minute bars from the API 3 hours apart
Age of an open position Negative: -10800 seconds
Querying trade history for the last 30 minutes An empty list, despite knowing there were entries
Opening time of the latest minute bar 3 hours in the future

The three-hour difference in every row made me stop fixing individual points and ask a different question: which timezone was each function actually using? I previously wrote about silent time shifts between applications and databases in When the App Uses UTC but People Think in Thai Time. This is a related problem, but one layer deeper: in the library that retrieves the data from the source.

A Table of 8 Functions: Input and Output Times Differ

I measured each function against broker A's terminal and got this table. The input column shows which timezone the datetime I pass in is interpreted as. The output column shows which timezone the timestamps in the results represent.

Function Input Output Required handling
copy_ticks_from UTC UTC Use as is; do not subtract the offset
copy_ticks_range UTC UTC Same as above
copy_rates_from_pos No time input SERVER Subtract the offset from the time column
copy_rates_range SERVER SERVER Add the offset to input; subtract it from output
history_deals_get SERVER UTC Add the offset to input; use output as is
history_orders_get SERVER SERVER I have not measured the output myself; using the expected behavior
positions_get No time input SERVER Subtract the offset from the opening time
symbol_info_tick No time input UTC Use as is
FunctionInput read asOutput returned ascopy_ticks_fromUTCUTCcopy_ticks_rangeUTCUTCcopy_rates_from_posNo time inputSERVERcopy_rates_rangeSERVERSERVERhistory_deals_getSERVERUTChistory_orders_getSERVERSERVER (expected)positions_getNo time inputSERVERsymbol_info_tickNo time inputUTCSERVER = UTC+3 summer, UTC+2 winterOne wrong cell = 10800 s
Input and output timezones for 8 functions measured against broker A's terminal. Red boxes indicate server time; blue boxes indicate UTC. The most dangerous row is the one with different colors on each side.

The row that cost me the most time was history_deals_get, because it accepts a time range in server time but returns timestamps in UTC. If I fix it by assuming "this function uses server time" and subtract the offset from the output too, I get another error in the opposite direction.

Column names do not tell you the timezone

Every function returns a column named time as integer seconds. No field says whether it is UTC or server time, and passing it to datetime.fromtimestamp(x, tz=timezone.utc) does not turn it into UTC. It merely attaches a UTC label to a number that may still represent server time. A wrong label like this is more dangerous than no label, because the next layer of code will trust it.

How to Verify It: Query the Same Range Two Ways and Check Which Is Complete

I no longer wanted to trust any documentation or forum thread, so I measured it directly. The principle is to call each function that accepts a time range twice for "the last hour": first passing UTC, then passing server time, and compare the results.

from datetime import datetime, timedelta, timezone
import MetaTrader5 as mt5

OFFSET_SEC = 10800                      # เซิร์ฟเวอร์ของโบรก A ช่วงฤดูร้อน = UTC+3

mt5.initialize()                        # terminal ต้องเปิดและล็อกอินอยู่แล้ว
now_utc = datetime.now(timezone.utc)
srv_now = now_utc + timedelta(seconds=OFFSET_SEC)

as_utc = mt5.history_deals_get(now_utc - timedelta(hours=1), now_utc)
as_srv = mt5.history_deals_get(srv_now - timedelta(hours=1), srv_now)

print('ส่งแบบ UTC   :', len(as_utc or ()))    # รอบที่ผมวัดได้ 16
print('ส่งแบบ SERVER:', len(as_srv or ()))    # รอบที่ผมวัดได้ 26

In my test, passing UTC returned 16 entries, while passing server time returned 26. The one with more entries had the correct time range, because if the function interprets its input as server time and I pass UTC, the upper bound lands three hours before the present. All the most recent entries are then excluded.

Counting entries can point me in the right direction, but for stronger evidence, I check which result set contains the latest entry that I know occurred just a few minutes ago. The set containing it is the one with the correct input interpretation. I verify the output separately by comparing the timestamp of the latest entry in the results against the machine's UTC clock. A difference of a few seconds means UTC; a difference of whole hours means server time.

  1. Choose one function at a time. Do not measure a group and assume they all behave alike, because even the history functions differ.
  2. If the function accepts a time range, query the same range in two forms: UTC and UTC plus the offset, then check which returns all the latest entries.
  3. Subtract the machine's time.time() from the latest timestamp in the results. A value near zero means UTC; a value near 7200 or 10800 means server time.
  4. Measure only while data is actively arriving. Measuring while the source has stopped sending data gives a stale tick from the previous day, and the difference could be anything.
  5. Record the results in a table with separate input and output columns, along with the measurement date and broker, and keep it in the project, not in your head.

Four Bugs from Two Opposing Assumptions

Once I had the table and revisited the four symptoms above, they came down to just two opposing assumptions. The first was assuming everything was UTC. The second was discovering server time and then subtracting the offset from everything.

First assumption

Everything is UTC

  • Using minute bar timestamps directly = 3 hours ahead
  • Subtracting an open position's opening time from now in UTC = an age of -10800 seconds
  • Passing UTC to history_deals_get = an empty or incomplete list
Second assumption

Subtract the offset from everything

  • Subtracting the offset from ticks already in UTC = 3 hours behind
  • Second bars built from those ticks then fail to match correctly adjusted minute bars
  • Subtracting the offset again from the output of history_deals_get

My fix was to stop letting individual parts of the code decide whether to add or subtract, and put all conversions in one module. Every function exposed by this module always returns UTC. No other code is allowed to touch the offset.

# mt5_time.py — ทุกอย่างที่ออกจากไฟล์นี้เป็น UTC แล้ว ชั้นอื่นห้ามบวกลบ offset เอง
from datetime import datetime, timedelta, timezone
import MetaTrader5 as mt5

def rates_utc(symbol, tf, count, offset_sec):
    rates = mt5.copy_rates_from_pos(symbol, tf, 0, count)      # ขาออก SERVER
    return [(int(r['time']) - offset_sec, r) for r in rates]

def ticks_utc(symbol, from_utc, count):
    ticks = mt5.copy_ticks_from(symbol, from_utc, count, mt5.COPY_TICKS_ALL)
    return [(int(t['time']), t) for t in ticks]                # UTC อยู่แล้ว ไม่ลบ

def open_age_sec(pos, offset_sec):
    opened = datetime.fromtimestamp(pos.time - offset_sec, tz=timezone.utc)
    return (datetime.now(timezone.utc) - opened).total_seconds()

def deals_last(minutes, offset_sec):
    srv_now = datetime.now(timezone.utc) + timedelta(seconds=offset_sec)
    deals = mt5.history_deals_get(srv_now - timedelta(minutes=minutes), srv_now)
    return [(datetime.fromtimestamp(d.time, tz=timezone.utc), d) for d in deals or ()]

The last function illustrates the most dangerous row in the table: add the offset to the input, but leave the output untouched. Without keeping this in one place, I am sure someone would come along three months later, "make it consistent," and break it again. That person would probably be me.

An Offset Measured Once and Rounded to Hours Fails Across a Clock Change

Where offset_sec in the code above comes from is another thing I got wrong more than once. In the first version, I measured it once at startup by subtracting the machine's UTC clock from the latest tick timestamp expressed in server time, then rounding to whole hours.

# เวอร์ชันแรก: วัดครั้งเดียวตอนเริ่ม แล้วใช้ค่านั้นตลอดอายุของ process
def detect_server_offset(symbol):
    tick = mt5.symbol_info_tick(symbol)
    utc_now = datetime.now(timezone.utc).timestamp()
    return int(round((tick.time_msc / 1000.0 - utc_now) / 3600) * 3600)

This method has three weaknesses. First, a daemon that runs across a daylight saving time transition keeps using the old offset, silently shifting the following day's data by one hour. Second, when fetching historical data, the date being retrieved may fall in a different season from today, so today's offset does not apply. Third, if the program starts while the source has stopped sending data, the latest tick is a stale tick from Friday. The measured difference is not the offset but the time elapsed since the source stopped sending data.

I also tried writing the clock-change rules by hand, finding each year's transition dates and choosing either 7200 or 10800. It worked, but it was code I had to maintain even though the operating system already provided a timezone database. The version I use now calculates the offset for each day using ZoneInfo, querying the midpoint of that trading day.

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

SRV_TZ = ZoneInfo(settings['SOURCE_TZ'])   # โซนเวลาของแหล่งข้อมูล อ่านจากค่าตั้งต่อโบรก ห้ามปล่อยให้เป็นค่า default

def day_offset(day_id):
    """offset (วินาที) ของวันทำการหนึ่งวัน วันทำการของชุดข้อมูลนี้เริ่ม 22:00 UTC"""
    mid = datetime.fromtimestamp(day_id * 86400 - 7200 + 43200, tz=timezone.utc)
    return int(mid.astimezone(SRV_TZ).utcoffset().total_seconds())

When fetching several days of historical data, I process one day at a time, using each day's own offset. I then trim the results to the actual UTC range requested, guarding against timezone arithmetic spilling an hour past the boundary.

Historical retrieval with daily offsetsRequested UTC rangeMicroseconds->Split into trading daysCut at 22:00 UTC->That day's day_offset7200 or 10800->Convert input and outputPer the table->Trim by UTCPrevent overflow

For live processing that needs the current offset, I still measure it from ticks, but I changed the rule: a tick only counts as evidence if its timestamp advances between two samples. A tick whose value does not change is treated as stale and does not count. At least two symbols must agree on the value. If only one symbol is available, I must see the same value in two rounds. The confirmed value is then written to a separate file for each server, so a weekend startup has the correct value from the start.

This Table Applies to Broker A, Not Universally

The main thing I want readers to be careful about is using the table above directly without taking their own measurements, because I have conflicting evidence even within my own work. When connected to broker B's terminal, I measured the latest tick timestamp as 3 hours ahead of UTC, even though the tick functions return UTC with broker A and the library documentation also says UTC. Another broker sets its server clock directly to UTC, giving an offset of zero. Every function then appears correct, until the same code is connected to another broker.

I have not yet measured all 8 functions with broker B, so I cannot conclude exactly where they differ. The only conclusion I can draw is that this API's timezones also depend on the connected terminal and server, not just the library.

A default timezone can silently corrupt data

My collector reads the server timezone from an environment variable, with a default set to the timezone of the broker I use most often. One day, the launcher lost this variable. The program did not crash; it simply used the default with a broker terminal whose clock was UTC. If I had let it write to the warehouse, every tick would have been 3 hours ahead. I added a check requiring the configured timezone to match the server actually connected. If they do not match, the collector shuts itself down instead of continuing to guess.

Checks Every Program Using This API Should Have

All the bugs in this article have one thing in common: they do not throw errors. What helps is checking whether timestamps are reasonable at the point of entry, before the data flows into the warehouse. The example below is an approach I derived from the four symptoms. It is very short, with each line tied to one type of bug.

import time

def sanity_check(bar_utc_ts, tick_utc_ts, open_age, tf_sec=60):
    now = time.time()
    # แท่งล่าสุดต้องไม่อยู่ในอนาคต และไม่เก่าเกินสองแท่งตอนที่ข้อมูลกำลังวิ่ง
    assert bar_utc_ts <= now + 5, f'เวลาแท่งอยู่ในอนาคต {bar_utc_ts - now:.0f}s'
    # tick กับแท่งล่าสุดต้องอยู่ห่างกันไม่เกินหนึ่งแท่ง
    assert abs(tick_utc_ts - bar_utc_ts) <= 2 * tf_sec, 'tick กับแท่งอยู่คนละเขตเวลา'
    # อายุของรายการที่เปิดค้างเป็นลบไม่ได้
    assert open_age >= 0, f'อายุติดลบ {open_age:.0f}s = ลืมลบ offset'
    # ส่วนต่างที่เป็นจำนวนชั่วโมงเต็มคือลายเซ็นของบั๊กเขตเวลา
    drift = round(bar_utc_ts - tick_utc_ts)
    assert drift % 3600 != 0 or drift == 0, f'เพี้ยน {drift // 3600} ชั่วโมงพอดี'

The last check is my favorite. A discrepancy of exactly a whole number of hours is almost impossible to explain by network latency or clock drift. It is nearly always a timezone issue. Whenever 3600, 7200, or 10800 appears, stop looking for other causes first.

These checks must run only while data is actively arriving. When the source stops sending data, the latest tick can legitimately be days old. Checks against the current clock should therefore be skipped when the tick does not advance between two samples, using the same rule as offset measurement.

The Numbers Behind This Work

8 functionsMeasured for input and output
5 typesOf input-output pairs in the table
10800 sSummer offset
7200 sWinter offset
16 and 26Results from querying the same range two ways
4 bugsFrom two opposing assumptions
1 fileAllowed to touch the offset
2 pieces of evidenceMinimum before trusting a live offset

In the end, I lost time on this because I was trying to find "the API's timezone," when no such thing existed. There are only the timezones of individual functions, separate for input and output, and tied to the connected broker. What is reusable is the measurement method, not the table itself. I will measure again whenever I change brokers or the terminal gets a major update, and keep the results alongside the code. If anyone has measurements from another broker that differ from this table, that is normal. It does not mean anyone measured incorrectly. As for the history_orders_get row, whose output I have not yet measured myself, and the incomplete measurements for broker B, I will come back and update those later.

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first