Skip to content

resolution

Find the unified instrument behind each of a broker's stored price series.

A series is resolved once, as a whole, rather than bar by bar. Its bars go back years before the first mapping date, so no date-by-date lookup could place them; what can be known is which instruments the series' token has pointed at over the mapping dates there are, and the choice between those is made here.

The evidence is tried in order of strength:

  1. The broker's own mapping (broker_mapping). The token's spans in unified.broker_mappings, restricted to the segments the identifier allows. One candidate settles it. Several are narrowed in three ways, in turn:

  2. A transient is dropped: a candidate seen on fewer dates, entirely inside another's span. On 2026-08-12 only zerodha's file was mapped, and without other brokers to cross-reference, its exchange traded funds and bonds were filed as uncategorised for that one day, under identities that exist on no other date.

  3. A consensus picks between candidates seen side by side: the one other brokers map the same exchange token to. flattrade's file keeps a stale row for DSP's IT ETF under its old symbol ITETFADD beside the current ITADD, both on token 17207, while every other broker maps 17207 to ITADD alone.
  4. Sequential candidates, one ending before the next begins, split the series in time at the boundary; bars from before the first mapping date belong to the earliest.

Anything still unresolved is ambiguous, and reported rather than guessed.

  1. Other brokers' mappings of the exchange token (exchange_token), when the broker never mapped the token at all. flattrade maps a symbol's EQ row and not its BE row, so its BE series - 566 of them in daily bars - have no mapping of their own, while the exchange token, shared by every broker's NSE and BSE files, names the instrument.

  2. The trading symbol (raw_symbol), matched against unified.instruments in the allowed segments, only when it matches exactly one instrument - counting an uncategorised twin of a classified instrument as no match, since that is a row a broker could not classify.

Resolution follows unified.instruments's idea of identity, in which the symbol is part of what an instrument is. A company that changes its symbol is therefore two instruments, and its series is split at the rename: MANBRO before 2026-09-03, KDGREEN from then on.

Classes:

Name Description
SeriesResolver

Resolves broker price series to unified instruments.

Functions:

Name Description
india_midnight

Midnight India time at the start of a date.

family_of

The family a SeriesContext's segments describe, for rebuilding Kite tokens.

SeriesResolver

SeriesResolver(mapping_resolver=None)

Resolves broker price series to unified instruments.

Attributes:

Name Type Description
mapping_resolver MappingResolver

The look-ups over the mapped tables.

engine Engine

The engine those look-ups use.

Build the resolver.

Parameters:

Name Type Description Default
mapping_resolver MappingResolver | None

An existing resolver to share, or None to build one.

None

Returns:

Type Description
None

This function returns nothing.

Example programs

examples/stock_brokers/instruments/historical/utilities/unified/resolution/SeriesResolver/example_1_resolving_flattrade_series.py
"""Resolves six of Flattrade's stored daily series to unified instruments, one for each rule the resolver applies.

A broker's price series is resolved once, as a whole: its bars reach back years before the first mapping date, so the question is which instruments its token has pointed at across the mapping dates there are, and which of them owns the series. `SeriesResolver.resolve` reads the identifier with the broker's own `series_context`, asks the mapping resolver for every instrument the token has been mapped to with the span of dates it was seen on, and chooses:

- one candidate settles it (RELIANCE);
- a candidate seen on fewer dates, entirely inside another's span, is a transient and is dropped (NIFTYBEES, filed as uncategorised for one day on 2026-08-12);
- between candidates seen side by side, the one other brokers map the same exchange token to wins, when at least two back it (ITADD over the stale ITETFADD row on token 17207);
- sequential candidates split the series in time at the boundary (MANBRO renamed KDGREEN on 2026-09-03);
- anything else is reported as ambiguous rather than guessed;
- a token the broker never mapped falls back on other brokers' mappings of its exchange token (INDIAGLYCO's BE series, backed by Dhan, Kotak and Zerodha, whose Kite token is rebuilt from the exchange token as 7001 shifted left eight bits plus NSE's segment code 1).

The mapping resolver and its database are replaced by small stand-ins holding those spans and other brokers' mappings, so no data store is reached; the stand-in engine answers the two queries `resolve` sends, and the names and dates follow the cases in the module's own documentation. The program finally calls `choose` by hand to show that a consensus of one broker is not enough.

Run it from the project root:

    python examples/stock_brokers/instruments/historical/utilities/unified/resolution/SeriesResolver/example_1_resolving_flattrade_series.py
"""

import datetime

from stock_brokers.instruments.historical.utilities.unified.resolution import (
    SeriesResolver,
)


class VoteRow:
    """A stand-in for one result row of the exchange token vote query.

    Attributes:
        broker (str): The broker that maps the token.
        broker_token (str): Its token.
        instrument_id (str): The instrument it maps the token to.
    """

    def __init__(self, broker, broker_token, instrument_id):
        """Holds the row's columns.

        Args:
            broker (str): The broker that maps the token.
            broker_token (str): Its token.
            instrument_id (str): The instrument it maps the token to.

        Returns:
            None: This method returns nothing.
        """
        self.broker = broker
        self.broker_token = broker_token
        self.instrument_id = instrument_id


class InstrumentRow:
    """A stand-in for one result row of an instrument master query, read through `_mapping`.

    Attributes:
        instrument_id (str): The instrument's id.
        _mapping (dict): Every column by name, as SQLAlchemy rows offer them.
    """

    def __init__(self, columns):
        """Holds the row's columns.

        Args:
            columns (dict): Every column by name.

        Returns:
            None: This method returns nothing.
        """
        self.instrument_id = columns['instrument_id']
        self._mapping = columns


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

    Attributes:
        rows (list): The rows.
    """

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

        Args:
            rows (list): The rows.

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

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

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

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

        Returns:
            InstrumentRow: The row.
        """
        return self.rows[0]


class StandInConnection:
    """A stand-in for a SQLAlchemy connection over the stand-in mapped tables.

    Attributes:
        engine (StandInEngine): The engine holding the tables.
    """

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

        Args:
            engine (StandInEngine): The engine holding the tables.

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

    def __enter__(self):
        """Opens the connection.

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

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

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

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

    def execute(self, statement, parameters):
        """Answers the exchange token vote query or the one-instrument query.

        Args:
            statement (sqlalchemy.TextClause): The SQL.
            parameters (dict): The bound parameters.

        Returns:
            StandInResult: The rows.
        """
        if 'GROUP BY' in str(statement):
            return StandInResult(self.engine.votes(parameters))
        instrument = self.engine.master[parameters['instrument_id']]
        rows = [
            InstrumentRow(instrument),
        ]
        return StandInResult(rows)


class StandInEngine:
    """A stand-in for the SQLAlchemy engine, holding other brokers' mappings and the instrument master.

    Attributes:
        mappings (list): (broker, broker_token, instrument_id, segment) rows of other brokers' mappings.
        master (dict): Instrument id to its row in the instrument master.
    """

    def __init__(self):
        """Fills in the mappings and the master.

        Returns:
            None: This method returns nothing.
        """
        self.mappings = []
        self.master = {}
        self.add_mapping('dhan', '17207', 'ITADD')
        self.add_mapping('kotak', '17207', 'ITADD')
        self.add_mapping('groww', '17207', 'ITADD')
        self.add_mapping('stoxkart', '17207', 'ITADD')
        self.add_mapping('dhan', '7001', 'INDIAGLYCO')
        self.add_mapping('kotak', '7001', 'INDIAGLYCO')
        self.add_mapping('zerodha', str(7001 << 8 | 1), 'INDIAGLYCO')
        self.master['INDIAGLYCO'] = {
            'instrument_id': 'INDIAGLYCO',
            'exchange': 'nse',
            'segment': 'nse_equities',
            'shape': 'security',
            'symbol': 'INDIAGLYCO',
            'first_mapping_date': datetime.date(2026, 8, 12),
            'last_mapping_date': datetime.date(2026, 9, 29),
        }

    def add_mapping(self, broker, broker_token, instrument_id):
        """Adds one other broker's mapping of an NSE share.

        Args:
            broker (str): The broker.
            broker_token (str): Its token.
            instrument_id (str): The instrument it maps to.

        Returns:
            None: This method returns nothing.
        """
        row = (
            broker,
            broker_token,
            instrument_id,
            'nse_equities',
        )
        self.mappings.append(row)

    def votes(self, parameters):
        """Selects the mappings the vote query asks for.

        Args:
            parameters (dict): The query's bound parameters.

        Returns:
            list: One VoteRow per matching (broker, token, instrument).
        """
        rows = []
        for broker, broker_token, instrument_id, segment in self.mappings:
            if segment not in parameters['segments']:
                continue
            voter = broker in parameters['voters'] and broker_token in parameters['exchange_tokens']
            zerodha = broker == 'zerodha' and parameters['include_zerodha'] and broker_token in parameters['zerodha_tokens']
            if voter or zerodha:
                rows.append(VoteRow(broker, broker_token, instrument_id))
        return rows

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

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


class StandInMappingResolver:
    """A stand-in for `MappingResolver` that knows Flattrade's token spans.

    Attributes:
        engine (StandInEngine): The engine the series resolver queries directly.
        spans (dict): Flattrade token to the spans it has been mapped over.
    """

    def __init__(self):
        """Fills in the spans for five tokens.

        Returns:
            None: This method returns nothing.
        """
        self.engine = StandInEngine()
        self.spans = {}
        self.add_span('2885', 'RELIANCE', 'RELIANCE', 'nse_equities', '2026-08-12', '2026-09-29', 35)
        self.add_span('10576', 'NIFTYBEES', 'NIFTYBEES', 'nse_exchange_traded_funds', '2026-08-12', '2026-09-29', 35)
        self.add_span('10576', 'NIFTYBEES-UNCATEGORISED', 'NIFTYBEES', 'nse_uncategorised', '2026-08-12', '2026-08-12', 1)
        self.add_span('17207', 'ITETFADD', 'ITETFADD', 'nse_exchange_traded_funds', '2026-08-12', '2026-09-25', 32)
        self.add_span('17207', 'ITADD', 'ITADD', 'nse_exchange_traded_funds', '2026-08-14', '2026-09-29', 34)
        self.add_span('4617', 'MANBRO', 'MANBRO', 'nse_equities', '2026-08-12', '2026-09-02', 16)
        self.add_span('4617', 'KDGREEN', 'KDGREEN', 'nse_equities', '2026-09-03', '2026-09-29', 19)
        self.add_span('9999', 'TWINSA', 'TWINSA', 'nse_equities', '2026-08-12', '2026-09-25', 32)
        self.add_span('9999', 'TWINSB', 'TWINSB', 'nse_equities', '2026-08-14', '2026-09-29', 34)

    def add_span(self, token, instrument_id, symbol, segment, first_date, last_date, mapping_dates):
        """Adds one instrument a token has been mapped to, with its span.

        Args:
            token (str): Flattrade's token.
            instrument_id (str): The instrument's id, a readable name in this program.
            symbol (str): The instrument's symbol.
            segment (str): The instrument's segment.
            first_date (str): The first mapping date it was seen on.
            last_date (str): The last mapping date it was seen on.
            mapping_dates (int): How many mapping dates it was seen on.

        Returns:
            None: This method returns nothing.
        """
        span = {
            'instrument_id': instrument_id,
            'exchange': 'nse',
            'segment': segment,
            'shape': 'security',
            'symbol': symbol,
            'first_mapping_date': datetime.date.fromisoformat(first_date),
            'last_mapping_date': datetime.date.fromisoformat(last_date),
            'mapping_dates': mapping_dates,
        }
        self.spans.setdefault(token, []).append(span)

    def identity_spans(self, broker, broker_tokens, segments=None, match_on='broker_token'):
        """Answers every instrument each token has been mapped to, oldest first.

        Args:
            broker (str): The broker.
            broker_tokens (list): The tokens to look up.
            segments (tuple | None): The allowed segments, or None for any.
            match_on (str): The mapping column compared, which this stand-in ignores.

        Returns:
            dict: Token to a list of spans; tokens with none are absent.
        """
        found = {}
        for token in broker_tokens:
            for span in self.spans.get(token, []):
                if segments is None or span['segment'] in segments:
                    found.setdefault(token, []).append(span)
        return found


class ResolvingFlattradeSeriesExample:
    """Resolves six Flattrade series and prints each resolution.

    Attributes:
        resolver (SeriesResolver): The resolver being shown.
    """

    def __init__(self):
        """Builds the resolver over the stand-in mapping resolver.

        Returns:
            None: This method returns nothing.
        """
        self.resolver = SeriesResolver(StandInMappingResolver())

    def describe(self, resolution):
        """Prints one resolution on one line.

        Args:
            resolution (Resolution): The resolution.

        Returns:
            None: This method returns nothing.
        """
        valid_from = resolution.valid_from.isoformat() if resolution.valid_from else None
        valid_to = resolution.valid_to.isoformat() if resolution.valid_to else None
        print(f'  -> {resolution.symbol} {resolution.status} by {resolution.resolved_by}, from {valid_from} to {valid_to}; {resolution.status_reason}')

    def run(self):
        """Resolves the series, then shows `choose` refusing a consensus of one.

        Returns:
            None: This method returns nothing.
        """
        identifiers = [
            'NSE|2885|RELIANCE-EQ',
            'NSE|10576|NIFTYBEES-EQ',
            'NSE|17207|ITETFADD-EQ',
            'NSE|4617|MANBRO-EQ',
            'NSE|9999|TWINS-EQ',
            'NSE|7001|INDIAGLYCO-BE',
        ]
        resolved = self.resolver.resolve('flattrade', identifiers)
        for identifier in identifiers:
            print(identifier)
            for resolution in resolved[identifier]:
                self.describe(resolution)
        spans = self.resolver.mapping_resolver.spans['9999']
        votes = {
            'TWINSA': 1,
        }
        print('choose between TWINSA and TWINSB with one broker behind TWINSA:')
        for resolution in self.resolver.choose('NSE|9999|TWINS-EQ', spans, votes):
            self.describe(resolution)


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

It prints:

NSE|2885|RELIANCE-EQ
  -> RELIANCE active by broker_mapping, from None to None; None
NSE|10576|NIFTYBEES-EQ
  -> NIFTYBEES active by broker_mapping, from None to None; 1 transient mapping(s) ignored
NSE|17207|ITETFADD-EQ
  -> ITADD active by broker_mapping, from None to None; chosen by 4 other brokers over 0
NSE|4617|MANBRO-EQ
  -> MANBRO active by broker_mapping, from None to 2026-09-03T00:00:00+05:30; split across 2 sequential instruments
  -> KDGREEN active by broker_mapping, from 2026-09-03T00:00:00+05:30 to None; split across 2 sequential instruments
NSE|9999|TWINS-EQ
  -> None ambiguous by broker_mapping, from None to None; token mapped to 2 instruments at once: nse_equities:TWINSA, nse_equities:TWINSB
NSE|7001|INDIAGLYCO-BE
  -> INDIAGLYCO active by exchange_token, from None to None; exchange token 7001 mapped by 3 brokers
choose between TWINSA and TWINSB with one broker behind TWINSA:
  -> None ambiguous by broker_mapping, from None to None; token mapped to 2 instruments at once: nse_equities:TWINSA, nse_equities:TWINSB
examples/stock_brokers/instruments/historical/utilities/unified/resolution/SeriesResolver/example_2_falling_back_on_symbols.py
"""Falls back on other brokers and on the trading symbol for series whose own token was never mapped, calling each step of `SeriesResolver` by hand.

Flattrade maps a share's EQ row and not its BE row, so its BE series have no mapping of their own. `SeriesResolver.fall_back` handles such a series in two steps. First, other brokers' mappings of the same exchange token vote, and an instrument backed by at least two brokers, and by more than any other, wins; `exchange_token_votes` gathers those votes in one query, rebuilding Zerodha's Kite token from the exchange token so its vote counts too. Failing that, the trading symbol is matched against the instrument master in the allowed segments, where a classified instrument outranks an uncategorised twin, several classified matches are ambiguous, and no match at all rejects the series.

This program calls `exchange_token_votes`, `fall_back`, `symbol_matches`, `instrument` and `resolution` directly, with contexts read by Flattrade's own `series_context`, so each step's answer is visible. The database behind the resolver is a stand-in engine holding a few mappings and instrument master rows, so no data store is reached; ORICONENT's temporary BE token 10163, filed as uncategorised by one broker, is the case the module's documentation describes. Notice that one broker's vote for the uncategorised twin is not enough to decide, so the symbol decides instead.

Run it from the project root:

    python examples/stock_brokers/instruments/historical/utilities/unified/resolution/SeriesResolver/example_2_falling_back_on_symbols.py
"""

import datetime

from stock_brokers.instruments.historical.flattrade import (
    FlattradeCandles,
)
from stock_brokers.instruments.historical.utilities.unified.resolution import (
    SeriesResolver,
)


class VoteRow:
    """A stand-in for one result row of the exchange token vote query.

    Attributes:
        broker (str): The broker that maps the token.
        broker_token (str): Its token.
        instrument_id (str): The instrument it maps the token to.
    """

    def __init__(self, broker, broker_token, instrument_id):
        """Holds the row's columns.

        Args:
            broker (str): The broker that maps the token.
            broker_token (str): Its token.
            instrument_id (str): The instrument it maps the token to.

        Returns:
            None: This method returns nothing.
        """
        self.broker = broker
        self.broker_token = broker_token
        self.instrument_id = instrument_id


class InstrumentRow:
    """A stand-in for one result row of an instrument master query, read through `_mapping`.

    Attributes:
        instrument_id (str): The instrument's id.
        _mapping (dict): Every column by name, as SQLAlchemy rows offer them.
    """

    def __init__(self, columns):
        """Holds the row's columns.

        Args:
            columns (dict): Every column by name.

        Returns:
            None: This method returns nothing.
        """
        self.instrument_id = columns['instrument_id']
        self._mapping = columns


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

    Attributes:
        rows (list): The rows.
    """

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

        Args:
            rows (list): The rows.

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

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

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

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

        Returns:
            InstrumentRow: The row.
        """
        return self.rows[0]


class StandInConnection:
    """A stand-in for a SQLAlchemy connection over the stand-in mapped tables.

    Attributes:
        engine (StandInEngine): The engine holding the tables.
    """

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

        Args:
            engine (StandInEngine): The engine holding the tables.

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

    def __enter__(self):
        """Opens the connection.

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

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

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

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

    def execute(self, statement, parameters):
        """Answers the exchange token vote query, the symbol query or the one-instrument query.

        Args:
            statement (sqlalchemy.TextClause): The SQL.
            parameters (dict): The bound parameters.

        Returns:
            StandInResult: The rows.
        """
        if 'GROUP BY' in str(statement):
            return StandInResult(self.engine.votes(parameters))
        if 'upper(m.symbol)' in str(statement):
            return StandInResult(self.engine.symbol_rows(parameters))
        instrument = self.engine.master[parameters['instrument_id']]
        rows = [
            InstrumentRow(instrument),
        ]
        return StandInResult(rows)


class StandInEngine:
    """A stand-in for the SQLAlchemy engine, holding other brokers' mappings and the instrument master.

    Attributes:
        mappings (list): (broker, broker_token, instrument_id, segment) rows of other brokers' mappings.
        master (dict): Instrument id to its row in the instrument master.
    """

    def __init__(self):
        """Fills in the mappings and the master rows.

        Returns:
            None: This method returns nothing.
        """
        self.mappings = []
        self.master = {}
        self.add_mapping('dhan', '7001', 'INDIAGLYCO')
        self.add_mapping('kotak', '7001', 'INDIAGLYCO')
        self.add_mapping('zerodha', str(7001 << 8 | 1), 'INDIAGLYCO')
        self.add_mapping('stoxkart', '10163', 'ORICONENT-UNCATEGORISED')
        self.add_instrument('INDIAGLYCO', 'INDIAGLYCO', 'nse_equities')
        self.add_instrument('ORICONENT', 'ORICONENT', 'nse_equities')
        self.add_instrument('ORICONENT-UNCATEGORISED', 'ORICONENT', 'nse_uncategorised')
        self.add_instrument('DUPL', 'DUPL', 'nse_equities')
        self.add_instrument('DUPL-ETF', 'DUPL', 'nse_exchange_traded_funds')

    def add_instrument(self, instrument_id, symbol, segment):
        """Adds one NSE instrument to the master.

        Args:
            instrument_id (str): The instrument's id, a readable name in this program.
            symbol (str): Its trading symbol.
            segment (str): Its segment.

        Returns:
            None: This method returns nothing.
        """
        self.master[instrument_id] = {
            'instrument_id': instrument_id,
            'exchange': 'nse',
            'segment': segment,
            'shape': 'security',
            'symbol': symbol,
            'first_mapping_date': datetime.date(2026, 8, 12),
            'last_mapping_date': datetime.date(2026, 9, 29),
        }

    def symbol_rows(self, parameters):
        """Selects the master rows the symbol query asks for.

        Args:
            parameters (dict): The query's bound parameters.

        Returns:
            list: One InstrumentRow per match.
        """
        rows = []
        for columns in self.master.values():
            if columns['exchange'] != parameters['exchange']:
                continue
            if columns['segment'] not in parameters['segments']:
                continue
            if columns['symbol'].upper() == parameters['symbol']:
                rows.append(InstrumentRow(columns))
        return rows

    def add_mapping(self, broker, broker_token, instrument_id):
        """Adds one other broker's mapping of an NSE share.

        Args:
            broker (str): The broker.
            broker_token (str): Its token.
            instrument_id (str): The instrument it maps to.

        Returns:
            None: This method returns nothing.
        """
        row = (
            broker,
            broker_token,
            instrument_id,
            'nse_equities',
        )
        self.mappings.append(row)

    def votes(self, parameters):
        """Selects the mappings the vote query asks for.

        Args:
            parameters (dict): The query's bound parameters.

        Returns:
            list: One VoteRow per matching (broker, token, instrument).
        """
        rows = []
        for broker, broker_token, instrument_id, segment in self.mappings:
            if segment not in parameters['segments']:
                continue
            voter = broker in parameters['voters'] and broker_token in parameters['exchange_tokens']
            zerodha = broker == 'zerodha' and parameters['include_zerodha'] and broker_token in parameters['zerodha_tokens']
            if voter or zerodha:
                rows.append(VoteRow(broker, broker_token, instrument_id))
        return rows

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

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


class StandInMappingResolver:
    """A stand-in for `MappingResolver` for series Flattrade never mapped.

    Attributes:
        engine (StandInEngine): The engine the series resolver queries directly.
    """

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

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

    def identity_spans(self, broker, broker_tokens, segments=None, match_on='broker_token'):
        """Answers that none of the tokens was ever mapped.

        Args:
            broker (str): The broker.
            broker_tokens (list): The tokens to look up.
            segments (tuple | None): The allowed segments, or None for any.
            match_on (str): The mapping column compared.

        Returns:
            dict: Always empty.
        """
        return {}


class FallingBackOnSymbolsExample:
    """Resolves series without mappings of their own, one step at a time.

    Attributes:
        resolver (SeriesResolver): The resolver being shown.
    """

    def __init__(self):
        """Builds the resolver over the stand-in mapping resolver.

        Returns:
            None: This method returns nothing.
        """
        self.resolver = SeriesResolver(StandInMappingResolver())

    def describe(self, resolution):
        """Prints one resolution on one line.

        Args:
            resolution (Resolution): The resolution.

        Returns:
            None: This method returns nothing.
        """
        print(f'  -> {resolution.instrument_id} {resolution.status} by {resolution.resolved_by}; {resolution.status_reason}')

    def fall_back(self, identifier):
        """Gathers the votes for one series and falls back on them and its symbol.

        Args:
            identifier (str): Flattrade's series identifier.

        Returns:
            None: This method returns nothing.
        """
        context = FlattradeCandles.series_context(identifier)
        contexts = [
            context,
        ]
        votes = self.resolver.exchange_token_votes('flattrade', contexts)
        print(f'{identifier}: votes {votes}')
        for resolution in self.resolver.fall_back(identifier, context, votes.get(context.exchange_token, {})):
            self.describe(resolution)

    def run(self):
        """Falls back for five series, then calls the look-ups directly.

        Returns:
            None: This method returns nothing.
        """
        self.fall_back('NSE|7001|INDIAGLYCO-BE')
        self.fall_back('NSE|10163|ORICONENT-BE')
        self.fall_back('NSE|20001|DUPL-BE')
        self.fall_back('NSE|30001|GONE-BE')
        self.fall_back('NFO|35003|NIFTY26SEPFUT')
        context = FlattradeCandles.series_context('NSE|10163|ORICONENT-BE')
        matches = self.resolver.symbol_matches('nse', context.segments, 'oriconent')
        for match in matches:
            print(f"symbol_matches ORICONENT: {match['instrument_id']} in {match['segment']}")
        instrument = self.resolver.instrument('INDIAGLYCO')
        print(f"instrument INDIAGLYCO: {instrument['segment']} seen {instrument['first_mapping_date']} to {instrument['last_mapping_date']}")
        resolution = self.resolver.resolution('NSE|7001|INDIAGLYCO-BE', instrument, 'manual', reason='set by hand')
        print(f'resolution built by hand: {resolution.instrument_id} {resolution.status} by {resolution.resolved_by}; {resolution.status_reason}')

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

It prints:

NSE|7001|INDIAGLYCO-BE: votes {'7001': {'INDIAGLYCO': 3}}
  -> INDIAGLYCO active by exchange_token; exchange token 7001 mapped by 3 brokers
NSE|10163|ORICONENT-BE: votes {'10163': {'ORICONENT-UNCATEGORISED': 1}}
  -> ORICONENT active by raw_symbol; symbol ORICONENT
NSE|20001|DUPL-BE: votes {}
  -> None ambiguous by raw_symbol; symbol DUPL matches 2 instruments
NSE|30001|GONE-BE: votes {}
  -> None rejected by None; no mapping, exchange token or symbol match
NFO|35003|NIFTY26SEPFUT: votes {}
  -> None rejected by None; no mapping, exchange token or symbol match
symbol_matches ORICONENT: ORICONENT in nse_equities
symbol_matches ORICONENT: ORICONENT-UNCATEGORISED in nse_uncategorised
instrument INDIAGLYCO: nse_equities seen 2026-08-12 to 2026-09-29
resolution built by hand: INDIAGLYCO active by manual; set by hand

Methods:

Name Description
resolve

Resolve a broker's series.

choose

Pick between the instruments a series' own token has been mapped to.

fall_back

Resolve a series whose own token was never mapped.

exchange_token_votes

Count, for each exchange token, how many other brokers map it to each instrument.

instrument

One instrument's identity and its span across every broker's mappings.

symbol_matches

Every instrument with this exact symbol in the allowed segments, on any date.

resolution

An active Resolution for a chosen candidate.

Source code in stock_brokers/instruments/historical/utilities/unified/resolution.py
def __init__(self, mapping_resolver=None):
    """
    Build the resolver.

    Args:
        mapping_resolver (MappingResolver | None): An existing resolver to share, or None to build one.

    Returns:
        None: This function returns nothing.
    """
    self.mapping_resolver = mapping_resolver or MappingResolver()
    self.engine = self.mapping_resolver.engine

resolve

resolve(broker, identifiers)

Resolve a broker's series.

Parameters:

Name Type Description Default
broker str

The broker name, for example "flattrade".

required
identifiers list[str]

Values of instrument_token in the broker's price table.

required

Returns:

Type Description
dict

Mapping of identifier to a list of Resolution, never empty.

Example usage

From example_1_resolving_flattrade_series.py
resolved = self.resolver.resolve('flattrade', identifiers)
Source code in stock_brokers/instruments/historical/utilities/unified/resolution.py
def resolve(self, broker, identifiers):
    """
    Resolve a broker's series.

    Args:
        broker (str): The broker name, for example "flattrade".
        identifiers (list[str]): Values of `instrument_token` in the broker's price table.

    Returns:
        dict: Mapping of identifier to a list of Resolution, never empty.
    """
    candles = downloader_for(broker)
    contexts = {}
    for identifier in identifiers:
        contexts[identifier] = candles.series_context(identifier)

    # Series that share segments and match column are looked up together.
    groups = {}
    for identifier, context in contexts.items():
        key = (tuple(context.segments) if context.segments else None, context.match_on)
        groups.setdefault(key, []).append(identifier)

    resolved = {}
    for (segments, match_on), members in groups.items():
        tokens = sorted({contexts[identifier].broker_token for identifier in members})
        spans = self.mapping_resolver.identity_spans(broker, tokens, segments, match_on)
        votes = self.exchange_token_votes(broker, [contexts[identifier] for identifier in members])
        unresolved = []
        for identifier in members:
            context = contexts[identifier]
            candidates = spans.get(context.broker_token, [])
            if candidates:
                resolved[identifier] = self.choose(identifier, candidates,
                                                   votes.get(context.exchange_token, {}))
            else:
                unresolved.append(identifier)
        for identifier in unresolved:
            context = contexts[identifier]
            resolved[identifier] = self.fall_back(identifier, context,
                                                  votes.get(context.exchange_token, {}))
    return resolved

choose

choose(identifier, candidates, votes)

Pick between the instruments a series' own token has been mapped to.

Parameters:

Name Type Description Default
identifier str

The series identifier.

required
candidates list[dict]

The token's spans, as identity_spans returns them.

required
votes dict

Other brokers' support per instrument_id for the exchange token.

required

Returns:

Type Description
list[Resolution]

One Resolution, or several for a sequential split.

Example usage

From example_1_resolving_flattrade_series.py
self.resolver.choose('NSE|9999|TWINS-EQ', spans, votes)
Source code in stock_brokers/instruments/historical/utilities/unified/resolution.py
def choose(self, identifier, candidates, votes):
    """
    Pick between the instruments a series' own token has been mapped to.

    Args:
        identifier (str): The series identifier.
        candidates (list[dict]): The token's spans, as `identity_spans` returns them.
        votes (dict): Other brokers' support per instrument_id for the exchange token.

    Returns:
        list[Resolution]: One Resolution, or several for a sequential split.
    """
    if len(candidates) == 1:
        return [self.resolution(identifier, candidates[0], "broker_mapping")]

    lasting = []
    for candidate in candidates:
        contained = False
        for other in candidates:
            if other is candidate:
                continue
            if (other["first_mapping_date"] <= candidate["first_mapping_date"]
                    and other["last_mapping_date"] >= candidate["last_mapping_date"]
                    and other["mapping_dates"] > candidate["mapping_dates"]):
                contained = True
                break
        if not contained:
            lasting.append(candidate)
    if len(lasting) == 1:
        dropped = len(candidates) - 1
        return [self.resolution(identifier, lasting[0], "broker_mapping",
                                reason=f"{dropped} transient mapping(s) ignored")]

    ranked = sorted(lasting, key=lambda candidate: votes.get(candidate["instrument_id"], 0), reverse=True)
    best = votes.get(ranked[0]["instrument_id"], 0)
    runner_up = votes.get(ranked[1]["instrument_id"], 0)
    if best >= MINIMUM_CONSENSUS and best > runner_up:
        return [self.resolution(identifier, ranked[0], "broker_mapping",
                                reason=f"chosen by {best} other brokers over {runner_up}")]

    ordered = sorted(lasting, key=lambda candidate: candidate["first_mapping_date"])
    sequential = True
    for earlier, later in zip(ordered, ordered[1:]):
        if earlier["last_mapping_date"] >= later["first_mapping_date"]:
            sequential = False
            break
    if sequential:
        resolutions = []
        for position, candidate in enumerate(ordered):
            valid_from = None
            valid_to = None
            if position > 0:
                valid_from = india_midnight(candidate["first_mapping_date"])
            if position < len(ordered) - 1:
                valid_to = india_midnight(ordered[position + 1]["first_mapping_date"])
            resolutions.append(self.resolution(identifier, candidate, "broker_mapping",
                                               reason=f"split across {len(ordered)} sequential instruments",
                                               valid_from=valid_from, valid_to=valid_to))
        return resolutions

    names = ", ".join(f"{candidate['segment']}:{candidate['symbol']}" for candidate in lasting)
    return [Resolution(identifier, None, None, None, None, "broker_mapping", "ambiguous",
                       f"token mapped to {len(lasting)} instruments at once: {names}",
                       None, None, None, None)]

fall_back

fall_back(identifier, context, votes)

Resolve a series whose own token was never mapped.

Parameters:

Name Type Description Default
identifier str

The series identifier.

required
context SeriesContext

What the identifier says about itself.

required
votes dict

Other brokers' support per instrument_id for the exchange token.

required

Returns:

Type Description
list[Resolution]

One Resolution, unresolved if nothing matched.

Example usage

From example_2_falling_back_on_symbols.py
self.resolver.fall_back(identifier, context, votes.get(context.exchange_token, {}))
Source code in stock_brokers/instruments/historical/utilities/unified/resolution.py
def fall_back(self, identifier, context, votes):
    """
    Resolve a series whose own token was never mapped.

    Args:
        identifier (str): The series identifier.
        context (SeriesContext): What the identifier says about itself.
        votes (dict): Other brokers' support per instrument_id for the exchange token.

    Returns:
        list[Resolution]: One Resolution, unresolved if nothing matched.
    """
    if votes:
        ranked = sorted(votes.items(), key=lambda item: item[1], reverse=True)
        best = ranked[0][1]
        runner_up = ranked[1][1] if len(ranked) > 1 else 0
        if best >= MINIMUM_CONSENSUS and best > runner_up:
            instrument = self.instrument(ranked[0][0])
            return [self.resolution(identifier, instrument, "exchange_token",
                                    reason=f"exchange token {context.exchange_token} mapped by {best} brokers")]

    if context.symbol and context.segments:
        matches = self.symbol_matches(context.exchange, context.segments, context.symbol)
        # An uncategorised twin is a row some broker could not classify, not a second security:
        # NSE's temporary BE token for ORICONENT, 10163, is mapped only by stoxkart, which files
        # BE rows as uncategorised. A classified match outranks it.
        classified = [match for match in matches if not match["segment"].endswith("uncategorised")]
        if len(classified) == 1:
            matches = classified
        if len(matches) == 1:
            return [self.resolution(identifier, matches[0], "raw_symbol",
                                    reason=f"symbol {context.symbol}")]
        if len(matches) > 1:
            return [Resolution(identifier, None, None, None, None, "raw_symbol", "ambiguous",
                               f"symbol {context.symbol} matches {len(matches)} instruments",
                               None, None, None, None)]

    return [Resolution(identifier, None, None, None, None, None, "rejected",
                       "no mapping, exchange token or symbol match", None, None, None, None)]

exchange_token_votes

exchange_token_votes(broker, contexts)

Count, for each exchange token, how many other brokers map it to each instrument.

Parameters:

Name Type Description Default
broker str

The broker whose series are being resolved, which does not vote.

required
contexts list[SeriesContext]

Series sharing one set of segments.

required

Returns:

Type Description
dict

exchange_token to {instrument_id: number of distinct brokers}.

Example usage

From example_2_falling_back_on_symbols.py
votes = self.resolver.exchange_token_votes('flattrade', contexts)
Source code in stock_brokers/instruments/historical/utilities/unified/resolution.py
def exchange_token_votes(self, broker, contexts):
    """
    Count, for each exchange token, how many other brokers map it to each instrument.

    Args:
        broker (str): The broker whose series are being resolved, which does not vote.
        contexts (list[SeriesContext]): Series sharing one set of segments.

    Returns:
        dict: exchange_token to {instrument_id: number of distinct brokers}.
    """
    with_tokens = [context for context in contexts if context.exchange_token and context.segments]
    if not with_tokens:
        return {}
    segments = list(with_tokens[0].segments)
    exchange_tokens = sorted({context.exchange_token for context in with_tokens})

    # Kite tokens rebuilt from the exchange token, so the lookup stays on the token index.
    zerodha_tokens = {}
    codes = ZERODHA_SEGMENT_CODES.get((with_tokens[0].exchange, family_of(segments)), ())
    for exchange_token in exchange_tokens:
        if not exchange_token.isdigit():
            continue
        for code in codes:
            zerodha_tokens[str(int(exchange_token) << 8 | code)] = exchange_token

    voters = [name for name in EXCHANGE_TOKEN_BROKERS if name != broker]
    statement = text(
        "SELECT b.broker, b.broker_token, b.instrument_id "
        f"FROM {tables.BROKER_MAPPINGS} b "
        f"JOIN {tables.MASTER} m ON m.instrument_id = b.instrument_id "
        "WHERE m.segment = ANY(:segments) AND ("
        "  (b.broker = ANY(:voters) AND b.broker_token = ANY(:exchange_tokens)) "
        "  OR (b.broker = 'zerodha' AND :include_zerodha AND b.broker_token = ANY(:zerodha_tokens))) "
        "GROUP BY b.broker, b.broker_token, b.instrument_id"
    )
    with self.engine.connect() as connection:
        rows = connection.execute(statement, {
            "segments": segments,
            "voters": voters,
            "exchange_tokens": exchange_tokens,
            "include_zerodha": broker != "zerodha",
            "zerodha_tokens": list(zerodha_tokens),
        }).all()

    supporters = {}
    for row in rows:
        if row.broker == "zerodha":
            exchange_token = zerodha_tokens[row.broker_token]
        else:
            exchange_token = row.broker_token
        supporters.setdefault(exchange_token, {}).setdefault(str(row.instrument_id), set()).add(row.broker)

    votes = {}
    for exchange_token, instruments in supporters.items():
        votes[exchange_token] = {instrument_id: len(names) for instrument_id, names in instruments.items()}
    return votes

instrument

instrument(instrument_id)

One instrument's identity and its span across every broker's mappings.

Parameters:

Name Type Description Default
instrument_id str

The unified id.

required

Returns:

Type Description
dict

The keys identity_spans returns.

Example usage

From example_2_falling_back_on_symbols.py
instrument = self.resolver.instrument('INDIAGLYCO')
Source code in stock_brokers/instruments/historical/utilities/unified/resolution.py
def instrument(self, instrument_id):
    """
    One instrument's identity and its span across every broker's mappings.

    Args:
        instrument_id (str): The unified id.

    Returns:
        dict: The keys `identity_spans` returns.
    """
    with self.engine.connect() as connection:
        row = connection.execute(text(
            "SELECT m.instrument_id, m.exchange, m.segment, m.shape, m.symbol, "
            "  m.first_seen_date AS first_mapping_date, m.last_seen_date AS last_mapping_date "
            f"FROM {tables.MASTER} m WHERE m.instrument_id = :instrument_id"
        ), {"instrument_id": instrument_id}).one()
    return dict(row._mapping, instrument_id=str(row.instrument_id), mapping_dates=None)

symbol_matches

symbol_matches(exchange, segments, symbol)

Every instrument with this exact symbol in the allowed segments, on any date.

Parameters:

Name Type Description Default
exchange str

The canonical exchange.

required
segments tuple[str]

The allowed prefixed segments.

required
symbol str

The trading symbol without its series suffix.

required

Returns:

Type Description
list[dict]

The keys identity_spans returns.

Example usage

From example_2_falling_back_on_symbols.py
matches = self.resolver.symbol_matches('nse', context.segments, 'oriconent')
Source code in stock_brokers/instruments/historical/utilities/unified/resolution.py
def symbol_matches(self, exchange, segments, symbol):
    """
    Every instrument with this exact symbol in the allowed segments, on any date.

    Args:
        exchange (str): The canonical exchange.
        segments (tuple[str]): The allowed prefixed segments.
        symbol (str): The trading symbol without its series suffix.

    Returns:
        list[dict]: The keys `identity_spans` returns.
    """
    with self.engine.connect() as connection:
        rows = connection.execute(text(
            "SELECT m.instrument_id, m.exchange, m.segment, m.shape, m.symbol, "
            "  m.first_seen_date AS first_mapping_date, m.last_seen_date AS last_mapping_date "
            f"FROM {tables.MASTER} m "
            "WHERE m.exchange = :exchange AND m.segment = ANY(:segments) AND upper(m.symbol) = :symbol"
        ), {"exchange": exchange, "segments": list(segments), "symbol": symbol.upper()}).all()
    return [dict(row._mapping, instrument_id=str(row.instrument_id), mapping_dates=None) for row in rows]

resolution staticmethod

resolution(identifier, candidate, resolved_by, reason=None, valid_from=None, valid_to=None)

An active Resolution for a chosen candidate.

Parameters:

Name Type Description Default
identifier str

The series identifier.

required
candidate dict

The chosen instrument, as identity_spans returns it.

required
resolved_by str

How it was found.

required
reason str | None

Why, when the choice was not the only candidate.

None
valid_from datetime | None

The start of the part of the series it owns.

None
valid_to datetime | None

The end of that part, exclusive.

None

Returns:

Type Description
Resolution

The resolution.

Example usage

From example_2_falling_back_on_symbols.py
resolution = self.resolver.resolution('NSE|7001|INDIAGLYCO-BE', instrument, 'manual', reason='set by hand')
Source code in stock_brokers/instruments/historical/utilities/unified/resolution.py
@staticmethod
def resolution(identifier, candidate, resolved_by, reason=None, valid_from=None, valid_to=None):
    """
    An active Resolution for a chosen candidate.

    Args:
        identifier (str): The series identifier.
        candidate (dict): The chosen instrument, as `identity_spans` returns it.
        resolved_by (str): How it was found.
        reason (str | None): Why, when the choice was not the only candidate.
        valid_from (datetime.datetime | None): The start of the part of the series it owns.
        valid_to (datetime.datetime | None): The end of that part, exclusive.

    Returns:
        Resolution: The resolution.
    """
    return Resolution(identifier, candidate["instrument_id"], candidate["exchange"], candidate["segment"],
                      candidate["symbol"], resolved_by, "active", reason,
                      candidate["first_mapping_date"], candidate["last_mapping_date"], valid_from, valid_to)

india_midnight

india_midnight(day)

Midnight India time at the start of a date.

Parameters:

Name Type Description Default
day date

The date.

required

Returns:

Type Description
datetime

A timezone aware timestamp.

Source code in stock_brokers/instruments/historical/utilities/unified/resolution.py
def india_midnight(day):
    """
    Midnight India time at the start of a date.

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

    Returns:
        datetime.datetime: A timezone aware timestamp.
    """
    return datetime.datetime(day.year, day.month, day.day, tzinfo=INDIA_TIMEZONE)

family_of

family_of(segments)

The family a SeriesContext's segments describe, for rebuilding Kite tokens.

Parameters:

Name Type Description Default
segments tuple[str] | None

Prefixed segments.

required

Returns:

Type Description
str | None

"cash" or "derivative", or None when the segments are neither.

Source code in stock_brokers/instruments/historical/utilities/unified/resolution.py
def family_of(segments):
    """
    The family a SeriesContext's segments describe, for rebuilding Kite tokens.

    Args:
        segments (tuple[str] | None): Prefixed segments.

    Returns:
        str | None: "cash" or "derivative", or None when the segments are neither.
    """
    if not segments:
        return None
    if any(segment.endswith(("_futures", "_options")) for segment in segments):
        return "derivative"
    if any(segment.endswith(("_equities", "_exchange_traded_funds")) for segment in segments):
        return "cash"
    return None