indmoney
¶
IND Money mapping adapter.
IND Money's file has three peculiarities that the rules engine cannot express on its own.
The first is that it has no index marker at all. An index row is identified by exclusion: its segment column holds the index's own name ("NIFTY 50", "BANK NIFTY") rather than one of the two real segment codes, E for the cash market and D for derivatives. So index classification is "anything that is neither", with the government securities index names excluded, and the name is then resolved to the canonical symbol through an alias table and, on the NSE side, the day's already-written master rows.
The second is that neither derivative segment has an underlying symbol column. It is extracted from the trading symbol, which is patterned as 360ONE-Nov2026-1000-CE on NSE and BANKEX-24Sep2026-57200-CE on BSE.
The third is the expiry format, MM/DD/YYYY HH:MM, which is month-first and needs an explicit parse.
This project's IND Money download captures an isin column, but only from 2026-09-02 onwards: every earlier snapshot has it empty. So the ISIN is read from the row where the row has one, and resolved through the cross-broker security identifier map where it does not. Both fixed income identity and fund detection in the equity segments go through that one resolution, which makes the adapter behave the same way on every stored date rather than only on the recent ones. The two exchange traded fund allowlists are still needed, because an ISIN says that a row is a fund but not whether it is a listed exchange traded fund or a mutual fund scheme.
Classes:
| Name | Description |
|---|---|
IndMoneyMappingAdapter |
Mapping adapter for IND Money, covering 17 real segments plus the uncategorised buckets. |
Functions:
| Name | Description |
|---|---|
normalize_index_name |
Reduce an index name to uppercase alphanumerics so that differently-spelled broker names collide. |
isin_valid_non_fund |
Check whether a row carries a usable ISIN that is not a fund-family ISIN. |
isin_is_fund |
Check whether an ISIN belongs to the fund family, which India marks with an INF prefix. |
IndMoneyMappingAdapter
¶
Bases: BrokerMappingAdapter
Mapping adapter for IND Money, covering 17 real segments plus the uncategorised buckets.
Example programs
"""Classifies IND Money rows, which have no index marker, no underlying column and, on older dates, no ISIN.
IND Money marks an index only by writing the index's own name, such as "Nifty Next 50", into its `segment` column where other rows hold E for the cash market or D for derivatives. `IndMoneyMappingAdapter.classify` therefore treats any row whose segment is neither as an index, and `to_identity` reads the name from that column and brings it to the spelling shared with other brokers. Derivatives have no underlying column, so the underlying is taken from the trading symbol, and expiries are month-first text such as `11/24/2026 14:30`.
IND Money only began publishing ISINs on 2026-09-02. `resolved_isin` reads the row's own ISIN where there is one and otherwise looks the security id up in a map built from other brokers' files. That decides whether a bond can be identified at all, and whether an equity row is really a fund and should be left uncategorised.
`run` normally builds the fund lists, the security id map and the index name lookup from the database. This program sets them by hand, so no database is needed; the security id map knows one government bond, 15542. `uncategorised_exchange` shows where a dropped row is filed.
Run it from the project root:
python examples/stock_brokers/instruments/mapping/indmoney/IndMoneyMappingAdapter/example_1_indices_underlyings_and_isins.py
"""
from stock_brokers.instruments.mapping.indmoney import (
IndMoneyMappingAdapter,
)
class IndicesUnderlyingsAndIsinsExample:
"""Classifies six hand-written IND Money rows and prints each one's segment and identity.
Attributes:
adapter (IndMoneyMappingAdapter): The adapter being shown.
raw_rows (list): The raw rows, as dictionaries of text columns.
"""
def __init__(self):
"""Builds the adapter, sets the lists and maps `run` would build, and writes the rows.
Returns:
None: This method returns nothing.
"""
self.adapter = IndMoneyMappingAdapter()
self.adapter.nse_etf_symbols = set()
self.adapter.bse_etf_symbols = set()
self.adapter.nse_index_master_lookup = {}
self.adapter.isin_by_security_id = {
'15542': 'IN0020160019',
}
self.raw_rows = [
self.raw_row('NSE', 'E', '2885', 'EQUITY', 'ES', 'EQ', 'RELIANCE', 'INE002A01018', None),
self.raw_row('NSE', 'Nifty Next 50', '26013', 'INDEX', 'INDEX', None, 'NIFTY NEXT 50', None, None),
self.raw_row('NSE', 'D', '41907', 'OPTSTK', 'OP', None, '360ONE-Nov2026-1000-CE', None, '11/24/2026 14:30'),
self.raw_row('BSE', 'D', '1143927', 'OPTIDX', 'OPTIDX', None, 'BANKEX-24Sep2026-57200-CE', None, '09/24/2026 14:30'),
self.raw_row('NSE', 'E', '15542', 'EQUITY', 'GB', 'GS', '762GS2036', None, None),
self.raw_row('NSE', 'E', '26401', 'EQUITY', 'ES', 'EQ', 'LIQUIDCASE', 'INF0R8F01034', None),
]
def raw_row(self, exchange, segment, security_id, instrument_name, instrument_type, series, trading_symbol, isin, expiry):
"""Builds one raw row in the shape of `indmoney.instruments`.
Args:
exchange (str): The `exch` column.
segment (str): E, D, or an index's own name.
security_id (str): IND Money's token, the exchange's security id.
instrument_name (str): The `instrument_name` column.
instrument_type (str): The `sem_exch_instrument_type` column.
series (str | None): The exchange series.
trading_symbol (str): The `trading_symbol` column.
isin (str | None): The ISIN, blank on snapshots before 2026-09-02.
expiry (str | None): The expiry as month-first text, for derivatives.
Returns:
dict: The raw row, every column as text as the raw table stores it.
"""
strike_price = None
option_type = None
if trading_symbol.endswith('-CE'):
strike_price = trading_symbol.split('-')[2]
option_type = 'CE'
return {
'exch': exchange,
'segment': segment,
'security_id': security_id,
'instrument_name': instrument_name,
'trading_symbol': trading_symbol,
'lot_units': '1',
'custom_symbol': trading_symbol,
'expiry_date': expiry,
'strike_price': strike_price,
'option_type': option_type,
'tick_size': '5.0',
'sem_exch_instrument_type': instrument_type,
'series': series,
'symbol_name': trading_symbol,
'isin': isin,
}
def run(self):
"""Prints each row's resolved ISIN, segment and identity, or where an unmatched row is filed.
Returns:
None: This method returns nothing.
"""
for raw_row in self.raw_rows:
label = f'{raw_row["exch"]} {raw_row["trading_symbol"]} (segment {raw_row["segment"]!r})'
print(label)
print(f' ISIN on the row {raw_row["isin"]}, resolved {self.adapter.resolved_isin(raw_row)}')
segment_configuration = self.adapter.classify(raw_row)
if segment_configuration is None:
exchange = self.adapter.uncategorised_exchange(raw_row)
print(f' no segment, filed under {exchange}_uncategorised')
continue
identity = self.adapter.to_identity(raw_row, segment_configuration)
print(f' {segment_configuration["segment"]} as {identity}')
if __name__ == '__main__':
IndicesUnderlyingsAndIsinsExample().run()
It prints:
NSE RELIANCE (segment 'E')
ISIN on the row INE002A01018, resolved INE002A01018
nse_equities as {'symbol': 'RELIANCE'}
NSE NIFTY NEXT 50 (segment 'Nifty Next 50')
ISIN on the row None, resolved None
nse_equity_indices as {'symbol': 'NIFTYNXT50'}
NSE 360ONE-Nov2026-1000-CE (segment 'D')
ISIN on the row None, resolved None
nse_equity_options as {'underlying_symbol': '360ONE', 'expiry_date': datetime.date(2026, 11, 24), 'strike_price': 1000.0, 'option_type': 'CE'}
BSE BANKEX-24Sep2026-57200-CE (segment 'D')
ISIN on the row None, resolved None
bse_equity_index_options as {'underlying_symbol': 'BANKEX', 'expiry_date': datetime.date(2026, 9, 24), 'strike_price': 57200.0, 'option_type': 'CE'}
NSE 762GS2036 (segment 'E')
ISIN on the row None, resolved IN0020160019
nse_fixed_income as {'symbol': 'IN0020160019'}
NSE LIQUIDCASE (segment 'E')
ISIN on the row INF0R8F01034, resolved INF0R8F01034
no segment, filed under nse_uncategorised
"""Maps one day of IND Money rows from before it published ISINs, filling the gaps from other brokers' files.
`IndMoneyMappingAdapter.run` first gathers the NSE and BSE exchange traded fund lists, builds a map from exchange security id to ISIN out of the brokers that do publish ISINs, and reads the NSE index names already stored in `unified.instruments`. It then hands over to the shared mapping run, which reads IND Money's raw rows, classifies them, and writes `unified.instruments` and `unified.broker_mappings` in one transaction.
The adapter normally talks to PostgreSQL, so this program replaces its `engine` with a stand-in. The stand-in connection answers queries by matching a piece of their text: the raw-row query gets three hand-written IND Money rows dated 2026-08-28, when IND Money's ISIN column was still empty; Dhan's security id and ISIN query gets two pairs; and Dhan's NSE exchange traded fund query names NIFTYBEES. Every other cross-broker query gets no rows, and every write is recorded instead of run. pandas only reads through something it recognises as an SQLAlchemy connectable, and it recognises one by the `ConnectionEventsTarget` base class, so the stand-in connection inherits from that class.
Notice that the bond is stored under the ISIN borrowed from Dhan's file, that NIFTYBEES is moved from equities to exchange traded funds, and that the liquid fund, whose borrowed ISIN is a fund ISIN, is left in `nse_uncategorised`.
Run it from the project root:
python examples/stock_brokers/instruments/mapping/indmoney/IndMoneyMappingAdapter/example_2_mapping_one_day.py
"""
import collections
import datetime
from sqlalchemy.engine import interfaces
from stock_brokers.instruments.mapping.indmoney import (
IndMoneyMappingAdapter,
)
class StandInResult:
"""A stand-in for an SQLAlchemy result holding a fixed set of rows.
Attributes:
columns (list): The column names.
rows (list): The rows, as dictionaries.
"""
def __init__(self, rows):
"""Holds the rows.
Args:
rows (list): The rows, as dictionaries that all share the same keys.
Returns:
None: This method returns nothing.
"""
self.rows = rows
self.columns = []
if rows:
self.columns = list(rows[0])
def keys(self):
"""The column names, as pandas asks for them.
Returns:
list: The column names.
"""
return self.columns
def fetchall(self):
"""Every row as a plain tuple, as pandas asks for them.
Returns:
list: The rows as tuples in column order.
"""
tuples = []
for row in self.rows:
tuples.append(tuple(row.values()))
return tuples
def all(self):
"""Every row as a named tuple, so a column can be read as an attribute.
Returns:
list: The rows as named tuples.
"""
row_type = collections.namedtuple('StandInRow', self.columns)
named_rows = []
for row in self.rows:
named_rows.append(row_type(**row))
return named_rows
class StandInConnection(interfaces.ConnectionEventsTarget):
"""A stand-in for a PostgreSQL connection that answers queries from prepared rows and records writes.
Attributes:
answers (list): Pairs of (a piece of query text, the rows to answer with).
unanswered_queries (int): How many queries matched no prepared answer and got no rows.
writes (list): Each write statement's first words and its parameters.
"""
def __init__(self, answers):
"""Holds the prepared answers and starts with nothing recorded.
Args:
answers (list): Pairs of (a piece of query text, the rows to answer with).
Returns:
None: This method returns nothing.
"""
self.answers = answers
self.unanswered_queries = 0
self.writes = []
def __enter__(self):
"""Opens the connection for a `with` block.
Returns:
StandInConnection: This connection.
"""
return self
def __exit__(self, exception_type, exception, traceback):
"""Closes the connection at the end of a `with` block.
Args:
exception_type (type | None): The type of any exception raised in the block.
exception (BaseException | None): Any exception raised in the block.
traceback (object | None): The traceback of any exception raised in the block.
Returns:
bool: False, so any exception carries on.
"""
return False
def execute(self, statement, parameters=None):
"""Answers a query from the prepared rows, or records a write.
Args:
statement (sqlalchemy.sql.elements.TextClause): The statement to run.
parameters (dict | list | None): Its bound parameters.
Returns:
StandInResult: The prepared rows whose text matches, otherwise an empty result.
"""
sql = ' '.join(str(statement).split())
if not sql.startswith('SELECT'):
self.writes.append((' '.join(sql.split()[:3]), parameters))
return StandInResult([])
for query_text, rows in self.answers:
if query_text in sql:
return StandInResult(rows)
self.unanswered_queries += 1
return StandInResult([])
class StandInEngine:
"""A stand-in for an SQLAlchemy engine that always hands out the same stand-in connection.
Attributes:
connection (StandInConnection): The connection handed out.
"""
def __init__(self, connection):
"""Holds the connection to hand out.
Args:
connection (StandInConnection): The connection handed out.
Returns:
None: This method returns nothing.
"""
self.connection = connection
def connect(self):
"""Hands out the connection for reading.
Returns:
StandInConnection: The connection.
"""
return self.connection
def begin(self):
"""Hands out the connection for one transaction.
Returns:
StandInConnection: The connection.
"""
return self.connection
class MappingOneDayExample:
"""Maps three IND Money rows for one date and prints what would be written.
Attributes:
mapping_date (datetime.date): The snapshot date being mapped.
connection (StandInConnection): The stand-in connection.
adapter (IndMoneyMappingAdapter): The adapter being shown, using the stand-in engine.
"""
def __init__(self):
"""Builds the adapter and swaps its engine for the stand-in.
Returns:
None: This method returns nothing.
"""
self.mapping_date = datetime.date(2026, 8, 28)
raw_rows = [
self.raw_row('15542', 'GB', 'GS', '762GS2036'),
self.raw_row('10576', 'ES', 'EQ', 'NIFTYBEES'),
self.raw_row('26401', 'ES', 'EQ', 'LIQUIDCASE'),
]
dhan_isins = [
{
'security_id': '15542',
'isin': 'IN0020160019',
},
{
'security_id': '26401',
'isin': 'INF0R8F01034',
},
]
dhan_exchange_traded_funds = [
{
'symbol': 'NIFTYBEES',
},
]
answers = [
(
'SELECT * FROM indmoney.instruments',
raw_rows,
),
(
'SELECT security_id, isin FROM dhan.instruments',
dhan_isins,
),
(
'instrument_type IN (\'MF\', \'ETF\') AND series = \'EQ\'',
dhan_exchange_traded_funds,
),
]
self.connection = StandInConnection(answers)
self.adapter = IndMoneyMappingAdapter()
self.adapter.engine = StandInEngine(self.connection)
def raw_row(self, security_id, instrument_type, series, trading_symbol):
"""Builds one NSE cash-market raw row in the shape of `indmoney.instruments`, with the ISIN still empty.
Args:
security_id (str): IND Money's token, the exchange's security id.
instrument_type (str): The `sem_exch_instrument_type` column.
series (str): The exchange series.
trading_symbol (str): The `trading_symbol` column.
Returns:
dict: The raw row, every column as text as the raw table stores it.
"""
return {
'exch': 'NSE',
'segment': 'E',
'security_id': security_id,
'instrument_name': 'EQUITY',
'trading_symbol': trading_symbol,
'lot_units': '1',
'custom_symbol': trading_symbol,
'expiry_date': None,
'strike_price': None,
'option_type': None,
'tick_size': '1.0',
'sem_exch_instrument_type': instrument_type,
'series': series,
'symbol_name': trading_symbol,
'isin': None,
'download_date': self.mapping_date,
}
def run(self):
"""Maps the day and prints the summary and the rows written.
Returns:
None: This method returns nothing.
"""
summary = self.adapter.run(self.mapping_date)
print(f'Security id to ISIN map: {self.adapter.isin_by_security_id}')
print(f'NSE exchange traded funds from other brokers: {sorted(self.adapter.nse_etf_symbols)}')
print(f'Cross-broker queries that found nothing: {self.connection.unanswered_queries}')
print(f'Summary: matched {summary["matched"]}, uncategorised {summary["uncategorised"]}, errors {summary["errors"]}')
for statement, parameters in self.connection.writes:
print(statement)
if not isinstance(parameters, list):
continue
for row in parameters:
if 'segment' in row:
print(f' {row["segment"]}: {row["symbol"]}')
else:
print(f' token {row["broker_token"]}: {row["broker_symbol"]}, tick size {row["tick_size"]}')
if __name__ == '__main__':
MappingOneDayExample().run()
It prints:
indmoney: 2/3 raw row(s) classified, 1 uncategorised, 3 segment membership(s), 3 distinct instrument(s), 0 error(s).
Security id to ISIN map: {'15542': 'IN0020160019', '26401': 'INF0R8F01034'}
NSE exchange traded funds from other brokers: ['NIFTYBEES']
Cross-broker queries that found nothing: 22
Summary: matched 2, uncategorised 1, errors []
DELETE FROM unified.broker_mappings
INSERT INTO unified.instruments
nse_exchange_traded_funds: NIFTYBEES
nse_fixed_income: IN0020160019
nse_uncategorised: LIQUIDCASE
INSERT INTO unified.broker_mappings
token 10576: NIFTYBEES, tick size 0.01
token 15542: 762GS2036, tick size 0.01
token 26401: LIQUIDCASE, tick size 1.0
Load and validate the broker's rules file and create the database engine.
Raises:
| Type | Description |
|---|---|
NotImplementedError
|
If the subclass did not set BROKER_NAME. |
ValueError
|
If the rules file fails validation. |
FileNotFoundError
|
If the broker has no rules file. |
Example programs
"""Classifies three raw instrument rows using nothing but a broker's rules file.
`BrokerMappingAdapter` is the base class every broker's mapping adapter subclasses. A subclass only has to name its broker in `BROKER_NAME`, and the base class loads the matching rules file from `stock_brokers/instruments/mapping/utilities/rules/`, checks it against the canonical segment vocabulary, and classifies rows by plain equality rules. This program's subclass sets `BROKER_NAME` to Dhan and overrides nothing, so every answer below comes from the base class and Dhan's rules file alone, without the extra filters the real `DhanMappingAdapter` adds.
The rows are written out by hand in the shape of `dhan.instruments`, where every column is text. The adapter builds a database engine when it is constructed, but an engine does not connect until it is used, and this program never asks it to, so nothing touches PostgreSQL.
For each row the program prints the segment the rules chose, any extra segments (the base class never adds any), the identity fields that go into the instrument id, the broker's own fields, and the deterministic instrument id. The third row matches no rule. The base class cannot tell which exchange it belongs to, so it falls into the unprefixed `uncategorised` segment, and the run would store its token as its symbol.
Run it from the project root:
python examples/stock_brokers/instruments/mapping/base/BrokerMappingAdapter/example_1_classifying_rows_by_rules.py
"""
from stock_brokers.instruments.mapping import base
from stock_brokers.instruments.mapping.base import (
BrokerMappingAdapter,
)
class DhanRulesOnlyAdapter(BrokerMappingAdapter):
"""A mapping adapter that uses Dhan's rules file and nothing but the base class's behaviour."""
BROKER_NAME = 'dhan'
class ClassifyingRowsByRulesExample:
"""Runs three hand-written Dhan rows through the base class's rules engine and prints the result.
Attributes:
adapter (DhanRulesOnlyAdapter): The adapter being shown.
raw_rows (list): The raw rows to classify, as dictionaries of text columns.
"""
def __init__(self):
"""Builds the adapter and the three raw rows.
Returns:
None: This method returns nothing.
"""
self.adapter = DhanRulesOnlyAdapter()
self.raw_rows = [
{
'exch_id': 'NSE',
'segment': 'E',
'security_id': '1594',
'isin': 'INE009A01021',
'instrument': 'EQUITY',
'underlying_symbol': 'INFY',
'symbol_name': 'INFOSYS LIMITED',
'display_name': 'Infosys',
'instrument_type': 'ES',
'series': 'EQ',
'lot_size': '1.0',
'sm_expiry_date': None,
'strike_price': None,
'option_type': None,
'tick_size': '10.0',
},
{
'exch_id': 'NSE',
'segment': 'D',
'security_id': '40215',
'isin': None,
'instrument': 'OPTIDX',
'underlying_symbol': 'NIFTY',
'symbol_name': 'NIFTY-Oct2026-25000-CE',
'display_name': 'NIFTY 27 OCT 25000 CALL',
'instrument_type': 'OP',
'series': None,
'lot_size': '75.0',
'sm_expiry_date': '2026-10-27',
'strike_price': '25000.00000',
'option_type': 'CE',
'tick_size': '5.0',
},
{
'exch_id': 'NSE',
'segment': 'X',
'security_id': '990001',
'isin': None,
'instrument': 'SPREAD',
'underlying_symbol': '',
'symbol_name': 'TEST SPREAD',
'display_name': 'Test spread',
'instrument_type': 'SP',
'series': None,
'lot_size': '1.0',
'sm_expiry_date': None,
'strike_price': None,
'option_type': None,
'tick_size': '0',
},
]
def run(self):
"""Prints how the rules file classifies each row.
Returns:
None: This method returns nothing.
"""
equities = self.adapter.segment_config('nse_equities')
print(f'Rules file: {self.adapter.config["raw_table"]}, {len(self.adapter.config["segments"])} segments')
print(f'nse_equities is a {equities["shape"]} on {equities["exchange"]} with {len(equities["rules"])} rules')
print(f'Unconfigured segment: {self.adapter.segment_config("nse_bonds")}')
for raw_row in self.raw_rows:
print()
print(f'Row {raw_row["security_id"]} ({raw_row["symbol_name"]})')
segment_configuration = self.adapter.classify(raw_row)
if segment_configuration is None:
exchange = self.adapter.uncategorised_exchange(raw_row)
print(f' No rule matched, and the base class finds no exchange: {exchange}')
segment_configuration = self.adapter.segment_config('uncategorised')
extra_segments = self.adapter.classify_extra(raw_row)
identity = self.adapter.to_identity(raw_row, segment_configuration)
broker_fields = self.adapter.to_broker_fields(raw_row, segment_configuration)
identifier = base.instrument_id(
segment_configuration['exchange'],
segment_configuration['segment'],
segment_configuration['shape'],
identity,
)
print(f' Segment: {segment_configuration["segment"]} ({segment_configuration["shape"]})')
print(f' Extra segments: {extra_segments}')
print(f' Identity: {identity}')
print(f' Broker fields: {broker_fields}')
print(f' Instrument id: {identifier}')
if __name__ == '__main__':
ClassifyingRowsByRulesExample().run()
It prints:
Rules file: dhan.instruments, 32 segments
nse_equities is a security on nse with 2 rules
Unconfigured segment: None
Row 1594 (INFOSYS LIMITED)
Segment: nse_equities (security)
Extra segments: []
Identity: {'symbol': 'INFY'}
Broker fields: {'broker_token': '1594', 'broker_symbol': 'Infosys', 'order_symbol': None, 'lot_size': 1.0, 'tick_size': 0.1}
Instrument id: f310e1b0-e082-5b16-808a-25f741abb9de
Row 40215 (NIFTY-Oct2026-25000-CE)
Segment: nse_equity_index_options (option)
Extra segments: []
Identity: {'underlying_symbol': 'NIFTY', 'expiry_date': datetime.date(2026, 10, 27), 'strike_price': 25000.0, 'option_type': 'CE'}
Broker fields: {'broker_token': '40215', 'broker_symbol': 'NIFTY-Oct2026-25000-CE', 'order_symbol': None, 'lot_size': 75.0, 'tick_size': 0.05}
Instrument id: fe85c704-ce49-5190-b068-d4d3646ed162
Row 990001 (TEST SPREAD)
No rule matched, and the base class finds no exchange: None
Segment: uncategorised (security)
Extra segments: []
Identity: {'symbol': ''}
Broker fields: {'broker_token': '990001', 'broker_symbol': 'Test spread', 'order_symbol': None, 'lot_size': 1.0, 'tick_size': None}
Instrument id: 55a8952d-9071-5670-a4fe-a5384d3876bd
"""Maps one day's raw rows into the two unified tables, against a stand-in database.
`run` is what the daily mapping job calls for each broker. It reads the broker's raw rows for one date with `read_raw_rows`, classifies each row, and then, inside one transaction, deletes that broker's mappings for the date and upserts one row per instrument into `unified.instruments` and one per broker mapping into `unified.broker_mappings`. It returns a summary and prints a one-line report.
The adapter normally reads and writes PostgreSQL, so this program replaces its `engine` with a small stand-in. The stand-in connection answers the raw-row query with four hand-written rows in the shape of `dhan.instruments`, and records every other statement it is sent instead of running it. pandas only reads through something it recognises as an SQLAlchemy connectable, and it recognises one by the `ConnectionEventsTarget` base class, so the stand-in connection inherits from that class and nothing else.
The subclass uses Dhan's rules file and overrides nothing. The fourth row has a strike price that is not a number, so building its identity fails; `run` records that as an error and carries on with the other rows rather than stopping. The unmatched row is stored under its token as its symbol, and its tick size of 5.0 is kept as written, because Dhan's rules divide the tick size by 100 only in the real segments and not in the uncategorised one. The last lines show that a subclass which forgets to set `BROKER_NAME` cannot be built.
Run it from the project root:
python examples/stock_brokers/instruments/mapping/base/BrokerMappingAdapter/example_2_mapping_one_day.py
"""
import datetime
from sqlalchemy.engine import interfaces
from stock_brokers.instruments.mapping.base import (
BrokerMappingAdapter,
)
class DhanRulesOnlyAdapter(BrokerMappingAdapter):
"""A mapping adapter that uses Dhan's rules file and nothing but the base class's behaviour."""
BROKER_NAME = 'dhan'
class UnnamedAdapter(BrokerMappingAdapter):
"""A mapping adapter that forgets to name its broker."""
class StandInResult:
"""A stand-in for an SQLAlchemy result holding a fixed set of rows.
Attributes:
columns (list): The column names.
rows (list): The rows, as tuples in column order.
"""
def __init__(self, columns, rows):
"""Holds the columns and rows.
Args:
columns (list): The column names.
rows (list): The rows, as tuples in column order.
Returns:
None: This method returns nothing.
"""
self.columns = columns
self.rows = rows
def keys(self):
"""The column names, as pandas asks for them.
Returns:
list: The column names.
"""
return self.columns
def fetchall(self):
"""Every row, as pandas asks for them.
Returns:
list: The rows.
"""
return self.rows
def all(self):
"""Every row, as the adapter's own queries ask for them.
Returns:
list: The rows.
"""
return self.rows
class StandInConnection(interfaces.ConnectionEventsTarget):
"""A stand-in for a PostgreSQL connection that serves one raw table and records every other statement.
Attributes:
raw_table (str): The raw table whose rows this connection serves.
raw_rows (list): The raw rows, as dictionaries of column values.
statements (list): Each recorded statement's first line and its parameters.
"""
def __init__(self, raw_table, raw_rows):
"""Holds the raw rows and starts with no statements recorded.
Args:
raw_table (str): The raw table whose rows this connection serves.
raw_rows (list): The raw rows, as dictionaries of column values.
Returns:
None: This method returns nothing.
"""
self.raw_table = raw_table
self.raw_rows = raw_rows
self.statements = []
def __enter__(self):
"""Opens the connection for a `with` block.
Returns:
StandInConnection: This connection.
"""
return self
def __exit__(self, exception_type, exception, traceback):
"""Closes the connection at the end of a `with` block.
Args:
exception_type (type | None): The type of any exception raised in the block.
exception (BaseException | None): Any exception raised in the block.
traceback (object | None): The traceback of any exception raised in the block.
Returns:
bool: False, so any exception carries on.
"""
return False
def execute(self, statement, parameters=None):
"""Answers the raw-row query from the held rows and records anything else.
Args:
statement (sqlalchemy.sql.elements.TextClause): The statement to run.
parameters (dict | list | None): Its bound parameters.
Returns:
StandInResult: The raw rows for the raw-row query, otherwise an empty result.
"""
sql = ' '.join(str(statement).split())
if sql.startswith(f'SELECT * FROM {self.raw_table} '):
columns = list(self.raw_rows[0])
rows = []
for raw_row in self.raw_rows:
values = []
for column in columns:
values.append(raw_row[column])
rows.append(tuple(values))
return StandInResult(columns, rows)
self.statements.append((sql, parameters))
return StandInResult([], [])
class StandInEngine:
"""A stand-in for an SQLAlchemy engine that always hands out the same stand-in connection.
Attributes:
connection (StandInConnection): The connection handed out.
"""
def __init__(self, connection):
"""Holds the connection to hand out.
Args:
connection (StandInConnection): The connection handed out.
Returns:
None: This method returns nothing.
"""
self.connection = connection
def connect(self):
"""Hands out the connection for reading.
Returns:
StandInConnection: The connection.
"""
return self.connection
def begin(self):
"""Hands out the connection for one transaction.
Returns:
StandInConnection: The connection.
"""
return self.connection
class MappingOneDayExample:
"""Maps four raw rows for one date and prints what would be written.
Attributes:
mapping_date (datetime.date): The snapshot date being mapped.
connection (StandInConnection): The stand-in connection holding the raw rows.
adapter (DhanRulesOnlyAdapter): The adapter being shown, using the stand-in engine.
"""
def __init__(self):
"""Builds the adapter and swaps its engine for the stand-in.
Returns:
None: This method returns nothing.
"""
self.mapping_date = datetime.date(2026, 9, 28)
raw_rows = [
self.raw_row('1594', 'NSE', 'E', 'EQUITY', 'ES', 'INFY', 'INFOSYS LIMITED', None, None, None),
self.raw_row('40215', 'NSE', 'D', 'OPTIDX', 'OP', 'NIFTY', 'NIFTY-Oct2026-25000-CE', '2026-10-27', '25000.00000', 'CE'),
self.raw_row('990001', 'NSE', 'X', 'SPREAD', 'SP', '', 'TEST SPREAD', None, None, None),
self.raw_row('40216', 'NSE', 'D', 'OPTIDX', 'OP', 'NIFTY', 'NIFTY-Oct2026-XX-PE', '2026-10-27', 'not a number', 'PE'),
]
self.connection = StandInConnection('dhan.instruments', raw_rows)
self.adapter = DhanRulesOnlyAdapter()
self.adapter.engine = StandInEngine(self.connection)
def raw_row(self, security_id, exchange, segment, instrument, instrument_type, underlying_symbol, symbol_name, expiry, strike_price, option_type):
"""Builds one raw row in the shape of `dhan.instruments`.
Args:
security_id (str): Dhan's token for the row.
exchange (str): The `exch_id` column.
segment (str): Dhan's one-letter segment code.
instrument (str): The `instrument` column.
instrument_type (str): The `instrument_type` column.
underlying_symbol (str): The `underlying_symbol` column.
symbol_name (str): The `symbol_name` column.
expiry (str | None): The `sm_expiry_date` column.
strike_price (str | None): The `strike_price` column.
option_type (str | None): The `option_type` column.
Returns:
dict: The raw row, every column as text as the raw table stores it.
"""
return {
'exch_id': exchange,
'segment': segment,
'security_id': security_id,
'isin': None,
'instrument': instrument,
'underlying_security_id': None,
'underlying_symbol': underlying_symbol,
'symbol_name': symbol_name,
'display_name': symbol_name,
'instrument_type': instrument_type,
'series': None,
'lot_size': '1.0',
'sm_expiry_date': expiry,
'strike_price': strike_price,
'option_type': option_type,
'tick_size': '5.0',
'download_date': self.mapping_date,
}
def short_row(self, row):
"""Shortens one written row to its filled-in columns, leaving out the dates the example already names.
Args:
row (dict): One row the adapter asked to insert.
Returns:
dict: The row's columns that hold a value, with the instrument id cut to its first eight characters.
"""
shortened = {}
for column, value in row.items():
if value is None or isinstance(value, datetime.date):
continue
if column == 'instrument_id':
value = value[:8]
shortened[column] = value
return shortened
def run(self):
"""Reads the raw rows, maps them, and prints the summary and the statements sent.
Returns:
None: This method returns nothing.
"""
raw = self.adapter.read_raw_rows(self.connection, self.mapping_date)
print(f'read_raw_rows returned {len(raw)} rows with {len(raw.columns)} columns')
print()
summary = self.adapter.run(self.mapping_date)
print()
for key in summary:
print(f'{key}: {summary[key]}')
print()
print('Statements sent inside the write transaction:')
for sql, parameters in self.connection.statements:
print(f' {sql[:60]}...')
if isinstance(parameters, list):
for row in parameters:
print(f' {self.short_row(row)}')
else:
print(f' {parameters}')
print()
try:
UnnamedAdapter()
except NotImplementedError as error:
print(f'NotImplementedError: {error}')
if __name__ == '__main__':
MappingOneDayExample().run()
It prints:
read_raw_rows returned 4 rows with 17 columns
dhan: 3/4 raw row(s) classified, 1 uncategorised, 3 segment membership(s), 3 distinct instrument(s), 1 error(s).
broker: dhan
mapping_date: 2026-09-28
raw_rows: 4
matched: 3
uncategorised: 1
memberships: 3
instruments_upserted: 3
errors: [('nse_equity_index_options', "could not convert string to float: 'not a number'")]
Statements sent inside the write transaction:
DELETE FROM unified.broker_mappings WHERE broker = :b AND ma...
{'b': 'dhan', 'd': datetime.date(2026, 9, 28)}
INSERT INTO unified.instruments AS master (instrument_id, ex...
{'instrument_id': '8fdcd38e', 'exchange': 'unknown', 'segment': 'uncategorised', 'shape': 'security', 'symbol': '990001'}
{'instrument_id': 'f310e1b0', 'exchange': 'nse', 'segment': 'nse_equities', 'shape': 'security', 'symbol': 'INFY'}
{'instrument_id': 'fe85c704', 'exchange': 'nse', 'segment': 'nse_equity_index_options', 'shape': 'option', 'underlying_symbol': 'NIFTY', 'strike_price': 25000.0, 'option_type': 'CE'}
INSERT INTO unified.broker_mappings (instrument_id, broker, ...
{'instrument_id': '8fdcd38e', 'broker': 'dhan', 'broker_token': '990001', 'broker_symbol': 'TEST SPREAD', 'lot_size': 1.0, 'tick_size': 5.0, 'attributes': '{"display_name": "TEST SPREAD", "instrument_type": "SP"}'}
{'instrument_id': 'f310e1b0', 'broker': 'dhan', 'broker_token': '1594', 'broker_symbol': 'INFOSYS LIMITED', 'lot_size': 1.0, 'tick_size': 0.05, 'attributes': '{"display_name": "INFOSYS LIMITED", "instrument_type": "ES"}'}
{'instrument_id': 'fe85c704', 'broker': 'dhan', 'broker_token': '40215', 'broker_symbol': 'NIFTY-Oct2026-25000-CE', 'lot_size': 1.0, 'tick_size': 0.05, 'attributes': '{"display_name": "NIFTY-Oct2026-25000-CE", "instrument_type": "OP"}'}
NotImplementedError: UnnamedAdapter must set BROKER_NAME
Methods:
| Name | Description |
|---|---|
run |
Map IND Money's raw rows for one date, precomputing the two exchange traded fund allowlists, the shared security identifier map and the NSE index lookup first. |
resolved_isin |
Read a row's ISIN, falling back to the cross-broker security identifier map where the row carries none. |
classify |
Classify a raw row, running the rules first and then the three code-driven checks for the segments no rule can express. |
to_identity |
Build the unified identity fields, extracting derivative underlyings from the trading symbol and resolving index names. |
uncategorised_exchange |
Determine the canonical exchange for an unmatched IND Money row from its exchange column. |
segment_config |
Look up one segment configuration by its segment value. |
classify_extra |
Return additional segments this raw row also belongs to beyond the primary match. |
to_broker_fields |
Build the per-broker mapping fields for a raw row under one segment. |
read_raw_rows |
Read the broker's raw rows for one date. |
Source code in stock_brokers/instruments/mapping/base.py
run
¶
Map IND Money's raw rows for one date, precomputing the two exchange traded fund allowlists, the shared security identifier map and the NSE index lookup first.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mapping_date
|
date
|
The raw snapshot date to map. |
required |
Returns:
| Type | Description |
|---|---|
dict
|
The run summary, as described in BrokerMappingAdapter.run. |
Example usage
Source code in stock_brokers/instruments/mapping/indmoney.py
_field
¶
Read one raw column value, adding IND Money's two broker-local transforms to the shared ones.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_row
|
dict
|
One raw row from indmoney.instruments. |
required |
specification
|
str | dict
|
A bare column name, or a {column, transform} pair. |
required |
Returns:
| Type | Description |
|---|---|
Any
|
The resolved value. |
Source code in stock_brokers/instruments/mapping/indmoney.py
resolved_isin
¶
Read a row's ISIN, falling back to the cross-broker security identifier map where the row carries none.
IND Money only began publishing an ISIN column on 2026-09-02, so every earlier snapshot needs the fallback. Without it the bond segments cannot be identified at all on those dates and their rows land in the uncategorised buckets.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_row
|
dict
|
One raw row from indmoney.instruments. |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
The ISIN, or None when neither the row nor the shared map has one. |
Example usage
print(f' ISIN on the row {raw_row["isin"]}, resolved {self.adapter.resolved_isin(raw_row)}')
Source code in stock_brokers/instruments/mapping/indmoney.py
classify
¶
Classify a raw row, running the rules first and then the three code-driven checks for the segments no rule can express.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_row
|
dict
|
One raw row from indmoney.instruments. |
required |
Returns:
| Type | Description |
|---|---|
dict | None
|
The matched segment configuration, or None when no segment matches. |
Example usage
segment_configuration = self.adapter.classify(raw_row)
Source code in stock_brokers/instruments/mapping/indmoney.py
_classify_index
¶
Classify an index row, which IND Money marks only by putting the index's own name in its segment column.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_row
|
dict
|
One raw row from indmoney.instruments. |
required |
Returns:
| Type | Description |
|---|---|
dict | None
|
The index segment configuration, or None when the row is not an index. |
Source code in stock_brokers/instruments/mapping/indmoney.py
_classify_exchange_traded_fund
¶
Classify a row the other brokers independently confirm to be an exchange traded fund.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_row
|
dict
|
One raw row from indmoney.instruments. |
required |
Returns:
| Type | Description |
|---|---|
dict | None
|
The exchange traded fund segment configuration, or None. |
Source code in stock_brokers/instruments/mapping/indmoney.py
_classify_fixed_income
¶
Classify a bond row by instrument type and series, requiring a real non-fund ISIN.
The identity for both fixed income segments is the ISIN itself, so a row without a usable one cannot be identified and is left for the uncategorised bucket.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_row
|
dict
|
One raw row from indmoney.instruments. |
required |
Returns:
| Type | Description |
|---|---|
dict | None
|
The fixed income segment configuration, or None. |
Source code in stock_brokers/instruments/mapping/indmoney.py
to_identity
¶
Build the unified identity fields, extracting derivative underlyings from the trading symbol and resolving index names.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_row
|
dict
|
One raw row from indmoney.instruments. |
required |
segment_configuration
|
dict
|
The segment configuration whose identity mapping applies. |
required |
Returns:
| Type | Description |
|---|---|
dict
|
Identity fields, as described in BrokerMappingAdapter.to_identity. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If an expiry value is numeric with no explicit transform. |
Example usage
identity = self.adapter.to_identity(raw_row, segment_configuration)
Source code in stock_brokers/instruments/mapping/indmoney.py
uncategorised_exchange
¶
Determine the canonical exchange for an unmatched IND Money row from its exchange column.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_row
|
dict
|
One raw row from indmoney.instruments. |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
"nse" or "bse", or None for any other value. |
Example usage
exchange = self.adapter.uncategorised_exchange(raw_row)
Source code in stock_brokers/instruments/mapping/indmoney.py
_validate_config
¶
Check the loaded rules file against the canonical vocabulary.
Every configured segment must be a real canonical segment for its exchange with the matching shape, and the segments must appear in the canonical asset-class order. The file must end with the unprefixed uncategorised entry on exchange unknown, which is the guaranteed fallback for rows whose exchange cannot be determined; unknown is allowed for that one entry only, since it is the value stored in the exchange column of the mapped table for those rows.
Raises:
| Type | Description |
|---|---|
ValueError
|
On any invalid exchange, segment, shape, ordering, or missing or misplaced uncategorised entry. |
Source code in stock_brokers/instruments/mapping/base.py
segment_config
¶
Look up one segment configuration by its segment value.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
segment
|
str
|
The exchange-prefixed segment value, for example "nse_equities". |
required |
Returns:
| Type | Description |
|---|---|
dict | None
|
The matching segment configuration, or None when the rules file does not configure that segment. |
Example usage
segment_configuration = self.adapter.segment_config('uncategorised')
print(f'Unconfigured segment: {self.adapter.segment_config("nse_bonds")}')
Source code in stock_brokers/instruments/mapping/base.py
_row_matches
¶
Check one equality rule against a raw row.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_row
|
dict
|
One raw row from the broker's instrument table. |
required |
match
|
dict
|
Mapping of raw column name to expected value, or to a list of acceptable values. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True when every column in the rule matches. |
Source code in stock_brokers/instruments/mapping/base.py
classify_extra
¶
Return additional segments this raw row also belongs to beyond the primary match.
A raw row can genuinely satisfy more than one segment's criteria at once — a BSE row can be both a real equity and a crossref-confirmed exchange traded fund. The base implementation returns an empty list; the brokers where dual membership was confirmed override it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_row
|
dict
|
One raw row from the broker's instrument table. |
required |
Returns:
| Type | Description |
|---|---|
list[dict]
|
Additional segment configurations, empty by default. |
Example usage
Source code in stock_brokers/instruments/mapping/base.py
to_broker_fields
¶
Build the per-broker mapping fields for a raw row under one segment.
A tick size of zero or below is stored as None, because no instrument moves in steps of nothing, and brokers send 0 or -1 where they have no tick for a row.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_row
|
dict
|
One raw row from the broker's instrument table. |
required |
segment_configuration
|
dict
|
The segment configuration whose broker field mapping applies. |
required |
Returns:
| Type | Description |
|---|---|
dict
|
Keys "broker_token" (str), "broker_symbol", "order_symbol", "lot_size" (float | None), and "tick_size" (float | None). |
Example usage
broker_fields = self.adapter.to_broker_fields(raw_row, segment_configuration)
Source code in stock_brokers/instruments/mapping/base.py
_uncategorised_config
¶
Pick the uncategorised segment configuration a row falls back into.
The per-exchange bucket is used when uncategorised_exchange resolves an exchange and the rules file configures that bucket; otherwise the unprefixed uncategorised entry is used.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw_row
|
dict
|
One raw row from the broker's instrument table. |
required |
Returns:
| Type | Description |
|---|---|
dict
|
The uncategorised segment configuration. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If no uncategorised configuration exists, which validation should have made impossible. |
Source code in stock_brokers/instruments/mapping/base.py
read_raw_rows
¶
Read the broker's raw rows for one date.
Adapters override this where the fetch itself is part of the mapping: to impose a row order the classification depends on, or to bring in rows the plain query would miss.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
connection
|
Connection
|
An open SQLAlchemy connection. |
required |
mapping_date
|
date
|
The raw snapshot date to read. |
required |
Returns:
| Type | Description |
|---|---|
DataFrame
|
The raw rows for that date. |
Example usage
raw = self.adapter.read_raw_rows(self.connection, self.mapping_date)
Source code in stock_brokers/instruments/mapping/base.py
_write_results
¶
Write one run's rows into the mapped tables inside a single transaction.
Both row lists are sorted by instrument id before the insert so concurrent writers acquire locks in the same order, which is the standard fix for the multi-writer deadlock this design was observed hitting when adapters ran in parallel.
The seen dates widen rather than overwrite, so they do not depend on the order dates happen to be mapped in. Assigning the run's date to the last seen date directly looks equivalent while a backfill walks forward, and is wrong the moment any older date is mapped afterwards: it drags the last seen date backwards, and leaves the first seen date frozen at whichever date was written first rather than the earliest the instrument was actually seen.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
instrument_rows
|
dict
|
Mapping of instrument id to the unified.instruments row. |
required |
broker_rows
|
dict
|
Mapping of instrument id to the unified.broker_mappings row. |
required |
mapping_date
|
date
|
The mapping date the run covers. |
required |
Source code in stock_brokers/instruments/mapping/base.py
normalize_index_name
¶
Reduce an index name to uppercase alphanumerics so that differently-spelled broker names collide.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The raw index name. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The name with every non-alphanumeric character removed and uppercased. |
Source code in stock_brokers/instruments/mapping/indmoney.py
isin_valid_non_fund
¶
Check whether a row carries a usable ISIN that is not a fund-family ISIN.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
isin
|
str | None
|
The raw ISIN value from an IND Money row. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True when the ISIN is present and does not start with "INF". |
Source code in stock_brokers/instruments/mapping/indmoney.py
isin_is_fund
¶
Check whether an ISIN belongs to the fund family, which India marks with an INF prefix.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
isin
|
str | None
|
The raw ISIN value from an IND Money row. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True when the ISIN is present and starts with "INF". |