Skip to content

zerodha

Zerodha mapping adapter.

Zerodha lumps almost everything under one flat instrument type per exchange, with no discriminating column of its own. NSE "EQ" covers equities, exchange traded funds, mutual fund plans, investment trusts, and corporate bonds together; BSE "EQ" the same minus the mutual funds; the currency segment covers currency pairs, government bond derivatives, and the overnight rate index together; and the derivative exchanges mix stock and index contracts.

Nothing here is a plain equality match, so this adapter classifies every row itself and the rules file carries no rules at all — only the identity and broker field mappings, which the base class still reads. Classification dispatches on the exchange, segment, and instrument type together, so each row lands in exactly one place.

The NSE flat bucket is decided by the trading symbol's suffix, taken as the last hyphen-separated part rather than the second, since a base symbol can itself contain a hyphen. The BSE flat bucket has no suffix convention and is decided by a sequence of name and symbol heuristics, with the cross-broker fund allowlist checked first: the fixed income heuristics are broad enough to claim a fund ticker otherwise.

Zerodha carries no ISIN column, so both fixed income segments take their identity from the shared security identifier map, keyed on this broker's own exchange token, which sits in the same exchange-assigned numbering the ISIN-bearing brokers use.

Classes:

Name Description
ZerodhaMappingAdapter

Mapping adapter for Zerodha, covering 30 real segments plus the uncategorised buckets.

Functions:

Name Description
is_blank

Check whether a raw value is absent or empty, in any of the forms the raw tables produce.

normalize_index_name

Reduce an index name to uppercase alphanumerics so that differently-spelled broker names collide.

ZerodhaMappingAdapter

ZerodhaMappingAdapter()

Bases: BrokerMappingAdapter

Mapping adapter for Zerodha, covering 30 real segments plus the uncategorised buckets.

Example programs

examples/stock_brokers/instruments/mapping/zerodha/ZerodhaMappingAdapter/example_1_one_flat_bucket_many_segments.py
"""Classifies Zerodha rows, where one instrument type covers equities, bonds, funds and trusts alike.

Zerodha gives every NSE cash row the instrument type EQ, whether it is a share, a government bond, a mutual fund plan or an investment trust, and it has no ISIN column. `ZerodhaMappingAdapter.classify` therefore decides everything in code: on NSE by the trading symbol's suffix (`-GS` is a bond, `-RR` a trust, `-BE` still an equity), on BSE by name and symbol heuristics, and on the derivative exchanges by whether the underlying is an index. A bond is kept only when its exchange token appears in a map from token to ISIN built out of other brokers' files, because the ISIN is its identity; the second bond below is unknown to that map and is left uncategorised.

`to_identity` strips series suffixes, stores bonds under the borrowed ISIN and brings index names to the shared spelling. `to_broker_fields` falls back to the trading symbol where a bond has no name. `classify_extra` gives a BSE row whose name equals its symbol (one of the bond heuristics) its equity membership as well.

`run` normally builds the fund lists, the token map and the index lookup from the database; this program sets them by hand, so no database is needed. `uncategorised_exchange` shows where a dropped row is filed.

Run it from the project root:

    python examples/stock_brokers/instruments/mapping/zerodha/ZerodhaMappingAdapter/example_1_one_flat_bucket_many_segments.py
"""

from stock_brokers.instruments.mapping.zerodha import (
    ZerodhaMappingAdapter,
)


class OneFlatBucketManySegmentsExample:
    """Classifies eight hand-written Zerodha rows and prints each one's segments, identity and broker symbol.

    Attributes:
        adapter (ZerodhaMappingAdapter): The adapter being shown.
        raw_rows (list): The raw rows, as dictionaries of text columns.
    """

    def __init__(self):
        """Builds the adapter, sets the lists and maps `run` would build, and writes the rows.

        Returns:
            None: This method returns nothing.
        """
        self.adapter = ZerodhaMappingAdapter()
        self.adapter.nse_fund_symbols = set()
        self.adapter.nse_etf_symbols = set()
        self.adapter.bse_fund_symbols = set()
        self.adapter.bse_trust_symbols = set()
        self.adapter.bse_fixed_income_symbols = set()
        self.adapter.bse_etf_symbols = set()
        self.adapter.nse_index_master_lookup = {}
        self.adapter.isin_by_token = {
            '15542': 'IN0020160019',
            '973412': 'INE530B07393',
        }
        self.raw_rows = [
            self.raw_row('408065', '1594', 'INFY', 'INFOSYS', 'EQ', 'NSE', 'NSE', None, None),
            self.raw_row('3677697', '14366', 'IDEA-BE', 'VODAFONE IDEA', 'EQ', 'NSE', 'NSE', None, None),
            self.raw_row('3978753', '15542', '762GS2036-GS', None, 'EQ', 'NSE', 'NSE', None, None),
            self.raw_row('3982849', '15558', '718GS2033-GS', None, 'EQ', 'NSE', 'NSE', None, None),
            self.raw_row('2455041', '9383', 'EMBASSY-RR', 'EMBASSY OFFICE PARKS REIT', 'EQ', 'NSE', 'NSE', None, None),
            self.raw_row('260105', '26009', 'NIFTY BANK', 'NIFTY BANK', 'EQ', 'INDICES', 'NSE', None, None),
            self.raw_row('12602626', '49229', 'NIFTY26OCT25000CE', 'NIFTY', 'CE', 'NFO-OPT', 'NFO', '2026-10-27', '25000'),
            self.raw_row('249193988', '973412', '800IIFL28', '800IIFL28', 'EQ', 'BSE', 'BSE', None, None),
        ]

    def raw_row(self, instrument_token, exchange_token, trading_symbol, name, instrument_type, segment, exchange, expiry, strike):
        """Builds one raw row in the shape of `zerodha.instruments`.

        Args:
            instrument_token (str): Zerodha's own token.
            exchange_token (str): The exchange's token for the row.
            trading_symbol (str): The `tradingsymbol` column.
            name (str | None): The `name` column, blank on many bonds.
            instrument_type (str): EQ, FUT, CE or PE.
            segment (str): The `segment` column, such as NSE, INDICES or NFO-OPT.
            exchange (str): The `exchange` column.
            expiry (str | None): The expiry date as text, for derivatives.
            strike (str | None): The strike price, for options.

        Returns:
            dict: The raw row, every column as text as the raw table stores it.
        """
        return {
            'instrument_token': instrument_token,
            'exchange_token': exchange_token,
            'tradingsymbol': trading_symbol,
            'name': name,
            'expiry': expiry,
            'strike': strike,
            'tick_size': '0.01',
            'lot_size': '1',
            'instrument_type': instrument_type,
            'segment': segment,
            'exchange': exchange,
        }

    def run(self):
        """Prints each row's segments, identity and broker symbol, or where an unmatched row is filed.

        Returns:
            None: This method returns nothing.
        """
        for raw_row in self.raw_rows:
            label = f'{raw_row["exchange"]} {raw_row["tradingsymbol"]} (name {raw_row["name"]})'
            segment_configuration = self.adapter.classify(raw_row)
            if segment_configuration is None:
                exchange = self.adapter.uncategorised_exchange(raw_row)
                print(f'{label} -> no segment, filed under {exchange}_uncategorised')
                continue
            extra_segments = []
            for extra_configuration in self.adapter.classify_extra(raw_row):
                extra_segments.append(extra_configuration['segment'])
            identity = self.adapter.to_identity(raw_row, segment_configuration)
            broker_fields = self.adapter.to_broker_fields(raw_row, segment_configuration)
            print(f'{label} -> {segment_configuration["segment"]}, also {extra_segments}')
            print(f'    identity {identity}, broker symbol {broker_fields["broker_symbol"]!r}')


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

It prints:

NSE INFY (name INFOSYS) -> nse_equities, also []
    identity {'symbol': 'INFY'}, broker symbol 'INFOSYS'
NSE IDEA-BE (name VODAFONE IDEA) -> nse_equities, also []
    identity {'symbol': 'IDEA'}, broker symbol 'VODAFONE IDEA'
NSE 762GS2036-GS (name None) -> nse_fixed_income, also []
    identity {'symbol': 'IN0020160019'}, broker symbol '762GS2036-GS'
NSE 718GS2033-GS (name None) -> no segment, filed under nse_uncategorised
NSE EMBASSY-RR (name EMBASSY OFFICE PARKS REIT) -> nse_investment_trusts, also []
    identity {'symbol': 'EMBASSY'}, broker symbol 'EMBASSY OFFICE PARKS REIT'
NSE NIFTY BANK (name NIFTY BANK) -> nse_equity_indices, also []
    identity {'symbol': 'BANKNIFTY'}, broker symbol 'NIFTY BANK'
NFO NIFTY26OCT25000CE (name NIFTY) -> nse_equity_index_options, also []
    identity {'underlying_symbol': 'NIFTY', 'expiry_date': datetime.date(2026, 10, 27), 'strike_price': 25000.0, 'option_type': 'CE'}, broker symbol 'NIFTY26OCT25000CE'
BSE 800IIFL28 (name 800IIFL28) -> bse_fixed_income, also ['bse_equities']
    identity {'symbol': 'INE530B07393'}, broker symbol '800IIFL28'
examples/stock_brokers/instruments/mapping/zerodha/ZerodhaMappingAdapter/example_2_mapping_one_day.py
"""Maps one day of Zerodha rows, gathering every list and map it needs from the other brokers first.

`ZerodhaMappingAdapter.run` first gathers six fund, trust and bond symbol lists from the other brokers' tables, builds a map from exchange token to ISIN out of the brokers that publish ISINs, and reads the NSE index names already stored in `unified.instruments`. It then hands over to the shared mapping run, which reads Zerodha's raw rows, classifies them, and writes `unified.instruments` and `unified.broker_mappings` in one transaction.

The adapter normally talks to PostgreSQL, so this program replaces its `engine` with a stand-in. The stand-in connection answers queries by matching a piece of their text: the raw-row query gets four hand-written Zerodha rows, and Groww's token and ISIN query knows the NSE bond. Every other cross-broker query gets no rows, and every write is recorded instead of run. pandas only reads through something it recognises as an SQLAlchemy connectable, and it recognises one by the `ConnectionEventsTarget` base class, so the stand-in connection inherits from that class.

Notice the summary: four raw rows give five segment memberships but only four instruments. `classify_extra` returns the equity segment for every ordinary BSE equity, including one already classified as an equity, so RELIANCE is counted twice and written once. Notice also that the uncategorised bond's broker symbol is `nan`: pandas turns the blank name into a missing number when it reads the rows, and only the bond segments fall back to the trading symbol.

Run it from the project root:

    python examples/stock_brokers/instruments/mapping/zerodha/ZerodhaMappingAdapter/example_2_mapping_one_day.py
"""

import collections
import datetime

from sqlalchemy.engine import interfaces

from stock_brokers.instruments.mapping.zerodha import (
    ZerodhaMappingAdapter,
)


class StandInResult:
    """A stand-in for an SQLAlchemy result holding a fixed set of rows.

    Attributes:
        columns (list): The column names.
        rows (list): The rows, as dictionaries.
    """

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

        Args:
            rows (list): The rows, as dictionaries that all share the same keys.

        Returns:
            None: This method returns nothing.
        """
        self.rows = rows
        self.columns = []
        if rows:
            self.columns = list(rows[0])

    def keys(self):
        """The column names, as pandas asks for them.

        Returns:
            list: The column names.
        """
        return self.columns

    def fetchall(self):
        """Every row as a plain tuple, as pandas asks for them.

        Returns:
            list: The rows as tuples in column order.
        """
        tuples = []
        for row in self.rows:
            tuples.append(tuple(row.values()))
        return tuples

    def all(self):
        """Every row as a named tuple, so a column can be read as an attribute.

        Returns:
            list: The rows as named tuples.
        """
        row_type = collections.namedtuple('StandInRow', self.columns)
        named_rows = []
        for row in self.rows:
            named_rows.append(row_type(**row))
        return named_rows


class StandInConnection(interfaces.ConnectionEventsTarget):
    """A stand-in for a PostgreSQL connection that answers queries from prepared rows and records writes.

    Attributes:
        answers (list): Pairs of (a piece of query text, the rows to answer with).
        unanswered_queries (int): How many queries matched no prepared answer and got no rows.
        writes (list): Each write statement's first words and its parameters.
    """

    def __init__(self, answers):
        """Holds the prepared answers and starts with nothing recorded.

        Args:
            answers (list): Pairs of (a piece of query text, the rows to answer with).

        Returns:
            None: This method returns nothing.
        """
        self.answers = answers
        self.unanswered_queries = 0
        self.writes = []

    def __enter__(self):
        """Opens the connection for a `with` block.

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

    def __exit__(self, exception_type, exception, traceback):
        """Closes the connection at the end of a `with` block.

        Args:
            exception_type (type | None): The type of any exception raised in the block.
            exception (BaseException | None): Any exception raised in the block.
            traceback (object | None): The traceback of any exception raised in the block.

        Returns:
            bool: False, so any exception carries on.
        """
        return False

    def execute(self, statement, parameters=None):
        """Answers a query from the prepared rows, or records a write.

        Args:
            statement (sqlalchemy.sql.elements.TextClause): The statement to run.
            parameters (dict | list | None): Its bound parameters.

        Returns:
            StandInResult: The prepared rows whose text matches, otherwise an empty result.
        """
        sql = ' '.join(str(statement).split())
        if not sql.startswith('SELECT'):
            self.writes.append((' '.join(sql.split()[:3]), parameters))
            return StandInResult([])
        for query_text, rows in self.answers:
            if query_text in sql:
                return StandInResult(rows)
        self.unanswered_queries += 1
        return StandInResult([])


class StandInEngine:
    """A stand-in for an SQLAlchemy engine that always hands out the same stand-in connection.

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

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

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

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

    def connect(self):
        """Hands out the connection for reading.

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

    def begin(self):
        """Hands out the connection for one transaction.

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


class MappingOneDayExample:
    """Maps four Zerodha rows for one date and prints what would be written.

    Attributes:
        mapping_date (datetime.date): The snapshot date being mapped.
        connection (StandInConnection): The stand-in connection.
        adapter (ZerodhaMappingAdapter): The adapter being shown, using the stand-in engine.
    """

    def __init__(self):
        """Builds the adapter and swaps its engine for the stand-in.

        Returns:
            None: This method returns nothing.
        """
        self.mapping_date = datetime.date(2026, 9, 28)
        raw_rows = [
            self.raw_row('408065', '1594', 'INFY', 'INFOSYS', 'NSE'),
            self.raw_row('3978753', '15542', '762GS2036-GS', None, 'NSE'),
            self.raw_row('3982849', '15558', '718GS2033-GS', None, 'NSE'),
            self.raw_row('128083204', '500325', 'RELIANCE', 'RELIANCE INDUSTRIES', 'BSE'),
        ]
        groww_isins = [
            {
                'exchange_token': '15542',
                'isin': 'IN0020160019',
            },
        ]
        answers = [
            (
                'SELECT * FROM zerodha.instruments',
                raw_rows,
            ),
            (
                'SELECT exchange_token, isin FROM groww.instruments',
                groww_isins,
            ),
        ]
        self.connection = StandInConnection(answers)
        self.adapter = ZerodhaMappingAdapter()
        self.adapter.engine = StandInEngine(self.connection)

    def raw_row(self, instrument_token, exchange_token, trading_symbol, name, exchange):
        """Builds one cash-market raw row in the shape of `zerodha.instruments`.

        Args:
            instrument_token (str): Zerodha's own token.
            exchange_token (str): The exchange's token for the row.
            trading_symbol (str): The `tradingsymbol` column.
            name (str | None): The `name` column, blank on many bonds.
            exchange (str): NSE or BSE, which is also the segment for cash rows.

        Returns:
            dict: The raw row, every column as text as the raw table stores it.
        """
        return {
            'instrument_token': instrument_token,
            'exchange_token': exchange_token,
            'tradingsymbol': trading_symbol,
            'name': name,
            'last_price': '0',
            'expiry': None,
            'strike': '0',
            'tick_size': '0.01',
            'lot_size': '1',
            'instrument_type': 'EQ',
            'segment': exchange,
            'exchange': exchange,
            'download_date': self.mapping_date,
        }

    def run(self):
        """Maps the day and prints the summary and the rows written.

        Returns:
            None: This method returns nothing.
        """
        summary = self.adapter.run(self.mapping_date)
        print(f'Token to ISIN map: {self.adapter.isin_by_token}')
        print(f'Cross-broker queries that found nothing: {self.connection.unanswered_queries}')
        print(f'Summary: raw rows {summary["raw_rows"]}, matched {summary["matched"]}, uncategorised {summary["uncategorised"]}, memberships {summary["memberships"]}, instruments {summary["instruments_upserted"]}')
        for statement, parameters in self.connection.writes:
            print(statement)
            if not isinstance(parameters, list):
                continue
            for row in parameters:
                if 'segment' in row:
                    print(f'  {row["segment"]}: {row["symbol"]}')
                else:
                    print(f'  token {row["broker_token"]}: {row["broker_symbol"]}')


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

It prints:

zerodha: 3/4 raw row(s) classified, 1 uncategorised, 5 segment membership(s), 4 distinct instrument(s), 0 error(s).
Token to ISIN map: {'15542': 'IN0020160019'}
Cross-broker queries that found nothing: 36
Summary: raw rows 4, matched 3, uncategorised 1, memberships 5, instruments 4
DELETE FROM unified.broker_mappings
INSERT INTO unified.instruments
  nse_uncategorised: 718GS2033-GS
  nse_fixed_income: IN0020160019
  bse_equities: RELIANCE
  nse_equities: INFY
INSERT INTO unified.broker_mappings
  token 3982849: nan
  token 3978753: 762GS2036-GS
  token 128083204: RELIANCE INDUSTRIES
  token 408065: INFOSYS

Load and validate the broker's rules file and create the database engine.

Raises:

Type Description
NotImplementedError

If the subclass did not set BROKER_NAME.

ValueError

If the rules file fails validation.

FileNotFoundError

If the broker has no rules file.

Example programs

examples/stock_brokers/instruments/mapping/base/BrokerMappingAdapter/example_1_classifying_rows_by_rules.py
"""Classifies three raw instrument rows using nothing but a broker's rules file.

`BrokerMappingAdapter` is the base class every broker's mapping adapter subclasses. A subclass only has to name its broker in `BROKER_NAME`, and the base class loads the matching rules file from `stock_brokers/instruments/mapping/utilities/rules/`, checks it against the canonical segment vocabulary, and classifies rows by plain equality rules. This program's subclass sets `BROKER_NAME` to Dhan and overrides nothing, so every answer below comes from the base class and Dhan's rules file alone, without the extra filters the real `DhanMappingAdapter` adds.

The rows are written out by hand in the shape of `dhan.instruments`, where every column is text. The adapter builds a database engine when it is constructed, but an engine does not connect until it is used, and this program never asks it to, so nothing touches PostgreSQL.

For each row the program prints the segment the rules chose, any extra segments (the base class never adds any), the identity fields that go into the instrument id, the broker's own fields, and the deterministic instrument id. The third row matches no rule. The base class cannot tell which exchange it belongs to, so it falls into the unprefixed `uncategorised` segment, and the run would store its token as its symbol.

Run it from the project root:

    python examples/stock_brokers/instruments/mapping/base/BrokerMappingAdapter/example_1_classifying_rows_by_rules.py
"""

from stock_brokers.instruments.mapping import base
from stock_brokers.instruments.mapping.base import (
    BrokerMappingAdapter,
)


class DhanRulesOnlyAdapter(BrokerMappingAdapter):
    """A mapping adapter that uses Dhan's rules file and nothing but the base class's behaviour."""

    BROKER_NAME = 'dhan'


class ClassifyingRowsByRulesExample:
    """Runs three hand-written Dhan rows through the base class's rules engine and prints the result.

    Attributes:
        adapter (DhanRulesOnlyAdapter): The adapter being shown.
        raw_rows (list): The raw rows to classify, as dictionaries of text columns.
    """

    def __init__(self):
        """Builds the adapter and the three raw rows.

        Returns:
            None: This method returns nothing.
        """
        self.adapter = DhanRulesOnlyAdapter()
        self.raw_rows = [
            {
                'exch_id': 'NSE',
                'segment': 'E',
                'security_id': '1594',
                'isin': 'INE009A01021',
                'instrument': 'EQUITY',
                'underlying_symbol': 'INFY',
                'symbol_name': 'INFOSYS LIMITED',
                'display_name': 'Infosys',
                'instrument_type': 'ES',
                'series': 'EQ',
                'lot_size': '1.0',
                'sm_expiry_date': None,
                'strike_price': None,
                'option_type': None,
                'tick_size': '10.0',
            },
            {
                'exch_id': 'NSE',
                'segment': 'D',
                'security_id': '40215',
                'isin': None,
                'instrument': 'OPTIDX',
                'underlying_symbol': 'NIFTY',
                'symbol_name': 'NIFTY-Oct2026-25000-CE',
                'display_name': 'NIFTY 27 OCT 25000 CALL',
                'instrument_type': 'OP',
                'series': None,
                'lot_size': '75.0',
                'sm_expiry_date': '2026-10-27',
                'strike_price': '25000.00000',
                'option_type': 'CE',
                'tick_size': '5.0',
            },
            {
                'exch_id': 'NSE',
                'segment': 'X',
                'security_id': '990001',
                'isin': None,
                'instrument': 'SPREAD',
                'underlying_symbol': '',
                'symbol_name': 'TEST SPREAD',
                'display_name': 'Test spread',
                'instrument_type': 'SP',
                'series': None,
                'lot_size': '1.0',
                'sm_expiry_date': None,
                'strike_price': None,
                'option_type': None,
                'tick_size': '0',
            },
        ]

    def run(self):
        """Prints how the rules file classifies each row.

        Returns:
            None: This method returns nothing.
        """
        equities = self.adapter.segment_config('nse_equities')
        print(f'Rules file: {self.adapter.config["raw_table"]}, {len(self.adapter.config["segments"])} segments')
        print(f'nse_equities is a {equities["shape"]} on {equities["exchange"]} with {len(equities["rules"])} rules')
        print(f'Unconfigured segment: {self.adapter.segment_config("nse_bonds")}')
        for raw_row in self.raw_rows:
            print()
            print(f'Row {raw_row["security_id"]} ({raw_row["symbol_name"]})')
            segment_configuration = self.adapter.classify(raw_row)
            if segment_configuration is None:
                exchange = self.adapter.uncategorised_exchange(raw_row)
                print(f'  No rule matched, and the base class finds no exchange: {exchange}')
                segment_configuration = self.adapter.segment_config('uncategorised')
            extra_segments = self.adapter.classify_extra(raw_row)
            identity = self.adapter.to_identity(raw_row, segment_configuration)
            broker_fields = self.adapter.to_broker_fields(raw_row, segment_configuration)
            identifier = base.instrument_id(
                segment_configuration['exchange'],
                segment_configuration['segment'],
                segment_configuration['shape'],
                identity,
            )
            print(f'  Segment: {segment_configuration["segment"]} ({segment_configuration["shape"]})')
            print(f'  Extra segments: {extra_segments}')
            print(f'  Identity: {identity}')
            print(f'  Broker fields: {broker_fields}')
            print(f'  Instrument id: {identifier}')


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

It prints:

Rules file: dhan.instruments, 32 segments
nse_equities is a security on nse with 2 rules
Unconfigured segment: None

Row 1594 (INFOSYS LIMITED)
  Segment: nse_equities (security)
  Extra segments: []
  Identity: {'symbol': 'INFY'}
  Broker fields: {'broker_token': '1594', 'broker_symbol': 'Infosys', 'order_symbol': None, 'lot_size': 1.0, 'tick_size': 0.1}
  Instrument id: f310e1b0-e082-5b16-808a-25f741abb9de

Row 40215 (NIFTY-Oct2026-25000-CE)
  Segment: nse_equity_index_options (option)
  Extra segments: []
  Identity: {'underlying_symbol': 'NIFTY', 'expiry_date': datetime.date(2026, 10, 27), 'strike_price': 25000.0, 'option_type': 'CE'}
  Broker fields: {'broker_token': '40215', 'broker_symbol': 'NIFTY-Oct2026-25000-CE', 'order_symbol': None, 'lot_size': 75.0, 'tick_size': 0.05}
  Instrument id: fe85c704-ce49-5190-b068-d4d3646ed162

Row 990001 (TEST SPREAD)
  No rule matched, and the base class finds no exchange: None
  Segment: uncategorised (security)
  Extra segments: []
  Identity: {'symbol': ''}
  Broker fields: {'broker_token': '990001', 'broker_symbol': 'Test spread', 'order_symbol': None, 'lot_size': 1.0, 'tick_size': None}
  Instrument id: 55a8952d-9071-5670-a4fe-a5384d3876bd
examples/stock_brokers/instruments/mapping/base/BrokerMappingAdapter/example_2_mapping_one_day.py
"""Maps one day's raw rows into the two unified tables, against a stand-in database.

`run` is what the daily mapping job calls for each broker. It reads the broker's raw rows for one date with `read_raw_rows`, classifies each row, and then, inside one transaction, deletes that broker's mappings for the date and upserts one row per instrument into `unified.instruments` and one per broker mapping into `unified.broker_mappings`. It returns a summary and prints a one-line report.

The adapter normally reads and writes PostgreSQL, so this program replaces its `engine` with a small stand-in. The stand-in connection answers the raw-row query with four hand-written rows in the shape of `dhan.instruments`, and records every other statement it is sent instead of running it. pandas only reads through something it recognises as an SQLAlchemy connectable, and it recognises one by the `ConnectionEventsTarget` base class, so the stand-in connection inherits from that class and nothing else.

The subclass uses Dhan's rules file and overrides nothing. The fourth row has a strike price that is not a number, so building its identity fails; `run` records that as an error and carries on with the other rows rather than stopping. The unmatched row is stored under its token as its symbol, and its tick size of 5.0 is kept as written, because Dhan's rules divide the tick size by 100 only in the real segments and not in the uncategorised one. The last lines show that a subclass which forgets to set `BROKER_NAME` cannot be built.

Run it from the project root:

    python examples/stock_brokers/instruments/mapping/base/BrokerMappingAdapter/example_2_mapping_one_day.py
"""

import datetime

from sqlalchemy.engine import interfaces

from stock_brokers.instruments.mapping.base import (
    BrokerMappingAdapter,
)


class DhanRulesOnlyAdapter(BrokerMappingAdapter):
    """A mapping adapter that uses Dhan's rules file and nothing but the base class's behaviour."""

    BROKER_NAME = 'dhan'


class UnnamedAdapter(BrokerMappingAdapter):
    """A mapping adapter that forgets to name its broker."""


class StandInResult:
    """A stand-in for an SQLAlchemy result holding a fixed set of rows.

    Attributes:
        columns (list): The column names.
        rows (list): The rows, as tuples in column order.
    """

    def __init__(self, columns, rows):
        """Holds the columns and rows.

        Args:
            columns (list): The column names.
            rows (list): The rows, as tuples in column order.

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

    def keys(self):
        """The column names, as pandas asks for them.

        Returns:
            list: The column names.
        """
        return self.columns

    def fetchall(self):
        """Every row, as pandas asks for them.

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

    def all(self):
        """Every row, as the adapter's own queries ask for them.

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


class StandInConnection(interfaces.ConnectionEventsTarget):
    """A stand-in for a PostgreSQL connection that serves one raw table and records every other statement.

    Attributes:
        raw_table (str): The raw table whose rows this connection serves.
        raw_rows (list): The raw rows, as dictionaries of column values.
        statements (list): Each recorded statement's first line and its parameters.
    """

    def __init__(self, raw_table, raw_rows):
        """Holds the raw rows and starts with no statements recorded.

        Args:
            raw_table (str): The raw table whose rows this connection serves.
            raw_rows (list): The raw rows, as dictionaries of column values.

        Returns:
            None: This method returns nothing.
        """
        self.raw_table = raw_table
        self.raw_rows = raw_rows
        self.statements = []

    def __enter__(self):
        """Opens the connection for a `with` block.

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

    def __exit__(self, exception_type, exception, traceback):
        """Closes the connection at the end of a `with` block.

        Args:
            exception_type (type | None): The type of any exception raised in the block.
            exception (BaseException | None): Any exception raised in the block.
            traceback (object | None): The traceback of any exception raised in the block.

        Returns:
            bool: False, so any exception carries on.
        """
        return False

    def execute(self, statement, parameters=None):
        """Answers the raw-row query from the held rows and records anything else.

        Args:
            statement (sqlalchemy.sql.elements.TextClause): The statement to run.
            parameters (dict | list | None): Its bound parameters.

        Returns:
            StandInResult: The raw rows for the raw-row query, otherwise an empty result.
        """
        sql = ' '.join(str(statement).split())
        if sql.startswith(f'SELECT * FROM {self.raw_table} '):
            columns = list(self.raw_rows[0])
            rows = []
            for raw_row in self.raw_rows:
                values = []
                for column in columns:
                    values.append(raw_row[column])
                rows.append(tuple(values))
            return StandInResult(columns, rows)
        self.statements.append((sql, parameters))
        return StandInResult([], [])


class StandInEngine:
    """A stand-in for an SQLAlchemy engine that always hands out the same stand-in connection.

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

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

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

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

    def connect(self):
        """Hands out the connection for reading.

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

    def begin(self):
        """Hands out the connection for one transaction.

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


class MappingOneDayExample:
    """Maps four raw rows for one date and prints what would be written.

    Attributes:
        mapping_date (datetime.date): The snapshot date being mapped.
        connection (StandInConnection): The stand-in connection holding the raw rows.
        adapter (DhanRulesOnlyAdapter): The adapter being shown, using the stand-in engine.
    """

    def __init__(self):
        """Builds the adapter and swaps its engine for the stand-in.

        Returns:
            None: This method returns nothing.
        """
        self.mapping_date = datetime.date(2026, 9, 28)
        raw_rows = [
            self.raw_row('1594', 'NSE', 'E', 'EQUITY', 'ES', 'INFY', 'INFOSYS LIMITED', None, None, None),
            self.raw_row('40215', 'NSE', 'D', 'OPTIDX', 'OP', 'NIFTY', 'NIFTY-Oct2026-25000-CE', '2026-10-27', '25000.00000', 'CE'),
            self.raw_row('990001', 'NSE', 'X', 'SPREAD', 'SP', '', 'TEST SPREAD', None, None, None),
            self.raw_row('40216', 'NSE', 'D', 'OPTIDX', 'OP', 'NIFTY', 'NIFTY-Oct2026-XX-PE', '2026-10-27', 'not a number', 'PE'),
        ]
        self.connection = StandInConnection('dhan.instruments', raw_rows)
        self.adapter = DhanRulesOnlyAdapter()
        self.adapter.engine = StandInEngine(self.connection)

    def raw_row(self, security_id, exchange, segment, instrument, instrument_type, underlying_symbol, symbol_name, expiry, strike_price, option_type):
        """Builds one raw row in the shape of `dhan.instruments`.

        Args:
            security_id (str): Dhan's token for the row.
            exchange (str): The `exch_id` column.
            segment (str): Dhan's one-letter segment code.
            instrument (str): The `instrument` column.
            instrument_type (str): The `instrument_type` column.
            underlying_symbol (str): The `underlying_symbol` column.
            symbol_name (str): The `symbol_name` column.
            expiry (str | None): The `sm_expiry_date` column.
            strike_price (str | None): The `strike_price` column.
            option_type (str | None): The `option_type` column.

        Returns:
            dict: The raw row, every column as text as the raw table stores it.
        """
        return {
            'exch_id': exchange,
            'segment': segment,
            'security_id': security_id,
            'isin': None,
            'instrument': instrument,
            'underlying_security_id': None,
            'underlying_symbol': underlying_symbol,
            'symbol_name': symbol_name,
            'display_name': symbol_name,
            'instrument_type': instrument_type,
            'series': None,
            'lot_size': '1.0',
            'sm_expiry_date': expiry,
            'strike_price': strike_price,
            'option_type': option_type,
            'tick_size': '5.0',
            'download_date': self.mapping_date,
        }

    def short_row(self, row):
        """Shortens one written row to its filled-in columns, leaving out the dates the example already names.

        Args:
            row (dict): One row the adapter asked to insert.

        Returns:
            dict: The row's columns that hold a value, with the instrument id cut to its first eight characters.
        """
        shortened = {}
        for column, value in row.items():
            if value is None or isinstance(value, datetime.date):
                continue
            if column == 'instrument_id':
                value = value[:8]
            shortened[column] = value
        return shortened

    def run(self):
        """Reads the raw rows, maps them, and prints the summary and the statements sent.

        Returns:
            None: This method returns nothing.
        """
        raw = self.adapter.read_raw_rows(self.connection, self.mapping_date)
        print(f'read_raw_rows returned {len(raw)} rows with {len(raw.columns)} columns')
        print()
        summary = self.adapter.run(self.mapping_date)
        print()
        for key in summary:
            print(f'{key}: {summary[key]}')
        print()
        print('Statements sent inside the write transaction:')
        for sql, parameters in self.connection.statements:
            print(f'  {sql[:60]}...')
            if isinstance(parameters, list):
                for row in parameters:
                    print(f'    {self.short_row(row)}')
            else:
                print(f'    {parameters}')
        print()
        try:
            UnnamedAdapter()
        except NotImplementedError as error:
            print(f'NotImplementedError: {error}')


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

It prints:

read_raw_rows returned 4 rows with 17 columns

dhan: 3/4 raw row(s) classified, 1 uncategorised, 3 segment membership(s), 3 distinct instrument(s), 1 error(s).

broker: dhan
mapping_date: 2026-09-28
raw_rows: 4
matched: 3
uncategorised: 1
memberships: 3
instruments_upserted: 3
errors: [('nse_equity_index_options', "could not convert string to float: 'not a number'")]

Statements sent inside the write transaction:
  DELETE FROM unified.broker_mappings WHERE broker = :b AND ma...
    {'b': 'dhan', 'd': datetime.date(2026, 9, 28)}
  INSERT INTO unified.instruments AS master (instrument_id, ex...
    {'instrument_id': '8fdcd38e', 'exchange': 'unknown', 'segment': 'uncategorised', 'shape': 'security', 'symbol': '990001'}
    {'instrument_id': 'f310e1b0', 'exchange': 'nse', 'segment': 'nse_equities', 'shape': 'security', 'symbol': 'INFY'}
    {'instrument_id': 'fe85c704', 'exchange': 'nse', 'segment': 'nse_equity_index_options', 'shape': 'option', 'underlying_symbol': 'NIFTY', 'strike_price': 25000.0, 'option_type': 'CE'}
  INSERT INTO unified.broker_mappings (instrument_id, broker, ...
    {'instrument_id': '8fdcd38e', 'broker': 'dhan', 'broker_token': '990001', 'broker_symbol': 'TEST SPREAD', 'lot_size': 1.0, 'tick_size': 5.0, 'attributes': '{"display_name": "TEST SPREAD", "instrument_type": "SP"}'}
    {'instrument_id': 'f310e1b0', 'broker': 'dhan', 'broker_token': '1594', 'broker_symbol': 'INFOSYS LIMITED', 'lot_size': 1.0, 'tick_size': 0.05, 'attributes': '{"display_name": "INFOSYS LIMITED", "instrument_type": "ES"}'}
    {'instrument_id': 'fe85c704', 'broker': 'dhan', 'broker_token': '40215', 'broker_symbol': 'NIFTY-Oct2026-25000-CE', 'lot_size': 1.0, 'tick_size': 0.05, 'attributes': '{"display_name": "NIFTY-Oct2026-25000-CE", "instrument_type": "OP"}'}

NotImplementedError: UnnamedAdapter must set BROKER_NAME

Methods:

Name Description
run

Map Zerodha's raw rows for one date, precomputing the six allowlists, the ISIN map, and the index lookup first.

classify

Classify a raw row, then check that a bond row can actually be identified.

classify_extra

Return the equity membership a BSE cash-market row also has when it meets the equity criteria in its own right.

to_identity

Build the unified identity fields, resolving bond ISINs, stripping series suffixes, and aliasing index names.

to_broker_fields

Build the per-broker mapping fields, falling back to the trading symbol where the name is blank.

uncategorised_exchange

Determine the canonical exchange for an unmatched Zerodha row from its exchange column.

segment_config

Look up one segment configuration by its segment value.

read_raw_rows

Read the broker's raw rows for one date.

Source code in stock_brokers/instruments/mapping/base.py
def __init__(self):
    """
    Load and validate the broker's rules file and create the database engine.

    Raises:
        NotImplementedError: If the subclass did not set BROKER_NAME.
        ValueError: If the rules file fails validation.
        FileNotFoundError: If the broker has no rules file.
    """
    if not self.BROKER_NAME:
        raise NotImplementedError(f"{type(self).__name__} must set BROKER_NAME")
    with open(RULES_DIRECTORY / f"{self.BROKER_NAME}.yaml") as rules_file:
        self.config = yaml.safe_load(rules_file)
    self._validate_config()
    self.engine = get_postgres_engine()
    self.raw_attributes = RawAttributes()

run

run(mapping_date)

Map Zerodha's raw rows for one date, precomputing the six allowlists, the ISIN map, and the index lookup first.

Parameters:

Name Type Description Default
mapping_date date

The raw snapshot date to map.

required

Returns:

Type Description
dict

The run summary, as described in BrokerMappingAdapter.run.

Example usage

From example_2_mapping_one_day.py
summary = self.adapter.run(self.mapping_date)
Source code in stock_brokers/instruments/mapping/zerodha.py
def run(self, mapping_date):
    """
    Map Zerodha's raw rows for one date, precomputing the six allowlists, the ISIN map, and the index lookup first.

    Args:
        mapping_date (datetime.date): The raw snapshot date to map.

    Returns:
        dict: The run summary, as described in BrokerMappingAdapter.run.
    """
    with self.engine.connect() as connection:
        self.nse_fund_symbols = known_nse_fund_symbols(connection, mapping_date)
        self.nse_etf_symbols = known_nse_etf_symbols(connection, mapping_date)
        self.bse_fund_symbols = known_bse_fund_symbols(connection, mapping_date)
        self.bse_trust_symbols = known_bse_investment_trust_symbols(connection, mapping_date)
        self.bse_fixed_income_symbols = known_bse_fixed_income_symbols(connection, mapping_date)
        self.bse_etf_symbols = known_bse_etf_symbols(connection, mapping_date)
        self.isin_by_token = security_id_to_isin(connection, mapping_date)
        self.nse_index_master_lookup = equity_index_lookup(connection, mapping_date)


    return super().run(mapping_date)

classify

classify(raw_row)

Classify a raw row, then check that a bond row can actually be identified.

Both fixed income segments take their identity from the shared security identifier map, so a row that map cannot resolve has no identity to be written under. It is left for the uncategorised bucket rather than dropped, which keeps the run's coverage reconciling: on a date where only this broker was downloaded, the map is empty and every bond row lands there.

Parameters:

Name Type Description Default
raw_row dict

One raw row from zerodha.instruments.

required

Returns:

Type Description
dict | None

The matched segment configuration, or None when no segment matches or a bond row cannot be identified.

Example usage

From example_1_one_flat_bucket_many_segments.py
segment_configuration = self.adapter.classify(raw_row)
Source code in stock_brokers/instruments/mapping/zerodha.py
def classify(self, raw_row):
    """
    Classify a raw row, then check that a bond row can actually be identified.

    Both fixed income segments take their identity from the shared security identifier map, so a row that map cannot resolve has no identity to be written under. It is left for the uncategorised bucket rather than dropped, which keeps the run's coverage reconciling: on a date where only this broker was downloaded, the map is empty and every bond row lands there.

    Args:
        raw_row (dict): One raw row from zerodha.instruments.

    Returns:
        dict | None: The matched segment configuration, or None when no segment matches or a bond row cannot be identified.
    """
    segment_configuration = self._classify_by_exchange(raw_row)
    if segment_configuration is None:
        return None
    if segment_configuration["segment"] in FIXED_INCOME_SEGMENTS:
        if str(raw_row.get("exchange_token")) not in self.isin_by_token:
            return None
    return segment_configuration

_classify_by_exchange

_classify_by_exchange(raw_row)

Dispatch a raw row on its exchange, segment, and instrument type together.

Parameters:

Name Type Description Default
raw_row dict

One raw row from zerodha.instruments.

required

Returns:

Type Description
dict | None

The matched segment configuration, or None when no segment matches.

Source code in stock_brokers/instruments/mapping/zerodha.py
def _classify_by_exchange(self, raw_row):
    """
    Dispatch a raw row on its exchange, segment, and instrument type together.

    Args:
        raw_row (dict): One raw row from zerodha.instruments.

    Returns:
        dict | None: The matched segment configuration, or None when no segment matches.
    """
    exchange = raw_row.get("exchange")
    segment = raw_row.get("segment")
    instrument_type = raw_row.get("instrument_type")

    if exchange == "NSE" and segment == "NSE" and instrument_type == "EQ":
        return self._classify_nse_flat(raw_row)
    if exchange == "NSE" and segment == "INDICES":
        return self._classify_nse_index(raw_row)
    if exchange == "NFO":
        return self._classify_nse_derivatives(raw_row, instrument_type)
    if exchange == "BSE" and segment == "BSE" and instrument_type == "EQ":
        return self._classify_bse_flat(raw_row)
    if exchange == "BSE" and segment == "INDICES":
        return self.segment_config("bse_equity_indices")
    if exchange == "BFO":
        return self._classify_bse_derivatives(instrument_type)
    if exchange == "MCX":
        return self._classify_mcx(raw_row, segment)
    if exchange == "NCO":
        return self._classify_nse_commodities(instrument_type)
    if exchange == "CDS":
        return self._classify_currency_derivatives(raw_row, instrument_type)
    return None

_classify_nse_flat

_classify_nse_flat(raw_row)

Route an NSE cash-market row, which shares one instrument type across five segments, by its trading symbol suffix.

Parameters:

Name Type Description Default
raw_row dict

One raw row from zerodha.instruments.

required

Returns:

Type Description
dict | None

The matched segment configuration, or None.

Source code in stock_brokers/instruments/mapping/zerodha.py
def _classify_nse_flat(self, raw_row):
    """
    Route an NSE cash-market row, which shares one instrument type across five segments, by its trading symbol suffix.

    Args:
        raw_row (dict): One raw row from zerodha.instruments.

    Returns:
        dict | None: The matched segment configuration, or None.
    """
    trading_symbol = raw_row.get("tradingsymbol") or ""
    name = raw_row.get("name")
    name_is_blank = is_blank(name)
    parts = trading_symbol.split("-")
    suffix = parts[-1] if len(parts) > 1 else None

    if suffix in NSE_BOND_SUFFIXES:
        return self.segment_config("nse_fixed_income")
    if name_is_blank and "-" in trading_symbol and suffix not in NSE_BLANK_NAME_NON_BOND_SUFFIXES:
        return self.segment_config("nse_fixed_income")
    if suffix in ("IV", "RR"):
        return self.segment_config("nse_investment_trusts")
    if suffix == "SF":
        return self.segment_config("nse_mutual_funds")
    if name_is_blank:
        return None
    if "INAV" in trading_symbol:
        return None
    if trading_symbol in self.nse_etf_symbols:
        return self.segment_config("nse_exchange_traded_funds")
    if "etf" in str(name).lower():
        return None
    if trading_symbol in self.nse_fund_symbols:
        return None
    return self.segment_config("nse_equities")

_classify_nse_index

_classify_nse_index(raw_row)

Route an NSE index row, excluding the bond-shaped and foreign reference rows that are not equity indices.

Parameters:

Name Type Description Default
raw_row dict

One raw row from zerodha.instruments.

required

Returns:

Type Description
dict | None

The index segment configuration, or None.

Source code in stock_brokers/instruments/mapping/zerodha.py
def _classify_nse_index(self, raw_row):
    """
    Route an NSE index row, excluding the bond-shaped and foreign reference rows that are not equity indices.

    Args:
        raw_row (dict): One raw row from zerodha.instruments.

    Returns:
        dict | None: The index segment configuration, or None.
    """
    trading_symbol = raw_row.get("tradingsymbol") or ""
    if trading_symbol in EXCLUDED_NSE_INDEX_SYMBOLS:
        return None
    for excluded_prefix in EXCLUDED_NSE_INDEX_PREFIXES:
        if trading_symbol.startswith(excluded_prefix):
            return None
    return self.segment_config("nse_equity_indices")

_classify_bse_flat

_classify_bse_flat(raw_row)

Route a BSE cash-market row, which has no suffix convention, by name and symbol heuristics.

The cross-broker fund check runs first: the fixed income heuristics below are broad — a blank name with any digit in the symbol is one of them — and would otherwise claim a fund ticker such as "GSEC10ADD".

Parameters:

Name Type Description Default
raw_row dict

One raw row from zerodha.instruments.

required

Returns:

Type Description
dict | None

The matched segment configuration, or None.

Source code in stock_brokers/instruments/mapping/zerodha.py
def _classify_bse_flat(self, raw_row):
    """
    Route a BSE cash-market row, which has no suffix convention, by name and symbol heuristics.

    The cross-broker fund check runs first: the fixed income heuristics below are broad — a blank name with any digit in the symbol is one of them — and would otherwise claim a fund ticker such as "GSEC10ADD".

    Args:
        raw_row (dict): One raw row from zerodha.instruments.

    Returns:
        dict | None: The matched segment configuration, or None.
    """
    trading_symbol = raw_row.get("tradingsymbol") or ""
    name = raw_row.get("name")
    name_is_blank = is_blank(name)

    is_exchange_traded_fund = trading_symbol in self.bse_etf_symbols
    if not name_is_blank and EXCHANGE_TRADED_FUND_NAME_PATTERN.search(str(name)):
        is_exchange_traded_fund = True
    if is_exchange_traded_fund:
        return self.segment_config("bse_exchange_traded_funds")

    if self._is_bse_fixed_income(raw_row, trading_symbol, name, name_is_blank):
        return self.segment_config("bse_fixed_income")

    if trading_symbol in self.bse_trust_symbols:
        return self.segment_config("bse_investment_trusts")
    if name_is_blank:
        return None
    if trading_symbol in self.bse_fund_symbols or trading_symbol in self.bse_fixed_income_symbols:
        return None
    return self.segment_config("bse_equities")

_is_bse_fixed_income

_is_bse_fixed_income(raw_row, trading_symbol, name, name_is_blank)

Check the heuristics that mark a BSE cash-market row as a bond.

Parameters:

Name Type Description Default
raw_row dict

One raw row from zerodha.instruments.

required
trading_symbol str

The row's trading symbol.

required
name str | None

The row's name column.

required
name_is_blank bool

Whether the name is absent.

required

Returns:

Type Description
bool

True when the row looks like a bond.

Source code in stock_brokers/instruments/mapping/zerodha.py
def _is_bse_fixed_income(self, raw_row, trading_symbol, name, name_is_blank):
    """
    Check the heuristics that mark a BSE cash-market row as a bond.

    Args:
        raw_row (dict): One raw row from zerodha.instruments.
        trading_symbol (str): The row's trading symbol.
        name (str | None): The row's name column.
        name_is_blank (bool): Whether the name is absent.

    Returns:
        bool: True when the row looks like a bond.
    """
    if not name_is_blank:
        if BSE_FIXED_INCOME_NAME_PATTERN.search(str(name)):
            return True
        if name == trading_symbol:
            return True
        if "SDL" in str(name).upper():
            return True
    if trading_symbol.startswith("SGB") and not name_is_blank:
        return True
    if name_is_blank and re.search(r"[0-9]", trading_symbol):
        return True
    return "SDL" in trading_symbol.upper()

classify_extra

classify_extra(raw_row)

Return the equity membership a BSE cash-market row also has when it meets the equity criteria in its own right.

A BSE row can meet more than one segment's criteria, so one row can belong to two segments at once. Of the Zerodha BSE rows whose name equals their trading symbol — one of the bond heuristics — a substantial minority are also genuinely equities. This re-checks the equity criteria independently of what the primary classification chose.

Parameters:

Name Type Description Default
raw_row dict

One raw row from zerodha.instruments.

required

Returns:

Type Description
list[dict]

The equity segment configuration, or an empty list.

Example usage

From example_1_one_flat_bucket_many_segments.py
self.adapter.classify_extra(raw_row)
Source code in stock_brokers/instruments/mapping/zerodha.py
def classify_extra(self, raw_row):
    """
    Return the equity membership a BSE cash-market row also has when it meets the equity criteria in its own right.

    A BSE row can meet more than one segment's criteria, so one row can belong to two segments at once. Of the Zerodha BSE rows whose name equals their trading symbol — one of the bond heuristics — a substantial minority are also genuinely equities. This re-checks the equity criteria independently of what the primary classification chose.

    Args:
        raw_row (dict): One raw row from zerodha.instruments.

    Returns:
        list[dict]: The equity segment configuration, or an empty list.
    """
    if raw_row.get("exchange") != "BSE" or raw_row.get("segment") != "BSE":
        return []
    if raw_row.get("instrument_type") != "EQ":
        return []
    trading_symbol = raw_row.get("tradingsymbol") or ""
    name = raw_row.get("name")
    if is_blank(name):
        return []
    if EXCHANGE_TRADED_FUND_NAME_PATTERN.search(str(name)) or trading_symbol in self.bse_etf_symbols:
        return []
    if trading_symbol in self.bse_trust_symbols:
        return []
    if trading_symbol in self.bse_fund_symbols or trading_symbol in self.bse_fixed_income_symbols:
        return []
    return [
        self.segment_config("bse_equities"),
    ]

_classify_nse_derivatives

_classify_nse_derivatives(raw_row, instrument_type)

Route an NSE derivative row, splitting stock from index contracts by the underlying name.

Parameters:

Name Type Description Default
raw_row dict

One raw row from zerodha.instruments.

required
instrument_type str

The row's instrument type.

required

Returns:

Type Description
dict | None

The matched segment configuration, or None.

Source code in stock_brokers/instruments/mapping/zerodha.py
def _classify_nse_derivatives(self, raw_row, instrument_type):
    """
    Route an NSE derivative row, splitting stock from index contracts by the underlying name.

    Args:
        raw_row (dict): One raw row from zerodha.instruments.
        instrument_type (str): The row's instrument type.

    Returns:
        dict | None: The matched segment configuration, or None.
    """
    is_index = raw_row.get("name") in INDEX_UNDERLYINGS
    if instrument_type == "FUT":
        if is_index:
            return self.segment_config("nse_equity_index_futures")
        return self.segment_config("nse_equity_futures")
    if instrument_type in ("CE", "PE"):
        if is_index:
            return self.segment_config("nse_equity_index_options")
        return self.segment_config("nse_equity_options")
    return None

_classify_bse_derivatives

_classify_bse_derivatives(instrument_type)

Route a BSE derivative row, which this broker's file carries for index contracts only.

Parameters:

Name Type Description Default
instrument_type str

The row's instrument type.

required

Returns:

Type Description
dict | None

The matched segment configuration, or None.

Source code in stock_brokers/instruments/mapping/zerodha.py
def _classify_bse_derivatives(self, instrument_type):
    """
    Route a BSE derivative row, which this broker's file carries for index contracts only.

    Args:
        instrument_type (str): The row's instrument type.

    Returns:
        dict | None: The matched segment configuration, or None.
    """
    if instrument_type == "FUT":
        return self.segment_config("bse_equity_index_futures")
    if instrument_type in ("CE", "PE"):
        return self.segment_config("bse_equity_index_options")
    return None

_classify_mcx

_classify_mcx(raw_row, segment)

Route an MCX row, splitting commodity from commodity index contracts by the underlying name.

Parameters:

Name Type Description Default
raw_row dict

One raw row from zerodha.instruments.

required
segment str

The row's segment column.

required

Returns:

Type Description
dict | None

The matched segment configuration, or None.

Source code in stock_brokers/instruments/mapping/zerodha.py
def _classify_mcx(self, raw_row, segment):
    """
    Route an MCX row, splitting commodity from commodity index contracts by the underlying name.

    Args:
        raw_row (dict): One raw row from zerodha.instruments.
        segment (str): The row's segment column.

    Returns:
        dict | None: The matched segment configuration, or None.
    """
    if segment == "INDICES":
        return self.segment_config("mcx_commodity_indices")
    is_index = raw_row.get("name") in MCX_INDEX_NAMES
    if segment == "MCX-FUT":
        if is_index:
            return self.segment_config("mcx_commodity_index_futures")
        return self.segment_config("mcx_commodity_futures")
    if segment == "MCX-OPT":
        if is_index:
            return self.segment_config("mcx_commodity_index_options")
        return self.segment_config("mcx_commodity_options")
    return None

_classify_nse_commodities

_classify_nse_commodities(instrument_type)

Route an NSE commodity row, a segment only this broker's file carries.

Parameters:

Name Type Description Default
instrument_type str

The row's instrument type.

required

Returns:

Type Description
dict | None

The matched segment configuration, or None.

Source code in stock_brokers/instruments/mapping/zerodha.py
def _classify_nse_commodities(self, instrument_type):
    """
    Route an NSE commodity row, a segment only this broker's file carries.

    Args:
        instrument_type (str): The row's instrument type.

    Returns:
        dict | None: The matched segment configuration, or None.
    """
    if instrument_type == "EQ":
        return self.segment_config("nse_commodities")
    if instrument_type == "FUT":
        return self.segment_config("nse_commodity_futures")
    if instrument_type in ("CE", "PE"):
        return self.segment_config("nse_commodity_options")
    return None

_classify_currency_derivatives

_classify_currency_derivatives(raw_row, instrument_type)

Route a currency-segment row, which mixes currency pairs, government bond derivatives, and the overnight rate index.

Parameters:

Name Type Description Default
raw_row dict

One raw row from zerodha.instruments.

required
instrument_type str

The row's instrument type.

required

Returns:

Type Description
dict | None

The matched segment configuration, or None.

Source code in stock_brokers/instruments/mapping/zerodha.py
def _classify_currency_derivatives(self, raw_row, instrument_type):
    """
    Route a currency-segment row, which mixes currency pairs, government bond derivatives, and the overnight rate index.

    Args:
        raw_row (dict): One raw row from zerodha.instruments.
        instrument_type (str): The row's instrument type.

    Returns:
        dict | None: The matched segment configuration, or None.
    """
    name = raw_row.get("name")
    if instrument_type == "FUT":
        if name in CURRENCY_NAMES:
            return self.segment_config("nse_currency_futures")
        if name == "ONMIBOR":
            return self.segment_config("nse_fixed_income_index_futures")
        return self.segment_config("nse_fixed_income_futures")
    if instrument_type in ("CE", "PE"):
        if name in CURRENCY_NAMES:
            return self.segment_config("nse_currency_options")
        return self.segment_config("nse_fixed_income_options")
    return None

to_identity

to_identity(raw_row, segment_configuration)

Build the unified identity fields, resolving bond ISINs, stripping series suffixes, and aliasing index names.

Parameters:

Name Type Description Default
raw_row dict

One raw row from zerodha.instruments.

required
segment_configuration dict

The segment configuration whose identity mapping applies.

required

Returns:

Type Description
dict

Identity fields, as described in BrokerMappingAdapter.to_identity.

Raises:

Type Description
ValueError

If a bond row's exchange token has no match in the shared security identifier map, since the ISIN is that segment's identity.

Example usage

From example_1_one_flat_bucket_many_segments.py
identity = self.adapter.to_identity(raw_row, segment_configuration)
Source code in stock_brokers/instruments/mapping/zerodha.py
def to_identity(self, raw_row, segment_configuration):
    """
    Build the unified identity fields, resolving bond ISINs, stripping series suffixes, and aliasing index names.

    Args:
        raw_row (dict): One raw row from zerodha.instruments.
        segment_configuration (dict): The segment configuration whose identity mapping applies.

    Returns:
        dict: Identity fields, as described in BrokerMappingAdapter.to_identity.

    Raises:
        ValueError: If a bond row's exchange token has no match in the shared security identifier map, since the ISIN is that segment's identity.
    """
    segment = segment_configuration["segment"]

    if segment in ("nse_fixed_income", "bse_fixed_income"):
        exchange_token = str(raw_row.get("exchange_token"))
        isin = self.isin_by_token.get(exchange_token)
        if isin is None:
            raise ValueError(
                f"no shared security identifier match for exchange token {exchange_token!r}, "
                f"so this fixed income row cannot be identified by ISIN"
            )
        return {
            "symbol": isin,
        }

    identity = super().to_identity(raw_row, segment_configuration)

    if segment == "nse_equities":
        identity["symbol"] = SERIES_SUFFIX_PATTERN.sub("", identity["symbol"])
    elif segment == "nse_investment_trusts":
        identity["symbol"] = TRUST_SUFFIX_PATTERN.sub("", identity["symbol"])
    elif segment == "nse_mutual_funds":
        identity["symbol"] = MUTUAL_FUND_SUFFIX_PATTERN.sub("", identity["symbol"])
    elif segment == "nse_equity_indices":
        normalized = normalize_index_name(identity["symbol"])
        if normalized in NSE_INDEX_ALIASES:
            identity["symbol"] = NSE_INDEX_ALIASES[normalized]
        elif normalized in self.nse_index_master_lookup:
            identity["symbol"] = self.nse_index_master_lookup[normalized]
    elif segment == "bse_equity_indices":
        identity["symbol"] = BSE_INDEX_ALIASES.get(identity["symbol"], identity["symbol"])
    elif segment == "mcx_commodity_indices":
        identity["symbol"] = MCX_INDEX_ALIASES.get(identity["symbol"], identity["symbol"])
    elif segment == "nse_commodities":
        name = raw_row.get("name")
        if is_blank(name) or name == MISLABELLED_COMMODITY_NAME:
            identity["symbol"] = raw_row.get("tradingsymbol")
    return identity

to_broker_fields

to_broker_fields(raw_row, segment_configuration)

Build the per-broker mapping fields, falling back to the trading symbol where the name is blank.

Parameters:

Name Type Description Default
raw_row dict

One raw row from zerodha.instruments.

required
segment_configuration dict

The segment configuration whose broker field mapping applies.

required

Returns:

Type Description
dict

Keys "broker_token" (str), "broker_symbol", "lot_size" (float | None), and "tick_size" (float | None).

Example usage

From example_1_one_flat_bucket_many_segments.py
broker_fields = self.adapter.to_broker_fields(raw_row, segment_configuration)
Source code in stock_brokers/instruments/mapping/zerodha.py
def to_broker_fields(self, raw_row, segment_configuration):
    """
    Build the per-broker mapping fields, falling back to the trading symbol where the name is blank.

    Args:
        raw_row (dict): One raw row from zerodha.instruments.
        segment_configuration (dict): The segment configuration whose broker field mapping applies.

    Returns:
        dict: Keys "broker_token" (str), "broker_symbol", "lot_size" (float | None), and "tick_size" (float | None).
    """
    broker_fields = super().to_broker_fields(raw_row, segment_configuration)
    segment = segment_configuration["segment"]
    if segment in BROKER_SYMBOL_FALLBACK_SEGMENTS and is_blank(broker_fields["broker_symbol"]):
        broker_fields["broker_symbol"] = raw_row.get("tradingsymbol")
    if segment == "nse_commodities":
        broker_fields["lot_size"] = None
        broker_fields["tick_size"] = None
    return broker_fields

uncategorised_exchange

uncategorised_exchange(raw_row)

Determine the canonical exchange for an unmatched Zerodha row from its exchange column.

Parameters:

Name Type Description Default
raw_row dict

One raw row from zerodha.instruments.

required

Returns:

Type Description
str | None

"nse", "bse", or "mcx", or None for any other value.

Example usage

From example_1_one_flat_bucket_many_segments.py
exchange = self.adapter.uncategorised_exchange(raw_row)
Source code in stock_brokers/instruments/mapping/zerodha.py
def uncategorised_exchange(self, raw_row):
    """
    Determine the canonical exchange for an unmatched Zerodha row from its exchange column.

    Args:
        raw_row (dict): One raw row from zerodha.instruments.

    Returns:
        str | None: "nse", "bse", or "mcx", or None for any other value.
    """
    exchange_identifier = raw_row.get("exchange")
    if exchange_identifier in ("NSE", "NFO", "CDS", "NCO"):
        return "nse"
    if exchange_identifier in ("BSE", "BFO"):
        return "bse"
    if exchange_identifier == "MCX":
        return "mcx"
    return None

_validate_config

_validate_config()

Check the loaded rules file against the canonical vocabulary.

Every configured segment must be a real canonical segment for its exchange with the matching shape, and the segments must appear in the canonical asset-class order. The file must end with the unprefixed uncategorised entry on exchange unknown, which is the guaranteed fallback for rows whose exchange cannot be determined; unknown is allowed for that one entry only, since it is the value stored in the exchange column of the mapped table for those rows.

Raises:

Type Description
ValueError

On any invalid exchange, segment, shape, ordering, or missing or misplaced uncategorised entry.

Source code in stock_brokers/instruments/mapping/base.py
def _validate_config(self):
    """
    Check the loaded rules file against the canonical vocabulary.

    Every configured segment must be a real canonical segment for its exchange with the matching shape, and the segments must appear in the canonical asset-class order. The file must end with the unprefixed ``uncategorised`` entry on exchange ``unknown``, which is the guaranteed fallback for rows whose exchange cannot be determined; ``unknown`` is allowed for that one entry only, since it is the value stored in the exchange column of the mapped table for those rows.

    Raises:
        ValueError: On any invalid exchange, segment, shape, ordering, or missing or misplaced uncategorised entry.
    """
    previous_rank = None
    has_plain_uncategorised = False
    for segment_configuration in self.config["segments"]:
        exchange = segment_configuration["exchange"]
        segment = segment_configuration["segment"]
        shape = segment_configuration["shape"]
        is_plain_uncategorised = segment == "uncategorised" and exchange == "unknown"
        if is_plain_uncategorised:
            has_plain_uncategorised = True
            previous_rank = (1000000000, 1000000000)
            continue
        if exchange not in CANONICAL_EXCHANGES:
            raise ValueError(
                f"{self.BROKER_NAME}: {exchange!r} is not a canonical exchange, "
                f"expected one of {CANONICAL_EXCHANGES} or 'unknown' on the plain "
                f"'uncategorised' entry"
            )
        bare_segment = segment
        if segment.startswith(f"{exchange}_"):
            bare_segment = segment[len(exchange) + 1:]
        try:
            declared_shape = segment_shape(bare_segment)
        except ValueError:
            raise ValueError(
                f"{self.BROKER_NAME}: {segment!r} is not a canonical segment for exchange "
                f"{exchange!r}; use the canonical segment value instead"
            )
        if declared_shape != shape:
            raise ValueError(
                f"{self.BROKER_NAME}: segment {segment!r} has shape {declared_shape!r} "
                f"in the canonical vocabulary, not {shape!r} as configured here"
            )
        rank = segment_rank(exchange, bare_segment)
        if previous_rank is not None and rank < previous_rank:
            raise ValueError(
                f"{self.BROKER_NAME}: segment {segment!r} appears out of canonical order; "
                f"segments must be listed fixed income, equities, currencies, commodities, "
                f"then the catch-alls, then the uncategorised buckets, and within a class "
                f"simple instruments, futures, options, indices, index futures, index options"
            )
        previous_rank = rank
    if not has_plain_uncategorised:
        raise ValueError(
            f"{self.BROKER_NAME}: the rules file must end with the unprefixed "
            f"'uncategorised' entry on exchange 'unknown'"
        )

segment_config

segment_config(segment)

Look up one segment configuration by its segment value.

Parameters:

Name Type Description Default
segment str

The exchange-prefixed segment value, for example "nse_equities".

required

Returns:

Type Description
dict | None

The matching segment configuration, or None when the rules file does not configure that segment.

Example usage

From example_1_classifying_rows_by_rules.py
equities = self.adapter.segment_config('nse_equities')
From example_1_classifying_rows_by_rules.py
segment_configuration = self.adapter.segment_config('uncategorised')
From example_1_classifying_rows_by_rules.py
print(f'Unconfigured segment: {self.adapter.segment_config("nse_bonds")}')
Source code in stock_brokers/instruments/mapping/base.py
def segment_config(self, segment):
    """
    Look up one segment configuration by its segment value.

    Args:
        segment (str): The exchange-prefixed segment value, for example "nse_equities".

    Returns:
        dict | None: The matching segment configuration, or None when the rules file does not configure that segment.
    """
    for segment_configuration in self.config["segments"]:
        if segment_configuration["segment"] == segment:
            return segment_configuration
    return None

_field

_field(raw_row, specification)

Read one raw column value, applying a named transform when the specification asks for one.

Parameters:

Name Type Description Default
raw_row dict

One raw row from the broker's instrument table.

required
specification str | dict

Either a bare column name, or a dict with "column" and optionally "transform" keys.

required

Returns:

Type Description
Any

The column value, transformed if requested.

Source code in stock_brokers/instruments/mapping/base.py
def _field(self, raw_row, specification):
    """
    Read one raw column value, applying a named transform when the specification asks for one.

    Args:
        raw_row (dict): One raw row from the broker's instrument table.
        specification (str | dict): Either a bare column name, or a dict with "column" and optionally "transform" keys.

    Returns:
        Any: The column value, transformed if requested.
    """
    if isinstance(specification, dict):
        value = raw_row.get(specification["column"])
        transform = specification.get("transform")
        if transform:
            return TRANSFORMS[transform](value)
        return value
    return raw_row.get(specification)

_row_matches

_row_matches(raw_row, match)

Check one equality rule against a raw row.

Parameters:

Name Type Description Default
raw_row dict

One raw row from the broker's instrument table.

required
match dict

Mapping of raw column name to expected value, or to a list of acceptable values.

required

Returns:

Type Description
bool

True when every column in the rule matches.

Source code in stock_brokers/instruments/mapping/base.py
def _row_matches(self, raw_row, match):
    """
    Check one equality rule against a raw row.

    Args:
        raw_row (dict): One raw row from the broker's instrument table.
        match (dict): Mapping of raw column name to expected value, or to a list of acceptable values.

    Returns:
        bool: True when every column in the rule matches.
    """
    for column_name, expected in match.items():
        actual = raw_row.get(column_name)
        if isinstance(expected, list):
            if actual not in expected:
                return False
        elif actual != expected:
            return False
    return True

_uncategorised_config

_uncategorised_config(raw_row)

Pick the uncategorised segment configuration a row falls back into.

The per-exchange bucket is used when uncategorised_exchange resolves an exchange and the rules file configures that bucket; otherwise the unprefixed uncategorised entry is used.

Parameters:

Name Type Description Default
raw_row dict

One raw row from the broker's instrument table.

required

Returns:

Type Description
dict

The uncategorised segment configuration.

Raises:

Type Description
ValueError

If no uncategorised configuration exists, which validation should have made impossible.

Source code in stock_brokers/instruments/mapping/base.py
def _uncategorised_config(self, raw_row):
    """
    Pick the uncategorised segment configuration a row falls back into.

    The per-exchange bucket is used when ``uncategorised_exchange`` resolves an exchange and the rules file configures that bucket; otherwise the unprefixed ``uncategorised`` entry is used.

    Args:
        raw_row (dict): One raw row from the broker's instrument table.

    Returns:
        dict: The uncategorised segment configuration.

    Raises:
        ValueError: If no uncategorised configuration exists, which validation should have made impossible.
    """
    exchange = self.uncategorised_exchange(raw_row)
    if exchange is not None:
        preferred = self.segment_config(segment_value(exchange, "uncategorised"))
        if preferred is not None:
            return preferred
    fallback = self.segment_config("uncategorised")
    if fallback is None:
        raise ValueError(
            f"{self.BROKER_NAME}: no uncategorised segment configuration found, "
            f"which validation should have made impossible"
        )
    return fallback

read_raw_rows

read_raw_rows(connection, mapping_date)

Read the broker's raw rows for one date.

Adapters override this where the fetch itself is part of the mapping: to impose a row order the classification depends on, or to bring in rows the plain query would miss.

Parameters:

Name Type Description Default
connection Connection

An open SQLAlchemy connection.

required
mapping_date date

The raw snapshot date to read.

required

Returns:

Type Description
DataFrame

The raw rows for that date.

Example usage

From example_2_mapping_one_day.py
raw = self.adapter.read_raw_rows(self.connection, self.mapping_date)
Source code in stock_brokers/instruments/mapping/base.py
def read_raw_rows(self, connection, mapping_date):
    """
    Read the broker's raw rows for one date.

    Adapters override this where the fetch itself is part of the mapping: to impose a row order the classification depends on, or to bring in rows the plain query would miss.

    Args:
        connection (sqlalchemy.engine.Connection): An open SQLAlchemy connection.
        mapping_date (datetime.date): The raw snapshot date to read.

    Returns:
        pandas.DataFrame: The raw rows for that date.
    """
    raw_table = self.config["raw_table"]
    return pd.read_sql(
        text(f"SELECT * FROM {raw_table} WHERE download_date = :d"),
        connection,
        params={
            "d": mapping_date,
        },
    )

_write_results

_write_results(instrument_rows, broker_rows, mapping_date)

Write one run's rows into the mapped tables inside a single transaction.

Both row lists are sorted by instrument id before the insert so concurrent writers acquire locks in the same order, which is the standard fix for the multi-writer deadlock this design was observed hitting when adapters ran in parallel.

The seen dates widen rather than overwrite, so they do not depend on the order dates happen to be mapped in. Assigning the run's date to the last seen date directly looks equivalent while a backfill walks forward, and is wrong the moment any older date is mapped afterwards: it drags the last seen date backwards, and leaves the first seen date frozen at whichever date was written first rather than the earliest the instrument was actually seen.

Parameters:

Name Type Description Default
instrument_rows dict

Mapping of instrument id to the unified.instruments row.

required
broker_rows dict

Mapping of instrument id to the unified.broker_mappings row.

required
mapping_date date

The mapping date the run covers.

required
Source code in stock_brokers/instruments/mapping/base.py
def _write_results(self, instrument_rows, broker_rows, mapping_date):
    """
    Write one run's rows into the mapped tables inside a single transaction.

    Both row lists are sorted by instrument id before the insert so concurrent writers acquire locks in the same order, which is the standard fix for the multi-writer deadlock this design was observed hitting when adapters ran in parallel.

    The seen dates widen rather than overwrite, so they do not depend on the order dates happen to be mapped in. Assigning the run's date to the last seen date directly looks equivalent while a backfill walks forward, and is wrong the moment any older date is mapped afterwards: it drags the last seen date backwards, and leaves the first seen date frozen at whichever date was written first rather than the earliest the instrument was actually seen.

    Args:
        instrument_rows (dict): Mapping of instrument id to the unified.instruments row.
        broker_rows (dict): Mapping of instrument id to the unified.broker_mappings row.
        mapping_date (datetime.date): The mapping date the run covers.
    """
    sorted_instruments = sorted(instrument_rows.values(), key=lambda row: row["instrument_id"])
    sorted_brokers = sorted(broker_rows.values(), key=lambda row: row["instrument_id"])

    with self.engine.begin() as connection:
        connection.execute(
            text(
                f"DELETE FROM {tables.BROKER_MAPPINGS} "
                "WHERE broker = :b AND mapping_date = :d"
            ),
            {
                "b": self.BROKER_NAME,
                "d": mapping_date,
            },
        )
        if sorted_instruments:
            connection.execute(
                text(
                    f"""
                    INSERT INTO {tables.MASTER} AS master
                        (instrument_id, exchange, segment, shape, symbol, underlying_symbol,
                         expiry_date, strike_price, option_type, first_seen_date, last_seen_date)
                    VALUES
                        (:instrument_id, :exchange, :segment, :shape, :symbol, :underlying_symbol,
                         :expiry_date, :strike_price, :option_type, :first_seen_date, :last_seen_date)
                    ON CONFLICT (instrument_id) DO UPDATE SET
                        first_seen_date = LEAST(master.first_seen_date, EXCLUDED.first_seen_date),
                        last_seen_date = GREATEST(master.last_seen_date, EXCLUDED.last_seen_date)
                    """
                ),
                sorted_instruments,
            )
        if sorted_brokers:
            connection.execute(
                text(
                    f"""
                    INSERT INTO {tables.BROKER_MAPPINGS}
                        (instrument_id, broker, broker_token, broker_symbol, order_symbol, lot_size, tick_size, mapping_date, attributes)
                    VALUES
                        (:instrument_id, :broker, :broker_token, :broker_symbol, :order_symbol, :lot_size, :tick_size, :mapping_date, CAST(:attributes AS jsonb))
                    ON CONFLICT (instrument_id, broker, mapping_date) DO UPDATE SET
                        broker_token = EXCLUDED.broker_token, broker_symbol = EXCLUDED.broker_symbol,
                        order_symbol = EXCLUDED.order_symbol,
                        lot_size = EXCLUDED.lot_size, tick_size = EXCLUDED.tick_size,
                        attributes = EXCLUDED.attributes
                    """
                ),
                sorted_brokers,
            )

is_blank

is_blank(value)

Check whether a raw value is absent or empty, in any of the forms the raw tables produce.

Parameters:

Name Type Description Default
value str | float | None

One raw column value.

required

Returns:

Type Description
bool

True when the value is None, an empty string, or a pandas missing value.

Source code in stock_brokers/instruments/mapping/zerodha.py
def is_blank(value):
    """
    Check whether a raw value is absent or empty, in any of the forms the raw tables produce.

    Args:
        value (str | float | None): One raw column value.

    Returns:
        bool: True when the value is None, an empty string, or a pandas missing value.
    """
    if value is None or value == "":
        return True
    return isinstance(value, float) and pd.isna(value)

normalize_index_name

normalize_index_name(name)

Reduce an index name to uppercase alphanumerics so that differently-spelled broker names collide.

Parameters:

Name Type Description Default
name str

The raw index name.

required

Returns:

Type Description
str

The name with every non-alphanumeric character removed and uppercased.

Source code in stock_brokers/instruments/mapping/zerodha.py
def normalize_index_name(name):
    """
    Reduce an index name to uppercase alphanumerics so that differently-spelled broker names collide.

    Args:
        name (str): The raw index name.

    Returns:
        str: The name with every non-alphanumeric character removed and uppercased.
    """
    return re.sub(r"[^A-Z0-9]", "", str(name).upper())