Skip to content

resolution

Look-ups over the mapped instrument tables.

Three questions can be asked, and between them they close the loop from an instrument identity to a broker's own raw row and back:

  • broker_tokens goes from an identity to every broker's token for it, which is what placing an order or requesting a quote needs.
  • identity goes the other way, from a broker's tokens back to the unified identities, which is what reading a position or holding back from a broker needs.
  • raw_row goes from a broker token to that broker's full raw row, which is the escape hatch for anything the mapped tables do not carry.

The third exists because a broker's own quote endpoint sometimes needs an identifier the mapped tables have no column for. Because broker_token is each broker's own join key into its raw table, that row is always one lookup away.

Placing an order is not such a case. The broker_symbol column is a display name for several brokers rather than a tradeable ticker, so unified.broker_mappings carries order_symbol beside it, filled from each broker's own tradeable column for the six brokers that order by symbol and left empty for the four that order by token alone. An order therefore reads a column rather than reconstructing a ticker from a raw row.

Two further look-ups exist for the case where a broker reports a position without a token at all. identity_by_isin identifies an instrument from its ISIN, which is the only identifier a Groww holding carries, and identity_by_symbol identifies one from its ticker.

Every method takes an as_of_date and uses the latest mapping on or before it, so a lookup for a past date sees what was mapped then rather than what is mapped now.

A lookup that lands on the current mapping date is answered through the MappingCache the resolver holds, which keeps it out of the database; one for an earlier date goes straight to Postgres and caches nothing.

The resolver is handed a cache rather than building one. The memory tier only pays for itself when a process has exactly one of it, so a resolver that made its own would give a process two sets of dictionaries, double the memory and halve the hit rate.

Classes:

Name Description
MappingResolver

Every look-up over the mapped instrument tables, cached where the date allows it and read from Postgres where it does not.

Functions:

Name Description
exchange_order

The sort key that puts a preferred exchange's identity first among an ISIN's answers.

MappingResolver

MappingResolver(mapping_cache=None)

Every look-up over the mapped instrument tables, cached where the date allows it and read from Postgres where it does not.

Attributes:

Name Type Description
mapping_cache MappingCache

The cache a request for the current date is answered from.

engine Engine

The SQLAlchemy engine over TimescaleDB.

Build the resolver around a cache.

Parameters:

Name Type Description Default
mapping_cache MappingCache | None

The cache to answer current-date requests from. None builds one, which is right for a command line tool and wrong for anything sharing a process with another resolver.

None

Returns:

Type Description
None

This function returns nothing.

Example programs

examples/stock_brokers/instruments/mapping/utilities/resolution/MappingResolver/example_1_tokens_for_an_instrument.py
"""Finds every broker's token for INFY on today's mapping and on an earlier one, and turns a token back into an identity.

A `MappingResolver` is handed a mapping cache rather than building one, and takes its database engine from that cache. When the date asked about falls on the cache's current mapping date, the cache supplies the date and the identities, and only the broker rows are read from the database. An earlier date is answered from the database alone, using the latest mapping on or before it.

Both the cache and the engine here are small stand-ins. The cache says the current mapping date is 2026-09-28 and knows one Zerodha token. The engine answers the resolver's queries from rows held in memory: INFY was mapped at three brokers on 2026-09-28 and at two on 2026-08-14. Each stand-in counts what it is asked, so the output shows which answers came from the cache and which from the database. No Redis or PostgreSQL is needed.

Notice that asking about 2026-08-20 lands on the 2026-08-14 mapping, because nothing was mapped between those dates, and that the identity for today comes from the cache without a database query.

Run it from the project root:

    python examples/stock_brokers/instruments/mapping/utilities/resolution/MappingResolver/example_1_tokens_for_an_instrument.py
"""

import datetime
import decimal
import types

from stock_brokers.instruments.mapping.base import instrument_id
from stock_brokers.instruments.mapping.utilities.resolution import (
    MappingResolver,
)


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

    Attributes:
        rows (list): The rows.
        value (object): The single value `scalar` returns.
    """

    def __init__(self, rows, value=None):
        """Holds the rows and the scalar value.

        Args:
            rows (list): The rows.
            value (object): The single value `scalar` returns.

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

    def all(self):
        """Every row.

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

    def one(self):
        """The only row.

        Returns:
            types.SimpleNamespace: The first row.
        """
        return self.rows[0]

    def scalar(self):
        """The single value of a one-column answer.

        Returns:
            object: The value.
        """
        return self.value


class StandInEngine:
    """A stand-in engine answering the resolver's queries about one instrument from rows in memory.

    Attributes:
        broker_rows (dict): Mapping date to the instrument's broker rows on that date.
        identity_rows (list): The rows a past-date identity query returns.
        queries (int): How many queries were run.
    """

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

        Args:
            broker_rows (dict): Mapping date to the instrument's broker rows on that date.
            identity_rows (list): The rows a past-date identity query returns.

        Returns:
            None: This method returns nothing.
        """
        self.broker_rows = broker_rows
        self.identity_rows = identity_rows
        self.queries = 0

    def connect(self):
        """Opens a connection.

        Returns:
            StandInEngine: This engine, which also acts as its own connection.
        """
        return self

    def __enter__(self):
        """Enters the connection block.

        Returns:
            StandInEngine: This engine.
        """
        return self

    def __exit__(self, error_type, error, traceback):
        """Leaves the connection block.

        Args:
            error_type (type | None): The exception type raised inside the block, if any.
            error (BaseException | None): The exception raised inside the block, if any.
            traceback (types.TracebackType | None): The traceback, if any.

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

    def latest_on_or_before(self, as_of_date):
        """The latest mapping date on or before a date.

        Args:
            as_of_date (datetime.date): The date asked about.

        Returns:
            datetime.date | None: The date, or None when nothing was mapped by then.
        """
        latest = None
        for mapping_date in self.broker_rows:
            if mapping_date <= as_of_date:
                if latest is None or mapping_date > latest:
                    latest = mapping_date
        return latest

    def execute(self, statement, parameters):
        """Answers one query from the rows in memory.

        Args:
            statement (sqlalchemy.sql.elements.TextClause): The query.
            parameters (dict): The query's parameters.

        Returns:
            StandInResult: The answer.
        """
        self.queries += 1
        sql = str(statement)
        if 'max(mapping_date) AS mapping_date' in sql:
            latest = self.latest_on_or_before(parameters['as_of_date'])
            return StandInResult([
                types.SimpleNamespace(
                    mapping_date=latest,
                ),
            ])
        if 'max(mapping_date)' in sql:
            return StandInResult([], self.latest_on_or_before(parameters['as_of_date']))
        if sql.startswith('SELECT broker, broker_token'):
            return StandInResult(self.broker_rows.get(parameters['mapping_date'], []))
        return StandInResult(self.identity_rows)


class StandInMappingCache:
    """A stand-in for `MappingCache` that knows today's mapping date and one token's identity.

    Attributes:
        engine (StandInEngine): The engine the resolver borrows.
        current_date (datetime.date): The current mapping date.
        identities (dict): Broker name to a dict of token to identity.
        lookups (int): How many identity look-ups were answered.
    """

    def __init__(self, engine, current_date, identities):
        """Holds the engine, the date and the identities.

        Args:
            engine (StandInEngine): The engine the resolver borrows.
            current_date (datetime.date): The current mapping date.
            identities (dict): Broker name to a dict of token to identity.

        Returns:
            None: This method returns nothing.
        """
        self.engine = engine
        self.current_date = current_date
        self.identities = identities
        self.lookups = 0

    def resolves_to_current_date(self, as_of_date):
        """The current mapping date when a request for this date lands on it.

        Args:
            as_of_date (datetime.date): The date asked about.

        Returns:
            datetime.date | None: The current mapping date, or None for an earlier date.
        """
        if as_of_date >= self.current_date:
            return self.current_date
        return None

    def identities_for_tokens(self, broker, broker_tokens, as_of_date, segments):
        """The identities of a broker's tokens on the current date.

        Args:
            broker (str): The broker name.
            broker_tokens (list): The tokens, as text.
            as_of_date (datetime.date): The date asked about.
            segments (list | None): The segments to restrict to, which this stand-in ignores.

        Returns:
            dict: Token to identity, for the tokens it knows.
        """
        self.lookups += 1
        found = {}
        known = self.identities.get(broker, {})
        for broker_token in broker_tokens:
            if broker_token in known:
                found[broker_token] = known[broker_token]
        return found


class TokensForAnInstrumentExample:
    """Looks INFY up on two dates and resolves a Zerodha token on two dates.

    Attributes:
        infy_id (str): INFY's instrument id on NSE.
        engine (StandInEngine): The stand-in engine.
        mapping_cache (StandInMappingCache): The stand-in cache.
        resolver (MappingResolver): The resolver being shown.
    """

    def __init__(self):
        """Builds the stand-ins and the resolver around them.

        Returns:
            None: This method returns nothing.
        """
        self.infy_id = instrument_id(
            'nse',
            'nse_equities',
            'security',
            {
                'symbol': 'INFY',
            },
        )
        today = datetime.date(2026, 9, 28)
        earlier = datetime.date(2026, 8, 14)
        self.engine = StandInEngine(
            {
                today: [
                    self.broker_row('dhan', '1594', 'INFY', None),
                    self.broker_row('fyers', '10100000001594', 'NSE:INFY-EQ', 'NSE:INFY-EQ'),
                    self.broker_row('zerodha', '408065', 'INFY', 'INFY'),
                ],
                earlier: [
                    self.broker_row('dhan', '1594', 'INFY', None),
                    self.broker_row('zerodha', '408065', 'INFY', 'INFY'),
                ],
            },
            [
                types.SimpleNamespace(
                    broker_token='408065',
                    mapping_date=earlier,
                    instrument_id=self.infy_id,
                    exchange='nse',
                    segment='nse_equities',
                    shape='security',
                    symbol='INFY',
                    underlying_symbol=None,
                    expiry_date=None,
                    strike_price=None,
                    option_type=None,
                ),
            ],
        )
        self.mapping_cache = StandInMappingCache(
            self.engine,
            today,
            {
                'zerodha': {
                    '408065': {
                        'instrument_id': self.infy_id,
                        'exchange': 'nse',
                        'segment': 'nse_equities',
                        'symbol': 'INFY',
                        'mapping_date': today,
                    },
                },
            },
        )
        self.resolver = MappingResolver(self.mapping_cache)

    def broker_row(self, broker, broker_token, broker_symbol, order_symbol):
        """Builds one broker mapping row with a lot of 1 and a tick of 0.05.

        Args:
            broker (str): The broker name.
            broker_token (str): The broker's token.
            broker_symbol (str): The broker's display symbol.
            order_symbol (str | None): The symbol an order is sent with, or None for a broker that orders by token.

        Returns:
            types.SimpleNamespace: The row.
        """
        return types.SimpleNamespace(
            broker=broker,
            broker_token=broker_token,
            broker_symbol=broker_symbol,
            order_symbol=order_symbol,
            lot_size=decimal.Decimal('1'),
            tick_size=decimal.Decimal('0.05'),
        )

    def print_tokens(self, label, as_of_date):
        """Looks INFY up for one date and prints the answer and the queries it cost.

        Args:
            label (str): What the look-up is.
            as_of_date (datetime.date): The date asked about.

        Returns:
            None: This method returns nothing.
        """
        queries_before = self.engine.queries
        mapping_date, rows = self.resolver.broker_tokens(
            'nse',
            'nse_equities',
            'security',
            {
                'symbol': 'INFY',
            },
            as_of_date,
        )
        print(f'{label}: mapping of {mapping_date}, {self.engine.queries - queries_before} database queries')
        for row in rows:
            print(f'  {row["broker"]}: token {row["broker_token"]}, order symbol {row["order_symbol"]}, lot {row["lot_size"]}, tick {row["tick_size"]}')

    def run(self):
        """Prints the mapping dates, the broker tokens and the identities.

        Returns:
            None: This method returns nothing.
        """
        print(f'INFY instrument id: {self.infy_id}')
        print(f'2026-09-30 resolves to {self.resolver.mapping_date_for(datetime.date(2026, 9, 30))}')
        print(f'2026-08-20 resolves to {self.resolver.mapping_date_for(datetime.date(2026, 8, 20))}')
        self.print_tokens('Today', datetime.date(2026, 9, 30))
        self.print_tokens('On 2026-08-20', datetime.date(2026, 8, 20))
        rows = self.resolver.broker_rows_on_date(self.infy_id, datetime.date(2026, 8, 14))
        print(f'Brokers mapped on 2026-08-14 exactly: {len(rows)}')
        queries_before = self.engine.queries
        today_identity = self.resolver.identity('zerodha', [408065], datetime.date(2026, 9, 30))
        print(f'Zerodha 408065 today: {today_identity["408065"]["symbol"]}, cache look-ups {self.mapping_cache.lookups}, database queries {self.engine.queries - queries_before}')
        earlier_identity = self.resolver.identity('zerodha', ['408065'], datetime.date(2026, 8, 20))
        print(f'Zerodha 408065 on 2026-08-20: {earlier_identity["408065"]["symbol"]} from the mapping of {earlier_identity["408065"]["mapping_date"]}')
        print(f'No tokens asked for: {self.resolver.identity("zerodha", [], datetime.date(2026, 9, 30))}')


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

It prints:

INFY instrument id: f310e1b0-e082-5b16-808a-25f741abb9de
2026-09-30 resolves to 2026-09-28
2026-08-20 resolves to 2026-08-14
Today: mapping of 2026-09-28, 1 database queries
  dhan: token 1594, order symbol None, lot 1, tick 0.05
  fyers: token 10100000001594, order symbol NSE:INFY-EQ, lot 1, tick 0.05
  zerodha: token 408065, order symbol INFY, lot 1, tick 0.05
On 2026-08-20: mapping of 2026-08-14, 2 database queries
  dhan: token 1594, order symbol None, lot 1, tick 0.05
  zerodha: token 408065, order symbol INFY, lot 1, tick 0.05
Brokers mapped on 2026-08-14 exactly: 2
Zerodha 408065 today: INFY, cache look-ups 1, database queries 0
Zerodha 408065 on 2026-08-20: INFY from the mapping of 2026-08-14
No tokens asked for: {}
examples/stock_brokers/instruments/mapping/utilities/resolution/MappingResolver/example_2_holdings_without_a_token.py
"""Identifies holdings that arrive without a broker token, by ISIN and by ticker, and lists every instrument a stored token has pointed at.

A Groww holding carries only an ISIN and a trading symbol. `identity_by_isin` looks the ISIN up in the raw files of the seven brokers that publish one, takes each broker's own token from there, and resolves that token like any other. A dual-listed share has an NSE and a BSE identity, so the answer is a list, ordered here with NSE first because that is the preferred exchange; the same instrument found through a second broker is not repeated. `identity_by_symbol` searches the master table by ticker within given segments and never picks a winner. `identity_spans` lists, for a price series stored under a broker token, every instrument that token has meant over all mapping dates; asking it to match on a column other than `broker_token` or `order_symbol` raises `ValueError`.

The mapping cache and the engine are small stand-ins answering from rows in memory, so no Redis or PostgreSQL is needed. The rows are made up but use real identifiers: INFY's ISIN INE009A01021, Dhan's NSE token 1594 and BSE token 500209, and Fyers' token for NSE INFY.

Run it from the project root:

    python examples/stock_brokers/instruments/mapping/utilities/resolution/MappingResolver/example_2_holdings_without_a_token.py
"""

import datetime
import types

from stock_brokers.instruments.mapping.base import instrument_id
from stock_brokers.instruments.mapping.utilities.resolution import (
    MappingResolver,
)


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

    Attributes:
        rows (list): The rows.
        value (object): The single value `scalar` returns.
    """

    def __init__(self, rows, value=None):
        """Holds the rows and the scalar value.

        Args:
            rows (list): The rows.
            value (object): The single value `scalar` returns.

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

    def all(self):
        """Every row.

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

    def one(self):
        """The only row.

        Returns:
            types.SimpleNamespace: The first row.
        """
        return self.rows[0]

    def scalar(self):
        """The single value of a one-column answer.

        Returns:
            object: The value.
        """
        return self.value


class StandInEngine:
    """A stand-in engine answering the ISIN, ticker and span queries from rows in memory.

    Attributes:
        raw_isin_rows (dict): Broker name to the rows its raw file gives for the ISIN query.
        symbol_rows (list): The rows the ticker query returns.
        span_rows (list): The rows the span query returns.
        brokers_searched (list): The raw files the ISIN look-up read, in order.
    """

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

        Args:
            raw_isin_rows (dict): Broker name to the rows its raw file gives for the ISIN query.
            symbol_rows (list): The rows the ticker query returns.
            span_rows (list): The rows the span query returns.

        Returns:
            None: This method returns nothing.
        """
        self.raw_isin_rows = raw_isin_rows
        self.symbol_rows = symbol_rows
        self.span_rows = span_rows
        self.brokers_searched = []

    def connect(self):
        """Opens a connection.

        Returns:
            StandInEngine: This engine, which also acts as its own connection.
        """
        return self

    def __enter__(self):
        """Enters the connection block.

        Returns:
            StandInEngine: This engine.
        """
        return self

    def __exit__(self, error_type, error, traceback):
        """Leaves the connection block.

        Args:
            error_type (type | None): The exception type raised inside the block, if any.
            error (BaseException | None): The exception raised inside the block, if any.
            traceback (types.TracebackType | None): The traceback, if any.

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

    def execute(self, statement, parameters):
        """Answers one query from the rows in memory.

        Args:
            statement (sqlalchemy.sql.elements.TextClause): The query.
            parameters (dict): The query's parameters.

        Returns:
            StandInResult: The answer.
        """
        sql = str(statement)
        if '.instruments WHERE download_date' in sql:
            broker = sql.split('FROM ')[1].split('.instruments')[0]
            self.brokers_searched.append(broker)
            return StandInResult(self.raw_isin_rows.get(broker, []))
        if 'first_mapping_date' in sql:
            return StandInResult(self.span_rows)
        return StandInResult(self.symbol_rows)


class StandInMappingCache:
    """A stand-in for `MappingCache` that knows today's mapping date and a few tokens' identities.

    Attributes:
        engine (StandInEngine): The engine the resolver borrows.
        current_date (datetime.date): The current mapping date.
        identities (dict): Broker name to a dict of token to identity.
    """

    def __init__(self, engine, current_date, identities):
        """Holds the engine, the date and the identities.

        Args:
            engine (StandInEngine): The engine the resolver borrows.
            current_date (datetime.date): The current mapping date.
            identities (dict): Broker name to a dict of token to identity.

        Returns:
            None: This method returns nothing.
        """
        self.engine = engine
        self.current_date = current_date
        self.identities = identities

    def resolves_to_current_date(self, as_of_date):
        """The current mapping date when a request for this date lands on it.

        Args:
            as_of_date (datetime.date): The date asked about.

        Returns:
            datetime.date | None: The current mapping date, or None for an earlier date.
        """
        if as_of_date >= self.current_date:
            return self.current_date
        return None

    def identities_for_tokens(self, broker, broker_tokens, as_of_date, segments):
        """The identities of a broker's tokens on the current date.

        Args:
            broker (str): The broker name.
            broker_tokens (list): The tokens, as text.
            as_of_date (datetime.date): The date asked about.
            segments (list | None): The segments to restrict to, which this stand-in ignores.

        Returns:
            dict: Token to identity, for the tokens it knows.
        """
        found = {}
        known = self.identities.get(broker, {})
        for broker_token in broker_tokens:
            if broker_token in known:
                found[broker_token] = known[broker_token]
        return found


class HoldingsWithoutATokenExample:
    """Resolves an ISIN and two tickers, and lists a token's spans.

    Attributes:
        engine (StandInEngine): The stand-in engine.
        resolver (MappingResolver): The resolver being shown.
        today (datetime.date): The current mapping date.
    """

    def __init__(self):
        """Builds the stand-ins and the resolver around them.

        Returns:
            None: This method returns nothing.
        """
        self.today = datetime.date(2026, 9, 28)
        nse_infy = self.identity('nse', 'INFY')
        bse_infy = self.identity('bse', 'INFY')
        nse_tcs = self.identity('nse', 'TCS')
        self.engine = StandInEngine(
            {
                'dhan': [
                    self.raw_row('1594'),
                    self.raw_row('500209'),
                ],
                'fyers': [
                    self.raw_row('10100000001594'),
                ],
            },
            [
                self.master_row(nse_infy),
                self.master_row(nse_tcs),
            ],
            [
                self.span_row(nse_infy, datetime.date(2025, 1, 2), self.today, 431),
            ],
        )
        mapping_cache = StandInMappingCache(
            self.engine,
            self.today,
            {
                'dhan': {
                    '1594': nse_infy,
                    '500209': bse_infy,
                },
                'fyers': {
                    '10100000001594': nse_infy,
                },
            },
        )
        self.resolver = MappingResolver(mapping_cache)

    def identity(self, exchange, symbol):
        """Builds the identity of one listed share on today's mapping.

        Args:
            exchange (str): The exchange, "nse" or "bse".
            symbol (str): The share's symbol.

        Returns:
            dict: The identity.
        """
        segment = f'{exchange}_equities'
        return {
            'instrument_id': instrument_id(
                exchange,
                segment,
                'security',
                {
                    'symbol': symbol,
                },
            ),
            'exchange': exchange,
            'segment': segment,
            'shape': 'security',
            'symbol': symbol,
            'underlying_symbol': None,
            'expiry_date': None,
            'strike_price': None,
            'option_type': None,
            'mapping_date': self.today,
        }

    def raw_row(self, broker_token):
        """Builds one raw-file row carrying INFY's ISIN.

        Args:
            broker_token (str): The broker's token in that row.

        Returns:
            types.SimpleNamespace: The row.
        """
        return types.SimpleNamespace(
            broker_token=broker_token,
            isin_code='INE009A01021',
        )

    def master_row(self, identity):
        """Builds one master-table row from an identity.

        Args:
            identity (dict): The identity.

        Returns:
            types.SimpleNamespace: The row.
        """
        return types.SimpleNamespace(
            instrument_id=identity['instrument_id'],
            exchange=identity['exchange'],
            segment=identity['segment'],
            shape=identity['shape'],
            symbol=identity['symbol'],
            underlying_symbol=None,
            expiry_date=None,
            strike_price=None,
            option_type=None,
        )

    def span_row(self, identity, first_mapping_date, last_mapping_date, mapping_dates):
        """Builds one span row for Flattrade's token 1594.

        Args:
            identity (dict): The instrument the token pointed at.
            first_mapping_date (datetime.date): The first date it did.
            last_mapping_date (datetime.date): The last date it did.
            mapping_dates (int): On how many mapping dates it did.

        Returns:
            types.SimpleNamespace: The row.
        """
        return types.SimpleNamespace(
            broker_token='1594',
            instrument_id=identity['instrument_id'],
            exchange=identity['exchange'],
            segment=identity['segment'],
            shape=identity['shape'],
            symbol=identity['symbol'],
            first_mapping_date=first_mapping_date,
            last_mapping_date=last_mapping_date,
            mapping_dates=mapping_dates,
        )

    def run(self):
        """Prints the ISIN answer, the ticker answer, the spans and the refused match column.

        Returns:
            None: This method returns nothing.
        """
        by_isin = self.resolver.identity_by_isin(
            [
                ' ine009a01021 ',
            ],
            self.today,
            preferred_exchange='nse',
        )
        print(f'Raw files searched: {self.engine.brokers_searched}')
        for found in by_isin['INE009A01021']:
            print(f'  INE009A01021 -> {found["exchange"]} {found["symbol"]}, found through {found["evidence_broker"]} token {found["evidence_token"]}')
        by_symbol = self.resolver.identity_by_symbol(
            'nse',
            [
                'nse_equities',
            ],
            [
                'infy',
                'tcs',
                'NOSUCHSHARE',
            ],
            self.today,
        )
        for symbol in by_symbol:
            print(f'Ticker {symbol}: {len(by_symbol[symbol])} match in {by_symbol[symbol][0]["segment"]}')
        spans = self.resolver.identity_spans(
            'flattrade',
            [
                1594,
            ],
            segments=[
                'nse_equities',
            ],
        )
        for span in spans['1594']:
            print(f'Flattrade 1594 meant {span["symbol"]} from {span["first_mapping_date"]} to {span["last_mapping_date"]} on {span["mapping_dates"]} dates')
        try:
            self.resolver.identity_spans(
                'flattrade',
                [
                    '1594',
                ],
                match_on='isin',
            )
        except ValueError as error:
            print(f'ValueError: {error}')


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

It prints:

Raw files searched: ['dhan', 'kotak', 'groww', 'stoxkart', 'fyers', 'wisdom_capital', 'indmoney']
  INE009A01021 -> nse INFY, found through dhan token 1594
  INE009A01021 -> bse INFY, found through dhan token 500209
Ticker INFY: 1 match in nse_equities
Ticker TCS: 1 match in nse_equities
Flattrade 1594 meant INFY from 2025-01-02 to 2026-09-28 on 431 dates
ValueError: cannot match broker_mappings on 'isin'
examples/stock_brokers/instruments/mapping/utilities/resolution/MappingResolver/example_3_a_brokers_raw_row.py
"""Fetches a broker's full raw instrument row for one of its tokens, from the latest snapshot on or before a date.

The mapped tables keep only a token, the symbols, the lot size and the tick size. When a caller needs something else from a broker's own file, `raw_row` reads the whole row from that broker's `instruments` table, using the token column the broker is keyed on, such as Zerodha's `instrument_token`.

`raw_row` reads through pandas, which needs a real SQLAlchemy connection, so this program gives the resolver an in-memory SQLite database instead of a small stand-in. SQLite has no schemas, so a second in-memory database is attached under the name `zerodha` to make the table `zerodha.instruments` exist; it holds two days of Zerodha's row for INFY, with the last price changed between them. Python's default way of passing a date to SQLite is deprecated, so the program registers `datetime.date.isoformat` as the adapter. A tiny stand-in cache only carries the engine, and no PostgreSQL or Redis is reached.

Notice that asking about 2026-09-27 returns that day's row even though a later one exists, that an unknown token gives None, and that a broker outside the mapped ten raises `ValueError` before any query, because the broker name becomes part of the table name.

Run it from the project root:

    python examples/stock_brokers/instruments/mapping/utilities/resolution/MappingResolver/example_3_a_brokers_raw_row.py
"""

import datetime
import sqlite3

import sqlalchemy

from stock_brokers.instruments.mapping.utilities.resolution import (
    MappingResolver,
)


class EngineOnlyMappingCache:
    """A stand-in for `MappingCache` that only lends its engine, which is all `raw_row` uses.

    Attributes:
        engine (sqlalchemy.engine.Engine): The in-memory SQLite engine.
    """

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

        Args:
            engine (sqlalchemy.engine.Engine): The in-memory SQLite engine.

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


class BrokersRawRowExample:
    """Fills an in-memory Zerodha snapshot and reads rows back through the resolver.

    Attributes:
        engine (sqlalchemy.engine.Engine): The in-memory SQLite engine.
        resolver (MappingResolver): The resolver being shown.
    """

    def __init__(self):
        """Builds the in-memory database, fills it and builds the resolver.

        Returns:
            None: This method returns nothing.
        """
        sqlite3.register_adapter(datetime.date, datetime.date.isoformat)
        self.engine = sqlalchemy.create_engine('sqlite://')
        with self.engine.begin() as connection:
            connection.exec_driver_sql("ATTACH DATABASE ':memory:' AS zerodha")
            connection.exec_driver_sql(
                'CREATE TABLE zerodha.instruments ('
                'instrument_token TEXT, exchange_token TEXT, tradingsymbol TEXT, name TEXT, '
                'last_price TEXT, tick_size TEXT, lot_size TEXT, exchange TEXT, download_date TEXT)'
            )
            connection.exec_driver_sql(
                'INSERT INTO zerodha.instruments VALUES '
                "('408065', '1594', 'INFY', 'INFOSYS', '1532.4', '0.1', '1', 'NSE', '2026-09-27'), "
                "('408065', '1594', 'INFY', 'INFOSYS', '1547.9', '0.1', '1', 'NSE', '2026-09-28')"
            )
        self.resolver = MappingResolver(EngineOnlyMappingCache(self.engine))

    def run(self):
        """Prints the row for two dates, the answer for an unknown token and the refusal of an unknown broker.

        Returns:
            None: This method returns nothing.
        """
        row = self.resolver.raw_row('zerodha', 408065, datetime.date(2026, 9, 27))
        print(f'As of 2026-09-27: {row}')
        row = self.resolver.raw_row('zerodha', '408065', datetime.date(2026, 9, 30))
        print(f'As of 2026-09-30: last price {row["last_price"]} from {row["download_date"]}')
        print(f'Unknown token: {self.resolver.raw_row("zerodha", "999999", datetime.date(2026, 9, 30))}')
        try:
            self.resolver.raw_row('upstox', '408065', datetime.date(2026, 9, 30))
        except ValueError as error:
            print(f'ValueError: {error}')


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

It prints:

As of 2026-09-27: {'instrument_token': '408065', 'exchange_token': '1594', 'tradingsymbol': 'INFY', 'name': 'INFOSYS', 'last_price': '1532.4', 'tick_size': '0.1', 'lot_size': '1', 'exchange': 'NSE', 'download_date': '2026-09-27'}
As of 2026-09-30: last price 1547.9 from 2026-09-28
Unknown token: None
ValueError: 'upstox' is not one of the mapped brokers: ['dhan', 'kotak', 'groww', 'stoxkart', 'fyers', 'wisdom_capital', 'indmoney', 'flattrade', 'shoonya', 'zerodha']

Methods:

Name Description
mapping_date_for

The mapping date a request for one as-of date resolves to.

broker_tokens

Find every broker's token for one instrument identity.

broker_rows_on_date

Read one instrument's broker rows for one exact mapping date.

identity

Find the unified identity behind each of one broker's tokens.

identity_spans

Find every instrument each of one broker's tokens has pointed at, over every mapping date.

identity_by_isin

Find the unified identities behind ISIN codes, by way of the brokers whose raw files carry an ISIN.

identity_by_symbol

Find the unified identities behind trading symbols, within given segments.

raw_row

Fetch a broker's full raw instrument row for one of its own tokens.

Source code in stock_brokers/instruments/mapping/utilities/resolution.py
def __init__(self, mapping_cache=None):
    """
    Build the resolver around a cache.

    Args:
        mapping_cache (MappingCache | None): The cache to answer current-date requests from. None builds one, which is right for a command line tool and wrong for anything sharing a process with another resolver.

    Returns:
        None: This function returns nothing.
    """
    self.mapping_cache = mapping_cache or MappingCache()
    self.engine = self.mapping_cache.engine

mapping_date_for

mapping_date_for(as_of_date)

The mapping date a request for one as-of date resolves to.

The current date comes from the cache, which reads it from a small Redis key rather than computing it, because a max over the whole hypertable costs about 1.2 seconds and has no selective filter of its own. Only a request for an earlier date pays that max, and it pays a filtered one.

Parameters:

Name Type Description Default
as_of_date date

The date the caller asked about.

required

Returns:

Type Description
date | None

The latest mapping date on or before as_of_date, or None when nothing was mapped by then.

Example usage

From example_1_tokens_for_an_instrument.py
print(f'2026-09-30 resolves to {self.resolver.mapping_date_for(datetime.date(2026, 9, 30))}')
From example_1_tokens_for_an_instrument.py
print(f'2026-08-20 resolves to {self.resolver.mapping_date_for(datetime.date(2026, 8, 20))}')
Source code in stock_brokers/instruments/mapping/utilities/resolution.py
def mapping_date_for(self, as_of_date):
    """
    The mapping date a request for one as-of date resolves to.

    The current date comes from the cache, which reads it from a small Redis key rather than computing it, because a max over the whole hypertable costs about 1.2 seconds and has no selective filter of its own. Only a request for an earlier date pays that max, and it pays a filtered one.

    Args:
        as_of_date (datetime.date): The date the caller asked about.

    Returns:
        datetime.date | None: The latest mapping date on or before as_of_date, or None when nothing was mapped by then.
    """
    current = self.mapping_cache.resolves_to_current_date(as_of_date)
    if current is not None:
        return current
    with self.engine.connect() as connection:
        return connection.execute(
            text(
                f"SELECT max(mapping_date) FROM {tables.BROKER_MAPPINGS} "
                "WHERE mapping_date <= :as_of_date"
            ),
            {
                "as_of_date": as_of_date,
            },
        ).scalar()

broker_tokens

broker_tokens(exchange, segment, shape, identity, as_of_date)

Find every broker's token for one instrument identity.

Parameters:

Name Type Description Default
exchange str

Canonical lowercase exchange name, for example "nse".

required
segment str

Exchange-prefixed segment value, for example "nse_equities".

required
shape str

One of "security", "future", or "option".

required
identity dict

The identity fields for the shape, for example {"symbol": "RELIANCE"}.

required
as_of_date date

Use the latest mapping on or before this date.

required

Returns:

Type Description
tuple

A (mapping_date, rows) pair, where mapping_date is the date the answer came from (datetime.date, or None when the instrument was never mapped) and rows is a list of dicts with keys "broker", "broker_token", "broker_symbol", "order_symbol", "lot_size", and "tick_size".

Example usage

From example_1_tokens_for_an_instrument.py
mapping_date, rows = self.resolver.broker_tokens(
    'nse',
    'nse_equities',
    'security',
    {
        'symbol': 'INFY',
    },
    as_of_date,
)
Source code in stock_brokers/instruments/mapping/utilities/resolution.py
def broker_tokens(self, exchange, segment, shape, identity, as_of_date):
    """
    Find every broker's token for one instrument identity.

    Args:
        exchange (str): Canonical lowercase exchange name, for example "nse".
        segment (str): Exchange-prefixed segment value, for example "nse_equities".
        shape (str): One of "security", "future", or "option".
        identity (dict): The identity fields for the shape, for example {"symbol": "RELIANCE"}.
        as_of_date (datetime.date): Use the latest mapping on or before this date.

    Returns:
        tuple: A (mapping_date, rows) pair, where mapping_date is the date the answer came from (datetime.date, or None when the instrument was never mapped) and rows is a list of dicts with keys "broker", "broker_token", "broker_symbol", "order_symbol", "lot_size", and "tick_size".
    """
    computed_id = instrument_id(exchange, segment, shape, identity)

    mapping_date = self.mapping_cache.resolves_to_current_date(as_of_date)
    rows = []
    if mapping_date is not None:
        rows = self.broker_rows_on_date(computed_id, mapping_date)
    if rows:
        return (mapping_date, rows)

    with self.engine.connect() as connection:
        latest = connection.execute(
            text(
                f"SELECT max(mapping_date) AS mapping_date FROM {tables.BROKER_MAPPINGS} "
                "WHERE instrument_id = :instrument_id AND mapping_date <= :as_of_date"
            ),
            {
                "instrument_id": computed_id,
                "as_of_date": as_of_date,
            },
        ).one()
        mapping_date = latest.mapping_date
    if mapping_date is None:
        return (None, [])

    return (mapping_date, self.broker_rows_on_date(computed_id, mapping_date))

broker_rows_on_date

broker_rows_on_date(computed_id, mapping_date)

Read one instrument's broker rows for one exact mapping date.

Parameters:

Name Type Description Default
computed_id str

The instrument id.

required
mapping_date date

The mapping date to read.

required

Returns:

Type Description
list[dict]

One dict per broker, with keys "broker", "broker_token", "broker_symbol", "order_symbol", "lot_size", and "tick_size", ordered by broker.

Example usage

From example_1_tokens_for_an_instrument.py
rows = self.resolver.broker_rows_on_date(self.infy_id, datetime.date(2026, 8, 14))
Source code in stock_brokers/instruments/mapping/utilities/resolution.py
def broker_rows_on_date(self, computed_id, mapping_date):
    """
    Read one instrument's broker rows for one exact mapping date.

    Args:
        computed_id (str): The instrument id.
        mapping_date (datetime.date): The mapping date to read.

    Returns:
        list[dict]: One dict per broker, with keys "broker", "broker_token", "broker_symbol", "order_symbol", "lot_size", and "tick_size", ordered by broker.
    """
    with self.engine.connect() as connection:
        result = connection.execute(
            text(
                "SELECT broker, broker_token, broker_symbol, order_symbol, lot_size, tick_size "
                f"FROM {tables.BROKER_MAPPINGS} "
                "WHERE instrument_id = :instrument_id AND mapping_date = :mapping_date "
                "ORDER BY broker"
            ),
            {
                "instrument_id": computed_id,
                "mapping_date": mapping_date,
            },
        ).all()

    rows = []
    for row in result:
        rows.append(
            {
                "broker": row.broker,
                "broker_token": row.broker_token,
                "broker_symbol": row.broker_symbol,
                "order_symbol": row.order_symbol,
                "lot_size": row.lot_size,
                "tick_size": row.tick_size,
            }
        )
    return rows

identity

identity(broker, broker_tokens, as_of_date, segments=None)

Find the unified identity behind each of one broker's tokens.

A token can point at more than one instrument, which happens when a broker's file reuses it, so the most recent mapping wins and the symbol breaks any remaining tie. That keeps the answer stable from one call to the next rather than depending on row order.

The tie-break alone is not enough when the caller already knows what kind of instrument it is holding, because the winning symbol can be the wrong one. Zerodha's BSE token 128224004 carries both ITC in bse_equities and a spurious ISIN-shaped row in bse_fixed_income, and the alphabetical tie-break prefers the latter. Passing the segments a holding can possibly be in removes the twin from consideration rather than hoping the tie-break lands on the right row.

Parameters:

Name Type Description Default
broker str

The broker name, for example "zerodha".

required
broker_tokens list[str]

The broker's own tokens to resolve.

required
as_of_date date

Use the latest mapping on or before this date.

required
segments list[str] | None

Canonical segment names to restrict the answer to, for example ["nse_equities", "bse_equities"], or None to accept any segment.

None

Returns:

Type Description
dict

Mapping of broker token to a dict with keys "instrument_id", "exchange", "segment", "shape", "symbol", "underlying_symbol", "expiry_date", "strike_price", "option_type", and "mapping_date". Tokens with no mapping, and tokens whose only mappings fall outside the requested segments, are absent from the result.

Example usage

From example_1_tokens_for_an_instrument.py
today_identity = self.resolver.identity('zerodha', [408065], datetime.date(2026, 9, 30))
From example_1_tokens_for_an_instrument.py
earlier_identity = self.resolver.identity('zerodha', ['408065'], datetime.date(2026, 8, 20))
From example_1_tokens_for_an_instrument.py
print(f'No tokens asked for: {self.resolver.identity("zerodha", [], datetime.date(2026, 9, 30))}')
Source code in stock_brokers/instruments/mapping/utilities/resolution.py
def identity(self, broker, broker_tokens, as_of_date, segments=None):
    """
    Find the unified identity behind each of one broker's tokens.

    A token can point at more than one instrument, which happens when a broker's file reuses it, so the most recent mapping wins and the symbol breaks any remaining tie. That keeps the answer stable from one call to the next rather than depending on row order.

    The tie-break alone is not enough when the caller already knows what kind of instrument it is holding, because the winning symbol can be the wrong one. Zerodha's BSE token 128224004 carries both ITC in bse_equities and a spurious ISIN-shaped row in bse_fixed_income, and the alphabetical tie-break prefers the latter. Passing the segments a holding can possibly be in removes the twin from consideration rather than hoping the tie-break lands on the right row.

    Args:
        broker (str): The broker name, for example "zerodha".
        broker_tokens (list[str]): The broker's own tokens to resolve.
        as_of_date (datetime.date): Use the latest mapping on or before this date.
        segments (list[str] | None): Canonical segment names to restrict the answer to, for example ["nse_equities", "bse_equities"], or None to accept any segment.

    Returns:
        dict: Mapping of broker token to a dict with keys "instrument_id", "exchange", "segment", "shape", "symbol", "underlying_symbol", "expiry_date", "strike_price", "option_type", and "mapping_date". Tokens with no mapping, and tokens whose only mappings fall outside the requested segments, are absent from the result.
    """
    if not broker_tokens:
        return {}

    tokens = []
    for token in broker_tokens:
        tokens.append(str(token))

    if self.mapping_cache.resolves_to_current_date(as_of_date) is not None:
        return self.mapping_cache.identities_for_tokens(broker, tokens, as_of_date, segments)

    statement = (
        "SELECT DISTINCT ON (b.broker_token) "
        "  b.broker_token, b.mapping_date, m.instrument_id, m.exchange, m.segment, m.shape, "
        "  m.symbol, m.underlying_symbol, m.expiry_date, m.strike_price, m.option_type "
        f"FROM {tables.BROKER_MAPPINGS} b "
        f"JOIN {tables.MASTER} m ON m.instrument_id = b.instrument_id "
        "WHERE b.broker = :broker AND b.mapping_date <= :as_of_date "
        "  AND b.broker_token = ANY(:tokens) "
    )
    parameters = {
        "broker": broker,
        "as_of_date": as_of_date,
        "tokens": tokens,
    }
    if segments is not None:
        statement = statement + "  AND m.segment = ANY(:segments) "
        parameters["segments"] = list(segments)
    statement = statement + "ORDER BY b.broker_token, b.mapping_date DESC, m.symbol ASC, m.instrument_id ASC"

    with self.engine.connect() as connection:
        result = connection.execute(text(statement), parameters).all()

    resolved = {}
    for row in result:
        resolved[row.broker_token] = {
            "instrument_id": str(row.instrument_id),
            "exchange": row.exchange,
            "segment": row.segment,
            "shape": row.shape,
            "symbol": row.symbol,
            "underlying_symbol": row.underlying_symbol,
            "expiry_date": row.expiry_date,
            "strike_price": row.strike_price,
            "option_type": row.option_type,
            "mapping_date": row.mapping_date,
        }
    return resolved

identity_spans

identity_spans(broker, broker_tokens, segments=None, match_on='broker_token')

Find every instrument each of one broker's tokens has pointed at, over every mapping date.

identity answers for one date, which suits a live position and does not suit a stored price series. A series holds bars from years before the first mapping date, and its token can have pointed at different instruments across the mapping dates there are - a symbol change, a move from EQ to BE, a day on which the broker's file filed a row under the wrong segment. So this returns all of them, each with the span of dates it was seen on, and leaves the choice between them to the caller, who knows what the series is.

Parameters:

Name Type Description Default
broker str

The broker name, for example "flattrade".

required
broker_tokens list[str]

The values to look up.

required
segments list[str] | None

Canonical segment names to restrict the answer to, or None to accept any segment.

None
match_on str

The broker_mappings column the values are compared with, "broker_token" or "order_symbol". Fyers stores its price series under its ticker, which the mapping keeps as order_symbol.

'broker_token'

Returns:

Type Description
dict

Mapping of each value to a list of dicts with keys "instrument_id", "exchange", "segment", "shape", "symbol", "first_mapping_date", "last_mapping_date" and "mapping_dates", ordered by first_mapping_date. Values with no mapping in the requested segments are absent.

Example usage

From example_2_holdings_without_a_token.py
spans = self.resolver.identity_spans(
    'flattrade',
    [
        1594,
    ],
    segments=[
        'nse_equities',
    ],
)
From example_2_holdings_without_a_token.py
self.resolver.identity_spans(
    'flattrade',
    [
        '1594',
    ],
    match_on='isin',
)
Source code in stock_brokers/instruments/mapping/utilities/resolution.py
def identity_spans(self, broker, broker_tokens, segments=None, match_on="broker_token"):
    """
    Find every instrument each of one broker's tokens has pointed at, over every mapping date.

    ``identity`` answers for one date, which suits a live position and does not suit a stored price series. A series holds bars from years before the first mapping date, and its token can have pointed at different instruments across the mapping dates there are - a symbol change, a move from EQ to BE, a day on which the broker's file filed a row under the wrong segment. So this returns all of them, each with the span of dates it was seen on, and leaves the choice between them to the caller, who knows what the series is.

    Args:
        broker (str): The broker name, for example "flattrade".
        broker_tokens (list[str]): The values to look up.
        segments (list[str] | None): Canonical segment names to restrict the answer to, or None to accept any segment.
        match_on (str): The broker_mappings column the values are compared with, "broker_token" or "order_symbol". Fyers stores its price series under its ticker, which the mapping keeps as order_symbol.

    Returns:
        dict: Mapping of each value to a list of dicts with keys "instrument_id", "exchange", "segment", "shape", "symbol", "first_mapping_date", "last_mapping_date" and "mapping_dates", ordered by first_mapping_date. Values with no mapping in the requested segments are absent.
    """
    if not broker_tokens:
        return {}
    if match_on not in ("broker_token", "order_symbol"):
        raise ValueError(f"cannot match broker_mappings on {match_on!r}")

    tokens = []
    for token in broker_tokens:
        tokens.append(str(token))

    statement = (
        f"SELECT b.{match_on} AS broker_token, m.instrument_id, m.exchange, m.segment, m.shape, m.symbol, "
        "  min(b.mapping_date) AS first_mapping_date, max(b.mapping_date) AS last_mapping_date, "
        "  count(*) AS mapping_dates "
        f"FROM {tables.BROKER_MAPPINGS} b "
        f"JOIN {tables.MASTER} m ON m.instrument_id = b.instrument_id "
        f"WHERE b.broker = :broker AND b.{match_on} = ANY(:tokens) "
    )
    parameters = {
        "broker": broker,
        "tokens": tokens,
    }
    if segments is not None:
        statement = statement + "  AND m.segment = ANY(:segments) "
        parameters["segments"] = list(segments)
    statement = statement + (
        f"GROUP BY b.{match_on}, m.instrument_id, m.exchange, m.segment, m.shape, m.symbol "
        f"ORDER BY b.{match_on}, first_mapping_date, m.instrument_id"
    )

    with self.engine.connect() as connection:
        result = connection.execute(text(statement), parameters).all()

    spans = {}
    for row in result:
        spans.setdefault(row.broker_token, []).append({
            "instrument_id": str(row.instrument_id),
            "exchange": row.exchange,
            "segment": row.segment,
            "shape": row.shape,
            "symbol": row.symbol,
            "first_mapping_date": row.first_mapping_date,
            "last_mapping_date": row.last_mapping_date,
            "mapping_dates": row.mapping_dates,
        })
    return spans

identity_by_isin

identity_by_isin(isin_codes, as_of_date, preferred_exchange=None)

Find the unified identities behind ISIN codes, by way of the brokers whose raw files carry an ISIN.

This is load-bearing rather than a fallback. Groww's holdings rows publish no broker token at all, only an ISIN and a trading symbol, so an ISIN is the only identifier a Groww holding arrives with. Seven brokers publish an ISIN column, and looking one up in any of their raw files yields that broker's own token, which resolves like any other token.

The token taken from each raw file is the broker's join key into unified.broker_mappings, which is not always the column an ISIN sits beside. Fyers is the case that matters: its ISIN sits with scrip_code while its stored token is fytoken, so taking the exchange-assigned security id here would silently find nothing.

An answer is a list rather than one identity, because a dual listed security legitimately has both an NSE and a BSE identity and neither is wrong. The list is ordered with the preferred exchange first so that a caller with a preference does not have to re-sort it.

Parameters:

Name Type Description Default
isin_codes list[str]

The ISIN codes to resolve.

required
as_of_date date

Use the latest mapping on or before this date.

required
preferred_exchange str | None

Canonical lowercase exchange name to order first, for example "nse", or None to leave the order to the exchange name and symbol.

None

Returns:

Type Description
dict

Mapping of ISIN code to a list of dicts, each holding the same keys identity returns plus "evidence_broker" and "evidence_token", naming the broker whose raw file carried the ISIN and the token it supplied. ISIN codes nothing maps are absent from the result.

Example usage

From example_2_holdings_without_a_token.py
by_isin = self.resolver.identity_by_isin(
    [
        ' ine009a01021 ',
    ],
    self.today,
    preferred_exchange='nse',
)
Source code in stock_brokers/instruments/mapping/utilities/resolution.py
def identity_by_isin(self, isin_codes, as_of_date, preferred_exchange=None):
    """
    Find the unified identities behind ISIN codes, by way of the brokers whose raw files carry an ISIN.

    This is load-bearing rather than a fallback. Groww's holdings rows publish no broker token at all, only an ISIN and a trading symbol, so an ISIN is the only identifier a Groww holding arrives with. Seven brokers publish an ISIN column, and looking one up in any of their raw files yields that broker's own token, which resolves like any other token.

    The token taken from each raw file is the broker's join key into unified.broker_mappings, which is not always the column an ISIN sits beside. Fyers is the case that matters: its ISIN sits with scrip_code while its stored token is fytoken, so taking the exchange-assigned security id here would silently find nothing.

    An answer is a list rather than one identity, because a dual listed security legitimately has both an NSE and a BSE identity and neither is wrong. The list is ordered with the preferred exchange first so that a caller with a preference does not have to re-sort it.

    Args:
        isin_codes (list[str]): The ISIN codes to resolve.
        as_of_date (datetime.date): Use the latest mapping on or before this date.
        preferred_exchange (str | None): Canonical lowercase exchange name to order first, for example "nse", or None to leave the order to the exchange name and symbol.

    Returns:
        dict: Mapping of ISIN code to a list of dicts, each holding the same keys identity returns plus "evidence_broker" and "evidence_token", naming the broker whose raw file carried the ISIN and the token it supplied. ISIN codes nothing maps are absent from the result.
    """
    if not isin_codes:
        return {}

    mapping_date = self.mapping_date_for(as_of_date)
    if mapping_date is None:
        return {}

    codes = []
    for isin_code in isin_codes:
        codes.append(str(isin_code).strip().upper())

    resolved = {}
    seen_instruments = {}
    for broker, isin_column in ISIN_COLUMNS:
        token_column = RAW_TOKEN_COLUMNS[broker]
        with self.engine.connect() as connection:
            rows = connection.execute(
                text(
                    f"SELECT {token_column} AS broker_token, upper({isin_column}) AS isin_code "
                    f"FROM {broker}.instruments "
                    f"WHERE download_date = :mapping_date AND upper({isin_column}) = ANY(:codes)"
                ),
                {
                    "mapping_date": mapping_date,
                    "codes": codes,
                },
            ).all()

        codes_by_token = {}
        for row in rows:
            if row.broker_token is None:
                continue
            codes_by_token.setdefault(str(row.broker_token), set()).add(row.isin_code)
        if not codes_by_token:
            continue

        identities = self.identity(broker, list(codes_by_token), as_of_date)
        for broker_token, found_identity in identities.items():
            for isin_code in codes_by_token[broker_token]:
                if found_identity["instrument_id"] in seen_instruments.setdefault(isin_code, set()):
                    continue
                seen_instruments[isin_code].add(found_identity["instrument_id"])
                found = dict(found_identity)
                found["evidence_broker"] = broker
                found["evidence_token"] = broker_token
                resolved.setdefault(isin_code, []).append(found)

    for isin_code in resolved:
        resolved[isin_code].sort(key=lambda found: exchange_order(found, preferred_exchange))
    return resolved

identity_by_symbol

identity_by_symbol(exchange, segments, symbols, as_of_date)

Find the unified identities behind trading symbols, within given segments.

A ticker is a weaker identifier than a token or an ISIN, so this returns a list per symbol and never picks a winner. An ambiguous match is something the caller has to see rather than something to resolve quietly, because a misfiled position is worse than an unidentified one.

Callers should restrict this to equities and exchange traded funds. A ticker match on a derivative can land on the wrong expiry or strike, where nothing about the answer looks wrong.

Parameters:

Name Type Description Default
exchange str

Canonical lowercase exchange name, for example "nse".

required
segments list[str]

Canonical segment names to search within, for example ["nse_equities"].

required
symbols list[str]

The trading symbols to resolve, matched case-insensitively.

required
as_of_date date

Use the mapping that was current on or before this date.

required

Returns:

Type Description
dict

Mapping of the upper-cased symbol to a list of dicts holding the same keys identity returns. Symbols nothing matches are absent from the result.

Example usage

From example_2_holdings_without_a_token.py
by_symbol = self.resolver.identity_by_symbol(
    'nse',
    [
        'nse_equities',
    ],
    [
        'infy',
        'tcs',
        'NOSUCHSHARE',
    ],
    self.today,
)
Source code in stock_brokers/instruments/mapping/utilities/resolution.py
def identity_by_symbol(self, exchange, segments, symbols, as_of_date):
    """
    Find the unified identities behind trading symbols, within given segments.

    A ticker is a weaker identifier than a token or an ISIN, so this returns a list per symbol and never picks a winner. An ambiguous match is something the caller has to see rather than something to resolve quietly, because a misfiled position is worse than an unidentified one.

    Callers should restrict this to equities and exchange traded funds. A ticker match on a derivative can land on the wrong expiry or strike, where nothing about the answer looks wrong.

    Args:
        exchange (str): Canonical lowercase exchange name, for example "nse".
        segments (list[str]): Canonical segment names to search within, for example ["nse_equities"].
        symbols (list[str]): The trading symbols to resolve, matched case-insensitively.
        as_of_date (datetime.date): Use the mapping that was current on or before this date.

    Returns:
        dict: Mapping of the upper-cased symbol to a list of dicts holding the same keys identity returns. Symbols nothing matches are absent from the result.
    """
    if not symbols or not segments:
        return {}

    mapping_date = self.mapping_date_for(as_of_date)
    if mapping_date is None:
        return {}

    wanted = []
    for symbol in symbols:
        wanted.append(str(symbol).strip().upper())

    with self.engine.connect() as connection:
        rows = connection.execute(
            text(
                "SELECT instrument_id, exchange, segment, shape, symbol, underlying_symbol, "
                "  expiry_date, strike_price, option_type "
                f"FROM {tables.MASTER} "
                "WHERE exchange = :exchange AND segment = ANY(:segments) "
                "  AND upper(symbol) = ANY(:symbols) "
                "  AND first_seen_date <= :mapping_date AND last_seen_date >= :mapping_date "
                "ORDER BY symbol ASC, segment ASC"
            ),
            {
                "exchange": exchange,
                "segments": list(segments),
                "symbols": wanted,
                "mapping_date": mapping_date,
            },
        ).all()

    resolved = {}
    for row in rows:
        found_identity = {
            "instrument_id": str(row.instrument_id),
            "exchange": row.exchange,
            "segment": row.segment,
            "shape": row.shape,
            "symbol": row.symbol,
            "underlying_symbol": row.underlying_symbol,
            "expiry_date": row.expiry_date,
            "strike_price": row.strike_price,
            "option_type": row.option_type,
            "mapping_date": mapping_date,
        }
        resolved.setdefault(row.symbol.upper(), []).append(found_identity)
    return resolved

raw_row

raw_row(broker, broker_token, as_of_date)

Fetch a broker's full raw instrument row for one of its own tokens.

Parameters:

Name Type Description Default
broker str

The broker name, which is also the schema its raw instruments table lives in.

required
broker_token str

The broker's own token, as stored in unified.broker_mappings.

required
as_of_date date

Use the latest downloaded snapshot on or before this date.

required

Returns:

Type Description
dict | None

Every column of the raw row, or None when that broker's file has no such token on or before the date.

Raises:

Type Description
ValueError

If the broker name is not one of the mapped brokers, since the name is interpolated into the table name.

Example usage

From example_3_a_brokers_raw_row.py
row = self.resolver.raw_row('zerodha', 408065, datetime.date(2026, 9, 27))
From example_3_a_brokers_raw_row.py
row = self.resolver.raw_row('zerodha', '408065', datetime.date(2026, 9, 30))
From example_3_a_brokers_raw_row.py
self.resolver.raw_row('upstox', '408065', datetime.date(2026, 9, 30))
Source code in stock_brokers/instruments/mapping/utilities/resolution.py
def raw_row(self, broker, broker_token, as_of_date):
    """
    Fetch a broker's full raw instrument row for one of its own tokens.

    Args:
        broker (str): The broker name, which is also the schema its raw instruments table lives in.
        broker_token (str): The broker's own token, as stored in unified.broker_mappings.
        as_of_date (datetime.date): Use the latest downloaded snapshot on or before this date.

    Returns:
        dict | None: Every column of the raw row, or None when that broker's file has no such token on or before the date.

    Raises:
        ValueError: If the broker name is not one of the mapped brokers, since the name is interpolated into the table name.
    """
    if broker not in MAPPED_BROKERS:
        raise ValueError(f"{broker!r} is not one of the mapped brokers: {MAPPED_BROKERS}")

    token_column = RAW_TOKEN_COLUMNS[broker]
    with self.engine.connect() as connection:
        raw = pd.read_sql(
            text(
                f"SELECT * FROM {broker}.instruments "
                f"WHERE {token_column} = :token AND download_date <= :as_of_date "
                f"ORDER BY download_date DESC LIMIT 1"
            ),
            connection,
            params={
                "token": str(broker_token),
                "as_of_date": as_of_date,
            },
        )
    if raw.empty:
        return None
    return raw.to_dict("records")[0]

exchange_order

exchange_order(found, preferred_exchange)

The sort key that puts a preferred exchange's identity first among an ISIN's answers.

Parameters:

Name Type Description Default
found dict

One resolved identity.

required
preferred_exchange str | None

The exchange to order first, or None for no preference.

required

Returns:

Type Description
tuple

A (preference, exchange, symbol) triple, where preference is 0 for the preferred exchange and 1 for every other.

Source code in stock_brokers/instruments/mapping/utilities/resolution.py
def exchange_order(found, preferred_exchange):
    """
    The sort key that puts a preferred exchange's identity first among an ISIN's answers.

    Args:
        found (dict): One resolved identity.
        preferred_exchange (str | None): The exchange to order first, or None for no preference.

    Returns:
        tuple: A (preference, exchange, symbol) triple, where preference is 0 for the preferred exchange and 1 for every other.
    """
    preference = 1
    if preferred_exchange is not None and found["exchange"] == preferred_exchange:
        preference = 0
    return (preference, found["exchange"] or "", found["symbol"] or "")