Skip to content

flattrade

Flattrade historical candle download.

Flattrade runs the Noren platform, so everything about the two endpoints, the refusal shapes and the field names lives in noren.py, shared with Shoonya. What is particular to Flattrade is here: its host, its rate, and how far back its deployment keeps data.

Measured on 2026-09-12 against RELIANCE: EODChartData reaches 2019-12-03 and no further, some sixteen hundred and fifty daily bars, and TPSeries keeps a rolling year of intraday bars with January to April 2026 missing from the middle of it.

Classes:

Name Description
FlattradeCandles

Downloads Flattrade's historical candles.

FlattradeCandles

FlattradeCandles(api=None)

Bases: NorenCandles

Downloads Flattrade's historical candles.

Example programs

examples/stock_brokers/instruments/historical/flattrade/FlattradeCandles/example_1_one_minute_bars_from_flattrade.py
"""Fetches one day of RELIANCE one minute bars from Flattrade's `TPSeries` endpoint and reads them.

Flattrade runs the Noren platform, so `FlattradeCandles` adds only what is particular to it to `NorenCandles`: its host, its documented ten requests a second, and a daily history that reaches back to 2019. The program prints those settings and then fetches one window, so the request can be seen going to Flattrade's host with the exchange, the numeric token and the window as epoch seconds.

A `FlattradeCandles` constructor opens a PostgreSQL connection and logs in to Flattrade 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 `FlattradeAPI`. The stand-in records the POST and answers with two bars shaped like the ones Flattrade returned for 2026-09-11, newest first as Noren sends them, wrapped in the `status_code` and `data` envelope the real API class returns. Notice that the bars come back oldest first, printed here in India time.

Run it from the project root:

    python examples/stock_brokers/instruments/historical/flattrade/FlattradeCandles/example_1_one_minute_bars_from_flattrade.py
"""

import datetime

from stock_brokers.instruments.historical.flattrade import (
    FlattradeCandles,
)


class RecordedFlattradeAPI:
    """A stand-in for `FlattradeAPI` that answers `TPSeries` with two recorded bars.

    Attributes:
        requests (list): The (url, body) pairs it was sent.
    """

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

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

    def post(self, url, data=None):
        """Records a POST request and answers it with recorded bars.

        Args:
            url (str): The endpoint the downloader called.
            data (dict): The request fields it sent.

        Returns:
            dict: The response envelope, with the bars under `data`.
        """
        self.requests.append((url, data))
        return {
            'status_code': 200,
            'data': [
                {
                    'stat': 'Ok',
                    'time': '11-09-2026 09:16:00',
                    'ssboe': '1789098360',
                    'into': '1262.70',
                    'inth': '1263.90',
                    'intl': '1262.10',
                    'intc': '1263.40',
                    'intv': '64210',
                    'v': '252777',
                    'oi': '0',
                },
                {
                    'stat': 'Ok',
                    'time': '11-09-2026 09:15:00',
                    'ssboe': '1789098300',
                    'into': '1266.40',
                    'inth': '1267.40',
                    'intl': '1261.50',
                    'intc': '1262.70',
                    'intv': '188567',
                    'v': '188567',
                    'oi': '0',
                },
            ],
        }


class OneMinuteBarsFromFlattradeExample:
    """Prints Flattrade's settings, then fetches and parses one window.

    Attributes:
        api (RecordedFlattradeAPI): The stand-in Flattrade API.
        candles (FlattradeCandles): 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 = RecordedFlattradeAPI()
        self.candles = object.__new__(FlattradeCandles)
        self.candles._api = self.api

    def run(self):
        """Prints the settings, fetches 2026-09-11 and prints the bars.

        Returns:
            None: This method returns nothing.
        """
        print(f'Broker: {FlattradeCandles.BROKER_NAME}')
        print(f'Host: {FlattradeCandles.BASE_URL}')
        print(f'Requests a second: {FlattradeCandles.REQUESTS_PER_SECOND}')
        print(f'Earliest date: {FlattradeCandles.EARLIEST_AVAILABLE_DATE}')
        day = datetime.date(2026, 9, 11)
        payload = self.candles.fetch_candles('NSE|2885|RELIANCE-EQ', '1minute', day, day)
        url, body = self.api.requests[0]
        print(f'Requested {url}')
        print(f'  body: {body}')
        india = datetime.timezone(datetime.timedelta(hours=5, minutes=30))
        for bar in self.candles.parse_response(payload, '1minute'):
            bar_time, open_price, high, low, close, volume, open_interest = bar
            print(f'  {bar_time.astimezone(india).isoformat()} open {open_price} high {high} low {low} close {close} volume {volume} oi {open_interest}')


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

It prints:

Broker: flattrade
Host: https://piconnect.flattrade.in/PiConnectAPI
Requests a second: 10.0
Earliest date: 2019-01-01
Requested https://piconnect.flattrade.in/PiConnectAPI/TPSeries
  body: {'exch': 'NSE', 'token': '2885', 'st': '1789065000', 'et': '1789151400', 'intrv': '1'}
  2026-09-11T09:15:00+05:30 open 1266.4 high 1267.4 low 1261.5 close 1262.7 volume 188567 oi 0
  2026-09-11T09:16:00+05:30 open 1262.7 high 1263.9 low 1262.1 close 1263.4 volume 64210 oi 0
examples/stock_brokers/instruments/historical/flattrade/FlattradeCandles/example_2_daily_bar_and_expired_session.py
"""Fetches a RELIANCE daily bar from Flattrade's `EODChartData` endpoint, then shows Flattrade refusing an expired session.

Daily bars come from a different Noren endpoint than intraday ones, addressed by `EXCHANGE:TRADINGSYMBOL` rather than by token, and each bar arrives as a JSON encoded string inside the answer list. `parse_response` decodes it and puts the bar at midnight India time on its trading date.

Noren refuses with HTTP 200 and an object carrying `stat` and `emsg`, so an expired session reaches `parse_response` rather than raising in the API class. There it becomes `CandleAuthenticationError`, which is what makes the base class log in once and try again. The stand-in for `FlattradeAPI` answers the first request with a recorded daily bar and the second with that refusal, each wrapped in the `status_code` and `data` envelope the real API class returns.

A `FlattradeCandles` constructor opens a PostgreSQL connection and logs in to Flattrade when the stored session has expired, so the program builds the object without running its constructor, as `test_runs/candle_parse.py` does.

Run it from the project root:

    python examples/stock_brokers/instruments/historical/flattrade/FlattradeCandles/example_2_daily_bar_and_expired_session.py
"""

import datetime
import json

from stock_brokers.instruments.historical.base import (
    CandleAuthenticationError,
)
from stock_brokers.instruments.historical.flattrade import (
    FlattradeCandles,
)


class ExpiringFlattradeAPI:
    """A stand-in for `FlattradeAPI` whose session expires after the first request.

    Attributes:
        requests (list): The (url, body) pairs it was sent.
    """

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

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

    def post(self, url, data=None):
        """Records a POST request and answers with a daily bar the first time and a refusal afterwards.

        Args:
            url (str): The endpoint the downloader called.
            data (dict): The request fields it sent.

        Returns:
            dict: The response envelope, with the decoded body under `data`.
        """
        self.requests.append((url, data))
        if len(self.requests) > 1:
            return {
                'status_code': 200,
                'data': {
                    'stat': 'Not_Ok',
                    'emsg': 'Session Expired :  Invalid Session Key',
                },
            }
        bar = {
            'time': '11-SEP-2026',
            'into': '1267.00',
            'inth': '1267.40',
            'intl': '1253.00',
            'intc': '1257.50',
            'ssboe': '1789084800',
            'intv': '8777736.00',
        }
        return {
            'status_code': 200,
            'data': [
                json.dumps(bar),
            ],
        }


class DailyBarAndExpiredSessionExample:
    """Fetches a daily bar, then a window the expired session refuses.

    Attributes:
        api (ExpiringFlattradeAPI): The stand-in Flattrade API.
        candles (FlattradeCandles): 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 = ExpiringFlattradeAPI()
        self.candles = object.__new__(FlattradeCandles)
        self.candles._api = self.api

    def run(self):
        """Fetches two daily windows and prints what each gave.

        Returns:
            None: This method returns nothing.
        """
        start_date = datetime.date(2026, 9, 7)
        end_date = datetime.date(2026, 9, 11)
        payload = self.candles.fetch_candles('NSE|2885|RELIANCE-EQ', 'day', start_date, end_date)
        url, body = self.api.requests[0]
        print(f'Requested {url}')
        print(f'  body: {body}')
        print(f'  answer element: {payload[0]}')
        for bar in self.candles.parse_response(payload, 'day'):
            bar_time, open_price, high, low, close, volume, open_interest = bar
            print(f'  {bar_time.isoformat()} open {open_price} high {high} low {low} close {close} volume {volume} oi {open_interest}')
        payload = self.candles.fetch_candles('NSE|2885|RELIANCE-EQ', 'day', start_date, end_date)
        try:
            self.candles.parse_response(payload, 'day')
        except CandleAuthenticationError as error:
            print(f'Second window: {type(error).__name__}: {error}')


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

It prints:

Requested https://piconnect.flattrade.in/PiConnectAPI/EODChartData
  body: {'sym': 'NSE:RELIANCE-EQ', 'from': '1788719400', 'to': '1789151400'}
  answer element: {"time": "11-SEP-2026", "into": "1267.00", "inth": "1267.40", "intl": "1253.00", "intc": "1257.50", "ssboe": "1789084800", "intv": "8777736.00"}
  2026-09-11T00:00:00+05:30 open 1267.0 high 1267.4 low 1253.0 close 1257.5 volume 8777736 oi None
Second window: CandleAuthenticationError: flattrade rejected the session key: Session Expired :  Invalid Session Key

Noren candle downloader.

  • api is an authenticated API class for this broker. One is built when not supplied, which establishes a session if the stored one has expired.

Example programs

examples/stock_brokers/instruments/historical/noren/NorenCandles/example_1_intraday_and_daily_endpoints.py
"""Shows a Noren platform downloader asking its two endpoints, `TPSeries` for intraday bars and `EODChartData` for daily ones, and reading both answers.

`NorenCandles` holds everything Flattrade and Shoonya share, and a real broker supplies only its name, host, rate, earliest date and the API class it logs in with. The program writes such a subclass for a made-up deployment at `noren.example.com`, so what is shown is the platform's behaviour rather than either broker's. Its constructor would open a PostgreSQL connection and log in, so the object is built without running it, as `test_runs/candle_parse.py` does, and the stand-in API is the one its `_build_api` returns.

The stored identifier `NSE|2885|RELIANCE-EQ` carries all three things the two endpoints need: `TPSeries` takes the exchange and numeric token with the window as epoch seconds, and `EODChartData` takes `NSE:RELIANCE-EQ`. The stand-in API records each POST and answers with bars shaped like the ones both hosts returned on 2026-09-11. Notice that the intraday answer arrives newest first and is sorted oldest first, that each bar's volume is its own `intv` rather than the day's running total `v` (188567 and 7938419 rather than 8776602), and that the daily bar arrives as a JSON encoded string and lands on midnight India time.

Run it from the project root:

    python examples/stock_brokers/instruments/historical/noren/NorenCandles/example_1_intraday_and_daily_endpoints.py
"""

import datetime
import json

from stock_brokers.instruments.historical.noren import (
    NorenCandles,
)


class RecordedNorenAPI:
    """A stand-in for a Noren broker's API class that answers each endpoint with recorded bars.

    Attributes:
        requests (list): The (url, body) pairs it was sent.
    """

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

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

    def post(self, url, data=None):
        """Records a POST request and answers it with recorded bars.

        Args:
            url (str): The endpoint the downloader called.
            data (dict): The request fields it sent.

        Returns:
            dict: The response envelope, with the decoded body under `data`.
        """
        self.requests.append((url, data))
        if url.endswith('/TPSeries'):
            body = [
                {
                    'stat': 'Ok',
                    'time': '11-09-2026 15:29:00',
                    'ssboe': '1789120740',
                    'into': '1257.50',
                    'inth': '1257.50',
                    'intl': '1257.50',
                    'intc': '1257.50',
                    'intv': '7938419',
                    'v': '8776602',
                    'oi': '0',
                },
                {
                    'stat': 'Ok',
                    'time': '11-09-2026 09:15:00',
                    'ssboe': '1789098300',
                    'into': '1266.40',
                    'inth': '1267.40',
                    'intl': '1261.50',
                    'intc': '1262.70',
                    'intv': '188567',
                    'v': '188567',
                    'oi': '0',
                },
            ]
        else:
            bar = {
                'time': '11-SEP-2026',
                'into': '1267.00',
                'inth': '1267.40',
                'intl': '1253.00',
                'intc': '1257.50',
                'ssboe': '1789084800',
                'intv': '8777736.00',
            }
            body = [
                json.dumps(bar),
            ]
        return {
            'status_code': 200,
            'data': body,
        }


class ExampleDeploymentCandles(NorenCandles):
    """Downloads candles from a made-up Noren deployment, to show what a broker subclass supplies."""

    BROKER_NAME = 'example_noren'
    BASE_URL = 'https://noren.example.com/NorenWClientAPI'
    REQUESTS_PER_SECOND = 2.0
    EARLIEST_AVAILABLE_DATE = datetime.date(2020, 1, 1)

    def _build_api(self):
        """Builds the stand-in API in place of a logged-in broker API class.

        Returns:
            RecordedNorenAPI: A new stand-in.
        """
        return RecordedNorenAPI()


class IntradayAndDailyEndpointsExample:
    """Fetches and parses one intraday and one daily window.

    Attributes:
        api (RecordedNorenAPI): The stand-in API the downloader was given.
        candles (ExampleDeploymentCandles): The downloader being shown.
    """

    def __init__(self):
        """Builds the downloader without opening a database connection and gives it its API.

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

    def fetch_and_print(self, interval):
        """Fetches 2026-09-11 for RELIANCE at one interval and prints the request and the bars.

        Args:
            interval (str): The stored interval name.

        Returns:
            None: This method returns nothing.
        """
        day = datetime.date(2026, 9, 11)
        payload = self.candles.fetch_candles('NSE|2885|RELIANCE-EQ', interval, day, day)
        url, body = self.api.requests[-1]
        print(f'{interval}: {url}')
        print(f'  body: {body}')
        for bar in self.candles.parse_response(payload, interval):
            bar_time, open_price, high, low, close, volume, open_interest = bar
            india_time = bar_time.astimezone(datetime.timezone(datetime.timedelta(hours=5, minutes=30)))
            print(f'  {india_time.isoformat()} open {open_price} high {high} low {low} close {close} volume {volume} oi {open_interest}')

    def run(self):
        """Fetches the intraday window and then the daily one.

        Returns:
            None: This method returns nothing.
        """
        self.fetch_and_print('1minute')
        self.fetch_and_print('day')


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

It prints:

1minute: https://noren.example.com/NorenWClientAPI/TPSeries
  body: {'exch': 'NSE', 'token': '2885', 'st': '1789065000', 'et': '1789151400', 'intrv': '1'}
  2026-09-11T09:15:00+05:30 open 1266.4 high 1267.4 low 1261.5 close 1262.7 volume 188567 oi 0
  2026-09-11T15:29:00+05:30 open 1257.5 high 1257.5 low 1257.5 close 1257.5 volume 7938419 oi 0
day: https://noren.example.com/NorenWClientAPI/EODChartData
  body: {'sym': 'NSE:RELIANCE-EQ', 'from': '1789065000', 'to': '1789151400'}
  2026-09-11T00:00:00+05:30 open 1267.0 high 1267.4 low 1253.0 close 1257.5 volume 8777736 oi None
examples/stock_brokers/instruments/historical/noren/NorenCandles/example_2_identifiers_and_refusal_objects.py
"""Builds Noren identifiers from an instrument master, reads what each says about its instrument, and shows how a Noren refusal object is read.

A Noren token is unique only within its exchange's scrip file, and the two endpoints need different fields, so `instruments` joins the exchange, the token and the trading symbol into one identifier and turns Noren's `27-OCT-2026` expiry into a date. `identifier_parts` splits it again, and `series_context`, a classmethod that needs no downloader, reads the exchange and segments. For NSE and BSE it also gives the exchange symbol with the series suffix removed, so `RELIANCE-EQ` becomes `RELIANCE`; an exchange name Noren's table does not know leaves everything but the token open.

A Noren refusal is a JSON object with `stat` and `emsg`, sent with HTTP 200. Its "no data" message is the same for an empty window and an unknown instrument, so `parse_response` reads it as no bars; only a throttle and an expired session raise, as `CandleThrottled` and `CandleAuthenticationError`, and the message names the broker.

The program uses `ShoonyaCandles`, the Noren subclass for Shoonya, so the instance has a broker name to report; everything shown is defined on `NorenCandles`. The downloader is built without its constructor, as in `test_runs/candle_parse.py`, and given a stand-in database connection whose cursor answers the instrument master query with four rows, so nothing connects to PostgreSQL or logs in.

Run it from the project root:

    python examples/stock_brokers/instruments/historical/noren/NorenCandles/example_2_identifiers_and_refusal_objects.py
"""

from stock_brokers.instruments.historical.base import (
    CandleError,
)
from stock_brokers.instruments.historical.noren import (
    NorenCandles,
)
from stock_brokers.instruments.historical.shoonya import (
    ShoonyaCandles,
)


class ScripFileCursor:
    """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 (exchange, token, trading symbol, instrument, expiry).

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

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

        Returns:
            ScripFileCursor: 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 ScripFileConnection:
    """A stand-in PostgreSQL connection over four rows of Noren scrip files.

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

    def __init__(self):
        """Builds the connection with four scrip file rows.

        Returns:
            None: This method returns nothing.
        """
        self.rows = [
            (
                'NSE',
                '2885',
                'RELIANCE-EQ',
                'EQ',
                None,
            ),
            (
                'NFO',
                '2885',
                'RELIANCE27OCT26F',
                'FUTSTK',
                '27-OCT-2026',
            ),
            (
                'MCX',
                '565899',
                'CRUDEOIL19OCT26',
                'FUTCOM',
                '19-OCT-2026',
            ),
            (
                'NSE',
                '26000',
                'Nifty 50',
                'INDEX',
                '',
            ),
        ]

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

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

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

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


class IdentifiersAndRefusalObjectsExample:
    """Lists identifiers, reads them, and parses three refusal objects.

    Attributes:
        candles (ShoonyaCandles): 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__(ShoonyaCandles)
        self.candles._connection = ScripFileConnection()

    def show_identifiers(self):
        """Lists the instruments and reads each identifier, then one with an unknown exchange.

        Returns:
            None: This method returns nothing.
        """
        identifiers = []
        for identifier, instrument_type, expiry in self.candles.instruments():
            print(f'{identifier}: {instrument_type}, expiry {expiry}')
            identifiers.append(identifier)
        identifiers.append('XYZ|123|UNKNOWN')
        for identifier in identifiers:
            exchange_name, token, trading_symbol = self.candles.identifier_parts(identifier)
            context = NorenCandles.series_context(identifier)
            if context.segments:
                segments = f'{len(context.segments)} segments'
            else:
                segments = 'any segment'
            print(f'  {exchange_name} {token} {trading_symbol}: exchange {context.exchange}, {segments}, symbol {context.symbol}')

    def show_refusals(self):
        """Parses three refusal objects and prints what each became.

        Returns:
            None: This method returns nothing.
        """
        messages = [
            'Error Occurred : 5 "no data"',
            'Rate_Limited: too many requests',
            'Session Expired :  Invalid Session Key',
        ]
        for message in messages:
            refusal = {
                'stat': 'Not_Ok',
                'emsg': message,
            }
            try:
                bars = self.candles.parse_response(refusal, '1minute')
                outcome = f'{len(bars)} bars'
            except CandleError as error:
                outcome = f'{type(error).__name__}: {error}'
            print(f'"{message}": {outcome}')

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

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


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

It prints:

NSE|2885|RELIANCE-EQ: EQ, expiry None
NFO|2885|RELIANCE27OCT26F: FUTSTK, expiry 2026-10-27
MCX|565899|CRUDEOIL19OCT26: FUTCOM, expiry 2026-10-19
NSE|26000|Nifty 50: INDEX, expiry None
  NSE 2885 RELIANCE-EQ: exchange nse, 10 segments, symbol RELIANCE
  NFO 2885 RELIANCE27OCT26F: exchange nse, 16 segments, symbol None
  MCX 565899 CRUDEOIL19OCT26: exchange mcx, 16 segments, symbol None
  NSE 26000 Nifty 50: exchange nse, 10 segments, symbol Nifty 50
  XYZ 123 UNKNOWN: exchange None, any segment, symbol None
"Error Occurred : 5 "no data"": 0 bars
"Rate_Limited: too many requests": CandleThrottled: shoonya refused the request for rate: Rate_Limited: too many requests
"Session Expired :  Invalid Session Key": CandleAuthenticationError: shoonya rejected the session key: Session Expired :  Invalid Session Key

Methods:

Name Description
series_context

Read the exchange and segment out of a Noren identifier.

fetch_candles

Fetch one window of Noren candles.

parse_response

Turn a Noren response into bars.

instruments

Every instrument this deployment has published, identified for both endpoints.

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.

claim

The next series due for a window, or None when there is nothing to do.

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.

run

Work the queue until stopped, out of work, or out of time.

summary

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

close

Close the database connection.

identifier_parts

Split a stored Noren identifier into the fields the two endpoints need.

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

    - `api` is an authenticated API class for this broker. One is built 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

        # 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 = self._build_api()
    self._api = api

_build_api

_build_api()

Construct the authenticated Flattrade API class.

Source code in stock_brokers/instruments/historical/flattrade.py
def _build_api(self):
    """
    Construct the authenticated Flattrade API class.
    """
    from stock_brokers.api.flattrade import FlattradeAPI

    return FlattradeAPI()

series_context classmethod

series_context(identifier)

Read the exchange and segment out of a Noren identifier.

Parameters:

Name Type Description Default
identifier str

For example "NSE|2885|RELIANCE-EQ".

required

Returns:

Type Description
SeriesContext

The numeric token, with the exchange and segments its exchange names.

Example usage

From example_2_identifiers_and_refusal_objects.py
context = NorenCandles.series_context(identifier)
Source code in stock_brokers/instruments/historical/noren.py
@classmethod
def series_context(cls, identifier):
    """
    Read the exchange and segment out of a Noren identifier.

    Args:
        identifier (str): For example "NSE|2885|RELIANCE-EQ".

    Returns:
        SeriesContext: The numeric token, with the exchange and segments its exchange names.
    """
    exchange_name, instrument_token, trading_symbol = cls.identifier_parts(identifier)
    exchange, family = cls.EXCHANGES.get(exchange_name, (None, None))
    if family is None:
        return SeriesContext(instrument_token)
    symbol = None
    if exchange_name in ("NSE", "BSE"):
        # An NSE cash symbol carries its series, as in RELIANCE-EQ or INDIAGLYCO-BE; BSE's do not.
        symbol = cls.SERIES_SUFFIX.sub("", trading_symbol)
    return SeriesContext(instrument_token, exchange, segments_of(exchange, family),
                         exchange_token=instrument_token, symbol=symbol)

fetch_candles

fetch_candles(token, interval, start_date, end_date)

Fetch one window of Noren candles.

The API class wraps the body into the jData=...&jKey=... form Noren expects and adds the user id, so only the fields particular to the request are named here.

  • token is the EXCHANGE|token|tradingsymbol identifier from instruments.
  • interval is the stored interval name.
  • start_date and end_date bound the window, inclusive.

Example usage

From example_1_intraday_and_daily_endpoints.py
payload = self.candles.fetch_candles('NSE|2885|RELIANCE-EQ', interval, day, day)
Source code in stock_brokers/instruments/historical/noren.py
def fetch_candles(self, token, interval, start_date, end_date):
    """
    Fetch one window of Noren candles.

    The API class wraps the body into the `jData=...&jKey=...` form Noren expects and adds the
    user id, so only the fields particular to the request are named here.

    - `token` is the `EXCHANGE|token|tradingsymbol` identifier from `instruments`.
    - `interval` is the stored interval name.
    - `start_date` and `end_date` bound the window, inclusive.
    """
    exchange, instrument_token, trading_symbol = self.identifier_parts(token)
    # The window ends at midnight of the day after, so the last day's bars are inside it.
    start = self._epoch_seconds(start_date)
    end = self._epoch_seconds(end_date + datetime.timedelta(days=1))

    if is_intraday(interval):
        endpoint = "TPSeries"
        body = {"exch": exchange, "token": instrument_token, "st": str(start),
                "et": str(end), "intrv": self.INTERVALS[interval]}
    else:
        endpoint = "EODChartData"
        body = {"sym": f"{exchange}:{trading_symbol}", "from": str(start), "to": str(end)}

    try:
        response = self._api.post(url=f"{self.BASE_URL}/{endpoint}", data=body)
    except Exception as exception:
        raise self._classify(exception) from exception

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

parse_response

parse_response(payload, interval)

Turn a Noren response into bars.

A refusal object is answered with no bars rather than raised, for the reason in _classify: its message cannot tell an empty window from a dead instrument.

EODChartData returns each bar as a JSON encoded string inside the list, so a string element is decoded before it is read. The volume taken is intv, the bar's own volume; the v beside it is the day's running total, and taking that by mistake would produce a rising series that looks plausible on a chart and is wrong everywhere.

  • payload is the decoded response, a list of bars or a refusal object.
  • interval is the stored interval name.

Example usage

From example_1_intraday_and_daily_endpoints.py
self.candles.parse_response(payload, interval)
From example_2_identifiers_and_refusal_objects.py
bars = self.candles.parse_response(refusal, '1minute')
Source code in stock_brokers/instruments/historical/noren.py
def parse_response(self, payload, interval):
    """
    Turn a Noren response into bars.

    A refusal object is answered with no bars rather than raised, for the reason in
    `_classify`: its message cannot tell an empty window from a dead instrument.

    `EODChartData` returns each bar as a JSON encoded string inside the list, so a string
    element is decoded before it is read. The volume taken is `intv`, the bar's own volume;
    the `v` beside it is the day's running total, and taking that by mistake would produce a
    rising series that looks plausible on a chart and is wrong everywhere.

    - `payload` is the decoded response, a list of bars or a refusal object.
    - `interval` is the stored interval name.
    """
    if isinstance(payload, dict):
        self._raise_for_refusal(payload)
        return []
    if not isinstance(payload, list):
        return []

    bars = []
    for element in payload:
        record = json.loads(element) if isinstance(element, str) else element
        if not isinstance(record, dict) or record.get("stat") == "Not_Ok":
            continue
        bar_time = datetime.datetime.fromtimestamp(int(record["ssboe"]),
                                                   datetime.timezone.utc)
        if not is_intraday(interval):
            bar_time = daily_bar_time(bar_time)
        open_interest = record.get("oi")
        bars.append((bar_time,
                     float(record["into"]), float(record["inth"]),
                     float(record["intl"]), float(record["intc"]),
                     int(float(record.get("intv") or 0)),
                     int(float(open_interest)) if open_interest is not None else None))
    bars.sort(key=lambda bar: bar[0])
    return bars

instruments

instruments()

Every instrument this deployment has published, identified for both endpoints.

The two endpoints want different things - TPSeries an exchange and a numeric token, EODChartData an exchange joined to a trading symbol - so all three are stored as one identifier and split again when the request is built. One progress row then serves both.

A Noren token is unique only within its scrip file: the same number appears under NSE and under NFO, some sixteen hundred times on this snapshot, which is why the exchange is part of the identifier rather than a detail recovered later.

The expiry is converted from Noren's 27-OCT-2026 to a date, because priority_for reads dates and would otherwise queue every expired contract as though it were live.

Example usage

From example_2_identifiers_and_refusal_objects.py
self.candles.instruments()
Source code in stock_brokers/instruments/historical/noren.py
def instruments(self):
    """
    Every instrument this deployment has published, identified for both endpoints.

    The two endpoints want different things - `TPSeries` an exchange and a numeric token,
    `EODChartData` an exchange joined to a trading symbol - so all three are stored as one
    identifier and split again when the request is built. One progress row then serves both.

    A Noren token is unique only within its scrip file: the same number appears under NSE and
    under NFO, some sixteen hundred times on this snapshot, which is why the exchange is part
    of the identifier rather than a detail recovered later.

    The expiry is converted from Noren's `27-OCT-2026` to a date, because `priority_for` reads
    dates and would otherwise queue every expired contract as though it were live.
    """
    query = f"""
        select distinct on (i.exchange, i."{self.TOKEN_COLUMN}")
               i.exchange, i."{self.TOKEN_COLUMN}", i.tradingsymbol,
               i."{self.TYPE_COLUMN}", i."{self.EXPIRY_COLUMN}"
        from {self.BROKER_NAME}.instruments i
        where i."{self.TOKEN_COLUMN}" is not null and i."{self.TOKEN_COLUMN}" <> ''
          and i.exchange is not null and i.exchange <> ''
          and i.tradingsymbol is not null and i.tradingsymbol <> ''
        order by i.exchange, i."{self.TOKEN_COLUMN}", i.download_date desc
    """
    with self._connection.cursor() as cursor:
        cursor.execute(query)
        rows = cursor.fetchall()
    self._connection.commit()

    return [(f"{exchange}|{token}|{trading_symbol}", instrument_type,
             self._expiry_date(expiry))
            for exchange, token, trading_symbol, instrument_type, expiry in rows]

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)

claim

claim()

The next series due for a window, or None when there is nothing to do.

Ordered by priority and then by how long a series has been waiting. Series that have reached the broker's limit, or that are backing off after failures, are skipped.

Example usage

From example_1_one_window_of_daily_bars.py
series = self.downloader.claim()
Source code in stock_brokers/instruments/historical/base.py
def claim(self):
    """
    The next series due for a window, or None when there is nothing to do.

    Ordered by priority and then by how long a series has been waiting. Series that have
    reached the broker's limit, or that are backing off after failures, are skipped.
    """
    with self._connection.cursor() as cursor:
        cursor.execute(f"""
            select instrument_token, "interval", earliest_bar_time, latest_bar_time,
                   oldest_requested_time, empty_window_streak, consecutive_failures
            from {self._progress_table}
            where reached_broker_limit = false
              and consecutive_failures < %s
              and (next_attempt_after is null or next_attempt_after <= now())
            order by priority, last_attempt_at nulls first
            limit 1
        """, (MAXIMUM_CONSECUTIVE_FAILURES,))
        row = cursor.fetchone()
    self._connection.commit()
    return row

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.")

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()

run

run(stop_event=None, deadline_seconds=None)

Work the queue until stopped, out of work, or out of time.

Returns a (visits, bars) pair.

  • stop_event is a threading.Event that ends the loop when set.
  • deadline_seconds ends the loop after this long, for a job with a nightly window.

Example usage

From example_2_run_until_the_queue_is_drained.py
visits, bars = self.downloader.run()
From example_2_run_until_the_queue_is_drained.py
print(f'A run asked to stop before it starts: {self.downloader.run(stop_event=stop_event)}')
From example_3_logging_in_again_after_a_refused_session.py
visits, bars = downloader.run()
Source code in stock_brokers/instruments/historical/base.py
def run(self, stop_event=None, deadline_seconds=None):
    """
    Work the queue until stopped, out of work, or out of time.

    Returns a `(visits, bars)` pair.

    - `stop_event` is a `threading.Event` that ends the loop when set.
    - `deadline_seconds` ends the loop after this long, for a job with a nightly window.
    """
    started = time.monotonic()
    visits = 0
    bars = 0

    while True:
        if stop_event is not None and stop_event.is_set():
            self._logger.info("Stopping: asked to.")
            break
        if deadline_seconds is not None and time.monotonic() - started > deadline_seconds:
            self._logger.info(f"Stopping: reached the {deadline_seconds}s deadline.")
            break

        series = self.claim()
        if series is None:
            self._logger.info("Nothing due. The queue is drained or backing off.")
            break

        try:
            stored = self.visit(series)
        except (CandleAuthenticationError, CandleBlocked) as exception:
            self._logger.error(f"Stopping: {type(exception).__name__}: {exception}")
            break
        except CandleThrottled as exception:
            self._logger.warning(f"Stopping: {exception}")
            break
        except Exception as exception:
            # Anything `visit` did not expect - a response shaped in a way no probe produced,
            # a write the database refuses - costs this one series and not the run. Before
            # this, a single window that failed on the way into Postgres took the process
            # down, and since the same window would be claimed again on restart it took it
            # down every ten minutes thereafter.
            self._connection.rollback()
            self._record_failure(series[0], series[1],
                                 f"{type(exception).__name__}: {str(exception)[:200]}")
            self._logger.exception(f"{series[0]} {series[1]}: unexpected failure, "
                                   f"carrying on with the next series.")
            stored = 0

        visits += 1
        bars += stored
        if visits % 200 == 0:
            self._logger.info(f"{visits} windows, {bars} bars so far.")

    self._logger.info(f"Finished: {visits} windows, {bars} bars.")
    return visits, bars

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()

_expiry_date staticmethod

_expiry_date(expiry)

Turn Noren's 27-OCT-2026 expiry into a date, or None when there is none.

  • expiry is the expiry column.
Source code in stock_brokers/instruments/historical/noren.py
@staticmethod
def _expiry_date(expiry):
    """
    Turn Noren's `27-OCT-2026` expiry into a date, or None when there is none.

    - `expiry` is the `expiry` column.
    """
    if not expiry:
        return None
    try:
        return datetime.datetime.strptime(str(expiry).strip(), "%d-%b-%Y").date()
    except ValueError:
        return None

_epoch_seconds staticmethod

_epoch_seconds(day)

Midnight India time on a date, as epoch seconds, which is what Noren's windows take.

  • day is the date to convert.
Source code in stock_brokers/instruments/historical/noren.py
@staticmethod
def _epoch_seconds(day):
    """
    Midnight India time on a date, as epoch seconds, which is what Noren's windows take.

    - `day` is the date to convert.
    """
    return int(datetime.datetime(day.year, day.month, day.day,
                                 tzinfo=INDIA_TIMEZONE).timestamp())

identifier_parts staticmethod

identifier_parts(identifier)

Split a stored Noren identifier into the fields the two endpoints need.

The identifier is built in instruments and taken apart here and nowhere else, so the two cannot drift apart.

Parameters:

Name Type Description Default
identifier str

For example "NSE|2885|RELIANCE-EQ".

required

Returns:

Type Description
tuple

The exchange, the numeric token and the trading symbol.

Example usage

From example_2_identifiers_and_refusal_objects.py
exchange_name, token, trading_symbol = self.candles.identifier_parts(identifier)
Source code in stock_brokers/instruments/historical/noren.py
@staticmethod
def identifier_parts(identifier):
    """
    Split a stored Noren identifier into the fields the two endpoints need.

    The identifier is built in `instruments` and taken apart here and nowhere else, so the two
    cannot drift apart.

    Args:
        identifier (str): For example "NSE|2885|RELIANCE-EQ".

    Returns:
        tuple: The exchange, the numeric token and the trading symbol.
    """
    exchange, instrument_token, trading_symbol = str(identifier).split("|", 2)
    return exchange, instrument_token, trading_symbol

_classify

_classify(exception)

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

Nothing here retires a series. Noren reports an unknown instrument and an empty window with the same message, so the only safe reading of that message is "no bars", and a dead instrument is retired by the base class when its walk ends having collected none.

  • exception is what the API call raised.
Source code in stock_brokers/instruments/historical/noren.py
def _classify(self, exception):
    """
    Decide what a Noren refusal means for the series being downloaded.

    Nothing here retires a series. Noren reports an unknown instrument and an empty window
    with the same message, so the only safe reading of that message is "no bars", and a dead
    instrument is retired by the base class when its walk ends having collected none.

    - `exception` is what the API call raised.
    """
    text = str(exception)
    lowered = text.lower()

    if "rate_limited" in lowered or "too many requests" in lowered or "429" in text:
        return CandleThrottled(text[:200])
    if "session expired" in lowered or "invalid session key" in lowered:
        return CandleAuthenticationError(text[:200])
    return exception

_raise_for_refusal

_raise_for_refusal(body)

Raise for the refusals that mean something other than an empty window.

Noren answers HTTP 200 with an object when it refuses, so a throttle and an expired session arrive here rather than as exceptions from the API class.

  • body is the refusal object, carrying stat and emsg.
Source code in stock_brokers/instruments/historical/noren.py
def _raise_for_refusal(self, body):
    """
    Raise for the refusals that mean something other than an empty window.

    Noren answers HTTP 200 with an object when it refuses, so a throttle and an expired
    session arrive here rather than as exceptions from the API class.

    - `body` is the refusal object, carrying `stat` and `emsg`.
    """
    message = str(body.get("emsg", ""))
    lowered = message.lower()
    if "rate_limited" in lowered or "too many requests" in lowered:
        raise CandleThrottled(f"{self.BROKER_NAME} refused the request for rate: "
                              f"{message[:150]}")
    if "session expired" in lowered or "invalid session key" in lowered:
        raise CandleAuthenticationError(f"{self.BROKER_NAME} rejected the session key: "
                                        f"{message[:150]}")