Skip to content

resolution

Resolving the tokens on broker ticks to unified instrument ids, and compiling what each needs.

A tick names its instrument in its broker's own spelling. The broker's normalizer turns that into the token and canonical segments the broker mappings are searched with; this module does the search, refuses to guess when the answer is not unique, and compiles an InstrumentPlan holding everything the per-tick path needs, so that path is a single dictionary look-up.

CacheCandidateSource answers the search, over the three tier mapping cache. It covers the current mapping date only, which is what a quote needs.

Misses are remembered for ten minutes, so a token nothing maps costs one search per ten minutes rather than one per tick, and a token mapped by the morning's download is picked up soon after.

Classes:

Name Description
CacheCandidateSource

Candidates from the three tier mapping cache, for the current mapping date.

TickResolver

Instrument plans for broker tokens, compiled once per token per mapping date.

Functions:

Name Description
order_symbol_index

A broker's order symbol to broker token dictionary, for one mapping date.

units_per_lot

How many underlying units one lot of an instrument is.

CacheCandidateSource

CacheCandidateSource(mapping_cache)

Candidates from the three tier mapping cache, for the current mapping date.

Attributes:

Name Type Description
mapping_cache MappingCache

The process's mapping cache.

Wrap a mapping cache.

Parameters:

Name Type Description Default
mapping_cache MappingCache

The process's mapping cache, shared with anything else in the process that resolves instruments.

required

Returns:

Type Description
None

This function returns nothing.

Example programs

examples/stock_brokers/instruments/ticks/utilities/resolution/CacheCandidateSource/example_1_asking_the_mapping_cache.py
"""Asks the mapping cache, through `CacheCandidateSource`, which instruments a broker's tokens can be and what every broker calls them.

`TickResolver` never searches the mappings itself. It asks a candidate source three questions: which mapping date answers for a trading day, every identity a list of broker tokens maps to inside some segments, and every broker's order handle for some instruments. `CacheCandidateSource` answers them from the process's three-tier `MappingCache`, which in the live service is backed by Redis and TimescaleDB.

This program gives it a small stand-in cache holding two Zerodha tokens for 2026-09-15, so it runs without any data store. The stand-in answers the same three calls the real `MappingCache` does, with the same shapes, and refuses a day before its mapping date by answering None, which is how the real cache tells the resolver to look elsewhere. Notice that the segment filter matters: token 738561 is searched only among the segments the caller names.

Run it from the project root:

    python examples/stock_brokers/instruments/ticks/utilities/resolution/CacheCandidateSource/example_1_asking_the_mapping_cache.py
"""

import datetime

from stock_brokers.instruments.ticks.utilities.resolution import (
    CacheCandidateSource,
)


class StandInMappingCache:
    """A stand-in for `MappingCache` that holds a few identities and handles in memory.

    Attributes:
        mapping_date (datetime.date): The one mapping date this cache holds.
        identities (dict): (broker, token) to the list of identities the token maps to.
        handles (dict): Instrument id to broker name to that broker's order handle.
        engine (None): The real cache's database engine, which this program never uses.
    """

    def __init__(self):
        """Fills the cache with RELIANCE on NSE and a crude oil future on MCX.

        Returns:
            None: This method returns nothing.
        """
        self.mapping_date = datetime.date(2026, 9, 15)
        self.engine = None
        reliance = {
            'instrument_id': '11111111-1111-5111-8111-000000000102',
            'exchange': 'nse',
            'segment': 'nse_equities',
            'shape': 'security',
            'symbol': 'RELIANCE',
            'underlying_symbol': None,
            'expiry_date': None,
            'strike_price': None,
            'option_type': None,
        }
        crude_oil = {
            'instrument_id': '11111111-1111-5111-8111-000000000101',
            'exchange': 'mcx',
            'segment': 'mcx_commodity_futures',
            'shape': 'future',
            'symbol': 'CRUDEOIL26OCTFUT',
            'underlying_symbol': 'CRUDEOIL',
            'expiry_date': datetime.date(2026, 10, 19),
            'strike_price': None,
            'option_type': None,
        }
        self.identities = {
            ('zerodha', '738561'): [
                reliance,
            ],
            ('zerodha', '5720583'): [
                crude_oil,
            ],
        }
        self.handles = {
            reliance['instrument_id']: {
                'zerodha': {
                    'broker_token': '738561',
                    'order_symbol': 'RELIANCE',
                    'lot_size': '1',
                    'tick_size': '0.1',
                },
                'dhan': {
                    'broker_token': '2885',
                    'order_symbol': 'RELIANCE',
                    'lot_size': '1',
                    'tick_size': '0.1',
                },
            },
        }

    def resolves_to_current_date(self, as_of_date):
        """Answers the mapping date for a day, or None for a day before it.

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

        Returns:
            datetime.date | None: The mapping date, or None.
        """
        if as_of_date < self.mapping_date:
            return None
        return self.mapping_date

    def candidates_for_tokens(self, broker, broker_tokens, as_of_date, segments=None):
        """Answers every identity each token maps to inside the segments.

        Args:
            broker (str): The broker name.
            broker_tokens (list): The broker's tokens.
            as_of_date (datetime.date): The trading day.
            segments (list | None): The segments to search, or None for any.

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

    def order_handles_for_instruments(self, instrument_identifiers, as_of_date, brokers=None):
        """Answers every broker's order handle for each instrument.

        Args:
            instrument_identifiers (list): The instrument ids.
            as_of_date (datetime.date): The trading day.
            brokers (list | None): Brokers to restrict the answer to, or None for all.

        Returns:
            dict: Instrument id to broker name to handle; instruments with none are absent.
        """
        found = {}
        for instrument_id in instrument_identifiers:
            if instrument_id in self.handles:
                found[instrument_id] = self.handles[instrument_id]
        return found


class AskingTheMappingCacheExample:
    """Asks a candidate source the resolver's three questions and prints the answers.

    Attributes:
        source (CacheCandidateSource): The source being shown.
    """

    def __init__(self):
        """Wraps the stand-in mapping cache in a candidate source.

        Returns:
            None: This method returns nothing.
        """
        self.source = CacheCandidateSource(StandInMappingCache())

    def run(self):
        """Prints the mapping date, candidates and handles.

        Returns:
            None: This method returns nothing.
        """
        today = datetime.date(2026, 9, 15)
        last_week = datetime.date(2026, 9, 8)
        print(f'Mapping date for {today}: {self.source.mapping_date(today)}')
        print(f'Mapping date for {last_week}: {self.source.mapping_date(last_week)}')
        tokens = [
            '738561',
            '5720583',
            '999',
        ]
        cash_segments = (
            'nse_equities',
            'nse_exchange_traded_funds',
        )
        found = self.source.candidates('zerodha', tokens, today, cash_segments)
        print(f'Tokens {tokens} among NSE cash segments:')
        for token, identities in found.items():
            for identity in identities:
                print(f"  {token} -> {identity['symbol']} ({identity['segment']}) {identity['instrument_id']}")
        commodity_segments = (
            'mcx_commodity_futures',
        )
        found = self.source.candidates('zerodha', tokens, today, commodity_segments)
        print(f'The same tokens among MCX commodity futures: {sorted(found)}')
        instrument_ids = [
            '11111111-1111-5111-8111-000000000102',
        ]
        handles = self.source.handles(instrument_ids, today)
        for instrument_id, by_broker in handles.items():
            for broker_name, handle in by_broker.items():
                print(f"Handle for {instrument_id} at {broker_name}: token {handle['broker_token']}, lot {handle['lot_size']}, tick {handle['tick_size']}")


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

It prints:

Mapping date for 2026-09-15: 2026-09-15
Mapping date for 2026-09-08: None
Tokens ['738561', '5720583', '999'] among NSE cash segments:
  738561 -> RELIANCE (nse_equities) 11111111-1111-5111-8111-000000000102
The same tokens among MCX commodity futures: ['5720583']
Handle for 11111111-1111-5111-8111-000000000102 at zerodha: token 738561, lot 1, tick 0.1
Handle for 11111111-1111-5111-8111-000000000102 at dhan: token 2885, lot 1, tick 0.1
examples/stock_brokers/instruments/ticks/utilities/resolution/CacheCandidateSource/example_2_order_symbols_for_fyers.py
"""Reads Fyers' order symbol to token dictionary through `CacheCandidateSource.order_symbols`.

Most brokers' ticks carry a token, but a Fyers tick names its instrument by symbol, such as `NSE:SBIN-EQ`. The broker mappings store that symbol as the order symbol, so the resolver asks the candidate source once per mapping date for the whole symbol-to-token dictionary, which is one query against `unified.broker_mappings`. For a day the cache cannot answer, the source returns an empty dictionary without querying at all.

The query goes through the mapping cache's SQLAlchemy engine. This program gives the source a stand-in cache whose `engine` answers that one query with three recorded-looking rows and records the SQL and parameters it was sent, so the output shows exactly what would have been asked of TimescaleDB. When two rows share a symbol, the first token is kept. No database is reached.

Run it from the project root:

    python examples/stock_brokers/instruments/ticks/utilities/resolution/CacheCandidateSource/example_2_order_symbols_for_fyers.py
"""

import datetime

from stock_brokers.instruments.ticks.utilities.resolution import (
    CacheCandidateSource,
)


class StandInConnection:
    """A stand-in for a SQLAlchemy connection that answers one query with fixed rows.

    Attributes:
        engine (StandInEngine): The engine that opened this connection, which records what was executed.
    """

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

        Args:
            engine (StandInEngine): The engine that opened this connection.

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

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

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

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

        Args:
            error_type (type | None): The exception type, if one was raised.
            error (BaseException | None): The exception, if one was raised.
            traceback (object | None): Its traceback.

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

    def execute(self, statement, parameters):
        """Records the statement and returns Fyers' rows.

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

        Returns:
            list: Rows of (order_symbol, broker_token).
        """
        self.engine.executed.append((str(statement), parameters))
        return [
            (
                'NSE:SBIN-EQ',
                '3045',
            ),
            (
                'NSE:RELIANCE-EQ',
                '2885',
            ),
            (
                'NSE:SBIN-EQ',
                '99999',
            ),
        ]


class StandInEngine:
    """A stand-in for a SQLAlchemy engine that records every query.

    Attributes:
        executed (list): (SQL, parameters) pairs, in order.
    """

    def __init__(self):
        """Starts with nothing executed.

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

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

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


class StandInMappingCache:
    """A stand-in for `MappingCache` that knows its mapping date and holds an engine.

    Attributes:
        engine (StandInEngine): The stand-in engine.
        mapping_date (datetime.date): The mapping date the cache holds.
    """

    def __init__(self):
        """Builds the cache for 2026-09-15.

        Returns:
            None: This method returns nothing.
        """
        self.engine = StandInEngine()
        self.mapping_date = datetime.date(2026, 9, 15)

    def resolves_to_current_date(self, as_of_date):
        """Answers the mapping date for a day, or None for a day before it.

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

        Returns:
            datetime.date | None: The mapping date, or None.
        """
        if as_of_date < self.mapping_date:
            return None
        return self.mapping_date


class OrderSymbolsForFyersExample:
    """Reads Fyers' order symbols for today and for a day the cache cannot answer.

    Attributes:
        cache (StandInMappingCache): The stand-in mapping cache.
        source (CacheCandidateSource): The source being shown.
    """

    def __init__(self):
        """Builds the source over the stand-in cache.

        Returns:
            None: This method returns nothing.
        """
        self.cache = StandInMappingCache()
        self.source = CacheCandidateSource(self.cache)

    def run(self):
        """Prints the dictionaries and the query that was sent.

        Returns:
            None: This method returns nothing.
        """
        symbols = self.source.order_symbols('fyers', datetime.date(2026, 9, 15))
        print(f'Order symbols on 2026-09-15: {symbols}')
        earlier = self.source.order_symbols('fyers', datetime.date(2026, 9, 1))
        print(f'Order symbols on 2026-09-01: {earlier}')
        print(f'Queries sent: {len(self.cache.engine.executed)}')
        for sql, parameters in self.cache.engine.executed:
            print(f'  {sql}')
            print(f'  {parameters}')


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

It prints:

Order symbols on 2026-09-15: {'NSE:SBIN-EQ': '3045', 'NSE:RELIANCE-EQ': '2885'}
Order symbols on 2026-09-01: {}
Queries sent: 1
  SELECT order_symbol, broker_token FROM unified.broker_mappings WHERE broker = :broker AND mapping_date = :mapping_date AND order_symbol IS NOT NULL
  {'broker': 'fyers', 'mapping_date': datetime.date(2026, 9, 15)}

Methods:

Name Description
order_symbols

A broker's order symbol to token dictionary for the current mapping date.

mapping_date

The mapping date that answers for a day.

candidates

Every identity each token maps to inside the segments.

handles

Every broker's order handle - token, symbol, lot size, tick size - for each instrument.

Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def __init__(self, mapping_cache):
    """
    Wrap a mapping cache.

    Args:
        mapping_cache (MappingCache): The process's mapping cache, shared with anything else in the process that resolves instruments.

    Returns:
        None: This function returns nothing.
    """
    self.mapping_cache = mapping_cache

order_symbols

order_symbols(broker, as_of_date)

A broker's order symbol to token dictionary for the current mapping date.

Parameters:

Name Type Description Default
broker str

The broker.

required
as_of_date date

The India trading day.

required

Returns:

Type Description
dict

Order symbol to broker token.

Example usage

From example_2_order_symbols_for_fyers.py
symbols = self.source.order_symbols('fyers', datetime.date(2026, 9, 15))
From example_2_order_symbols_for_fyers.py
earlier = self.source.order_symbols('fyers', datetime.date(2026, 9, 1))
Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def order_symbols(self, broker, as_of_date):
    """
    A broker's order symbol to token dictionary for the current mapping date.

    Args:
        broker (str): The broker.
        as_of_date (datetime.date): The India trading day.

    Returns:
        dict: Order symbol to broker token.
    """
    mapping_date = self.mapping_date(as_of_date)
    return order_symbol_index(self.mapping_cache.engine, broker, mapping_date) if mapping_date else {}

mapping_date

mapping_date(as_of_date)

The mapping date that answers for a day.

Parameters:

Name Type Description Default
as_of_date date

The India trading day the ticks belong to.

required

Returns:

Type Description
date | None

The current mapping date, or None when the cache cannot answer for that day.

Example usage

From example_1_asking_the_mapping_cache.py
print(f'Mapping date for {today}: {self.source.mapping_date(today)}')
From example_1_asking_the_mapping_cache.py
print(f'Mapping date for {last_week}: {self.source.mapping_date(last_week)}')
Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def mapping_date(self, as_of_date):
    """
    The mapping date that answers for a day.

    Args:
        as_of_date (datetime.date): The India trading day the ticks belong to.

    Returns:
        datetime.date | None: The current mapping date, or None when the cache cannot answer for that day.
    """
    return self.mapping_cache.resolves_to_current_date(as_of_date)

candidates

candidates(broker, broker_tokens, as_of_date, segments)

Every identity each token maps to inside the segments.

Parameters:

Name Type Description Default
broker str

The broker name.

required
broker_tokens list[str]

Tokens as stored in unified.broker_mappings.

required
as_of_date date

The India trading day.

required
segments tuple[str]

Canonical segments to search.

required

Returns:

Type Description
dict

Token to a list of identity dicts.

Example usage

From example_1_asking_the_mapping_cache.py
found = self.source.candidates('zerodha', tokens, today, cash_segments)
From example_1_asking_the_mapping_cache.py
found = self.source.candidates('zerodha', tokens, today, commodity_segments)
Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def candidates(self, broker, broker_tokens, as_of_date, segments):
    """
    Every identity each token maps to inside the segments.

    Args:
        broker (str): The broker name.
        broker_tokens (list[str]): Tokens as stored in unified.broker_mappings.
        as_of_date (datetime.date): The India trading day.
        segments (tuple[str]): Canonical segments to search.

    Returns:
        dict: Token to a list of identity dicts.
    """
    return self.mapping_cache.candidates_for_tokens(broker, broker_tokens, as_of_date, list(segments))

handles

handles(instrument_ids, as_of_date)

Every broker's order handle - token, symbol, lot size, tick size - for each instrument.

Parameters:

Name Type Description Default
instrument_ids list[str]

Instrument ids.

required
as_of_date date

The India trading day.

required

Returns:

Type Description
dict

Instrument id to broker name to handle dict.

Example usage

From example_1_asking_the_mapping_cache.py
handles = self.source.handles(instrument_ids, today)
Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def handles(self, instrument_ids, as_of_date):
    """
    Every broker's order handle - token, symbol, lot size, tick size - for each instrument.

    Args:
        instrument_ids (list[str]): Instrument ids.
        as_of_date (datetime.date): The India trading day.

    Returns:
        dict: Instrument id to broker name to handle dict.
    """
    return self.mapping_cache.order_handles_for_instruments(instrument_ids, as_of_date)

TickResolver

TickResolver(source, normalizers, logger=None)

Instrument plans for broker tokens, compiled once per token per mapping date.

Attributes:

Name Type Description
unresolved Counter

(broker, token, reason) to ticks that could not be resolved since last drained, where reason is one of no_normalizer, unplaceable, unmapped, ambiguous or no_mapping_date.

notes dict

(broker, token) to a note about a compiled plan, such as a lot size disagreement.

as_of_date date | None

The India trading day plans are compiled for.

mapping_date date | None

The mapping date plans are compiled from.

Build the resolver.

Parameters:

Name Type Description Default
source CacheCandidateSource

Where candidates come from.

required
normalizers dict

Broker name to TickNormalizer.

required
logger Logger | None

Where decisions worth knowing are reported.

None

Returns:

Type Description
None

This function returns nothing.

Example programs

examples/stock_brokers/instruments/ticks/utilities/resolution/TickResolver/example_1_prewarming_zerodha_plans.py
"""Compiles instrument plans for Zerodha's subscribed tokens before their first ticks arrive, and reads them back.

`TickResolver` turns the token on a tick into an `InstrumentPlan`. The live service calls `prewarm` with the broker's whole subscription set when it starts, so the mapping search happens once, off the per-tick path; after that `plan_for` is a single dictionary look-up. Plans are filed under the tick's spelling, which for Kite is an integer, while the subscription set holds strings.

Compiling a plan decides the instrument's lot size from every broker's order handle. On MCX the authority is Groww's lot, the only one that makes price times quantity the contract's value; elsewhere the most common lot wins, and a disagreement is kept in `notes` and logged once. This program gives the resolver a stand-in mapping cache with three Zerodha instruments and a stand-in logger that prints, so it needs no data store. The brokers' disagreement over the NIFTY future's lot is invented for the demonstration. The clock is fixed at 09:30 India time on 2026-09-15.

The subscription set also holds `NSE:INFY`, which is not a Kite token, so it is recorded as unplaceable. That miss is recorded before the resolver has first read its mapping date, so the first `refresh` counts it as a plan to drop and logs that plans will be recompiled, although none had been compiled yet.

Run it from the project root:

    python examples/stock_brokers/instruments/ticks/utilities/resolution/TickResolver/example_1_prewarming_zerodha_plans.py
"""

import datetime
import zoneinfo

from stock_brokers.instruments.ticks.utilities.registry import (
    build_normalizers,
)
from stock_brokers.instruments.ticks.utilities.resolution import (
    CacheCandidateSource,
    TickResolver,
)

INDIA = zoneinfo.ZoneInfo('Asia/Kolkata')


class PrintingLogger:
    """A stand-in logger that prints each message with its level."""

    def info(self, message):
        """Prints an informational message.

        Args:
            message (str): The message.

        Returns:
            None: This method returns nothing.
        """
        print(f'LOG info: {message}')

    def warning(self, message):
        """Prints a warning.

        Args:
            message (str): The message.

        Returns:
            None: This method returns nothing.
        """
        print(f'LOG warning: {message}')


class StandInMappingCache:
    """A stand-in for `MappingCache` holding three Zerodha instruments for 2026-09-15.

    Attributes:
        mapping_date (datetime.date): The one mapping date this cache holds.
        identities (dict): Zerodha token to the identities it maps to.
        handles (dict): Instrument id to broker name to order handle.
        engine (None): The real cache's database engine, which this program never uses.
    """

    def __init__(self):
        """Fills the cache.

        Returns:
            None: This method returns nothing.
        """
        self.mapping_date = datetime.date(2026, 9, 15)
        self.engine = None
        self.identities = {
            '738561': [
                self.identity('102', 'nse', 'nse_equities', 'security', 'RELIANCE', None, None),
            ],
            '5720583': [
                self.identity('101', 'mcx', 'mcx_commodity_futures', 'future', 'CRUDEOIL26OCTFUT', 'CRUDEOIL', '2026-10-19'),
            ],
            '13238786': [
                self.identity('105', 'nse', 'nse_equity_index_futures', 'future', 'NIFTY26SEPFUT', 'NIFTY', '2026-09-29'),
            ],
        }
        self.handles = {
            self.instrument_id('102'): {
                'zerodha': self.handle('738561', '1'),
                'dhan': self.handle('2885', '1'),
            },
            self.instrument_id('101'): {
                'zerodha': self.handle('5720583', '100'),
                'kotak': self.handle('440001', '1'),
                'groww': self.handle('440001', '100'),
            },
            self.instrument_id('105'): {
                'zerodha': self.handle('13238786', '75'),
                'dhan': self.handle('35003', '75'),
                'kotak': self.handle('35003', '65'),
            },
        }

    def instrument_id(self, suffix):
        """Builds an instrument id.

        Args:
            suffix (str): The last digits of the id.

        Returns:
            str: The id.
        """
        return f'11111111-1111-5111-8111-000000000{suffix}'

    def identity(self, suffix, exchange, segment, shape, symbol, underlying_symbol, expiry_date):
        """Builds one identity as the real cache returns it.

        Args:
            suffix (str): The last digits of the instrument id.
            exchange (str): The canonical exchange.
            segment (str): The canonical segment.
            shape (str): "security", "future" or "option".
            symbol (str): The trading symbol.
            underlying_symbol (str | None): The underlying, for a derivative.
            expiry_date (str | None): The expiry, for a derivative.

        Returns:
            dict: The identity.
        """
        return {
            'instrument_id': self.instrument_id(suffix),
            'exchange': exchange,
            'segment': segment,
            'shape': shape,
            'symbol': symbol,
            'underlying_symbol': underlying_symbol,
            'expiry_date': expiry_date,
            'strike_price': None,
            'option_type': None,
        }

    def handle(self, broker_token, lot_size):
        """Builds one broker's order handle.

        Args:
            broker_token (str): The broker's token.
            lot_size (str): The broker's lot size.

        Returns:
            dict: The handle.
        """
        return {
            'broker_token': broker_token,
            'order_symbol': None,
            'lot_size': lot_size,
            'tick_size': '0.05',
        }

    def resolves_to_current_date(self, as_of_date):
        """Answers the mapping date for a day, or None for a day before it.

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

        Returns:
            datetime.date | None: The mapping date, or None.
        """
        if as_of_date < self.mapping_date:
            return None
        return self.mapping_date

    def candidates_for_tokens(self, broker, broker_tokens, as_of_date, segments=None):
        """Answers every identity each token maps to inside the segments.

        Args:
            broker (str): The broker name.
            broker_tokens (list): The broker's tokens.
            as_of_date (datetime.date): The trading day.
            segments (list | None): The segments to search, or None for any.

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

    def order_handles_for_instruments(self, instrument_identifiers, as_of_date, brokers=None):
        """Answers every broker's order handle for each instrument.

        Args:
            instrument_identifiers (list): The instrument ids.
            as_of_date (datetime.date): The trading day.
            brokers (list | None): Brokers to restrict the answer to, or None for all.

        Returns:
            dict: Instrument id to broker name to handle.
        """
        found = {}
        for instrument_id in instrument_identifiers:
            if instrument_id in self.handles:
                found[instrument_id] = self.handles[instrument_id]
        return found


class PrewarmingZerodhaPlansExample:
    """Prewarms Zerodha's subscription set and prints the compiled plans.

    Attributes:
        resolver (TickResolver): The resolver being shown.
        now (float): The fixed current instant, in epoch seconds.
    """

    def __init__(self):
        """Builds the resolver over the stand-in cache with Zerodha's normalizer.

        Returns:
            None: This method returns nothing.
        """
        source = CacheCandidateSource(StandInMappingCache())
        brokers = [
            'zerodha',
        ]
        self.resolver = TickResolver(source, build_normalizers(brokers), PrintingLogger())
        moment = datetime.datetime(2026, 9, 15, 9, 30, tzinfo=INDIA)
        self.now = moment.timestamp()

    def run(self):
        """Prewarms, looks one plan up, and prints every plan and note.

        Returns:
            None: This method returns nothing.
        """
        subscriptions = [
            '738561',
            '5720583',
            '13238786',
            'NSE:INFY',
        ]
        planned, total = self.resolver.prewarm('zerodha', subscriptions, self.now)
        print(f'Prewarmed {planned} of {total} subscribed tokens')
        print(f'As of {self.resolver.as_of_date}, from mapping date {self.resolver.mapping_date}')
        plan = self.resolver.plan_for('zerodha', 738561, self.now)
        print(f'plan_for 738561: {plan.instrument_id} {plan.segment}, session closes trading at {plan.session.trading_close} seconds')
        for key, compiled in self.resolver.plans().items():
            broker_name, token = key
            print(f"{broker_name} {token!r}: {compiled.shape} on {compiled.exchange}, lot {compiled.lot_size}, volume multiplier {compiled.multipliers['volume']}, close policy {compiled.close_policy}")
        print(f'Notes: {self.resolver.notes}')
        print(f'Unresolved: {dict(self.resolver.drain_unresolved())}')


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

It prints:

LOG warning: zerodha token NSE:INFY did not resolve: unplaceable.
LOG info: Mapping date is now 2026-09-15; instrument plans will be recompiled.
LOG warning: zerodha 13238786 (nse_equity_index_futures): lot size 75 - brokers disagree: 75x2, 65x1.
Prewarmed 3 of 4 subscribed tokens
As of 2026-09-15, from mapping date 2026-09-15
plan_for 738561: 11111111-1111-5111-8111-000000000102 nse_equities, session closes trading at 55800 seconds
zerodha 738561: security on nse, lot 1, volume multiplier 1, close policy always
zerodha 5720583: future on mcx, lot 100, volume multiplier 100, close policy always
zerodha 13238786: future on nse, lot 75, volume multiplier 1, close policy always
Notes: {('zerodha', 13238786): 'brokers disagree: 75x2, 65x1'}
Unresolved: {('zerodha', 'NSE:INFY', 'unplaceable'): 1}
examples/stock_brokers/instruments/ticks/utilities/resolution/TickResolver/example_2_misses_retries_and_a_new_mapping_date.py
"""Shows how `TickResolver` refuses to guess, how long it leaves a miss alone, and what a new mapping date does to its plans.

A token that does not resolve to exactly one live instrument gets no plan. The resolver records why: `unplaceable` when the broker's normalizer cannot read the token, `unmapped` when nothing in the mappings matches it (an expired contract counts as no match), `ambiguous` when it matches more than one instrument, and `no_normalizer` for a broker it has no normalizer for. Writing one instrument's prices under another's id would be worse than writing nothing, so an ambiguous token is never resolved to its first candidate.

A miss is remembered for ten minutes, so a token nothing maps costs one search per ten minutes rather than one per tick. `drain_unresolved` hands the counts to whoever reports them and starts again. `refresh` is called every thirty seconds by the live service; when the mapping date moves, after the morning's download, it drops every plan so they are compiled again from the new mappings.

The stand-in mapping cache counts every search it answers, which shows when the resolver asks again. A stand-in logger prints each warning, and the clock is passed in, fixed at 09:30 India time on 2026-09-15. No data store is involved.

Run it from the project root:

    python examples/stock_brokers/instruments/ticks/utilities/resolution/TickResolver/example_2_misses_retries_and_a_new_mapping_date.py
"""

import datetime
import zoneinfo

from stock_brokers.instruments.ticks.utilities.registry import (
    build_normalizers,
)
from stock_brokers.instruments.ticks.utilities.resolution import (
    CacheCandidateSource,
    TickResolver,
)

INDIA = zoneinfo.ZoneInfo('Asia/Kolkata')


class PrintingLogger:
    """A stand-in logger that prints each message with its level."""

    def info(self, message):
        """Prints an informational message.

        Args:
            message (str): The message.

        Returns:
            None: This method returns nothing.
        """
        print(f'LOG info: {message}')

    def warning(self, message):
        """Prints a warning.

        Args:
            message (str): The message.

        Returns:
            None: This method returns nothing.
        """
        print(f'LOG warning: {message}')


class CountingMappingCache:
    """A stand-in for `MappingCache` that counts the searches it answers.

    Attributes:
        mapping_date (datetime.date): The mapping date the cache holds, which the program moves.
        searches (int): How many candidate searches have been answered.
        identities (dict): Zerodha token to the identities it maps to.
        engine (None): The real cache's database engine, which this program never uses.
    """

    def __init__(self):
        """Fills the cache with one good token, one ambiguous token and one expired contract.

        Returns:
            None: This method returns nothing.
        """
        self.mapping_date = datetime.date(2026, 9, 15)
        self.searches = 0
        self.engine = None
        self.identities = {
            '738561': [
                self.identity('102', 'RELIANCE', None),
            ],
            '408065': [
                self.identity('107', 'INFY', None),
                self.identity('108', 'INFY-BE', None),
            ],
            '13238786': [
                self.identity('109', 'NIFTY26AUGFUT', '2026-08-27'),
            ],
        }

    def identity(self, suffix, symbol, expiry_date):
        """Builds one identity as the real cache returns it.

        Args:
            suffix (str): The last digits of the instrument id.
            symbol (str): The trading symbol.
            expiry_date (str | None): The expiry, for a derivative.

        Returns:
            dict: The identity.
        """
        segment = 'nse_equities'
        shape = 'security'
        if expiry_date is not None:
            segment = 'nse_equity_index_futures'
            shape = 'future'
        return {
            'instrument_id': f'11111111-1111-5111-8111-000000000{suffix}',
            'exchange': 'nse',
            'segment': segment,
            'shape': shape,
            'symbol': symbol,
            'underlying_symbol': None,
            'expiry_date': expiry_date,
            'strike_price': None,
            'option_type': None,
        }

    def resolves_to_current_date(self, as_of_date):
        """Answers the mapping date for a day, or None for a day before it.

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

        Returns:
            datetime.date | None: The mapping date, or None.
        """
        if as_of_date < self.mapping_date:
            return None
        return self.mapping_date

    def candidates_for_tokens(self, broker, broker_tokens, as_of_date, segments=None):
        """Answers every identity each token maps to inside the segments, and counts the search.

        Args:
            broker (str): The broker name.
            broker_tokens (list): The broker's tokens.
            as_of_date (datetime.date): The trading day.
            segments (list | None): The segments to search, or None for any.

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

    def order_handles_for_instruments(self, instrument_identifiers, as_of_date, brokers=None):
        """Answers each instrument's handles, which here are only Zerodha's lot of 1.

        Args:
            instrument_identifiers (list): The instrument ids.
            as_of_date (datetime.date): The trading day.
            brokers (list | None): Brokers to restrict the answer to, or None for all.

        Returns:
            dict: Instrument id to broker name to handle.
        """
        found = {}
        for instrument_id in instrument_identifiers:
            found[instrument_id] = {
                'zerodha': {
                    'broker_token': None,
                    'order_symbol': None,
                    'lot_size': '1',
                    'tick_size': '0.05',
                },
            }
        return found


class MissesAndRetriesExample:
    """Resolves tokens that miss, retries them, and moves the mapping date.

    Attributes:
        cache (CountingMappingCache): The stand-in mapping cache.
        resolver (TickResolver): The resolver being shown.
        now (float): The fixed current instant, in epoch seconds.
    """

    def __init__(self):
        """Builds the resolver with Zerodha's normalizer only.

        Returns:
            None: This method returns nothing.
        """
        self.cache = CountingMappingCache()
        brokers = [
            'zerodha',
        ]
        self.resolver = TickResolver(CacheCandidateSource(self.cache), build_normalizers(brokers), PrintingLogger())
        moment = datetime.datetime(2026, 9, 15, 9, 30, tzinfo=INDIA)
        self.now = moment.timestamp()

    def run(self):
        """Prints each step's outcome.

        Returns:
            None: This method returns nothing.
        """
        self.resolver.refresh(self.now)
        tokens = [
            738561,
            408065,
            13238786,
            1025,
            'NSE:INFY',
        ]
        compiled = self.resolver.compile('zerodha', tokens, self.now)
        print(f'Compiled: {sorted(compiled)}')
        print(f'Searches so far: {self.cache.searches}')
        print(f"plan_for upstox 42: {self.resolver.plan_for('upstox', 42, self.now)}")
        five_minutes_later = self.now + 300
        print(f"plan_for 408065 five minutes later: {self.resolver.plan_for('zerodha', 408065, five_minutes_later)}, searches {self.cache.searches}")
        eleven_minutes_later = self.now + 660
        print(f"plan_for 408065 eleven minutes later: {self.resolver.plan_for('zerodha', 408065, eleven_minutes_later)}, searches {self.cache.searches}")
        drained = self.resolver.drain_unresolved()
        for key in sorted(drained, key=str):
            print(f'Unresolved {key}: {drained[key]}')
        print(f'Unresolved after draining: {dict(self.resolver.unresolved)}')
        print(f'refresh later the same day: {self.resolver.refresh(self.now + 3600)}')
        self.cache.mapping_date = datetime.date(2026, 9, 16)
        next_morning = datetime.datetime(2026, 9, 16, 8, 0, tzinfo=INDIA)
        print(f'refresh after the next mapping date: {self.resolver.refresh(next_morning.timestamp())}')
        print(f'Plans left: {self.resolver.plans()}, mapping date {self.resolver.mapping_date}')


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

It prints:

LOG warning: zerodha token NSE:INFY did not resolve: unplaceable.
LOG warning: zerodha token 408065 did not resolve: ambiguous (nse_equities:INFY, nse_equities:INFY-BE).
LOG warning: zerodha token 1025 did not resolve: unmapped.
LOG warning: zerodha token 13238786 did not resolve: unmapped.
Compiled: [('zerodha', 738561)]
Searches so far: 2
LOG warning: upstox token 42 did not resolve: no_normalizer.
plan_for upstox 42: None
plan_for 408065 five minutes later: None, searches 2
plan_for 408065 eleven minutes later: None, searches 3
Unresolved ('upstox', '42', 'no_normalizer'): 1
Unresolved ('zerodha', '1025', 'unmapped'): 1
Unresolved ('zerodha', '13238786', 'unmapped'): 1
Unresolved ('zerodha', '408065', 'ambiguous'): 2
Unresolved ('zerodha', 'NSE:INFY', 'unplaceable'): 1
Unresolved after draining: {}
refresh later the same day: False
LOG info: Mapping date is now 2026-09-16; instrument plans will be recompiled.
refresh after the next mapping date: True
Plans left: {}, mapping date 2026-09-16

Methods:

Name Description
refresh

Move to the India trading day an instant falls on, dropping every plan if the mapping changed.

plan_for

The plan for a tick's token, compiling it on first sight.

prewarm

Compile plans for every token a broker's feed is subscribed to, before its ticks arrive.

compile

Resolve a batch of one broker's tokens and compile their plans.

drain_unresolved

Take the unresolved counts accumulated since the last call.

plans

Every compiled plan, by (broker, token).

Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def __init__(self, source, normalizers, logger=None):
    """
    Build the resolver.

    Args:
        source (CacheCandidateSource): Where candidates come from.
        normalizers (dict): Broker name to TickNormalizer.
        logger (logging.Logger | None): Where decisions worth knowing are reported.

    Returns:
        None: This function returns nothing.
    """
    self._source = source
    self._normalizers = normalizers
    self._logger = logger
    self._plans = {}
    self._order_symbols = {}
    self.unresolved = Counter()
    self.notes = {}
    self.as_of_date = None
    self.mapping_date = None
    self._logged = set()

refresh

refresh(epoch)

Move to the India trading day an instant falls on, dropping every plan if the mapping changed.

The live service calls this every thirty seconds with the wall clock.

Parameters:

Name Type Description Default
epoch float

An instant on the day to resolve for.

required

Returns:

Type Description
bool

True when the plans were dropped because the day's mapping date differs from theirs.

Example usage

From example_2_misses_retries_and_a_new_mapping_date.py
self.resolver.refresh(self.now)
From example_2_misses_retries_and_a_new_mapping_date.py
print(f'refresh later the same day: {self.resolver.refresh(self.now + 3600)}')
From example_2_misses_retries_and_a_new_mapping_date.py
print(f'refresh after the next mapping date: {self.resolver.refresh(next_morning.timestamp())}')
Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def refresh(self, epoch):
    """
    Move to the India trading day an instant falls on, dropping every plan if the mapping changed.

    The live service calls this every thirty seconds with the wall clock.

    Args:
        epoch (float): An instant on the day to resolve for.

    Returns:
        bool: True when the plans were dropped because the day's mapping date differs from theirs.
    """
    # The mapping date is asked for on every call, not only when the day changes: the morning's
    # download moves it at 07:45, hours after midnight moved the day, and a resolver that checked
    # only at midnight would serve the previous day's plans until the next one.
    as_of_date = india_date(epoch)
    mapping_date = self._source.mapping_date(as_of_date)
    self.as_of_date = as_of_date
    if mapping_date == self.mapping_date:
        return False
    dropped = bool(self._plans)
    self._plans = {}
    self._order_symbols = {}
    self.notes = {}
    self._logged = set()
    self.mapping_date = mapping_date
    if dropped and self._logger:
        self._logger.info(f"Mapping date is now {mapping_date}; instrument plans will be recompiled.")
    return dropped

plan_for

plan_for(broker, instrument_token, now)

The plan for a tick's token, compiling it on first sight.

Parameters:

Name Type Description Default
broker str

The broker the tick came from.

required
instrument_token int | str

The tick's instrument_token.

required
now float

The current instant, used to decide when a miss may be retried.

required

Returns:

Type Description
InstrumentPlan | None

The plan, or None when the token does not resolve to one instrument.

Example usage

From example_1_prewarming_zerodha_plans.py
plan = self.resolver.plan_for('zerodha', 738561, self.now)
From example_2_misses_retries_and_a_new_mapping_date.py
print(f"plan_for upstox 42: {self.resolver.plan_for('upstox', 42, self.now)}")
From example_2_misses_retries_and_a_new_mapping_date.py
print(f"plan_for 408065 five minutes later: {self.resolver.plan_for('zerodha', 408065, five_minutes_later)}, searches {self.cache.searches}")
Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def plan_for(self, broker, instrument_token, now):
    """
    The plan for a tick's token, compiling it on first sight.

    Args:
        broker (str): The broker the tick came from.
        instrument_token (int | str): The tick's `instrument_token`.
        now (float): The current instant, used to decide when a miss may be retried.

    Returns:
        InstrumentPlan | None: The plan, or None when the token does not resolve to one instrument.
    """
    key = (broker, instrument_token)
    plan = self._plans.get(key)
    if plan is not None:
        if plan.__class__ is InstrumentPlan:
            return plan
        if now < plan:
            return None
    return self.compile(broker, [instrument_token], now).get(key)

prewarm

prewarm(broker, subscription_tokens, now)

Compile plans for every token a broker's feed is subscribed to, before its ticks arrive.

This is what keeps the mapping search off the per-tick path: by the time a subscribed instrument's first tick arrives its plan is already filed under the tick's spelling.

Parameters:

Name Type Description Default
broker str

The broker name.

required
subscription_tokens list[str]

Members of the broker's subscription set.

required
now float

The current instant.

required

Returns:

Type Description
tuple[int, int]

How many of the tokens have a plan, and how many there were.

Example usage

From example_1_prewarming_zerodha_plans.py
planned, total = self.resolver.prewarm('zerodha', subscriptions, self.now)
Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def prewarm(self, broker, subscription_tokens, now):
    """
    Compile plans for every token a broker's feed is subscribed to, before its ticks arrive.

    This is what keeps the mapping search off the per-tick path: by the time a subscribed
    instrument's first tick arrives its plan is already filed under the tick's spelling.

    Args:
        broker (str): The broker name.
        subscription_tokens (list[str]): Members of the broker's subscription set.
        now (float): The current instant.

    Returns:
        tuple[int, int]: How many of the tokens have a plan, and how many there were.
    """
    normalizer = self._normalizers.get(broker)
    if normalizer is None:
        return 0, len(subscription_tokens)
    spellings = []
    for token in subscription_tokens:
        spelling = normalizer.tick_spelling(token)
        if spelling is None:
            self._miss(broker, token, "unplaceable", now + MISS_RETRY_SECONDS)
        else:
            spellings.append(spelling)
    missing = [spelling for spelling in spellings if (broker, spelling) not in self._plans]
    if missing:
        self.compile(broker, missing, now)
    planned = sum(1 for spelling in spellings if self._plans.get((broker, spelling)).__class__ is InstrumentPlan)
    return planned, len(subscription_tokens)

compile

compile(broker, instrument_tokens, now)

Resolve a batch of one broker's tokens and compile their plans.

Parameters:

Name Type Description Default
broker str

The broker name.

required
instrument_tokens list

Tokens spelled as they appear on ticks.

required
now float

The current instant.

required

Returns:

Type Description
dict

(broker, token) to InstrumentPlan, for the tokens that resolved.

Example usage

From example_2_misses_retries_and_a_new_mapping_date.py
compiled = self.resolver.compile('zerodha', tokens, self.now)
Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def compile(self, broker, instrument_tokens, now):
    """
    Resolve a batch of one broker's tokens and compile their plans.

    Args:
        broker (str): The broker name.
        instrument_tokens (list): Tokens spelled as they appear on ticks.
        now (float): The current instant.

    Returns:
        dict: (broker, token) to InstrumentPlan, for the tokens that resolved.
    """
    if self.as_of_date is None:
        self.refresh(now)
    retry_at = now + MISS_RETRY_SECONDS
    normalizer = self._normalizers.get(broker)
    if normalizer is None:
        for token in instrument_tokens:
            self._miss(broker, token, "no_normalizer", retry_at)
        return {}
    if self.mapping_date is None:
        for token in instrument_tokens:
            self._miss(broker, token, "no_mapping_date", retry_at)
        return {}

    groups = defaultdict(list)
    for token in instrument_tokens:
        feed_key = normalizer.feed_key(token)
        if feed_key is None:
            self._miss(broker, token, "unplaceable", retry_at)
            continue
        broker_token = feed_key.broker_token
        if broker_token is None and feed_key.order_symbol is not None:
            if broker not in self._order_symbols:
                self._order_symbols[broker] = self._source.order_symbols(broker, self.as_of_date)
            broker_token = self._order_symbols[broker].get(feed_key.order_symbol)
        if broker_token is None:
            self._miss(broker, token, "unmapped", retry_at)
            continue
        groups[feed_key.segments].append((token, broker_token))

    chosen = {}
    for segments, items in groups.items():
        found = self._source.candidates(broker, sorted({broker_token for _, broker_token in items}),
                                        self.as_of_date, segments)
        for token, broker_token in items:
            candidates = [identity for identity in found.get(broker_token, ())
                          if not _expired(identity, self.as_of_date)]
            distinct = {identity["instrument_id"] for identity in candidates}
            if not distinct:
                self._miss(broker, token, "unmapped", retry_at)
            elif len(distinct) > 1:
                self._miss(broker, token, "ambiguous", retry_at,
                           ", ".join(sorted(f"{c['segment']}:{c.get('symbol') or c.get('underlying_symbol')}"
                                            for c in candidates)))
            else:
                chosen[token] = (broker_token, candidates[0])

    if not chosen:
        return {}

    handles = self._source.handles(sorted({identity["instrument_id"] for _, identity in chosen.values()}),
                                   self.as_of_date)
    compiled = {}
    for token, (broker_token, identity) in chosen.items():
        plan = self._compile_plan(normalizer, broker, token, broker_token, identity,
                                  handles.get(identity["instrument_id"], {}))
        self._plans[(broker, token)] = plan
        compiled[(broker, token)] = plan
    return compiled

drain_unresolved

drain_unresolved()

Take the unresolved counts accumulated since the last call.

Returns:

Type Description
Counter

(broker, token, reason) to count.

Example usage

From example_1_prewarming_zerodha_plans.py
print(f'Unresolved: {dict(self.resolver.drain_unresolved())}')
From example_2_misses_retries_and_a_new_mapping_date.py
drained = self.resolver.drain_unresolved()
Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def drain_unresolved(self):
    """
    Take the unresolved counts accumulated since the last call.

    Returns:
        Counter: (broker, token, reason) to count.
    """
    counts, self.unresolved = self.unresolved, Counter()
    return counts

plans

plans()

Every compiled plan, by (broker, token).

Returns:

Type Description
dict

The plans; misses are left out.

Example usage

From example_1_prewarming_zerodha_plans.py
self.resolver.plans()
From example_2_misses_retries_and_a_new_mapping_date.py
print(f'Plans left: {self.resolver.plans()}, mapping date {self.resolver.mapping_date}')
Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def plans(self):
    """
    Every compiled plan, by (broker, token).

    Returns:
        dict: The plans; misses are left out.
    """
    return {key: plan for key, plan in self._plans.items() if plan.__class__ is InstrumentPlan}

_compile_plan

_compile_plan(normalizer, broker, token, broker_token, identity, handles_by_broker)

Compile the plan for one resolved token from its instrument's identity and every broker's handle.

The lot size is decided from the handles, and a note about it, such as brokers disagreeing, is recorded in notes and logged once. Each quantity field then gets the multiplier that turns this broker's figure into units: the lot size for a field reported in lots, the lot size divided by the broker's own lot size for a field reported in broker lots, None when either lot size is unknown, and 1 otherwise.

Parameters:

Name Type Description Default
normalizer TickNormalizer

The broker's normalizer, which says how it reports each quantity field and its close.

required
broker str

The broker name.

required
token int | str

The token as it is spelled on the broker's ticks.

required
broker_token str

The token as stored in unified.broker_mappings.

required
identity dict

The one instrument identity the token resolved to.

required
handles_by_broker dict

Broker name to that broker's order handle for the instrument.

required

Returns:

Type Description
InstrumentPlan

The compiled plan.

Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def _compile_plan(self, normalizer, broker, token, broker_token, identity, handles_by_broker):
    """
    Compile the plan for one resolved token from its instrument's identity and every broker's handle.

    The lot size is decided from the handles, and a note about it, such as brokers disagreeing, is recorded in `notes` and logged once. Each quantity field then gets the multiplier that turns this broker's figure into units: the lot size for a field reported in lots, the lot size divided by the broker's own lot size for a field reported in broker lots, None when either lot size is unknown, and 1 otherwise.

    Args:
        normalizer (TickNormalizer): The broker's normalizer, which says how it reports each quantity field and its close.
        broker (str): The broker name.
        token (int | str): The token as it is spelled on the broker's ticks.
        broker_token (str): The token as stored in unified.broker_mappings.
        identity (dict): The one instrument identity the token resolved to.
        handles_by_broker (dict): Broker name to that broker's order handle for the instrument.

    Returns:
        InstrumentPlan: The compiled plan.
    """
    exchange = identity["exchange"]
    segment = identity["segment"]
    shape = identity["shape"]
    lot_size, note = units_per_lot(identity, handles_by_broker)
    if note:
        self.notes[(broker, token)] = note
        self._log_once(("lot", broker, token), f"{broker} {token} ({segment}): lot size {lot_size} - {note}.")

    broker_lot = _whole_number((handles_by_broker.get(broker) or {}).get("lot_size"))
    multipliers = {}
    for field in QUANTITY_FIELDS:
        basis = normalizer.quantity_basis(exchange, field)
        if basis == BASIS_LOTS:
            multipliers[field] = lot_size
        elif basis == BASIS_BROKER_LOTS:
            if lot_size is None or broker_lot is None:
                multipliers[field] = None
            elif lot_size % broker_lot == 0:
                multipliers[field] = lot_size // broker_lot
            else:
                multipliers[field] = lot_size / broker_lot
        else:
            multipliers[field] = 1

    return InstrumentPlan(
        instrument_id=identity["instrument_id"],
        broker=broker,
        broker_token=broker_token,
        exchange=exchange,
        segment=segment,
        shape=shape,
        session=session_for(segment),
        lot_size=lot_size,
        multipliers=multipliers,
        price_decimals=4 if "_currenc" in segment else 2,
        negative_prices=shape != "security" and exchange in ("mcx", "ncdex"),
        close_policy=normalizer.close_policy(exchange),
        has_open_interest=shape != "security",
        trusts_last_trade_time=normalizer.TRUSTS_LAST_TRADE_TIME,
        trusts_exchange_time=normalizer.TRUSTS_EXCHANGE_TIME,
        identity_json=identity_json(identity["instrument_id"], broker, broker_token, identity, lot_size),
    )

_miss

_miss(broker, token, reason, retry_at, detail=None)

Record that a token did not resolve, and when it may be tried again.

The retry instant is filed in place of a plan, so plan_for returns None without searching again until then. The miss is counted in unresolved and logged once per broker, token and reason.

Parameters:

Name Type Description Default
broker str

The broker name.

required
token int | str

The token as it is spelled on the broker's ticks.

required
reason str

Why it did not resolve: no_normalizer, unplaceable, unmapped, ambiguous or no_mapping_date.

required
retry_at float

The epoch instant before which the token is not searched for again.

required
detail str | None

More about the miss for the log line, such as the candidates of an ambiguous token.

None
Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def _miss(self, broker, token, reason, retry_at, detail=None):
    """
    Record that a token did not resolve, and when it may be tried again.

    The retry instant is filed in place of a plan, so `plan_for` returns None without searching again until then. The miss is counted in `unresolved` and logged once per broker, token and reason.

    Args:
        broker (str): The broker name.
        token (int | str): The token as it is spelled on the broker's ticks.
        reason (str): Why it did not resolve: no_normalizer, unplaceable, unmapped, ambiguous or no_mapping_date.
        retry_at (float): The epoch instant before which the token is not searched for again.
        detail (str | None): More about the miss for the log line, such as the candidates of an ambiguous token.
    """
    self._plans[(broker, token)] = retry_at
    self.unresolved[(broker, str(token), reason)] += 1
    message = f"{broker} token {token} did not resolve: {reason}"
    self._log_once((broker, token, reason), message + (f" ({detail})." if detail else "."))

_log_once

_log_once(key, message)

Log a warning the first time a key is seen since the plans were last dropped.

Nothing is logged when the resolver has no logger.

Parameters:

Name Type Description Default
key tuple

What identifies the message, so the same event is not logged twice.

required
message str

The warning to log.

required
Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def _log_once(self, key, message):
    """
    Log a warning the first time a key is seen since the plans were last dropped.

    Nothing is logged when the resolver has no logger.

    Args:
        key (tuple): What identifies the message, so the same event is not logged twice.
        message (str): The warning to log.
    """
    if key in self._logged or self._logger is None:
        return
    self._logged.add(key)
    self._logger.warning(message)

order_symbol_index

order_symbol_index(engine, broker, mapping_date)

A broker's order symbol to broker token dictionary, for one mapping date.

The mapping cache is keyed by token, but a Fyers tick names its instrument by symbol, and the symbol is what the broker mappings store as the order symbol. One query per mapping date - about 160,000 rows for Fyers - builds the translation.

Parameters:

Name Type Description Default
engine Engine

An engine over TimescaleDB.

required
broker str

The broker.

required
mapping_date date

The mapping date.

required

Returns:

Type Description
dict

Order symbol to broker token. A symbol listed twice keeps its first token.

Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def order_symbol_index(engine, broker, mapping_date):
    """
    A broker's order symbol to broker token dictionary, for one mapping date.

    The mapping cache is keyed by token, but a Fyers tick names its instrument by symbol, and the
    symbol is what the broker mappings store as the order symbol. One query per mapping date
    - about 160,000 rows for Fyers - builds the translation.

    Args:
        engine (sqlalchemy.engine.Engine): An engine over TimescaleDB.
        broker (str): The broker.
        mapping_date (datetime.date): The mapping date.

    Returns:
        dict: Order symbol to broker token. A symbol listed twice keeps its first token.
    """
    index = {}
    with engine.connect() as connection:
        statement = text(f"SELECT order_symbol, broker_token FROM {tables.BROKER_MAPPINGS} "
                         "WHERE broker = :broker AND mapping_date = :mapping_date AND order_symbol IS NOT NULL")
        for order_symbol, broker_token in connection.execute(statement, {"broker": broker, "mapping_date": mapping_date}):
            index.setdefault(order_symbol, broker_token)
    return index

_whole_number

_whole_number(value)

A lot size as a positive int, or None.

Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def _whole_number(value):
    """A lot size as a positive int, or None."""
    if value is None:
        return None
    try:
        number = Decimal(str(value))
    except (InvalidOperation, ValueError):
        return None
    if number <= 0 or number != number.to_integral_value():
        return None
    return int(number)

units_per_lot

units_per_lot(identity, handles_by_broker)

How many underlying units one lot of an instrument is.

Parameters:

Name Type Description Default
identity dict

The instrument's identity.

required
handles_by_broker dict

Broker name to that broker's handle for the instrument.

required

Returns:

Type Description
tuple[int | None, str | None]

The units per lot, or None when it cannot be decided, and a note when the brokers disagreed or the authority was missing.

Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def units_per_lot(identity, handles_by_broker):
    """
    How many underlying units one lot of an instrument is.

    Args:
        identity (dict): The instrument's identity.
        handles_by_broker (dict): Broker name to that broker's handle for the instrument.

    Returns:
        tuple[int | None, str | None]: The units per lot, or None when it cannot be decided, and a note when the brokers disagreed or the authority was missing.
    """
    if identity.get("shape") == "security":
        return 1, None

    if identity.get("exchange") == "mcx":
        handle = handles_by_broker.get(MCX_LOT_AUTHORITY)
        size = _whole_number(handle.get("lot_size")) if handle else None
        if size is None:
            return None, f"no {MCX_LOT_AUTHORITY} lot size"
        return size, None

    sizes = Counter()
    for handle in handles_by_broker.values():
        size = _whole_number(handle.get("lot_size"))
        if size is not None:
            sizes[size] += 1
    if not sizes:
        return None, "no broker lot size"
    ranked = sizes.most_common()
    note = None
    if len(ranked) > 1:
        note = "brokers disagree: " + ", ".join(f"{size}x{count}" for size, count in ranked)
        if ranked[0][1] == ranked[1][1]:
            return None, note
    return ranked[0][0], note

_expired

_expired(identity, as_of_date)

Whether a derivative had expired before a day, which rules it out as a live candidate.

Source code in stock_brokers/instruments/ticks/utilities/resolution.py
def _expired(identity, as_of_date):
    """Whether a derivative had expired before a day, which rules it out as a live candidate."""
    expiry = identity.get("expiry_date")
    if not expiry:
        return False
    return str(expiry)[:10] < as_of_date.isoformat()