Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions khata/adapters/dhan/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)]

Expand All @@ -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)
57 changes: 57 additions & 0 deletions khata/adapters/dhan/fees.py
Original file line number Diff line number Diff line change
@@ -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
74 changes: 68 additions & 6 deletions khata/adapters/dhan/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"PUT": OptionType.PE,
"PE": OptionType.PE,
"": None,
"NA": None,
None: None,
}

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions tests/fixtures/dhan/trades_today_unsettled.json
Original file line number Diff line number Diff line change
@@ -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
}
]
107 changes: 107 additions & 0 deletions tests/test_dhan_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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