Skip to content

calendar

The days each exchange traded, taken from the bars already stored.

No exchange holiday list is kept, because the stored bars already say which days traded, special sessions included: the Budget sessions on Saturday 2020-02-01, 2025-02-01 and Sunday 2026-02-01, the Muhurat session on Sunday 2023-11-12, and the Saturday disaster recovery sessions of 2024.

A date is a trading day for an exchange when either of two sources says so:

  • zerodha's daily bar for the exchange's benchmark index exists - NIFTY 50 for NSE, SENSEX for BSE, both back to 2005; or
  • flattrade has daily bars on that date for at least a tenth of the series it had on the busiest day of that month.

The second condition exists for days the index bar is missing, and its threshold is what keeps out the bars that are not sessions at all: seven BSE series with bars on Saturdays through 2026, and two NSE series with Tuesday-only bars through the 2021-22 winter.

Intraday bars are held to the same days and additionally to the session grid, which starts at 09:15 India time.

Classes:

Name Description
TradingCalendar

Trading days per exchange, loaded once per exchange and kept.

Functions:

Name Description
on_grid

Whether a bar's timestamp sits where its interval's bars start.

TradingCalendar

TradingCalendar()

Trading days per exchange, loaded once per exchange and kept.

Attributes:

Name Type Description
days dict

Exchange to a set of datetime.date, filled on first use.

Build an empty calendar; exchanges are loaded when first asked for.

Returns:

Type Description
None

This function returns nothing.

Example programs

examples/stock_brokers/instruments/historical/utilities/unified/calendar/TradingCalendar/example_1_trading_days_from_stored_bars.py
"""Works out which days NSE traded from the bars already stored, including a Sunday budget session.

The unified price history keeps no holiday list. `TradingCalendar.load` reads two things from PostgreSQL instead: the dates on which Zerodha stored a daily bar for NSE's benchmark index, NIFTY 50, and how many Flattrade daily series have a bar on each date. A date counts when the index has a bar, or when Flattrade has bars for at least a tenth of the series it had on the busiest day of that month. The second rule catches a day whose index bar is missing, and its threshold keeps out a handful of stray bars on a day that was not a session.

`load` opens its own connection through `get_postgres`, and the example runner points PostgreSQL at a closed port, so this program replaces `get_postgres` in the calendar module with a stand-in that returns an in-memory connection. That connection answers the two queries with a fortnight of recorded-looking rows and counts the queries it is sent. What to notice: Sunday 2026-02-01, the Union Budget session, trades because the index has a bar; Tuesday 2026-02-03 trades although the index bar is missing, because 1,850 of the month's busiest 2,000 Flattrade series have one; Saturday 2026-01-31 does not, because only 7 series have a bar; and asking a second time sends no more queries, because the calendar keeps each exchange's days after the first read.

Run it from the project root:

    python examples/stock_brokers/instruments/historical/utilities/unified/calendar/TradingCalendar/example_1_trading_days_from_stored_bars.py
"""

import datetime

from stock_brokers.instruments.historical.utilities.unified import calendar as unified_calendar
from stock_brokers.instruments.historical.utilities.unified.calendar import (
    TradingCalendar,
)


class StandInCursor:
    """A stand-in for a psycopg2 cursor that answers the calendar's two queries.

    Attributes:
        connection (StandInConnection): The connection holding the canned rows.
        result (list): The rows the last statement produced.
    """

    def __init__(self, connection):
        """Builds a cursor over the stand-in connection.

        Args:
            connection (StandInConnection): The connection holding the canned rows.

        Returns:
            None: This method returns nothing.
        """
        self.connection = connection
        self.result = []

    def __enter__(self):
        """Opens the cursor as a context manager.

        Returns:
            StandInCursor: This cursor.
        """
        return self

    def __exit__(self, exception_type, exception, traceback):
        """Closes the cursor, letting any exception carry on.

        Args:
            exception_type (type | None): The class of an exception raised inside the block.
            exception (BaseException | None): The exception raised inside the block.
            traceback (types.TracebackType | None): Where it was raised.

        Returns:
            bool: Always False, so an exception is not swallowed.
        """
        return False

    def execute(self, statement, parameters):
        """Answers the benchmark index query or the Flattrade count query.

        Args:
            statement (str): The SQL text.
            parameters (tuple): The statement's parameters.

        Returns:
            None: This method returns nothing.
        """
        self.connection.queries += 1
        if 'zerodha.price_history' in statement:
            self.result = self.connection.index_rows
        else:
            self.result = self.connection.count_rows

    def fetchall(self):
        """Every row the last statement produced.

        Returns:
            list: The rows.
        """
        return self.result


class StandInConnection:
    """A stand-in for a psycopg2 connection holding a fortnight of NSE rows.

    Attributes:
        index_rows (list): One (date,) row per day NIFTY 50 has a daily bar.
        count_rows (list): (date, series count) rows for Flattrade's daily bars.
        queries (int): How many statements were executed.
        closed (bool): Whether `close` has been called.
    """

    def __init__(self):
        """Fills in the rows for late January and early February 2026.

        Returns:
            None: This method returns nothing.
        """
        self.index_rows = []
        index_days = [
            datetime.date(2026, 1, 27),
            datetime.date(2026, 1, 28),
            datetime.date(2026, 1, 29),
            datetime.date(2026, 1, 30),
            datetime.date(2026, 2, 1),
            datetime.date(2026, 2, 2),
            datetime.date(2026, 2, 4),
        ]
        for day in index_days:
            row = (
                day,
            )
            self.index_rows.append(row)
        self.count_rows = [
            (
                datetime.date(2026, 1, 30),
                1990,
            ),
            (
                datetime.date(2026, 1, 31),
                7,
            ),
            (
                datetime.date(2026, 2, 2),
                2000,
            ),
            (
                datetime.date(2026, 2, 3),
                1850,
            ),
        ]
        self.queries = 0
        self.closed = False

    def cursor(self):
        """Opens a stand-in cursor.

        Returns:
            StandInCursor: The cursor.
        """
        return StandInCursor(self)

    def close(self):
        """Marks the connection closed.

        Returns:
            None: This method returns nothing.
        """
        self.closed = True


class StandInPostgres:
    """Hands out the stand-in connection in the place of `get_postgres`.

    Attributes:
        connection (StandInConnection): The connection handed out.
    """

    def __init__(self, connection):
        """Remembers the connection to hand out.

        Args:
            connection (StandInConnection): The connection handed out.

        Returns:
            None: This method returns nothing.
        """
        self.connection = connection

    def get_postgres(self):
        """Returns the stand-in connection.

        Returns:
            StandInConnection: The connection.
        """
        return self.connection


class TradingDaysFromStoredBarsExample:
    """Reads NSE's trading days through the stand-in connection and asks about single dates.

    Attributes:
        connection (StandInConnection): The in-memory connection.
        calendar (TradingCalendar): The calendar being shown.
    """

    def __init__(self):
        """Installs the stand-in connection and builds an empty calendar.

        Returns:
            None: This method returns nothing.
        """
        self.connection = StandInConnection()
        unified_calendar.get_postgres = StandInPostgres(self.connection).get_postgres
        self.calendar = TradingCalendar()

    def ask(self, day):
        """Prints whether NSE traded on a date.

        Args:
            day (datetime.date): The date.

        Returns:
            None: This method returns nothing.
        """
        traded = self.calendar.is_trading_day('nse', day)
        print(f"{day} ({day.strftime('%A')}): traded {traded}")

    def run(self):
        """Prints the loaded days and a few single answers.

        Returns:
            None: This method returns nothing.
        """
        days = self.calendar.trading_days('nse')
        names = []
        for day in sorted(days):
            names.append(day.isoformat())
        print(f'NSE trading days: {names}')
        print(f'Queries after the first read: {self.connection.queries}, connection closed {self.connection.closed}')
        self.ask(datetime.date(2026, 1, 31))
        self.ask(datetime.date(2026, 2, 1))
        self.ask(datetime.date(2026, 2, 3))
        self.ask(datetime.date(2026, 2, 5))
        print(f'Queries after asking again: {self.connection.queries}')
        loaded_again = TradingCalendar.load('nse')
        print(f'Calling load directly reads afresh: {len(loaded_again)} days, queries now {self.connection.queries}')


if __name__ == '__main__':
    TradingDaysFromStoredBarsExample().run()

It prints:

NSE trading days: ['2026-01-27', '2026-01-28', '2026-01-29', '2026-01-30', '2026-02-01', '2026-02-02', '2026-02-03', '2026-02-04']
Queries after the first read: 2, connection closed True
2026-01-31 (Saturday): traded False
2026-02-01 (Sunday): traded True
2026-02-03 (Tuesday): traded True
2026-02-05 (Thursday): traded False
Queries after asking again: 2
Calling load directly reads afresh: 8 days, queries now 4
examples/stock_brokers/instruments/historical/utilities/unified/calendar/TradingCalendar/example_2_bse_saturday_bars_left_out.py
"""Shows the tenth-of-the-busiest-day rule on BSE, where a few series carry bars on Saturdays.

`TradingCalendar.load` counts a date for BSE when SENSEX has a Zerodha daily bar that day, or when Flattrade has daily bars for at least a tenth of the series it had on the busiest day of the same month. Through 2026 seven BSE series carry bars on Saturdays that were never sessions, and the threshold is what keeps them out. Because the threshold is taken per month, a thin month is judged against its own busiest day: in March 2020, when Flattrade's BSE history begins with only a few dozen series, a day with 8 series still counts.

`load` opens its own connection through `get_postgres`, and the example runner points PostgreSQL at a closed port, so this program replaces `get_postgres` in the calendar module with a stand-in that returns an in-memory connection. The stand-in answers the two queries with recorded-looking rows and records the parameters each was sent with, so the output shows that BSE is read through SENSEX's Zerodha token, 265, and Flattrade identifiers beginning `BSE|`. The program also shows that a calendar built by someone else can be shared: its `days` are filled once and every later question is answered from them.

Run it from the project root:

    python examples/stock_brokers/instruments/historical/utilities/unified/calendar/TradingCalendar/example_2_bse_saturday_bars_left_out.py
"""

import datetime

from stock_brokers.instruments.historical.utilities.unified import calendar as unified_calendar
from stock_brokers.instruments.historical.utilities.unified.calendar import (
    TradingCalendar,
)


class StandInCursor:
    """A stand-in for a psycopg2 cursor that answers the calendar's two queries.

    Attributes:
        connection (StandInConnection): The connection holding the canned rows.
        result (list): The rows the last statement produced.
    """

    def __init__(self, connection):
        """Builds a cursor over the stand-in connection.

        Args:
            connection (StandInConnection): The connection holding the canned rows.

        Returns:
            None: This method returns nothing.
        """
        self.connection = connection
        self.result = []

    def __enter__(self):
        """Opens the cursor as a context manager.

        Returns:
            StandInCursor: This cursor.
        """
        return self

    def __exit__(self, exception_type, exception, traceback):
        """Closes the cursor, letting any exception carry on.

        Args:
            exception_type (type | None): The class of an exception raised inside the block.
            exception (BaseException | None): The exception raised inside the block.
            traceback (types.TracebackType | None): Where it was raised.

        Returns:
            bool: Always False, so an exception is not swallowed.
        """
        return False

    def execute(self, statement, parameters):
        """Answers the benchmark index query or the Flattrade count query.

        Args:
            statement (str): The SQL text.
            parameters (tuple): The statement's parameters.

        Returns:
            None: This method returns nothing.
        """
        self.connection.queries += 1
        self.connection.parameters.append(parameters)
        if 'zerodha.price_history' in statement:
            self.result = self.connection.index_rows
        else:
            self.result = self.connection.count_rows

    def fetchall(self):
        """Every row the last statement produced.

        Returns:
            list: The rows.
        """
        return self.result


class StandInConnection:
    """A stand-in for a psycopg2 connection holding BSE rows from two months.

    Attributes:
        index_rows (list): One (date,) row per day SENSEX has a daily bar.
        count_rows (list): (date, series count) rows for Flattrade's daily bars.
        queries (int): How many statements were executed.
        parameters (list): The parameters each statement was sent with.
        closed (bool): Whether `close` has been called.
    """

    def __init__(self):
        """Fills in the rows for March 2020 and September 2026.

        Returns:
            None: This method returns nothing.
        """
        self.index_rows = []
        index_days = [
            datetime.date(2026, 9, 10),
            datetime.date(2026, 9, 11),
            datetime.date(2026, 9, 14),
        ]
        for day in index_days:
            row = (
                day,
            )
            self.index_rows.append(row)
        self.count_rows = []
        self.add_count(datetime.date(2020, 3, 2), 60)
        self.add_count(datetime.date(2020, 3, 3), 8)
        self.add_count(datetime.date(2020, 3, 7), 5)
        self.add_count(datetime.date(2026, 9, 11), 4100)
        self.add_count(datetime.date(2026, 9, 12), 7)
        self.add_count(datetime.date(2026, 9, 15), 4080)
        self.queries = 0
        self.parameters = []
        self.closed = False

    def add_count(self, day, series_count):
        """Adds one Flattrade count row.

        Args:
            day (datetime.date): The date.
            series_count (int): How many series have a daily bar that day.

        Returns:
            None: This method returns nothing.
        """
        row = (
            day,
            series_count,
        )
        self.count_rows.append(row)

    def cursor(self):
        """Opens a stand-in cursor.

        Returns:
            StandInCursor: The cursor.
        """
        return StandInCursor(self)

    def close(self):
        """Marks the connection closed.

        Returns:
            None: This method returns nothing.
        """
        self.closed = True


class StandInPostgres:
    """Hands out the stand-in connection in the place of `get_postgres`.

    Attributes:
        connection (StandInConnection): The connection handed out.
    """

    def __init__(self, connection):
        """Remembers the connection to hand out.

        Args:
            connection (StandInConnection): The connection handed out.

        Returns:
            None: This method returns nothing.
        """
        self.connection = connection

    def get_postgres(self):
        """Returns the stand-in connection.

        Returns:
            StandInConnection: The connection.
        """
        return self.connection


class BseSaturdayBarsExample:
    """Reads BSE's trading days through the stand-in connection and shares the calendar.

    Attributes:
        connection (StandInConnection): The in-memory connection.
        calendar (TradingCalendar): The calendar being shown.
    """

    def __init__(self):
        """Installs the stand-in connection and builds an empty calendar.

        Returns:
            None: This method returns nothing.
        """
        self.connection = StandInConnection()
        unified_calendar.get_postgres = StandInPostgres(self.connection).get_postgres
        self.calendar = TradingCalendar()

    def ask(self, calendar, day):
        """Prints whether BSE traded on a date, according to one calendar.

        Args:
            calendar (TradingCalendar): The calendar to ask.
            day (datetime.date): The date.

        Returns:
            None: This method returns nothing.
        """
        traded = calendar.is_trading_day('bse', day)
        print(f"{day} ({day.strftime('%A')}): traded {traded}")

    def run(self):
        """Prints the loaded days, the query parameters and answers from a shared calendar.

        Returns:
            None: This method returns nothing.
        """
        days = self.calendar.trading_days('bse')
        names = []
        for day in sorted(days):
            names.append(day.isoformat())
        print(f'BSE trading days: {names}')
        print(f'Query parameters: {self.connection.parameters}')
        self.ask(self.calendar, datetime.date(2020, 3, 3))
        self.ask(self.calendar, datetime.date(2020, 3, 7))
        self.ask(self.calendar, datetime.date(2026, 9, 12))
        self.ask(self.calendar, datetime.date(2026, 9, 15))
        shared = TradingCalendar()
        shared.days = self.calendar.days
        self.ask(shared, datetime.date(2026, 9, 14))
        print(f'Queries sent in all: {self.connection.queries}')

if __name__ == '__main__':
    BseSaturdayBarsExample().run()

It prints:

BSE trading days: ['2020-03-02', '2020-03-03', '2026-09-10', '2026-09-11', '2026-09-14', '2026-09-15']
Query parameters: [('265',), ('BSE|%',)]
2020-03-03 (Tuesday): traded True
2020-03-07 (Saturday): traded False
2026-09-12 (Saturday): traded False
2026-09-15 (Tuesday): traded True
2026-09-14 (Monday): traded True
Queries sent in all: 2

Methods:

Name Description
trading_days

Every date an exchange traded on, as far back as the stored bars go.

is_trading_day

Whether an exchange traded on a date.

load

Read an exchange's trading days from the stored bars.

Source code in stock_brokers/instruments/historical/utilities/unified/calendar.py
def __init__(self):
    """
    Build an empty calendar; exchanges are loaded when first asked for.

    Returns:
        None: This function returns nothing.
    """
    self.days = {}

trading_days

trading_days(exchange)

Every date an exchange traded on, as far back as the stored bars go.

Parameters:

Name Type Description Default
exchange str

The canonical exchange, "nse" or "bse".

required

Returns:

Type Description
set[date]

The trading dates.

Example usage

From example_1_trading_days_from_stored_bars.py
days = self.calendar.trading_days('nse')
From example_2_bse_saturday_bars_left_out.py
days = self.calendar.trading_days('bse')
Source code in stock_brokers/instruments/historical/utilities/unified/calendar.py
def trading_days(self, exchange):
    """
    Every date an exchange traded on, as far back as the stored bars go.

    Args:
        exchange (str): The canonical exchange, "nse" or "bse".

    Returns:
        set[datetime.date]: The trading dates.
    """
    if exchange not in self.days:
        self.days[exchange] = self.load(exchange)
    return self.days[exchange]

is_trading_day

is_trading_day(exchange, day)

Whether an exchange traded on a date.

Parameters:

Name Type Description Default
exchange str

The canonical exchange.

required
day date

The date.

required

Returns:

Type Description
bool

True if it traded.

Example usage

From example_1_trading_days_from_stored_bars.py
traded = self.calendar.is_trading_day('nse', day)
From example_2_bse_saturday_bars_left_out.py
traded = calendar.is_trading_day('bse', day)
Source code in stock_brokers/instruments/historical/utilities/unified/calendar.py
def is_trading_day(self, exchange, day):
    """
    Whether an exchange traded on a date.

    Args:
        exchange (str): The canonical exchange.
        day (datetime.date): The date.

    Returns:
        bool: True if it traded.
    """
    return day in self.trading_days(exchange)

load staticmethod

load(exchange)

Read an exchange's trading days from the stored bars.

Parameters:

Name Type Description Default
exchange str

The canonical exchange, "nse" or "bse".

required

Returns:

Type Description
set[date]

The trading dates.

Example usage

From example_1_trading_days_from_stored_bars.py
loaded_again = TradingCalendar.load('nse')
Source code in stock_brokers/instruments/historical/utilities/unified/calendar.py
@staticmethod
def load(exchange):
    """
    Read an exchange's trading days from the stored bars.

    Args:
        exchange (str): The canonical exchange, "nse" or "bse".

    Returns:
        set[datetime.date]: The trading dates.
    """
    days = set()
    connection = get_postgres()
    try:
        with connection.cursor() as cursor:
            cursor.execute(
                "SELECT DISTINCT (\"time\" AT TIME ZONE 'Asia/Kolkata')::date "
                "FROM zerodha.price_history WHERE instrument_token = %s AND \"interval\" = 'day'",
                (BENCHMARK_TOKENS[exchange],))
            for (day,) in cursor.fetchall():
                days.add(day)

            cursor.execute(
                "SELECT (\"time\" AT TIME ZONE 'Asia/Kolkata')::date AS day, count(*) "
                "FROM flattrade.price_history "
                "WHERE \"interval\" = 'day' AND instrument_token LIKE %s "
                "GROUP BY 1",
                (exchange.upper() + "|%",))
            counts = dict(cursor.fetchall())
    finally:
        connection.close()

    busiest = {}
    for day, count in counts.items():
        month = (day.year, day.month)
        busiest[month] = max(busiest.get(month, 0), count)
    for day, count in counts.items():
        if count >= MINIMUM_SHARE_OF_MONTH * busiest[(day.year, day.month)]:
            days.add(day)
    return days

on_grid

on_grid(moment, interval)

Whether a bar's timestamp sits where its interval's bars start.

Daily bars sit at midnight India time. Intraday bars start at 09:15 India time and every interval's length after it.

Parameters:

Name Type Description Default
moment datetime

The bar's timezone aware timestamp.

required
interval str

The stored interval name.

required

Returns:

Type Description
bool

True if the bar is on the grid.

Source code in stock_brokers/instruments/historical/utilities/unified/calendar.py
def on_grid(moment, interval):
    """
    Whether a bar's timestamp sits where its interval's bars start.

    Daily bars sit at midnight India time. Intraday bars start at 09:15 India time and every
    interval's length after it.

    Args:
        moment (datetime.datetime): The bar's timezone aware timestamp.
        interval (str): The stored interval name.

    Returns:
        bool: True if the bar is on the grid.
    """
    local = moment.astimezone(INDIA_TIMEZONE)
    if interval == "day":
        return local.hour == 0 and local.minute == 0 and local.second == 0
    minutes = INTRADAY_MINUTES.get(interval)
    if minutes is None:
        return False
    since_open = (local.hour * 60 + local.minute) - (SESSION_START.hour * 60 + SESSION_START.minute)
    return local.second == 0 and since_open >= 0 and since_open % minutes == 0