Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Handling Exchange Dates, Session Boundaries, and Asia/Kolkata Timestamps in Python

Hello. In the previous lesson, you created returns, moving averages, volatility estimates, and lagged features on a per-stock daily panel. Those calculations were only meaningful because each row had a reliable date and a clear statement of when its information became available.

Now we make that date handling explicit. For an Indian equity swing system, a “day” is not simply a calendar date generated by your laptop: it is an NSE trading session. You will learn to distinguish session dates from timestamps, represent instants in Asia/Kolkata, convert correctly from UTC, and prevent pre-open, post-close, weekend, and holiday data from leaking into regular-session research.

Study time: about 40 minutes.


A date is a label; a timestamp is an instant

These ideas look similar in a DataFrame but serve different purposes:

  • A session date is a business label such as 2024-01-02. A daily OHLCV row for INFY belongs to this session.
  • A timestamp is a precise instant such as 2024-01-02 09:15:00+05:30.
  • A time zone tells Python how to interpret the clock time in a timestamp.

For a daily swing-strategy panel, the index from the prior lesson can remain:

(symbol, date)

where date means the Indian exchange session date, normally stored as a timezone-naive midnight label such as 2024-01-02 00:00:00.

For intraday data, order logs, data-download times, and operational logs, a date alone is inadequate. These records need timezone-aware timestamps. A robust convention is:

Field Meaning Recommended representation
session_date NSE session to which a bar belongs datetime64[ns], date label at local midnight
timestamp_utc Exact instant, independent of location timezone-aware UTC timestamp
timestamp_ist Same instant displayed in Indian market time timezone-aware Asia/Kolkata timestamp
bar_label Provider’s assigned bar time preserve provider convention and document it

The most important rule is:

Never infer the time zone from your computer’s settings or from an unlabelled timestamp.

A timestamp such as 2024-01-02 09:15:00 is naive. It says what a clock displayed but not where that clock was. It could mean 09:15 in Mumbai, London, or New York. Python cannot safely guess.

Asia/Kolkata is the standard IANA time-zone name to use for Indian market time. India is UTC+05:30 and does not currently observe daylight saving time. Still, use the named zone instead of manually adding five and a half hours: named zones make your intent clear and generalize correctly when your pipeline later processes data from other markets.


Define the session before filtering data

For this course, the relevant instrument is a regular NSE equity delivery trade. NSE’s official timings distinguish several sessions around the regular market, not one undifferentiated “trading day.”

Market Timings

Read NSE’s official market-timing table to establish the boundaries your code must respect. It is the source for the session constants used below.

In the Capital Market tab, expand Equities and read the equity timetable. Focus on the distinction between the regular pre-open period, the normal or odd-lot market, and the later closing session. For this lesson, note especially 09:00, 09:08, 09:15, 15:30, 15:40, and 16:00.

For ordinary NSE equity research, the key boundaries are:

Market period Time in Asia/Kolkata Practical interpretation
Regular pre-open 09:00–09:08 Price discovery before continuous trading
Normal equity market 09:15–15:30 Default regular-session window for equity bars and most swing-trading assumptions
Closing session 15:40–16:00 Separate session; do not silently blend it into regular intraday bars
Weekends and announced holidays Closed No normal session exists

There are also block-deal, auction, and special-session arrangements. The correct engineering response is not to assume they never matter; it is to make your system’s policy explicit.

For example, a first version of a regular-equity bar pipeline might state:

SESSION_TZ = "Asia/Kolkata"

REGULAR_OPEN_HOUR = 9
REGULAR_OPEN_MINUTE = 15

REGULAR_CLOSE_HOUR = 15
REGULAR_CLOSE_MINUTE = 30

INCLUDE_PREOPEN = False
INCLUDE_CLOSING_SESSION = False

These are business assumptions, not merely constants. If your source’s “daily close” includes a particular auction or closing-session price, record that in dataset metadata rather than assuming it means the same thing as a normal-market last trade.

A further detail matters for intraday bars: providers can label a five-minute bar by its start or its end.

  • A start-labelled 09:15 bar may cover 09:15 up to, but not including, 09:20.
  • An end-labelled 09:20 bar may cover that exact same interval.

Therefore, a filter that excludes a 15:30 timestamp is correct for ticks and start-labelled bars, but may incorrectly discard the final bar from an end-labelled feed. Establish the vendor’s convention first, then test it.


Localize versus convert: two operations with opposite meanings

Pandas provides two operations that are easy to confuse:

  1. Localize a naive timestamp when you know the clock time was recorded in a particular zone.
  2. Convert an already timezone-aware timestamp to display the same instant in another zone.

Time series / date functionality — pandas 2.3.3 documentation

Read the pandas documentation sections that distinguish time-zone assignment from time-zone conversion, then connect date ranges to an exchange-aware calendar.

In Working with time zones, read the localization and conversion explanation. Then read Custom business days, beginning with custom business days, to see why a market calendar needs more than the default weekday logic. Finally, in Generating ranges of timestamps, read date range generation and note the difference between calendar days and business days.

Case 1: Your source gives naive local Indian clock times

Suppose a CSV contains:

timestamp,close
2024-01-02 09:15:00,2450.10
2024-01-02 09:20:00,2452.35

If the vendor documents these as NSE local times, localize them:

import pandas as pd

TZ = "Asia/Kolkata"

raw = pd.read_csv("bars.csv")

raw["timestamp"] = pd.to_datetime(raw["timestamp"], errors="raise")

assert raw["timestamp"].dt.tz is None

raw["timestamp_ist"] = raw["timestamp"].dt.tz_localize(TZ)

Localization does not change the displayed clock time. It adds the missing statement: “09:15 occurred in India.”

Case 2: Your source gives UTC timestamps

Suppose an API returns an actual UTC instant:

utc_time = pd.Timestamp("2024-01-02 03:45:00Z")

ist_time = utc_time.tz_convert(TZ)

print(ist_time)
# 2024-01-02 09:15:00+05:30

Here, conversion changes the displayed clock time while preserving the instant. The two values identify precisely the same moment.

For a Series from an API:

raw["timestamp_utc"] = pd.to_datetime(
    raw["timestamp"],
    utc=True,
    errors="raise",
)

raw["timestamp_ist"] = raw["timestamp_utc"].dt.tz_convert(TZ)

The high-cost mistake

Do not apply utc=True to naive timestamps that actually represent Indian local times:

# Wrong if raw timestamps are NSE local clock times
pd.to_datetime(raw["timestamp"], utc=True)

That code interprets 09:15 as 09:15 UTC. Converting it to India then produces 14:45 IST, shifting your entire dataset by 5 hours and 30 minutes.

A concise decision rule is:

Input from source Correct operation
Naive timestamps documented as India local time .dt.tz_localize("Asia/Kolkata")
UTC timestamps, including Z suffixes parse with utc=True, then .dt.tz_convert("Asia/Kolkata")
Aware timestamps with another offset or region .dt.tz_convert("Asia/Kolkata")
Naive timestamps with unknown origin Stop and verify the data contract

The following short video illustrates the same distinction in pandas.

Pandas Time Series Analysis 6: Timezone Handling

Watch codebasics’ “Pandas Time Series Analysis 6: Timezone Handling” for a compact visual demonstration of naive timestamps, localization, and conversion.

Watch naive versus aware for the terminology. Then watch localizing a series to see why localization must be assigned back to the DataFrame. Finish with time zone conversion, focusing on the fact that conversion preserves the instant while changing its local clock display.


Assign the correct NSE session date

Always derive session_date from the timestamp after converting to Indian market time.

bars = raw.copy()

bars["session_date"] = (
    bars["timestamp_ist"]
    .dt.tz_localize(None)
    .dt.normalize()
)

This yields a clean date label:

timestamp_ist                 session_date
2024-01-02 09:15:00+05:30     2024-01-02
2024-01-02 15:25:00+05:30     2024-01-02

Dropping the time zone is intentional only for the date label. The original timestamp_ist remains timezone-aware and should be retained for auditability.

Why not simply take the date from a UTC timestamp? Because a UTC date and an Indian local date can differ near midnight. A market rule expressed as “the session date” is an exchange-local concept, so derive it in Asia/Kolkata.

For a daily panel, you can now retain the familiar structure:

daily_prices = (
    bars
    .groupby(["symbol", "session_date"], as_index=True)
    .agg(
        open=("open", "first"),
        high=("high", "max"),
        low=("low", "min"),
        close=("close", "last"),
        volume=("volume", "sum"),
    )
    .sort_index()
)

Do not use this aggregation blindly on arbitrary intraday data. First filter for valid session dates and for the intended market period. Otherwise, pre-open or closing-session observations might alter the reported daily OHLCV values.


Filter regular-session observations with timezone-aware boundaries

For tick data or start-labelled bars, construct boundaries in local market time:

local = bars["timestamp_ist"]

session_midnight = local.dt.normalize()

regular_open = (
    session_midnight
    + pd.Timedelta(hours=9, minutes=15)
)

regular_close = (
    session_midnight
    + pd.Timedelta(hours=15, minutes=30)
)

in_regular_session = local.ge(regular_open) & local.lt(regular_close)

regular_bars = bars.loc[in_regular_session].copy()

The condition is intentionally:

That includes the opening instant and excludes events after the normal market closes. It is suitable for tick data and bars labelled by their start time.

A fast sanity check can catch many timestamp errors:

assert regular_bars["timestamp_ist"].dt.tz is not None

assert (
    regular_bars["timestamp_ist"].dt.hour.between(9, 15)
).all()

The hour check is only a coarse diagnostic. The boundary comparison is the real rule because 15:45 has hour 15 but lies outside the normal market.

For a planned five-minute, start-labelled bar grid on one known valid session:

session_open = pd.Timestamp("2024-01-02 09:15", tz=TZ)
session_close = pd.Timestamp("2024-01-02 15:30", tz=TZ)

expected_labels = pd.date_range(
    start=session_open,
    end=session_close,
    freq="5min",
    inclusive="left",
)

len(expected_labels)
# 75

That expected index runs from 09:15 through 15:25. It represents 75 five-minute intervals in the regular 375-minute session. Later, this type of expected grid will help you identify missing bars, duplicates, and feed outages.


Weekdays are not an exchange calendar

A common research error is to generate weekdays with:

pd.bdate_range("2024-01-01", "2024-12-31")

This removes Saturdays and Sundays, but it does not know NSE holidays, special sessions, or exceptional exchange notices. A generic “business day” is not automatically an NSE trading day.

Pandas can apply a maintained holiday list through CustomBusinessDay:

from pandas.tseries.offsets import CustomBusinessDay

official_holiday_dates = pd.to_datetime(
    [
        # Load reviewed NSE holiday dates here.
    ]
)

nse_business_day = CustomBusinessDay(
    weekmask="Mon Tue Wed Thu Fri",
    holidays=official_holiday_dates,
)

candidate_sessions = pd.date_range(
    start="2024-01-01",
    end="2024-12-31",
    freq=nse_business_day,
)

This is useful, but it comes with an operational responsibility: your holiday list must come from an authoritative, versioned source and be updated when exchange schedules change. It must also accommodate special trading sessions rather than assuming every valid session has the normal 09:15–15:30 window.

A practical initial policy is:

  1. Maintain an explicit set of valid NSE equity session dates for each year.
  2. Keep special-session dates and their hours in a separate configuration table.
  3. Filter raw observations against that calendar before calculating features.
  4. Preserve the calendar version alongside each research dataset.

For example:

valid_sessions = pd.DatetimeIndex(official_session_dates).normalize()

bars = bars.loc[
    bars["session_date"].isin(valid_sessions)
].copy()

In the next data-pipeline module, you will formalize calendar storage and validation. For now, avoid silently treating all weekdays as tradeable.


Decision time, session close, and feature availability

Time-zone correctness is ultimately about preventing false claims of what the strategy knew.

Suppose your daily signal uses the close of Tuesday’s regular session:

  • The Tuesday close becomes known only after the market has closed and the data provider has delivered a completed bar.
  • It can support an order decision made after Tuesday’s session.
  • It cannot justify an order allegedly placed at Tuesday’s 10:00 AM.
  • If you plan to trade at Wednesday’s open, your code must make clear that the signal came from Tuesday’s completed data.

A clean convention for a daily swing system is:

Item Session date Information available
Completed daily bar Tuesday After Tuesday’s close and data retrieval
Signal calculated from completed bar Tuesday After Tuesday’s close
Target position for next session Wednesday Before Wednesday’s intended order time
Fill assumption Wednesday At the modeled open or later, never at an unavailable past price

This matches the lagging discipline from the prior lesson. A timestamp-aware pipeline makes that discipline enforceable rather than merely aspirational.


Implementation checklist

Before trusting timestamps in a research dataset, verify:

  • Every intraday timestamp is either timezone-aware or has a documented source zone before localization.
  • UTC inputs are converted to Asia/Kolkata; they are not localized as though they were already Indian local times.
  • session_date is derived from Indian local time, not from UTC.
  • Regular-session filtering uses explicit 09:15 and 15:30 local boundaries.
  • The bar-label convention, whether start-labelled or end-labelled, is documented and tested.
  • Weekends, NSE holidays, and special sessions are handled by an exchange calendar rather than by a generic weekday assumption.
  • Your daily feature and order logic states when data became available.

A minimal unit-test set should include a UTC-to-IST conversion that produces 09:15 IST, a local naive timestamp that remains 09:15 after localization, an exclusion test for a timestamp outside the regular window, and a calendar fixture that excludes a configured holiday.


Key takeaways

Indian-market time handling has three layers: the exact timestamp, the exchange-local session boundary, and the calendar of valid sessions.

  • Use Asia/Kolkata for NSE-local timestamps.
  • Use tz_localize to assign a zone to a known naive clock time; use tz_convert to display an already-aware instant in another zone.
  • Derive daily session_date labels from local Indian time.
  • Treat 09:15–15:30 as the normal NSE equity market window, while explicitly deciding how pre-open and closing sessions are treated.
  • Do not equate Monday–Friday with valid NSE sessions; holidays and special sessions require an exchange-aware calendar.
  • Preserve the timing of data availability so a backtest never acts on information unavailable at its claimed decision time.

Next, you will separate exploratory notebook work from reusable research and production modules, so these timestamp rules become tested, dependable code rather than cells that must be rerun by hand.

Can't find a good explanation? Sign up and we'll make it for you

Sign up