From 412a1c65f8f63bda2fc5bb297d188e79306a5b48 Mon Sep 17 00:00:00 2001 From: Swathi Date: Tue, 21 Apr 2026 00:53:39 +0530 Subject: [PATCH] fix(dhan): symbol parsing + recompute fees for unsettled today rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running against a live account exposed three more shape quirks in Dhan v2's /trades (today) endpoint vs /trades/{from}/{to}/{page} (history): 1. Hyphenated tradingSymbol on today's rows 'NIFTY-Apr2026-24300-PE' was resolving underlying to 'NIFTY-Apr' because the parser only stopped at digits. Now stops at digit, hyphen, or whitespace — handles all three formats observed: concat: NIFTY24APR2624300PE space-sep: NIFTY 21 APR 24300 PUT hyphenated: NIFTY-Apr2026-24300-PE 2. drvOptionType='NA' on today's rows Today's /trades leaves drvOptionType='NA' even when the tradingSymbol clearly ends '-PE' / '-CE'. Added _option_type_from_symbol fallback that reads the suffix. 3. `instrument` field missing entirely on today's rows Every row defaulted to InstrumentType.EQ, which blocked the new option_type fallback and the fee recompute below. Added _infer_instrument_type: explicit field → option markers → FNO segment → EQ. 4. Fees absent on today's rows (not a mapping bug, real data gap) Today's /trades omits fee fields entirely — they settle at EOD. Added khata/adapters/dhan/fees.py with Indian F&O options fee formula (brokerage flat ₹20 or 0.03% whichever lower, STT 0.0625% on SELL, NSE exch 0.03503%, SEBI ₹10/crore, stamp 0.003% on BUY, IPFT 0.0005%, GST 18%). Exposed via DhanAdapter.charges_for, called from fetch_trades when broker-reported fees total zero. Tests: 7 new cases (22 total, all green). Fixtures use synthetic IDs only. Verified against live account: today's 04-20 trade went from fees=₹0.00 to ₹715.32 and underlying 'NIFTY-Apr' to 'NIFTY'. --- khata/adapters/dhan/adapter.py | 18 +++ khata/adapters/dhan/fees.py | 57 ++++++++++ khata/adapters/dhan/mapper.py | 74 +++++++++++- .../fixtures/dhan/trades_today_unsettled.json | 44 +++++++ tests/test_dhan_mapper.py | 107 ++++++++++++++++++ 5 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 khata/adapters/dhan/fees.py create mode 100644 tests/fixtures/dhan/trades_today_unsettled.json diff --git a/khata/adapters/dhan/adapter.py b/khata/adapters/dhan/adapter.py index aac1f53..c1dd5e9 100644 --- a/khata/adapters/dhan/adapter.py +++ b/khata/adapters/dhan/adapter.py @@ -5,11 +5,13 @@ from datetime import UTC, date, datetime, timedelta from khata.adapters.dhan.client import DhanClient +from khata.adapters.dhan.fees import compute_fees from khata.adapters.dhan.mapper import map_trade from khata.core.adapter import ( AuthFlow, BrokerAdapter, CanonicalExecution, + CanonicalFees, CanonicalOrder, CanonicalPosition, Session, @@ -57,6 +59,16 @@ def fetch_trades(self, session: Session, since: datetime) -> list[CanonicalExecu rows.extend(client.get_trades()) executions = [map_trade(r, broker=self.name) for r in rows] + + # Today's /trades endpoint omits fee fields — they finalise at EOD. + # For any execution where fees are all zero, recompute from first + # principles. Historical rows already carry broker-reported fees. + for e in executions: + if e.fees.total_paise == 0: + recomputed = self.charges_for(e) + if recomputed is not None: + e.fees = recomputed + # Filter to `since` precisely (Statement API is date-granular). return [e for e in executions if e.ts >= since.astimezone(UTC)] @@ -67,3 +79,9 @@ def fetch_positions(self, session: Session) -> list[CanonicalPosition]: def fetch_orders(self, session: Session, on_date: date) -> list[CanonicalOrder]: # Stub: order-book mapper lands with the UI work. return [] + + def charges_for(self, execution: CanonicalExecution) -> CanonicalFees | None: + """Recompute Indian F&O charges when broker-reported fees are missing. + Options only for now; futures/equity return None. + """ + return compute_fees(execution) diff --git a/khata/adapters/dhan/fees.py b/khata/adapters/dhan/fees.py new file mode 100644 index 0000000..ca4eff6 --- /dev/null +++ b/khata/adapters/dhan/fees.py @@ -0,0 +1,57 @@ +"""Recompute Indian F&O charges from first principles. + +Used when the broker's response omits fee fields (Dhan's today /trades endpoint +doesn't populate fees until EOD settlement; history rows do). Keeping the +formulas in one place so they're easy to update when regulation shifts. + +Rates as of 2026-04. Sources: Zerodha/Dhan brokerage calculators, NSE +transaction charge circulars, SEBI turnover-fee notification. + +Known limitations: +- Brokerage assumes Dhan's intraday flat ₹20 / executed order. Not correct + for delivery (CNC) trades or for brokers with per-leg pricing. +- NSE transaction charges updated 2024-10 to 0.03503%. Older trades may have + used 0.053%; we apply the current rate uniformly — acceptable drift. +- IPFT rate is nominal (₹0.05 per lakh turnover); we approximate. +""" + +from __future__ import annotations + +from khata.core.adapter import CanonicalExecution, CanonicalFees, InstrumentType, Side + + +def _round_paise(rupees: float) -> int: + return round(rupees * 100) + + +def compute_fno_options_fees(e: CanonicalExecution) -> CanonicalFees: + """Compute standard Indian F&O option charges for one execution.""" + turnover_rs = (e.qty * e.price_paise) / 100 # premium in rupees + + brokerage_rs = min(20.0, turnover_rs * 0.0003) # Dhan intraday F&O flat ₹20 + stt_rs = turnover_rs * 0.000625 if e.side == Side.SELL else 0.0 # SELL side only + exch_txn_rs = turnover_rs * 0.0003503 # NSE options, post-Oct-2024 + sebi_rs = turnover_rs * 10 / 1_00_00_000 # ₹10 per crore + stamp_rs = turnover_rs * 0.00003 if e.side == Side.BUY else 0.0 # 0.003% BUY only + ipft_rs = turnover_rs * 0.000005 # NSE IPFT + + gst_rs = (brokerage_rs + exch_txn_rs + sebi_rs + ipft_rs) * 0.18 + + return CanonicalFees( + brokerage_paise=_round_paise(brokerage_rs), + stt_paise=_round_paise(stt_rs), + exch_txn_paise=_round_paise(exch_txn_rs), + sebi_paise=_round_paise(sebi_rs), + stamp_paise=_round_paise(stamp_rs), + gst_paise=_round_paise(gst_rs), + ipft_paise=_round_paise(ipft_rs), + ) + + +def compute_fees(e: CanonicalExecution) -> CanonicalFees | None: + """Dispatch by instrument type. Returns None for types we can't price yet.""" + if e.instrument_type == InstrumentType.OPT: + return compute_fno_options_fees(e) + # FUT and EQ formulas differ; leave as a follow-up. We won't pollute the + # trade with wrong numbers in the meantime. + return None diff --git a/khata/adapters/dhan/mapper.py b/khata/adapters/dhan/mapper.py index 481b454..3ea7484 100644 --- a/khata/adapters/dhan/mapper.py +++ b/khata/adapters/dhan/mapper.py @@ -60,6 +60,7 @@ "PUT": OptionType.PE, "PE": OptionType.PE, "": None, + "NA": None, None: None, } @@ -87,23 +88,77 @@ def _parse_date(raw: str | None) -> date | None: def _underlying_from_symbol(trading_symbol: str | None, custom_symbol: str | None) -> str | None: - """Best-effort underlying extraction. - Handles both 'NIFTY25APR25350CE' and 'NIFTY 21 APR 24300 PUT'. + """Extract the underlying from a Dhan symbol. + + Three observed formats in the wild: + - 'NIFTY24APR2624300PE' (concat, history equity/options) + - 'NIFTY 21 APR 24300 PUT' (space-separated, history options customSymbol) + - 'NIFTY-Apr2026-24300-PE' (hyphenated, today's /trades tradingSymbol) + + The underlying is always the first token. Stop at first digit, hyphen, or + whitespace. """ src = custom_symbol or trading_symbol or "" for i, ch in enumerate(src): - if ch.isdigit(): + if ch.isdigit() or ch in "- \t": return src[:i].strip() or None return src.strip() or None +def _option_type_from_symbol( + trading_symbol: str | None, custom_symbol: str | None +) -> OptionType | None: + """Fallback option-type parser when drvOptionType is 'NA' or missing. + + Checks the symbol tail for '-CE'/'-PE'/'CE'/'PE'/'CALL'/'PUT'. + """ + src = (custom_symbol or trading_symbol or "").upper().strip() + if not src: + return None + for suffix, ot in ( + ("-CE", OptionType.CE), + ("-PE", OptionType.PE), + (" CALL", OptionType.CE), + (" PUT", OptionType.PE), + ("CE", OptionType.CE), + ("PE", OptionType.PE), + ): + if src.endswith(suffix): + return ot + return None + + +def _infer_instrument_type(row: dict) -> InstrumentType: + """Today's /trades omits the `instrument` field. Infer it. + + Priority: explicit `instrument` → option markers → FNO segment fallback → EQ. + """ + explicit = row.get("instrument") or "" + if explicit in _INSTRUMENT_TO_CANONICAL: + return _INSTRUMENT_TO_CANONICAL[explicit] + + segment = (row.get("exchangeSegment") or "").upper() + + has_option_markers = ( + row.get("drvStrikePrice") not in (None, 0) + or (row.get("drvOptionType") or "").upper() in ("CALL", "PUT", "CE", "PE") + or _option_type_from_symbol(row.get("tradingSymbol"), row.get("customSymbol")) is not None + ) + + if has_option_markers: + return InstrumentType.OPT + if "FNO" in segment or "CURRENCY" in segment or "COMM" in segment: + # FNO segment without option markers → futures + return InstrumentType.FUT + return InstrumentType.EQ + + def map_trade(row: dict, broker: str = "dhan") -> CanonicalExecution: """Map one Dhan trade-book row to a CanonicalExecution.""" segment = row.get("exchangeSegment") or "" exchange = _SEGMENT_TO_EXCHANGE.get(segment, segment.split("_")[0] or "NSE") - instrument_raw = row.get("instrument") or "" - instrument_type = _INSTRUMENT_TO_CANONICAL.get(instrument_raw, InstrumentType.EQ) + instrument_type = _infer_instrument_type(row) side = Side.BUY if (row.get("transactionType") or "").upper() == "BUY" else Side.SELL @@ -136,7 +191,14 @@ def map_trade(row: dict, broker: str = "dhan") -> CanonicalExecution: exchange=exchange, segment=segment or exchange, instrument_type=instrument_type, - option_type=_OPTION_TYPE.get(row.get("drvOptionType")), + option_type=( + _OPTION_TYPE.get(row.get("drvOptionType")) + or ( + _option_type_from_symbol(row.get("tradingSymbol"), row.get("customSymbol")) + if instrument_type == InstrumentType.OPT + else None + ) + ), strike_paise=strike_paise, expiry=_parse_date(row.get("drvExpiryDate")), side=side, diff --git a/tests/fixtures/dhan/trades_today_unsettled.json b/tests/fixtures/dhan/trades_today_unsettled.json new file mode 100644 index 0000000..0bc5ca8 --- /dev/null +++ b/tests/fixtures/dhan/trades_today_unsettled.json @@ -0,0 +1,44 @@ +[ + { + "dhanClientId": "TEST_CLIENT_001", + "orderId": "TEST_ORDER_U0001", + "exchangeOrderId": "TEST_EXCH_ORDER_U0001", + "exchangeTradeId": "TEST_EXCH_TRADE_U0001", + "transactionType": "BUY", + "exchangeSegment": "NSE_FNO", + "productType": "INTRADAY", + "orderType": "MARKET", + "tradingSymbol": "NIFTY-Apr2026-24300-PE", + "customSymbol": null, + "securityId": "00000", + "tradedQuantity": 195, + "tradedPrice": 100.2, + "createTime": "2026-04-20 10:26:00", + "updateTime": "2026-04-20 10:26:01", + "exchangeTime": "2026-04-20 10:26:01", + "drvExpiryDate": "2026-04-21", + "drvOptionType": "NA", + "drvStrikePrice": 24300.0 + }, + { + "dhanClientId": "TEST_CLIENT_001", + "orderId": "TEST_ORDER_U0002", + "exchangeOrderId": "TEST_EXCH_ORDER_U0002", + "exchangeTradeId": "TEST_EXCH_TRADE_U0002", + "transactionType": "SELL", + "exchangeSegment": "NSE_FNO", + "productType": "INTRADAY", + "orderType": "MARKET", + "tradingSymbol": "NIFTY-Apr2026-24300-PE", + "customSymbol": null, + "securityId": "00000", + "tradedQuantity": 195, + "tradedPrice": 88.0, + "createTime": "2026-04-20 11:00:00", + "updateTime": "2026-04-20 11:00:01", + "exchangeTime": "2026-04-20 11:00:01", + "drvExpiryDate": "2026-04-21", + "drvOptionType": "NA", + "drvStrikePrice": 24300.0 + } +] diff --git a/tests/test_dhan_mapper.py b/tests/test_dhan_mapper.py index fffdb54..53a8398 100644 --- a/tests/test_dhan_mapper.py +++ b/tests/test_dhan_mapper.py @@ -121,3 +121,110 @@ def test_mapper_handles_missing_na_exchange_time(): row["createTime"] = "NA" e = map_trade(row) assert e.ts.tzinfo is UTC # defaulted to now, no exception + + +# ── unsettled today rows: hyphenated tradingSymbol + 'NA' drvOptionType + no fees ── +def test_underlying_handles_hyphenated_tradingSymbol(): + """Today's /trades returns 'NIFTY-Apr2026-24300-PE' with null customSymbol. + Extractor must stop at first hyphen, not at 'Apr'.""" + row = _load("trades_today_unsettled.json")[0] + assert row["tradingSymbol"] == "NIFTY-Apr2026-24300-PE" + assert row["customSymbol"] is None + e = map_trade(row) + assert e.underlying == "NIFTY" + + +def test_option_type_falls_back_to_symbol_when_drv_is_na(): + """drvOptionType='NA' is common on today's rows. Parser must read the + '-PE' suffix off tradingSymbol instead.""" + row = _load("trades_today_unsettled.json")[0] + assert row["drvOptionType"] == "NA" + e = map_trade(row) + assert e.option_type == OptionType.PE + + +def test_option_type_still_preferred_from_drv_when_valid(): + """Don't regress the primary path: if drvOptionType is populated, use it.""" + row = _load("trades_today.json")[0] + assert row["drvOptionType"] == "PUT" + e = map_trade(row) + assert e.option_type == OptionType.PE + + +def test_unsettled_row_maps_with_zero_fees(): + """The mapper doesn't invent fees. Recompute happens at adapter layer.""" + row = _load("trades_today_unsettled.json")[0] + e = map_trade(row) + assert e.fees.total_paise == 0 + + +# ── charges_for: Indian F&O fee recomputation ───────────────────────── +def test_charges_for_computes_standard_fno_fees(): + from khata.adapters.dhan.fees import compute_fno_options_fees + + row = _load("trades_today_unsettled.json")[0] # BUY 195 @ ₹100.20 + e = map_trade(row) + fees = compute_fno_options_fees(e) + + # Turnover = 195 * 100.20 = ₹19,539 + # Brokerage = min(₹20, 19539 * 0.0003) = min(20, 5.86) = ₹5.86 + assert 500 <= fees.brokerage_paise <= 600 # ~₹5.86 = 586 paise + # STT BUY side only for options = 0 + assert fees.stt_paise == 0 + # Stamp duty on BUY = 0.003% of 19539 = ~₹0.59 + assert 50 <= fees.stamp_paise <= 70 + # Exchange txn = 0.03503% of 19539 = ~₹6.84 + assert 650 <= fees.exch_txn_paise <= 750 + # Total > 0 + assert fees.total_paise > 0 + + +def test_charges_for_sell_adds_stt(): + from khata.adapters.dhan.fees import compute_fno_options_fees + + row = _load("trades_today_unsettled.json")[1] # SELL 195 @ ₹88 + e = map_trade(row) + fees = compute_fno_options_fees(e) + + # Turnover = 195 * 88 = ₹17,160 + # STT SELL side = 0.0625% of 17160 = ~₹10.73 + assert 1050 <= fees.stt_paise <= 1100 + # Stamp duty SELL side = 0 + assert fees.stamp_paise == 0 + + +def test_charges_for_returns_none_for_non_options(): + """Futures and equity formulas differ — return None rather than pollute.""" + from khata.adapters.dhan.fees import compute_fees + from khata.core.adapter import ( + CanonicalExecution, + CanonicalFees, + ) + from khata.core.adapter import ( + InstrumentType as IT, + ) + from khata.core.adapter import ( + Side as S, + ) + + eq_exec = CanonicalExecution( + broker="dhan", + broker_trade_id="x", + broker_order_id="x", + symbol="RELIANCE", + underlying="RELIANCE", + exchange="NSE", + segment="NSE_EQ", + instrument_type=IT.EQ, + option_type=None, + strike_paise=None, + expiry=None, + side=S.BUY, + qty=10, + price_paise=300000, + ts=datetime(2026, 4, 20, tzinfo=UTC), + product_type="CNC", + fees=CanonicalFees(), + raw={}, + ) + assert compute_fees(eq_exec) is None