MT5, Python API, UTC confusion — why do my daily candles need an extra +3h shift?

 
Please, help me understand what’s going on — and if I can stop worrying.

I have a frontend using TradingView’s Advanced Charts library.

On the backend, I have a Node.js service that acts as a public REST API layer and persists data to a ClickHouse database.
This backend communicates with the frontend to provide all the candle data TradingView requires for chart rendering.

There are two main data sources in the backend: Binance and MetaTrader 5.
Binance is integrated directly into the Node.js gateway and works perfectly on TradingView.

MT5 is another story.
Since this solution needs to run in the cloud inside a container, I wrapped MT5 in Ubuntu using Wine and built a small Flask API in Python (also installed under Wine).
This API communicates with the MT5 terminal and my Node.js backend.
Actually, only the Node backend makes requests to the MT5 Python API — never the other way around.

The Python API is simple: it receives a request with symbol, from, to, and timeframe, and returns the data.

However, there’s one important detail. The MT5 documentation says:

"When creating the 'datetime' object, Python uses the local time zone,
while MetaTrader 5 stores tick and bar open time in UTC time zone (without the shift).
Therefore, 'datetime' should be created in UTC time for executing functions that use time.
Data received from the MetaTrader 5 terminal has UTC time."

So, I expected to be able to pass timestamps to MT5 in UTC+0 without worrying about the broker’s timezone.
In my case, the broker is +3 relative to UTC+0 — the same timezone (UTC+0) is used by both the host and the Docker container running MT5 terminal in it.
While locally on the frontend I’m in UTC+2.

Here’s what’s happening:
When I send timestamps in UTC+0 to MT5, it returns fewer candles than expected — exactly as many as the broker’s timezone offset (+3).
However, if I add the broker offset (+3) to the timestamps before calling MT5, then the result is correct.

At the end of the process, before sending the candles back to the Node.js backend, I subtract that same offset (+3) from every candle’s timestamp — so theoretically, everything should end up normalized to UTC+0 before storing it in the database.

Here’s the Python code that fetches the candles:
UTC_TZ = pytz.UTC
TZDetector = TimezoneDetector()

data_bp = Blueprint('data', __name__)
logger = logging.getLogger(__name__)

def fetch_data_range_endpoint():
    """
    Fetch Data within a Date Range
    ---
    description: Retrieve historical price data for a given symbol within a specified date range.
    """
    try:
        symbol = request.args.get('symbol')
        timeframe = request.args.get('timeframe', 'M1')
        start_ms = request.args.get('start', type=int)
        end_ms = request.args.get('end', type=int)
        if not all([symbol, start_ms, end_ms]):
            return jsonify({"error": "Symbol, start, and end parameters are required"}), 400

        BROKER_TZ_STR = TZDetector.get_broker_timezone()["timezone"]
        BROKER_OFFSET_SEC = TZDetector.get_broker_timezone()["offset_seconds"]
        BROKER_TZ = pytz.timezone(BROKER_TZ_STR)

        dt_start_utc = datetime.fromtimestamp(start_ms / 1000, tz=UTC_TZ)
        dt_end_utc = datetime.fromtimestamp(end_ms / 1000, tz=UTC_TZ)

        dt_start_broker = dt_start_utc.astimezone(BROKER_TZ)
        dt_end_broker = dt_end_utc.astimezone(BROKER_TZ)

        dt_start_naive = dt_start_broker.replace(tzinfo=None)
        dt_end_naive = dt_end_broker.replace(tzinfo=None)

        symbol_info = mt5.symbol_info(symbol)

        if symbol_info is None:
            return jsonify({"error": "Invalid symbol"}), 400

        if not symbol_info.visible:
            if not mt5.symbol_select(symbol, True):
                return jsonify({"error": "Error adding symbol to market watch"}), 500

        mt5_timeframe = get_timeframe(timeframe)

        rates = mt5.copy_rates_range(symbol, mt5_timeframe, dt_start_naive, dt_end_naive)

        if rates is None:
            logger.warning("Error fetching rates. Reached max retries. Giving up")
            return jsonify({"error": "No data available"}), 404

        df = pd.DataFrame(rates)
        
        # Disabled since it feels wrong to manipulate the timestamp like this
        # df["time"] = (df["time"] - BROKER_OFFSET_SEC) * 1000

        df["time"] = df["time"] * 1000
        df = df[["time", "open", "high", "low", "close", "tick_volume"]]

        if symbol != "BTCUSD":
            mt5.symbol_select(symbol, False)

        return jsonify({
            "data": df.values.tolist(),
            "broker_last_time": get_symbol_last_time('BTCUSD', BROKER_OFFSET_SEC)
        })
    except ValueError as e:
        return jsonify({"error": f"Invalid timestamp: {str(e)}"}), 400
    except Exception as e:
        logger.exception("Error in fetch_data_range: {str(e)}")
        return jsonify({"error": "Internal server error"}), 500

On the Node.js side, I have an endpoint that returns the TradingView symbol configuration:
const result: IResolveSymbolResult = {
            exchange: xm,
            name: symbol,
            type: isMT5Exchange(xm) ? 'forex' : 'crypto',
            format: 'price',
            data_status: 'streaming',
            session: '24x7',
            has_seconds: hasSeconds,
            has_intraday: hasIntraday,
            has_daily: true,
            has_weekly_and_monthly: true,
            daily_multipliers: ['1'],

            seconds_multipliers: secondsMultipliers,
            intraday_multipliers: intradayMultipliers,
            has_empty_bars: false,
            listed_exchange: xm,
            full_name: symbol,
            ticker: `${xm}~${symbol}`,
            description: symbol,
            minmov: 1,
            pricescale: +priceScale,
            timezone: isMT5Exchange(xm) ? 'Europe/Moscow' : 'Etc/UTC',
            supported_resolutions: this.exchange
                .getExchangeTimeframes(xm)
                .map((tf) =>
                    TimeFrame.internalTimeframeToTradingviewResolution(tf),
                ),
        };

With this setup, all symbols seem to work correctly and show the right times on TradingView —
no missing candles, no wrong sessions, and no timezone mismatches.


Except for the daily timeframes and upwards.

To align the daily candles with what MT5 shows in its own terminal and what TradingView shows for the same broker on its official platform, I need to add 3 hours to every daily candle timestamp.
Only then does everything line up correctly.

If I enable this line in Python:
df["time"] = (df["time"] - BROKER_OFFSET_SEC) * 1000

and keep the Node.js symbol config timezone as "Europe/Moscow" (equivalent to UTC+3), all intraday timeframes are fine — but for daily, I must add those 3 hours back again.
If I don’t subtract the broker offset in Python, then I get missing candles on TradingView.

Now here’s what’s confusing me and making me feel like the code is “unstable”:

1. If I subtract the broker shift (+3) in Python before returning the candles,
why does everything look fine except daily, which then requires me to re-add that offset?

2. If I’m already removing the offset in Python (so data is UTC+0),
why do I still need to define "Europe/Moscow" (UTC+3) as the timezone in the TradingView symbol config?

Shouldn’t I be able to use "Etc/UTC" instead?
If I do, the candles appear at the wrong times in the chart.

As a note:
TradingView automatically uses the user’s local timezone (in my case UTC+2),
but it uses the symbol timezone (from the backend config) for interpreting bar timestamps.

So yes, everything seems to work — but it feels like it’s held together with tape and toothpicks.

My questions are:
- Is this behavior normal for MT5 brokers?
- Will this logic hold for all brokers?
- Am I doing this the “correct” way?
- Or am I missing some conceptual point about how MT5 handles timezones?

I even tried another Python variant:
def fetch_data_range_endpoint():
    try:
        symbol = request.args.get('symbol')
        timeframe = request.args.get('timeframe', 'M1')
        start_ms = request.args.get('start', type=int)
        end_ms = request.args.get('end', type=int)

        if not all([symbol, start_ms, end_ms]):
            return jsonify({"error": "Symbol, start, and end parameters are required"}), 400

        BROKER_TZ_STR = TZDetector.get_broker_timezone()["timezone"]
        BROKER_OFFSET_SEC = TZDetector.get_broker_timezone()["offset_seconds"]
        BROKER_TZ = pytz.timezone(BROKER_TZ_STR)

        dt_start_utc = datetime.fromtimestamp(start_ms / 1000, tz=UTC_TZ)
        dt_end_utc = datetime.fromtimestamp(end_ms / 1000, tz=UTC_TZ)

        dt_start_broker = dt_start_utc.astimezone(BROKER_TZ)
        dt_end_broker = dt_end_utc.astimezone(BROKER_TZ)

        dt_start_naive = dt_start_broker.replace(tzinfo=None)
        dt_end_naive = dt_end_broker.replace(tzinfo=None)

        symbol_info = mt5.symbol_info(symbol)

        if symbol_info is None:
            return jsonify({"error": "Invalid symbol"}), 400

        if not symbol_info.visible:
            if not mt5.symbol_select(symbol, True):
                return jsonify({"error": "Error adding symbol to market watch"}), 500

        mt5_timeframe = get_timeframe(timeframe)
        rates = mt5.copy_rates_range(
            symbol, mt5_timeframe, dt_start_naive, dt_end_naive)

        if rates is None:
            logger.warning("Error fetching rates")
            return jsonify({"error": "No data available"}), 404

        df = pd.DataFrame(rates)

        is_daily_or_higher = timeframe in ['D1', 'W1', 'MN1']
        
        if is_daily_or_higher:            
            df["time"] = pd.to_datetime(df["time"], unit='s', utc=True)
            df["time"] = df["time"].dt.tz_localize(None)
            df["time"] = df["time"].dt.tz_localize(BROKER_TZ)
            df["time"] = df["time"].dt.tz_convert(UTC_TZ)
            df["time"] = (df["time"].astype('int64') // 10**6).astype('int64')
        else:
            df["time"] = df["time"] * 1000

        df = df[["time", "open", "high", "low", "close", "tick_volume"]]

        if symbol != "BTCUSD":
            mt5.symbol_select(symbol, False)

        return jsonify({
            "data": df.values.tolist(),
            "broker_last_time": get_symbol_last_time('BTCUSD', BROKER_OFFSET_SEC)
        })
    except ValueError as e:
        return jsonify({"error": f"Invalid timestamp: {str(e)}"}), 400
    except Exception as e:
        logger.exception(f"Error in fetch_data_range: {str(e)}")
        return jsonify({"error": "Internal server error"}), 500

Everything works perfectly except daily and upwards.
TradingView shifts daily candles backward (e.g., Oct 13 becomes Oct 12, and Oct 11 becomes Oct 9 😱). I guess is due to the fact that internally, tradingview ignores candles that falls within weekends? 
Taking for granted that a "forex" symbol shouldn't be active during the weekend? Would make sense, but still, the root issue seems to be elsewhere to me, so 
I added these debug logs:
first_candle_timestamp = rates[0]['time']
last_candle_timestamp = rates[-1]['time']

dt_first_utc = datetime.fromtimestamp(first_candle_timestamp, tz=UTC_TZ)
dt_last_utc = datetime.fromtimestamp(last_candle_timestamp, tz=UTC_TZ)

dt_first_broker_naive = datetime.fromtimestamp(first_candle_timestamp)
dt_first_broker = BROKER_TZ.localize(dt_first_broker_naive)

logger.info(f"========== MT5 DATA DEBUG ==========")
logger.info(f"Timeframe: {timeframe}")
logger.info(f"Broker TZ: {BROKER_TZ_STR}")
logger.info(f"First candle raw timestamp: {first_candle_timestamp}")
logger.info(f"First candle as UTC: {dt_first_utc}")
logger.info(f"First candle as Broker TZ: {dt_first_broker}")
logger.info(f"Last candle raw timestamp: {last_candle_timestamp}")
logger.info(f"Last candle as UTC: {dt_last_utc}")
logger.info(f"====================================")

And this is what I get:
========== MT5 DATA DEBUG ==========
Timeframe: H1
Broker TZ: Europe/Moscow
First candle raw timestamp: 1759104000
First candle as UTC: 2025-09-29 00:00:00+00:00
First candle as Broker TZ: 2025-09-29 00:00:00+03:00
Last candle raw timestamp: 1759392000
Last candle as UTC: 2025-10-02 08:00:00+00:00
====================================

========== MT5 DATA DEBUG ==========
Timeframe: D1
Broker TZ: Europe/Moscow
First candle raw timestamp: 1728864000
First candle as UTC: 2024-10-14 00:00:00+00:00
First candle as Broker TZ: 2024-10-14 00:00:00+03:00
Last candle raw timestamp: 1735862400
Last candle as UTC: 2025-01-03 00:00:00+00:00
====================================

There’s also a Stack Overflow user with the exact same issue,
but the answer they got doesn’t really help:

👉 https://stackoverflow.com/questions/79595025/timezones-and-offsets-handling-when-fetching-metatrader5-data-via-mt5-api

I’m completely lost.
I’ve been fighting this for a week, day and night.

It’s driving me crazy and I don’t know where else to look 😭

I feel like I'm missing some actual knowledge about how meta trader 5 actually treats timezones, since online I've found conflicting opinions.
Plus what I'm trying to do seems quite "simple" to me, it's no rocket science. I want to query MT5 with a given range of dates and symbol, 
get the data, store it in the db. So I can reuse it as cache for the next requests of same data. Ideally building an incremental cache db over time.
(I'm skipping all the nodejs data layer logics since are irrelevant for this issue)


✅ TL;DR / Summary
- MT5 documentation says “use UTC”
- When I do that, I get fewer candles than expected
- When I add the broker offset (+3), results are correct
- To normalize, I subtract +3 before returning data
- Everything looks correct on TradingView except daily candles
- Daily timeframe seems to require re-adding that offset
- Using "Europe/Moscow" in TradingView config “fixes” the visuals, but "Etc/UTC" causes time shifts
- Feels like a timezone house of cards — I want to know if it’s normal or if I’m doing something wrong
Documentation on MQL5: Date and Time / TimeLocal
Documentation on MQL5: Date and Time / TimeLocal
  • www.mql5.com
Returns the local time of a computer, where the client terminal is running. There are 2 variants of the function. Call without parameters Call with...
 

Forum on trading, automated trading systems and testing trading strategies

Help with Time Zone Handling and Data Fetching in MT5

Stanislav Korotky, 2025.04.28 13:30

Yes, it looks like the documentation is incorrect, and the timestamps in the quotes are returned in the timezone of the server, the same way as on charts and in MT5 history.



 
Your topic has been moved to the section: Expert Advisors and Automated Trading
Please consider which section is most appropriate — https://www.mql5.com/en/forum/172166/page6#comment_49114893