Skip to content

fyers

Fyers historical candle download.

Fyers is addressed by ticker rather than by token: the request carries NSE:RELIANCE-EQ, not the fytoken the instrument master is keyed on, so the ticker is what the progress table stores.

Four things were settled against the live API on 2026-09-12 rather than taken from the documentation, which is a JavaScript application rather than a page.

The endpoint answers at api-t1.fyers.in/data/history, and its authorization header is the application id joined to the access token by a colon with no Bearer prefix - which is what this project's FyersAPI already sends.

The range caps are stated in the refusal: one hundred days for every intraday resolution, three hundred and sixty six for 1D, 1W and 1M. They are refusals, not truncations.

Daily history reaches 2000: RELIANCE answers for every year from 2000 and no_data for 1995.

An empty window comes back as HTTP 200 with s holding no_data, so emptiness has to be read from that field rather than from the status line. A candle row is six elements for cash and seven for a derivative, the seventh being open interest, so it is read by length.

Fyers stamps a daily bar at midnight UTC where Kite stamps midnight India time - the same trading day, five and a half hours apart - which is why day, week and month bars go through daily_bar_time before they are stored.

Classes:

Name Description
FyersCandles

Downloads Fyers' historical candles.

FyersCandles

FyersCandles(api=None)

Bases: BrokerCandles

Downloads Fyers' historical candles.

Fyers candle downloader.

  • api is an authenticated FyersAPI. One is constructed when not supplied, which establishes a session if the stored one has expired.

Example programs

examples/stock_brokers/instruments/historical/fyers/FyersCandles/example_1_day_bars_stamped_in_utc.py
"""Fetches two windows of RELIANCE day bars from Fyers, one with bars and one Fyers reports as `no_data`.

Fyers is addressed by ticker, so the request carries `NSE:RELIANCE-EQ` together with a resolution code (`1D` for the stored `day`), and it always asks for open interest and continuous data. Fyers stamps a day bar at midnight UTC where Kite stamps midnight India time, which is the same trading day five and a half hours apart. `parse_response` moves every day bar onto midnight India time, so the bar stamped 2026-08-03 00:00 UTC is stored as 2026-08-03 00:00 India time. An empty window comes back as HTTP 200 with `s` holding `no_data`, and is read as no bars rather than an error.

A `FyersCandles` constructor opens a PostgreSQL connection and logs in to Fyers when the stored session has expired, so the program builds the object without running its constructor, as `test_runs/candle_parse.py` does, and gives it a stand-in for `FyersAPI`. The stand-in records each request and answers with a body shaped like Fyers' recorded answers, wrapped in the `status_code` and `data` envelope the real API class returns.

Run it from the project root:

    python examples/stock_brokers/instruments/historical/fyers/FyersCandles/example_1_day_bars_stamped_in_utc.py
"""

import datetime

from stock_brokers.instruments.historical.fyers import (
    FyersCandles,
)


class RecordedFyersAPI:
    """A stand-in for `FyersAPI` that answers a window in 2026 with two day bars and any older window with `no_data`.

    Attributes:
        requests (list): The (url, parameters) pairs it was asked for.
    """

    def __init__(self):
        """Builds the stand-in with no requests recorded.

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

    def get(self, url, params=None):
        """Records a GET request and answers it.

        Args:
            url (str): The URL the downloader asked for.
            params (dict): The query parameters it sent.

        Returns:
            dict: The response envelope, with Fyers' body under `data`.
        """
        self.requests.append((url, params))
        if params['range_from'] < '2000-01-01':
            body = {
                's': 'no_data',
                'candles': [],
            }
        else:
            body = {
                's': 'ok',
                'candles': [
                    [
                        1785715200,
                        1391.0,
                        1399.9,
                        1386.2,
                        1394.4,
                        6412337,
                    ],
                    [
                        1785801600,
                        1394.4,
                        1402.0,
                        1388.0,
                        1398.1,
                        5530124,
                    ],
                ],
            }
        return {
            'status_code': 200,
            'data': body,
        }


class DayBarsStampedInUtcExample:
    """Fetches and parses a window with bars and a window with none.

    Attributes:
        api (RecordedFyersAPI): The stand-in Fyers API.
        candles (FyersCandles): The downloader being shown.
    """

    def __init__(self):
        """Builds the downloader around the stand-in API without opening a database connection.

        Returns:
            None: This method returns nothing.
        """
        self.api = RecordedFyersAPI()
        self.candles = object.__new__(FyersCandles)
        self.candles._api = self.api

    def fetch_and_print(self, start_date, end_date):
        """Fetches one window of RELIANCE day bars and prints the request and the bars.

        Args:
            start_date (datetime.date): The first day of the window.
            end_date (datetime.date): The last day of the window.

        Returns:
            None: This method returns nothing.
        """
        payload = self.candles.fetch_candles('NSE:RELIANCE-EQ', 'day', start_date, end_date)
        url, parameters = self.api.requests[-1]
        print(f'Requested {url}')
        print(f'  parameters: {parameters}')
        print(f'  status: {payload["s"]}')
        bars = self.candles.parse_response(payload, 'day')
        print(f'  bars: {len(bars)}')
        for index, bar in enumerate(bars):
            bar_time, open_price, high, low, close, volume, open_interest = bar
            sent_epoch = payload['candles'][index][0]
            sent_time = datetime.datetime.fromtimestamp(sent_epoch, datetime.timezone.utc)
            print(f'  sent {sent_time.isoformat()}, stored {bar_time.isoformat()}: open {open_price} high {high} low {low} close {close} volume {volume} oi {open_interest}')

    def run(self):
        """Fetches the window with bars, then the empty one.

        Returns:
            None: This method returns nothing.
        """
        self.fetch_and_print(datetime.date(2026, 8, 1), datetime.date(2026, 8, 4))
        self.fetch_and_print(datetime.date(1995, 1, 1), datetime.date(1995, 12, 31))


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

It prints:

Requested https://api-t1.fyers.in/data/history
  parameters: {'symbol': 'NSE:RELIANCE-EQ', 'resolution': '1D', 'date_format': '1', 'range_from': '2026-08-01', 'range_to': '2026-08-04', 'cont_flag': '1', 'oi_flag': '1'}
  status: ok
  bars: 2
  sent 2026-08-03T00:00:00+00:00, stored 2026-08-03T00:00:00+05:30: open 1391.0 high 1399.9 low 1386.2 close 1394.4 volume 6412337 oi None
  sent 2026-08-04T00:00:00+00:00, stored 2026-08-04T00:00:00+05:30: open 1394.4 high 1402.0 low 1388.0 close 1398.1 volume 5530124 oi None
Requested https://api-t1.fyers.in/data/history
  parameters: {'symbol': 'NSE:RELIANCE-EQ', 'resolution': '1D', 'date_format': '1', 'range_from': '1995-01-01', 'range_to': '1995-12-31', 'cont_flag': '1', 'oi_flag': '1'}
  status: no_data
  bars: 0
examples/stock_brokers/instruments/historical/fyers/FyersCandles/example_2_tickers_and_refusals.py
"""Lists Fyers tickers with the kind and expiry the queue needs, reads what each ticker says, and sorts Fyers' refusals into the downloader's error kinds.

Fyers names an instrument's kind with a number that is not stable across segments, and its expiry is epoch seconds, which the queue's priority cannot read. `instruments` therefore reads the kind from the ticker's ending (`FUT` for a future, `CE` or `PE` for an option) and turns the expiry into a date. The program gives the downloader a stand-in database connection whose cursor answers with four tickers. `series_context`, a classmethod that needs no downloader, says the series is matched on the ticker as `order_symbol`, with the exchange and segments the ticker implies.

The second half fetches through a stand-in for `FyersAPI` that raises the `FyersAPIException` each of five refusals produces. An invalid symbol (-300) retires the series, code -16 means the session was refused, Cloudflare's error 1015 page is a block that must stop the broker, an ordinary 429 is a throttle, and -50, Fyers' catch-all input error, is left as it is, because it means this module asked for too wide a window rather than that the instrument is dead. The downloader is built without its constructor, as in `test_runs/candle_parse.py`, so nothing connects to PostgreSQL or logs in.

Run it from the project root:

    python examples/stock_brokers/instruments/historical/fyers/FyersCandles/example_2_tickers_and_refusals.py
"""

import datetime

from stock_brokers.api.fyers import (
    FyersAPIException,
)
from stock_brokers.instruments.historical.base import (
    CandleError,
)
from stock_brokers.instruments.historical.fyers import (
    FyersCandles,
)


class TickerCursor:
    """A stand-in database cursor that answers the instrument master query with fixed rows.

    Attributes:
        rows (list): The rows `fetchall` returns.
    """

    def __init__(self, rows):
        """Holds the rows to answer with.

        Args:
            rows (list): Tuples of (ticker, expiry in epoch seconds as text).

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

    def __enter__(self):
        """Enters the `with` block.

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

    def __exit__(self, exception_type, exception, traceback):
        """Leaves the `with` block without suppressing an exception.

        Args:
            exception_type (type | None): The exception's class, if one was raised.
            exception (BaseException | None): The exception, if one was raised.
            traceback (types.TracebackType | None): Its traceback, if one was raised.

        Returns:
            bool: Always False.
        """
        return False

    def execute(self, query):
        """Accepts the query without running it.

        Args:
            query (str): The SQL the downloader sent.

        Returns:
            None: This method returns nothing.
        """

    def fetchall(self):
        """Returns the fixed rows.

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


class TickerConnection:
    """A stand-in PostgreSQL connection over four rows of Fyers' instrument master.

    Attributes:
        rows (list): The rows every cursor answers with.
    """

    def __init__(self):
        """Builds the connection with four tickers.

        Returns:
            None: This method returns nothing.
        """
        self.rows = [
            (
                'NSE:RELIANCE-EQ',
                '0',
            ),
            (
                'NSE:NIFTY50-INDEX',
                '',
            ),
            (
                'NSE:RELIANCE26SEPFUT',
                '1790676000',
            ),
            (
                'NSE:NIFTY25D3026000CE',
                '1767088800',
            ),
        ]

    def cursor(self):
        """Opens a cursor.

        Returns:
            TickerCursor: A cursor over the rows.
        """
        return TickerCursor(self.rows)

    def commit(self):
        """Accepts a commit.

        Returns:
            None: This method returns nothing.
        """


class RefusingFyersAPI:
    """A stand-in for `FyersAPI` that refuses with a chosen Fyers error.

    Attributes:
        code (int): The code the refusal carries.
        message (str): The message the refusal carries.
    """

    def __init__(self, code, message):
        """Holds the refusal to raise.

        Args:
            code (int): Fyers' code, or the HTTP status for a page that is not Fyers' JSON.
            message (str): The message.

        Returns:
            None: This method returns nothing.
        """
        self.code = code
        self.message = message

    def get(self, url, params=None):
        """Refuses a GET request.

        Args:
            url (str): The URL the downloader asked for.
            params (dict): The query parameters it sent.

        Returns:
            dict: Never returns.

        Raises:
            FyersAPIException: Always, carrying the chosen code and message.
        """
        raise FyersAPIException(code=self.code, message=self.message)


class TickersAndRefusalsExample:
    """Lists tickers, reads them, and classifies five refusals.

    Attributes:
        candles (FyersCandles): The downloader being shown.
    """

    def __init__(self):
        """Builds the downloader around the stand-in connection without running its constructor.

        Returns:
            None: This method returns nothing.
        """
        self.candles = object.__new__(FyersCandles)
        self.candles._connection = TickerConnection()

    def show_tickers(self):
        """Lists the instruments and reads each ticker.

        Returns:
            None: This method returns nothing.
        """
        for ticker, kind, expiry in self.candles.instruments():
            context = FyersCandles.series_context(ticker)
            print(f'{ticker}: kind {kind}, expiry {expiry}, matched on {context.match_on}, exchange {context.exchange}, {len(context.segments)} segments to search')

    def show_refusals(self):
        """Fetches through five refusing stand-ins and prints what each became.

        Returns:
            None: This method returns nothing.
        """
        refusals = [
            (
                -300,
                'Invalid symbol provided',
            ),
            (
                -16,
                'Could not authenticate the user',
            ),
            (
                429,
                'error code: 1015 - the owner of this website has banned you temporarily',
            ),
            (
                429,
                'request limit reached',
            ),
            (
                -50,
                'Date range cannot exceed 366 days for 1D, 1W and 1M',
            ),
        ]
        for code, message in refusals:
            self.candles._api = RefusingFyersAPI(code, message)
            try:
                self.candles.fetch_candles(
                    'NSE:RELIANCE-EQ',
                    'day',
                    datetime.date(2025, 1, 1),
                    datetime.date(2026, 9, 11),
                )
                outcome = 'answered'
            except CandleError as error:
                outcome = type(error).__name__
            except FyersAPIException as error:
                outcome = f'left as {type(error).__name__}'
            print(f'{code} "{message}": {outcome}')

    def run(self):
        """Runs both parts in turn.

        Returns:
            None: This method returns nothing.
        """
        self.show_tickers()
        self.show_refusals()


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

It prints:

NSE:RELIANCE-EQ: kind EQ, expiry None, matched on order_symbol, exchange nse, 6 segments to search
NSE:NIFTY50-INDEX: kind EQ, expiry None, matched on order_symbol, exchange nse, 4 segments to search
NSE:RELIANCE26SEPFUT: kind FUT, expiry 2026-09-29, matched on order_symbol, exchange nse, 16 segments to search
NSE:NIFTY25D3026000CE: kind OPT, expiry 2025-12-30, matched on order_symbol, exchange nse, 16 segments to search
-300 "Invalid symbol provided": CandleInstrumentUnknown
-16 "Could not authenticate the user": CandleAuthenticationError
429 "error code: 1015 - the owner of this website has banned you temporarily": CandleBlocked
429 "request limit reached": CandleThrottled
-50 "Date range cannot exceed 366 days for 1D, 1W and 1M": left as FyersAPIException
examples/stock_brokers/instruments/historical/fyers/FyersCandles/example_3_trading_hours_pause.py
"""Shows when the Fyers candle download pauses for trading hours, and that a stop request ends the pause at once.

Every Fyers request counts toward the app's 200 a minute, shared with the pollers and orders, so the history download stays out of trading hours: 09:00 to 23:55 India time, Monday to Friday, from the equity pre-open to the latest close of MCX's evening session. Weekends are free all day, and `daily_cap` raises the cap there from 45,000 requests to 100,000. `seconds_until_trading_ends` says how long the pause lasts from a given moment, and `claim` waits it out before claiming a series, on the stop event `run` remembers, so `systemctl stop` does not have to wait. This program builds the downloader without logging in or opening a database, which is enough for these two methods.

Notice that 08:59 and 23:55 on a Wednesday, and noon on a Saturday, have no pause, and that the stopped claim returns None within a fraction of a second.

Run it from the project root:

    python examples/stock_brokers/instruments/historical/fyers/FyersCandles/example_3_trading_hours_pause.py
"""

import datetime
import logging
import threading
import time

from stock_brokers.instruments.historical.base import INDIA_TIMEZONE
from stock_brokers.instruments.historical.fyers import FyersCandles


class ClockedFyersCandles(FyersCandles):
    """The Fyers downloader built without a session or a database, with its clock fixed at noon on Wednesday 2026-09-30."""

    def __init__(self):
        """Builds the downloader with only a logger.

        Returns:
            None: This method returns nothing.
        """
        self._logger = logging.getLogger('example')
        self._logger.addHandler(logging.NullHandler())
        self._logger.propagate = False

    def seconds_until_trading_ends(self, now=None):
        """The pause from noon on Wednesday 2026-09-30, whatever the real time.

        Args:
            now (datetime.datetime | None): Ignored.

        Returns:
            float: The seconds until 23:55.
        """
        del now
        noon = datetime.datetime(2026, 9, 30, 12, 0, tzinfo=INDIA_TIMEZONE)
        return super().seconds_until_trading_ends(noon)


class TradingHoursPauseExample:
    """Prints the pause at several moments and stops a paused claim.

    Attributes:
        candles (ClockedFyersCandles): The downloader.
    """

    def __init__(self):
        """Builds the downloader.

        Returns:
            None: This method returns nothing.
        """
        self.candles = ClockedFyersCandles()

    def run(self):
        """Prints the pause at six moments, then stops a run whose claim is paused.

        Returns:
            None: This method returns nothing.
        """
        moments = [
            ('Wednesday 08:59', datetime.datetime(2026, 9, 30, 8, 59, tzinfo=INDIA_TIMEZONE)),
            ('Wednesday 09:00', datetime.datetime(2026, 9, 30, 9, 0, tzinfo=INDIA_TIMEZONE)),
            ('Wednesday 15:30', datetime.datetime(2026, 9, 30, 15, 30, tzinfo=INDIA_TIMEZONE)),
            ('Wednesday 23:55', datetime.datetime(2026, 9, 30, 23, 55, tzinfo=INDIA_TIMEZONE)),
            ('Saturday 12:00', datetime.datetime(2026, 10, 3, 12, 0, tzinfo=INDIA_TIMEZONE)),
            ('Sunday 20:00', datetime.datetime(2026, 10, 4, 20, 0, tzinfo=INDIA_TIMEZONE)),
        ]
        for label, moment in moments:
            pause = FyersCandles.seconds_until_trading_ends(self.candles, moment)
            print(f'{label}: pause for {pause / 3600:.2f} hours')
        for label, moment in [moments[2], moments[4]]:
            print(f'Daily cap on {label}: {self.candles.daily_cap(moment)}')
        stop_event = threading.Event()
        stop_event.set()
        visits, bars = self.candles.run(stop_event=stop_event)
        print(f'A run asked to stop before it starts: {visits} windows, {bars} bars')
        stop_event.clear()
        threading.Timer(0.2, stop_event.set).start()
        started = time.monotonic()
        claimed = self.candles.claim()
        waited = time.monotonic() - started
        print(f'A claim at noon, stopped after 0.2 s: {claimed}, back within a second: {waited < 1}')


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

It prints:

Wednesday 08:59: pause for 0.00 hours
Wednesday 09:00: pause for 14.92 hours
Wednesday 15:30: pause for 8.42 hours
Wednesday 23:55: pause for 0.00 hours
Saturday 12:00: pause for 0.00 hours
Sunday 20:00: pause for 0.00 hours
Daily cap on Wednesday 15:30: 45000
Daily cap on Saturday 12:00: 100000
A run asked to stop before it starts: 0 windows, 0 bars
A claim at noon, stopped after 0.2 s: None, back within a second: True

Methods:

Name Description
run

Work the queue as the base class does, remembering the stop event so the trading-hours pause can end at once when the process is asked to stop.

seconds_until_trading_ends

How long until the markets stop trading, or zero outside trading hours.

daily_cap

The daily request cap for a day: REQUESTS_PER_DAY on a trading day, and WEEKEND_REQUESTS_PER_DAY at the weekend, when the download runs all day, as the user asked on 2026-09-30.

claim

Claim the next series, first waiting out trading hours without holding any series, and apply the day's cap.

instruments

Every ticker Fyers has ever published, with the fields the queue's priority needs.

series_context

Read the exchange and segment out of a Fyers ticker.

fetch_candles

Fetch one window of Fyers candles.

parse_response

Turn Fyers' candle list into bars.

priority_for

Where a series sits in the queue. Lower is worked first.

seed

Make sure every instrument and interval this broker serves has a progress row.

next_window

The date range to ask for next, as a (start, end, direction) triple.

relogin

Establish a fresh session and rebuild everything built on the old one.

after_relogin

Re-establish anything a downloader keeps on top of the broker session. Nothing by default.

visit

Fetch one window for one series and store it, advancing the watermark.

summary

How far the download has got, for a status display.

close

Close the database connection.

Source code in stock_brokers/instruments/historical/fyers.py
def __init__(self, api=None):
    """
    Fyers candle downloader.

    - `api` is an authenticated `FyersAPI`. One is constructed when not supplied, which
      establishes a session if the stored one has expired.
    """
    super().__init__()
    if api is None:
        from stock_brokers.api.utilities.session import ensure_session
        from stock_brokers.api.fyers import FyersAPI

        # Through ensure_session, so this shares the Redis lock and the login rate limiter
        # with any other process using ensure_session. A backfill runs for weeks and will be
        # alive across a token expiry.
        ensure_session(self.BROKER_NAME, logger=self._logger)
        api = FyersAPI()
    self._api = api

run

run(stop_event=None, deadline_seconds=None)

Work the queue as the base class does, remembering the stop event so the trading-hours pause can end at once when the process is asked to stop.

  • stop_event is a threading.Event that ends the loop when set.
  • deadline_seconds ends the loop after this long.

Example usage

From example_3_trading_hours_pause.py
visits, bars = self.candles.run(stop_event=stop_event)
Source code in stock_brokers/instruments/historical/fyers.py
def run(self, stop_event=None, deadline_seconds=None):
    """
    Work the queue as the base class does, remembering the stop event so the trading-hours pause can end at once when the process is asked to stop.

    - `stop_event` is a `threading.Event` that ends the loop when set.
    - `deadline_seconds` ends the loop after this long.
    """
    self._stop_event = stop_event
    return super().run(stop_event=stop_event, deadline_seconds=deadline_seconds)

seconds_until_trading_ends

seconds_until_trading_ends(now=None)

How long until the markets stop trading, or zero outside trading hours.

Trading hours are 09:00 to 23:55 India time, Monday to Friday: from the equity pre-open to the latest close of MCX's evening session. The download stays out of them so its requests never compete with the pollers and orders for Fyers' 200 a minute. Exchange holidays are treated as trading days.

  • now is the moment to reckon from, or None for now in India.

Example usage

From example_3_trading_hours_pause.py
return super().seconds_until_trading_ends(noon)
From example_3_trading_hours_pause.py
pause = FyersCandles.seconds_until_trading_ends(self.candles, moment)
Source code in stock_brokers/instruments/historical/fyers.py
def seconds_until_trading_ends(self, now=None):
    """
    How long until the markets stop trading, or zero outside trading hours.

    Trading hours are 09:00 to 23:55 India time, Monday to Friday: from the equity pre-open to the latest close of MCX's evening session. The download stays out of them so its requests never compete with the pollers and orders for Fyers' 200 a minute. Exchange holidays are treated as trading days.

    - `now` is the moment to reckon from, or None for now in India.
    """
    now = now or datetime.datetime.now(INDIA_TIMEZONE)
    if now.weekday() not in self.TRADING_DAYS:
        return 0
    opens = now.replace(hour=self.TRADING_OPENS.hour, minute=self.TRADING_OPENS.minute, second=0, microsecond=0)
    closes = now.replace(hour=self.TRADING_CLOSES.hour, minute=self.TRADING_CLOSES.minute, second=0, microsecond=0)
    if now < opens or now >= closes:
        return 0
    return (closes - now).total_seconds()

daily_cap

daily_cap(now=None)

The daily request cap for a day: REQUESTS_PER_DAY on a trading day, and WEEKEND_REQUESTS_PER_DAY at the weekend, when the download runs all day, as the user asked on 2026-09-30.

  • now is the moment to reckon from, or None for now in India.

Example usage

From example_3_trading_hours_pause.py
print(f'Daily cap on {label}: {self.candles.daily_cap(moment)}')
Source code in stock_brokers/instruments/historical/fyers.py
def daily_cap(self, now=None):
    """
    The daily request cap for a day: `REQUESTS_PER_DAY` on a trading day, and `WEEKEND_REQUESTS_PER_DAY` at the weekend, when the download runs all day, as the user asked on 2026-09-30.

    - `now` is the moment to reckon from, or None for now in India.
    """
    now = now or datetime.datetime.now(INDIA_TIMEZONE)
    if now.weekday() in self.TRADING_DAYS:
        return self.REQUESTS_PER_DAY
    return self.WEEKEND_REQUESTS_PER_DAY

claim

claim()

Claim the next series, first waiting out trading hours without holding any series, and apply the day's cap.

Returns None, which ends the run, when the process is asked to stop during the wait.

Example usage

From example_3_trading_hours_pause.py
claimed = self.candles.claim()
Source code in stock_brokers/instruments/historical/fyers.py
def claim(self):
    """
    Claim the next series, first waiting out trading hours without holding any series, and apply the day's cap.

    Returns None, which ends the run, when the process is asked to stop during the wait.
    """
    waiting = self.seconds_until_trading_ends()
    if waiting > 0:
        self._logger.info(f"Trading hours: pausing the download for {waiting / 3600:.1f} hours, until "
                          f"{self.TRADING_CLOSES.strftime('%H:%M')} IST.")
    while waiting > 0:
        stop_event = getattr(self, "_stop_event", None)
        pause = min(waiting, self.PAUSE_CHECK_SECONDS)
        if stop_event is not None:
            if stop_event.wait(pause):
                return None
        else:
            time.sleep(pause)
        waiting = self.seconds_until_trading_ends()
    self._limiter.set_requests_per_day(self.daily_cap())
    return super().claim()

instruments

instruments()

Every ticker Fyers has ever published, with the fields the queue's priority needs.

Two translations happen here rather than in priority_for, because both are Fyers' vocabulary rather than this project's.

Fyers names an instrument's kind with a number - 13 is a stock future, 15 a stock option - and the numbers are not stable enough across segments to be worth a lookup table when the ticker already says it plainly: it ends in FUT for a future and CE or PE for an option. And its expiry is epoch seconds rather than a date, which priority_for cannot read, so it is converted here. Without the conversion every expired contract would be queued as though it were live, ahead of cash intraday bars that are actually wanted.

Example usage

From example_2_tickers_and_refusals.py
self.candles.instruments()
Source code in stock_brokers/instruments/historical/fyers.py
def instruments(self):
    """
    Every ticker Fyers has ever published, with the fields the queue's priority needs.

    Two translations happen here rather than in `priority_for`, because both are Fyers'
    vocabulary rather than this project's.

    Fyers names an instrument's kind with a number - 13 is a stock future, 15 a stock option -
    and the numbers are not stable enough across segments to be worth a lookup table when the
    ticker already says it plainly: it ends in `FUT` for a future and `CE` or `PE` for an
    option. And its expiry is epoch seconds rather than a date, which `priority_for` cannot
    read, so it is converted here. Without the conversion every expired contract would be
    queued as though it were live, ahead of cash intraday bars that are actually wanted.
    """
    query = f"""
        select distinct on (i."{self.TOKEN_COLUMN}")
               i."{self.TOKEN_COLUMN}", i."{self.EXPIRY_COLUMN}"
        from {self.BROKER_NAME}.instruments i
        where i."{self.TOKEN_COLUMN}" is not null and i."{self.TOKEN_COLUMN}" <> ''
        order by i."{self.TOKEN_COLUMN}", i.download_date desc
    """
    with self._connection.cursor() as cursor:
        cursor.execute(query)
        rows = cursor.fetchall()
    self._connection.commit()

    instruments = []
    for ticker, expiry in rows:
        upper = str(ticker).upper()
        if upper.endswith("FUT"):
            kind = "FUT"
        elif upper.endswith("CE") or upper.endswith("PE"):
            kind = "OPT"
        else:
            kind = "EQ"
        instruments.append((ticker, kind, self._expiry_date(expiry)))
    return instruments

_expiry_date staticmethod

_expiry_date(expiry)

Turn Fyers' epoch expiry into a date, or None when the instrument does not expire.

  • expiry is the expiry_date column, epoch seconds as text.
Source code in stock_brokers/instruments/historical/fyers.py
@staticmethod
def _expiry_date(expiry):
    """
    Turn Fyers' epoch expiry into a date, or None when the instrument does not expire.

    - `expiry` is the `expiry_date` column, epoch seconds as text.
    """
    try:
        seconds = int(expiry)
    except (TypeError, ValueError):
        return None
    if seconds <= 0:
        return None
    return datetime.datetime.fromtimestamp(seconds, datetime.timezone.utc).date()

series_context classmethod

series_context(identifier)

Read the exchange and segment out of a Fyers ticker.

The price table names an instrument by its ticker, while the mapping keys Fyers by its numeric fytoken. The ticker is kept in the mapping as order_symbol, so that is what the series is matched on.

Parameters:

Name Type Description Default
identifier str

For example "NSE:RELIANCE-EQ" or "NSE:NIFTY50-INDEX".

required

Returns:

Type Description
SeriesContext

The ticker, to be matched on order_symbol, with its exchange and segments.

Example usage

From example_2_tickers_and_refusals.py
context = FyersCandles.series_context(ticker)
Source code in stock_brokers/instruments/historical/fyers.py
@classmethod
def series_context(cls, identifier):
    """
    Read the exchange and segment out of a Fyers ticker.

    The price table names an instrument by its ticker, while the mapping keys Fyers by its
    numeric fytoken. The ticker is kept in the mapping as `order_symbol`, so that is what the
    series is matched on.

    Args:
        identifier (str): For example "NSE:RELIANCE-EQ" or "NSE:NIFTY50-INDEX".

    Returns:
        SeriesContext: The ticker, to be matched on `order_symbol`, with its exchange and segments.
    """
    ticker = str(identifier)
    exchange = cls.EXCHANGES.get(ticker.split(":", 1)[0])
    if exchange is None:
        return SeriesContext(ticker, match_on="order_symbol")
    upper = ticker.upper()
    if upper.endswith("-INDEX"):
        family = INDEX_SEGMENTS
    elif upper.endswith(("FUT", "CE", "PE")):
        family = DERIVATIVE_SEGMENTS
    else:
        family = CASH_SEGMENTS
    return SeriesContext(ticker, exchange, segments_of(exchange, family), "order_symbol")

fetch_candles

fetch_candles(token, interval, start_date, end_date)

Fetch one window of Fyers candles.

  • token is the Fyers ticker, such as NSE:RELIANCE-EQ.
  • interval is the stored interval name.
  • start_date and end_date bound the window, inclusive.

Example usage

From example_1_day_bars_stamped_in_utc.py
payload = self.candles.fetch_candles('NSE:RELIANCE-EQ', 'day', start_date, end_date)
From example_2_tickers_and_refusals.py
self.candles.fetch_candles(
    'NSE:RELIANCE-EQ',
    'day',
    datetime.date(2025, 1, 1),
    datetime.date(2026, 9, 11),
)
Source code in stock_brokers/instruments/historical/fyers.py
def fetch_candles(self, token, interval, start_date, end_date):
    """
    Fetch one window of Fyers candles.

    - `token` is the Fyers ticker, such as `NSE:RELIANCE-EQ`.
    - `interval` is the stored interval name.
    - `start_date` and `end_date` bound the window, inclusive.
    """
    parameters = {"symbol": token, "resolution": self.INTERVALS[interval], "date_format": "1",
                  "range_from": start_date.isoformat(), "range_to": end_date.isoformat(),
                  "cont_flag": "1", "oi_flag": "1"}

    try:
        response = self._api.get(url=HISTORICAL_URL, params=parameters)
    except Exception as exception:
        # A session error comes back as CandleAuthenticationError, and the base class logs in
        # once and asks for this window again. See `BrokerCandles._fetch_bars`.
        raise self._classify(exception) from exception

    return (response or {}).get("data") or {}

_classify staticmethod

_classify(exception)

Decide what a Fyers refusal means for the series being downloaded.

Fyers answers an unknown ticker with code -300, "Invalid symbol provided", which is the one that retires a series. Code -50 is its catch-all input error and is what a window wider than the cap returns, so it is a fault in this module rather than a dead instrument and is left to back off.

The session codes are matched on the number Fyers sends, not on the wording. Cloudflare's block page is recognised before any 429, since it is served as one and must stop the broker rather than slow it down.

  • exception is what the API call raised.
Source code in stock_brokers/instruments/historical/fyers.py
@staticmethod
def _classify(exception):
    """
    Decide what a Fyers refusal means for the series being downloaded.

    Fyers answers an unknown ticker with code -300, "Invalid symbol provided", which is the
    one that retires a series. Code -50 is its catch-all input error and is what a window
    wider than the cap returns, so it is a fault in this module rather than a dead instrument
    and is left to back off.

    The session codes are matched on the number Fyers sends, not on the wording. Cloudflare's
    block page is recognised before any 429, since it is served as one and must stop the
    broker rather than slow it down.

    - `exception` is what the API call raised.
    """
    text = str(exception)
    lowered = text.lower()
    code = getattr(exception, "code", None)
    try:
        code = int(code)
    except (TypeError, ValueError):
        code = None

    # Checked before anything mentioning 429, because the block page is itself a 429.
    if (any(marker in lowered for marker in BLOCK_PAGE_MARKERS)
            or (code == 429 and "cloudflare" in lowered)):
        return CandleBlocked(f"refused by Cloudflare in front of Fyers, which blocks the IP "
                             f"address rather than the session: {text[:160]}")
    if code == -300 or "invalid symbol" in lowered or "-300" in text:
        return CandleInstrumentUnknown(text[:200])
    if (code in AUTHENTICATION_ERROR_CODES
            or "could not authenticate" in lowered
            or "token expired" in lowered
            or ("token" in lowered and "invalid" in lowered)
            or "unauthor" in lowered):
        return CandleAuthenticationError(text[:200])
    if (code == 429 or "too many requests" in lowered or "rate limit" in lowered
            or "request limit" in lowered or "429" in text):
        return CandleThrottled(text[:200])
    return exception

parse_response

parse_response(payload, interval)

Turn Fyers' candle list into bars.

An empty window is an HTTP 200 carrying s of no_data, which is read here and answered with no bars. A row is [epoch, open, high, low, close, volume] for a cash instrument and carries open interest as a seventh element for a derivative, so it is read by length.

  • payload is the decoded response body.
  • interval is the stored interval name.

Example usage

From example_1_day_bars_stamped_in_utc.py
bars = self.candles.parse_response(payload, 'day')
Source code in stock_brokers/instruments/historical/fyers.py
def parse_response(self, payload, interval):
    """
    Turn Fyers' candle list into bars.

    An empty window is an HTTP 200 carrying `s` of `no_data`, which is read here and answered
    with no bars. A row is `[epoch, open, high, low, close, volume]` for a cash instrument and
    carries open interest as a seventh element for a derivative, so it is read by length.

    - `payload` is the decoded response body.
    - `interval` is the stored interval name.
    """
    payload = payload or {}
    status = payload.get("s")
    if status == "no_data":
        return []
    if status not in (None, "ok"):
        raise ValueError(f"fyers answered with s={status!r}: {str(payload)[:200]}")

    bars = []
    for candle in payload.get("candles") or []:
        if len(candle) < 6:
            continue
        bar_time = datetime.datetime.fromtimestamp(candle[0], datetime.timezone.utc)
        if not is_intraday(interval):
            # Fyers stamps a day bar at midnight UTC, which is the same trading day Kite
            # stamps at midnight India time. Normalised so the two can be compared.
            bar_time = daily_bar_time(bar_time)
        open_interest = candle[6] if len(candle) > 6 else None
        bars.append((bar_time, candle[1], candle[2], candle[3], candle[4], candle[5],
                     open_interest))
    return bars

priority_for

priority_for(instrument_type, expiry, interval)

Where a series sits in the queue. Lower is worked first.

The ordering is the difference between useful data in days and useful data in months. Cash instruments come first because they have the deepest history and are wanted most; expired options come last because there are tens of thousands of them, each holding a few weeks of data, and most will be refused outright. Coarse intervals beat fine ones within each tier, since a day bar covers a year in one request and a minute bar covers two months.

  • instrument_type is the broker's own type string.
  • expiry is the contract expiry, or None for cash.
  • interval is the stored interval name.

Example usage

From example_1_one_window_of_daily_bars.py
priority = self.downloader.priority_for(instrument_type, expiry, interval)
Source code in stock_brokers/instruments/historical/base.py
def priority_for(self, instrument_type, expiry, interval):
    """
    Where a series sits in the queue. Lower is worked first.

    The ordering is the difference between useful data in days and useful data in months.
    Cash instruments come first because they have the deepest history and are wanted most;
    expired options come last because there are tens of thousands of them, each holding a few
    weeks of data, and most will be refused outright. Coarse intervals beat fine ones within
    each tier, since a day bar covers a year in one request and a minute bar covers two
    months.

    - `instrument_type` is the broker's own type string.
    - `expiry` is the contract expiry, or None for cash.
    - `interval` is the stored interval name.
    """
    kind = (instrument_type or "").upper()
    expired = False
    if expiry:
        try:
            expired = datetime.date.fromisoformat(str(expiry)[:10]) < datetime.date.today()
        except ValueError:
            expired = False

    if not expiry:
        base = 10                                   # cash: equities, indices
    elif kind in ("FUT", "FUTSTK", "FUTIDX", "FUTCOM", "FUTCUR"):
        base = 80 if expired else 30
    else:
        base = 90 if expired else 60                # options, live or dead

    if interval in ("day", "week", "month"):
        return base
    minutes = self._interval_minutes(interval)
    return base + (5 if minutes >= 15 else 15)

_interval_minutes staticmethod

_interval_minutes(interval)

Minutes in an interval name like 15minute, or 0 when it is not a minute interval.

  • interval is the stored interval name.
Source code in stock_brokers/instruments/historical/base.py
@staticmethod
def _interval_minutes(interval):
    """
    Minutes in an interval name like `15minute`, or 0 when it is not a minute interval.

    - `interval` is the stored interval name.
    """
    if not interval.endswith("minute"):
        return 0
    try:
        return int(interval[:-len("minute")])
    except ValueError:
        return 0

seed

seed()

Make sure every instrument and interval this broker serves has a progress row.

Safe to run again: existing rows are left exactly as they are, so re-seeding after a new instrument master picks up what is new without disturbing what is already downloaded. Returns how many rows were added.

Example usage

From example_1_one_window_of_daily_bars.py
print(f'Series added by the first seed: {self.downloader.seed()}')
From example_1_one_window_of_daily_bars.py
print(f'Series added by a second seed: {self.downloader.seed()}')
Source code in stock_brokers/instruments/historical/base.py
def seed(self):
    """
    Make sure every instrument and interval this broker serves has a progress row.

    Safe to run again: existing rows are left exactly as they are, so re-seeding after a new
    instrument master picks up what is new without disturbing what is already downloaded.
    Returns how many rows were added.
    """
    instruments = self.instruments()
    if not instruments:
        self._logger.warning(f"No instruments for {self.BROKER_NAME}; nothing to seed. "
                             f"Has the instrument master been downloaded?")
        return 0

    today = datetime.date.today()
    considered = 0
    batch = []

    with self._connection.cursor() as cursor:
        # Counted by measuring the table, not by summing cursor.rowcount: execute_values
        # splits a batch into pages of its own, and rowcount then reports only the last page,
        # which made a complete seed of 1,149,064 rows announce itself as 22,964.
        cursor.execute(f"select count(*) from {self._progress_table}")
        before = cursor.fetchone()[0]

        # Written a batch at a time as the instruments are walked rather than assembled in
        # full first. Wisdom Capital alone is 372,545 instruments across thirteen intervals,
        # and holding all 4.8 million rows in memory to write them costs a gigabyte for
        # nothing.
        for token, instrument_type, expiry in instruments:
            for interval in self.INTERVALS:
                batch.append((str(token), interval,
                              self.priority_for(instrument_type, expiry, interval), today))
            considered += len(self.INTERVALS)
            if len(batch) >= WRITE_BATCH_ROWS:
                self._insert_series(cursor, batch)
                batch = []
        if batch:
            self._insert_series(cursor, batch)

        cursor.execute(f"select count(*) from {self._progress_table}")
        added = cursor.fetchone()[0] - before
    self._connection.commit()
    self._logger.info(f"Seeded {added} new series ({considered} considered).")
    return added

_insert_series

_insert_series(cursor, batch)

Register one batch of series, leaving any that already exist exactly as they are.

  • cursor is an open cursor on this broker's connection.
  • batch is a list of (token, interval, priority, seeded_date) tuples.
Source code in stock_brokers/instruments/historical/base.py
def _insert_series(self, cursor, batch):
    """
    Register one batch of series, leaving any that already exist exactly as they are.

    - `cursor` is an open cursor on this broker's connection.
    - `batch` is a list of `(token, interval, priority, seeded_date)` tuples.
    """
    execute_values(cursor, f"""
        insert into {self._progress_table}
            (instrument_token, "interval", priority, seeded_date)
        values %s
        on conflict (instrument_token, "interval") do nothing
    """, batch, page_size=WRITE_BATCH_ROWS)

next_window

next_window(series)

The date range to ask for next, as a (start, end, direction) triple.

The backward walk comes first and runs to completion. A series starts at today and steps back a window at a time until it reaches the broker's earliest date or the broker stops returning anything, and only then does it start filling forward with whatever has happened since.

Doing it the other way round - preferring forward whenever the last stored bar is older than today - looks reasonable and does not work: outside market hours the last bar is always older than today, so the series asks for the same empty recent window forever and never walks back at all.

  • series is a row from claim.

Example usage

From example_1_one_window_of_daily_bars.py
start_date, end_date, direction = self.downloader.next_window(series)
Source code in stock_brokers/instruments/historical/base.py
def next_window(self, series):
    """
    The date range to ask for next, as a `(start, end, direction)` triple.

    The backward walk comes first and runs to completion. A series starts at today and steps
    back a window at a time until it reaches the broker's earliest date or the broker stops
    returning anything, and only then does it start filling forward with whatever has
    happened since.

    Doing it the other way round - preferring forward whenever the last stored bar is older
    than today - looks reasonable and does not work: outside market hours the last bar is
    always older than today, so the series asks for the same empty recent window forever and
    never walks back at all.

    - `series` is a row from `claim`.
    """
    _, interval, earliest, latest, oldest_requested, _, _ = series
    span = datetime.timedelta(days=self.MAXIMUM_WINDOW_DAYS.get(interval, 60) - 1)
    today = datetime.date.today()

    backward_done = (oldest_requested is not None
                     and oldest_requested.date() <= self.EARLIEST_AVAILABLE_DATE)

    if not backward_done:
        end = today if oldest_requested is None else (oldest_requested.date()
                                                      - datetime.timedelta(days=1))
        start = max(end - span, self.EARLIEST_AVAILABLE_DATE)
        if start > end:
            return None, None, "exhausted"
        return start, end, "backward"

    if latest is None:
        return None, None, "exhausted"
    start = latest.date()
    if start >= today:
        return None, None, "current"
    return start, min(start + span, today), "forward"

_fetch_bars

_fetch_bars(token, interval, start_date, end_date)

Fetch and parse one window, logging in once first if the broker refuses the session.

A token expiring does not mean the download is over, and it does not mean this series is at fault. The rule is to log in first and then carry on: one login, then the same window again. Only if that login fails, or the fresh session is refused as well, does the error go back up - where visit and run stop the broker rather than carry on with a dead token.

Both alternatives are worse. Stopping outright leaves the download waiting for systemd to restart it into a login, and recording the refusal against whichever series it was on and moving to the next sends thousands of refused requests an hour - enough for Cloudflare to ban the machine from Fyers.

The login is re-armed by any window that comes back without a session error, so a backfill that runs for weeks gets its one login at each daily expiry.

  • token, interval, start_date and end_date are as for fetch_candles.
Source code in stock_brokers/instruments/historical/base.py
def _fetch_bars(self, token, interval, start_date, end_date):
    """
    Fetch and parse one window, logging in once first if the broker refuses the session.

    A token expiring does not mean the download is over, and it does not mean this series is at
    fault. The rule is to log in first and then carry on: one login, then the same window
    again. Only if that login fails, or the fresh session is refused as well, does the error go
    back up - where `visit` and `run` stop the broker rather than carry on with a dead token.

    Both alternatives are worse. Stopping outright leaves the download waiting for systemd to
    restart it into a login, and recording the refusal against whichever series it was on and
    moving to the next sends thousands of refused requests an hour - enough for Cloudflare to
    ban the machine from Fyers.

    The login is re-armed by any window that comes back without a session error, so a
    backfill that runs for weeks gets its one login at each daily expiry.

    - `token`, `interval`, `start_date` and `end_date` are as for `fetch_candles`.
    """
    try:
        bars = self.parse_response(self.fetch_candles(token, interval, start_date, end_date),
                                   interval)
    except CandleAuthenticationError as failure:
        if self._relogin_used:
            raise
        self._relogin_used = True
        self._logger.warning(f"{self.BROKER_NAME} refused the session ({failure}). Logging in "
                             f"once before continuing.")
        self.relogin()
        self._limiter.take()
        bars = self.parse_response(self.fetch_candles(token, interval, start_date, end_date),
                                   interval)
    self._relogin_used = False
    return bars

relogin

relogin()

Establish a fresh session and rebuild everything built on the old one.

Through ensure_session, so it takes the same Redis lock and obeys the same floor on login attempts as any other process using it - which means it may wait, and may find another process has already logged in, in which case that session is simply used.

Raises:

Type Description
CandleAuthenticationError

If no working session could be established.

Example usage

From example_3_logging_in_again_after_a_refused_session.py
downloader.relogin()
Source code in stock_brokers/instruments/historical/base.py
def relogin(self):
    """
    Establish a fresh session and rebuild everything built on the old one.

    Through `ensure_session`, so it takes the same Redis lock and obeys the same floor on login
    attempts as any other process using it - which means it may wait, and may find another
    process has already logged in, in which case that session is simply used.

    Raises:
        CandleAuthenticationError: If no working session could be established.
    """
    from stock_brokers.api.utilities.session import ensure_session

    try:
        ensure_session(self.BROKER_NAME, logger=self._logger)
        self._api = self._build_api()
        self.after_relogin()
    except CandleAuthenticationError:
        raise
    except Exception as exception:
        raise CandleAuthenticationError(
            f"logging in again did not produce a working session: "
            f"{type(exception).__name__}: {str(exception)[:200]}") from exception
    self._logger.info(f"{self.BROKER_NAME} logged in again. Continuing.")

_build_api

_build_api()

Construct this broker's authenticated API class, around whatever session is now stored.

The broker's own class from api_class_for, which is what every downloader builds. A downloader that builds its API differently overrides this.

Source code in stock_brokers/instruments/historical/base.py
def _build_api(self):
    """
    Construct this broker's authenticated API class, around whatever session is now stored.

    The broker's own class from `api_class_for`, which is what every downloader builds. A
    downloader that builds its API differently overrides this.
    """
    from stock_brokers.api.utilities.session import api_class_for

    return api_class_for(self.BROKER_NAME)()

after_relogin

after_relogin()

Re-establish anything a downloader keeps on top of the broker session. Nothing by default.

For a broker whose historical endpoint takes a second credential minted from the session, such as a separate market data token, this is where that credential is renewed.

Example usage

From example_3_logging_in_again_after_a_refused_session.py
downloader.after_relogin()
Source code in stock_brokers/instruments/historical/base.py
def after_relogin(self):
    """
    Re-establish anything a downloader keeps on top of the broker session. Nothing by default.

    For a broker whose historical endpoint takes a second credential minted from the session,
    such as a separate market data token, this is where that credential is renewed.
    """

visit

visit(series)

Fetch one window for one series and store it, advancing the watermark.

Returns the number of bars stored. The bars and the watermark are written in the same transaction, so an interruption costs at most the window in flight - and repeating that window is harmless, because bars upsert on their key.

  • series is a row from claim.

Example usage

From example_1_one_window_of_daily_bars.py
stored = self.downloader.visit(series)
From example_3_logging_in_again_after_a_refused_session.py
stored = downloader.visit(self.series('738561'))
Source code in stock_brokers/instruments/historical/base.py
def visit(self, series):
    """
    Fetch one window for one series and store it, advancing the watermark.

    Returns the number of bars stored. The bars and the watermark are written in the same
    transaction, so an interruption costs at most the window in flight - and repeating that
    window is harmless, because bars upsert on their key.

    - `series` is a row from `claim`.
    """
    token, interval, _, previous_latest, _, empty_streak, _ = series
    start_date, end_date, direction = self.next_window(series)

    if direction == "exhausted":
        if previous_latest is None:
            # Walked the whole range the broker serves and never saw a bar. A contract that
            # listed and never traded is the usual case, and there are tens of thousands of
            # them. Without this they would be claimed again the moment they were put down -
            # `next_window` answers "exhausted" every time, so nothing would move - and the
            # queue would spin through them at database speed making no requests at all.
            self._retire(token, interval,
                         "the broker served no bars anywhere in the range it offers")
            return 0
        # The backward walk is finished. Not retired: the series still gets new bars each
        # day, it simply has no more past to collect.
        self._finish_backfill(token, interval, "reached the broker's earliest date")
        return 0
    if direction == "current":
        self._defer_until_tomorrow(token, interval, "up to date")
        return 0

    self._limiter.take()
    try:
        bars = self._fetch_bars(token, interval, start_date, end_date)
    except CandleInstrumentUnknown as exception:
        self._retire(token, interval, str(exception))
        return 0
    except CandleThrottled as exception:
        # A throttle says something about the pace, not about this series, so it costs the
        # limiter five seconds and leaves the series claimable. Counting it as a failure
        # instead would push a perfectly good series an hour into the future - and a broker
        # that throttles does it to a run of series, not to one.
        self._limiter.back_off(5.0)
        self._record_throttle(token, interval, str(exception))
        return 0
    except (CandleAuthenticationError, CandleBlocked):
        # Neither says anything about this series, so neither is recorded against it. Before a
        # dead token was re-raised here, every refused request landed on whichever series was
        # claimed, and after five of those a good series stopped being claimed at all.
        raise
    except Exception as exception:
        self._record_failure(token, interval,
                             f"{type(exception).__name__}: {str(exception)[:200]}")
        return 0

    if not bars:
        streak = (empty_streak or 0) + 1
        if direction == "backward" and streak >= MAXIMUM_EMPTY_WINDOWS:
            # The broker has nothing older. Stop walking back, keep collecting forward.
            self._finish_backfill(token, interval,
                                  f"{streak} empty windows walking back from {end_date}")
        elif direction == "forward":
            self._defer_until_tomorrow(token, interval, "no new bars yet")
        else:
            self._record_empty(token, interval, streak, start_date, direction)
        return 0

    self._store(token, interval, bars, start_date, direction, previous_latest)
    return len(bars)

_store

_store(token, interval, bars, requested_from, direction, previous_latest=None)

Write a window's bars and move the watermark, in one transaction.

  • token is the broker's instrument identifier.
  • interval is the stored interval name.
  • bars are the parsed (time, open, high, low, close, volume, oi) tuples.
  • requested_from is the start of the window that was asked for.
  • direction is whether the walk was forward or backward.
  • previous_latest is the newest bar already stored, used to tell a forward window that gained ground from one that returned only what was already known.
Source code in stock_brokers/instruments/historical/base.py
def _store(self, token, interval, bars, requested_from, direction, previous_latest=None):
    """
    Write a window's bars and move the watermark, in one transaction.

    - `token` is the broker's instrument identifier.
    - `interval` is the stored interval name.
    - `bars` are the parsed `(time, open, high, low, close, volume, oi)` tuples.
    - `requested_from` is the start of the window that was asked for.
    - `direction` is whether the walk was forward or backward.
    - `previous_latest` is the newest bar already stored, used to tell a forward window that
      gained ground from one that returned only what was already known.
    """
    # Collapsed on bar time before the write, last one winning. A broker can answer with the
    # same timestamp twice in one window - Dhan does - and Postgres refuses a batch that
    # touches the same row twice with "ON CONFLICT DO UPDATE command cannot affect row a
    # second time", which killed the whole process rather than the window.
    by_time = {}
    for bar in bars:
        volume = self._value_within_bigint(token, interval, bar[0], "volume", bar[5])
        open_interest = self._value_within_bigint(token, interval, bar[0], "oi", bar[6])
        by_time[bar[0]] = (bar[0], str(token), interval,
                           bar[1], bar[2], bar[3], bar[4], volume, open_interest)
    rows = list(by_time.values())
    times = list(by_time)

    try:
        with self._connection.cursor() as cursor:
            for start in range(0, len(rows), WRITE_BATCH_ROWS):
                execute_values(cursor, f"""
                    insert into {self._table}
                        ("time", instrument_token, "interval", open, high, low, close,
                         volume, oi)
                    values %s
                    on conflict (instrument_token, "interval", "time") do update set
                        open = excluded.open, high = excluded.high, low = excluded.low,
                        close = excluded.close, volume = excluded.volume, oi = excluded.oi,
                        downloaded_at = now()
                """, rows[start:start + WRITE_BATCH_ROWS])

            cursor.execute(f"""
                update {self._progress_table} set
                    earliest_bar_time = least(coalesce(earliest_bar_time, %s), %s),
                    latest_bar_time = greatest(coalesce(latest_bar_time, %s), %s),
                    oldest_requested_time = case when %s = 'backward'
                        then least(coalesce(oldest_requested_time, %s), %s)
                        else oldest_requested_time end,
                    bar_count = bar_count + %s,
                    empty_window_streak = 0,
                    consecutive_failures = 0,
                    next_attempt_after = null,
                    last_attempt_at = now(),
                    last_outcome = 'stored',
                    last_failure_reason = null
                where instrument_token = %s and "interval" = %s
            """, (min(times), min(times), max(times), max(times),
                  direction, requested_from, requested_from,
                  len(rows), str(token), interval))
        self._connection.commit()
    except Exception:
        self._connection.rollback()
        raise

    # A forward window that returned nothing newer than what was already stored means the
    # market has not produced more yet. Without this the series would be claimed again
    # immediately and spend the rate limit re-fetching the same bars all weekend.
    if direction == "forward" and previous_latest is not None and max(times) <= previous_latest:
        self._defer_until_tomorrow(token, interval, "forward window gained no new bars")

_value_within_bigint

_value_within_bigint(token, interval, bar_time, column, value)

Returns a bar's count, or None when the count cannot fit in a BIGINT column.

Parameters:

Name Type Description Default
token str

The broker's instrument identifier.

required
interval str

The stored interval name.

required
bar_time datetime

The time of the bar the value belongs to.

required
column str

The name of the column the value is written to, either "volume" or "oi".

required
value int | float | None

The count exactly as the broker sent it.

required

Returns:

Type Description
int | float | None

The value unchanged when it fits in a BIGINT or is not a number, and None when it does not fit.

Source code in stock_brokers/instruments/historical/base.py
def _value_within_bigint(self, token, interval, bar_time, column, value):
    """Returns a bar's count, or None when the count cannot fit in a BIGINT column.

    Args:
        token (str): The broker's instrument identifier.
        interval (str): The stored interval name.
        bar_time (datetime.datetime): The time of the bar the value belongs to.
        column (str): The name of the column the value is written to, either "volume" or "oi".
        value (int | float | None): The count exactly as the broker sent it.

    Returns:
        int | float | None: The value unchanged when it fits in a BIGINT or is not a number, and None when it does not fit.
    """
    if not isinstance(value, (int, float)):
        return value
    if BIGINT_MINIMUM <= value <= BIGINT_MAXIMUM:
        return value
    self._logger.warning(
        "%s %s %s: %s %s does not fit in BIGINT, storing NULL.",
        token,
        interval,
        bar_time.isoformat(),
        column,
        value,
    )
    return None

_record_empty

_record_empty(token, interval, streak, requested_from, direction)

Note that a window came back with nothing, and step past it.

  • token, interval identify the series.
  • streak is how many empty windows in a row this makes.
  • requested_from is the start of the window that was asked for.
  • direction is whether the walk was forward or backward.
Source code in stock_brokers/instruments/historical/base.py
def _record_empty(self, token, interval, streak, requested_from, direction):
    """
    Note that a window came back with nothing, and step past it.

    - `token`, `interval` identify the series.
    - `streak` is how many empty windows in a row this makes.
    - `requested_from` is the start of the window that was asked for.
    - `direction` is whether the walk was forward or backward.
    """
    with self._connection.cursor() as cursor:
        cursor.execute(f"""
            update {self._progress_table} set
                empty_window_streak = %s,
                oldest_requested_time = case when %s = 'backward'
                    then least(coalesce(oldest_requested_time, %s), %s)
                    else oldest_requested_time end,
                last_attempt_at = now(),
                last_outcome = 'empty',
                consecutive_failures = 0,
                next_attempt_after = null
            where instrument_token = %s and "interval" = %s
        """, (streak, direction, requested_from, requested_from, str(token), interval))
    self._connection.commit()

_finish_backfill

_finish_backfill(token, interval, reason)

Mark the backward walk complete, leaving the series collecting forward.

Distinct from _retire: the broker has no more past for this series, but it will keep producing new bars. Retiring it here would quietly stop a live instrument updating.

  • token, interval identify the series.
  • reason is why the walk stopped, kept for review.
Source code in stock_brokers/instruments/historical/base.py
def _finish_backfill(self, token, interval, reason):
    """
    Mark the backward walk complete, leaving the series collecting forward.

    Distinct from `_retire`: the broker has no more *past* for this series, but it will keep
    producing new bars. Retiring it here would quietly stop a live instrument updating.

    - `token`, `interval` identify the series.
    - `reason` is why the walk stopped, kept for review.
    """
    with self._connection.cursor() as cursor:
        cursor.execute(f"""
            update {self._progress_table} set
                oldest_requested_time = %s,
                empty_window_streak = 0,
                consecutive_failures = 0,
                next_attempt_after = null,
                last_attempt_at = now(),
                last_outcome = 'backfilled',
                limit_reason = %s
            where instrument_token = %s and "interval" = %s
        """, (self.EARLIEST_AVAILABLE_DATE, reason[:500], str(token), interval))
    self._connection.commit()
    self._logger.info(f"{token} {interval}: backfill complete ({reason}).")

_defer_until_tomorrow

_defer_until_tomorrow(token, interval, reason)

Put a series aside until the next day.

Reached when a forward window brings nothing new, which outside market hours is every forward window. Without it the highest priority series would be re-fetched continuously and spend the whole rate limit re-reading bars already stored.

  • token, interval identify the series.
  • reason is recorded as the outcome.
Source code in stock_brokers/instruments/historical/base.py
def _defer_until_tomorrow(self, token, interval, reason):
    """
    Put a series aside until the next day.

    Reached when a forward window brings nothing new, which outside market hours is every
    forward window. Without it the highest priority series would be re-fetched continuously
    and spend the whole rate limit re-reading bars already stored.

    - `token`, `interval` identify the series.
    - `reason` is recorded as the outcome.
    """
    with self._connection.cursor() as cursor:
        cursor.execute(f"""
            update {self._progress_table} set
                next_attempt_after = date_trunc('day', now()) + interval '1 day',
                last_attempt_at = now(),
                last_outcome = %s,
                consecutive_failures = 0
            where instrument_token = %s and "interval" = %s
        """, (reason[:100], str(token), interval))
    self._connection.commit()

_record_throttle

_record_throttle(token, interval, reason)

Note that the broker asked us to slow down, without holding it against the series.

The window is left exactly as it was, so the same series is claimed again once the limiter's back off has passed and the work is simply redone.

  • token, interval identify the series.
  • reason is what the broker said.
Source code in stock_brokers/instruments/historical/base.py
def _record_throttle(self, token, interval, reason):
    """
    Note that the broker asked us to slow down, without holding it against the series.

    The window is left exactly as it was, so the same series is claimed again once the
    limiter's back off has passed and the work is simply redone.

    - `token`, `interval` identify the series.
    - `reason` is what the broker said.
    """
    with self._connection.cursor() as cursor:
        cursor.execute(f"""
            update {self._progress_table} set
                last_attempt_at = now(),
                last_outcome = 'throttled',
                last_failure_reason = %s
            where instrument_token = %s and "interval" = %s
        """, (reason[:500], str(token), interval))
    self._connection.commit()
    self._logger.warning(f"{token} {interval}: throttled: {reason[:150]}")

_record_failure

_record_failure(token, interval, reason)

Note a failure and push the next attempt out exponentially.

An hour, then two, four, eight and so on, so a series that is permanently broken stops crowding out the ones that are not.

  • token, interval identify the series.
  • reason is what went wrong, stored for a human to read.
Source code in stock_brokers/instruments/historical/base.py
def _record_failure(self, token, interval, reason):
    """
    Note a failure and push the next attempt out exponentially.

    An hour, then two, four, eight and so on, so a series that is permanently broken stops
    crowding out the ones that are not.

    - `token`, `interval` identify the series.
    - `reason` is what went wrong, stored for a human to read.
    """
    with self._connection.cursor() as cursor:
        cursor.execute(f"""
            update {self._progress_table} set
                consecutive_failures = consecutive_failures + 1,
                next_attempt_after = now() + interval '1 hour'
                                     * power(2, least(consecutive_failures, 6)),
                last_attempt_at = now(),
                last_outcome = 'failed',
                last_failure_reason = %s
            where instrument_token = %s and "interval" = %s
        """, (reason[:500], str(token), interval))
    self._connection.commit()
    self._logger.warning(f"{token} {interval}: {reason}")

_retire

_retire(token, interval, reason)

Stop asking for a series the broker will not serve.

  • token, interval identify the series.
  • reason is why, stored so the decision can be reviewed.
Source code in stock_brokers/instruments/historical/base.py
def _retire(self, token, interval, reason):
    """
    Stop asking for a series the broker will not serve.

    - `token`, `interval` identify the series.
    - `reason` is why, stored so the decision can be reviewed.
    """
    with self._connection.cursor() as cursor:
        cursor.execute(f"""
            update {self._progress_table} set
                reached_broker_limit = true,
                limit_reason = %s,
                last_attempt_at = now(),
                last_outcome = 'retired'
            where instrument_token = %s and "interval" = %s
        """, (reason[:500], str(token), interval))
    self._connection.commit()

summary

summary()

How far the download has got, for a status display.

Example usage

From example_1_one_window_of_daily_bars.py
print(f'Summary: {self.downloader.summary()}')
From example_2_run_until_the_queue_is_drained.py
print(f'Summary: {self.downloader.summary()}')
Source code in stock_brokers/instruments/historical/base.py
def summary(self):
    """
    How far the download has got, for a status display.
    """
    with self._connection.cursor() as cursor:
        cursor.execute(f"""
            select count(*),
                   count(*) filter (where reached_broker_limit),
                   count(*) filter (where bar_count > 0),
                   coalesce(sum(bar_count), 0),
                   count(*) filter (where consecutive_failures >= %s)
            from {self._progress_table}
        """, (MAXIMUM_CONSECUTIVE_FAILURES,))
        total, retired, started, stored, stuck = cursor.fetchone()
    self._connection.commit()
    return {"series": total, "retired": retired, "with_bars": started,
            "bars": int(stored), "stuck": stuck}

close

close()

Close the database connection.

Example usage

From example_1_one_window_of_daily_bars.py
self.downloader.close()
From example_2_run_until_the_queue_is_drained.py
self.downloader.close()
From example_3_logging_in_again_after_a_refused_session.py
downloader.close()
Source code in stock_brokers/instruments/historical/base.py
def close(self):
    """
    Close the database connection.
    """
    self._connection.close()