← Backtest Brewery
About

Transaction Costs Are a Black Hole

§ 1 Been a Few Days

Heads-down on microstructure strategies for a while now. Genuinely fun, genuinely frustrating - starting from scratch with no playbook, no mentor, just market data and a suspicion that there's something extractable at the tick level. Found a family of trigger-happy setups that do not lose roughly 90% of the time. Sharpes in double digits. Looked great until I remembered there are three states, not two.

§ 2 The Three-State Problem

Most people think in terms of win and loss. At the microstructure level there's a third state: the draw. A draw is when you correctly predict a 1-tick directional move - the model is right - but the spread width equals that 1 tick. You beat the spread by exactly nothing. Gross P&L: zero.

Win
45%
Correct prediction, move > 1 tick. Gross P&L positive.
Draw
45%
Correct prediction, move = 1 tick. Spread eats it. Gross P&L zero.
Loss
10%
Wrong prediction. Gross P&L negative.

On paper that 45/45/10 split looks fine - you're right 90% of the time. In gross terms you end up flat on half your trades. The real kick in the teeth comes when you layer transaction costs on top. Draws become losses. Some wins become draws. The 65% that looked like acceptable churn is now net-negative territory. The strategy that didn't lose is now definitely losing.

§ 3 What the Cost Actually Is on NSE

The US version of this problem involves a broker fee and an exchange liquidity removal fee. India's version is structurally different and, at the tick level, considerably more hostile. There are six separate charges stacked on every round trip, and three of them are levied by the government regardless of whether the trade is profitable.

Take a liquid large-cap on NSE at ₹500/share, 1,000 shares, intraday. Tick size is ₹0.05. Here's where the money actually goes:

Charge Side Rate ₹ / share Ticks
STT Sell only 0.025% 0.1250 2.50
Stamp Duty Buy only 0.015% 0.0750 1.50
NSE Exchange Txn Both 0.00297% each 0.0297 0.59
SEBI Turnover Fee Both 0.0001% each 0.0010 0.02
NSE IPFT Both 0.0001% each 0.0010 0.02
GST (18%) On exchange + SEBI + IPFT - 0.0056 0.11
Brokerage Both ₹20 flat / order 0.0400 0.80
Round-trip total ₹0.2773 5.55 ticks

The draw zone on NSE isn't 1 tick wide. It's 5.5 ticks wide. You need to capture at least a ₹0.28/share move just to break even. For a ₹500 stock that's a 0.056% move per trade - on a strategy designed to trade 1–2 tick setups. The math collapses before you've written a single line of strategy logic.

STT is the largest single line item

STT alone - 0.025% on sell-side turnover - costs 2.5 ticks per round trip. Stamp Duty on the buy side costs another 1.5. Together, those two government levies account for 4 of your 5.5-tick break-even before the exchange has taken a rupee. There's no way to negotiate or route around them.

§ 4 Futures Are No Escape

The knee-jerk response is to switch to Nifty futures where tick size is 0.5 points and contract value is larger - costs get diluted, right? Partially. But Budget 2026 had other ideas. From April 1st, STT on futures went from 0.02% to 0.05% on the sell side. A 150% jump.

On a single Nifty lot (75 units, ~₹24,000 index level, contract value ≈ ₹18 lakh), the round-trip cost breaks down roughly as:

Charge ₹ / lot
STT (0.05% on sell, Budget 2026) 900
Stamp Duty (0.002% on buy) 36
NSE Exchange Txn (0.00173% × 2) 62
SEBI + IPFT + GST 19
Brokerage (₹20 × 2) 40
Round-trip total ≈ ₹1,057

Each Nifty point is worth ₹75. Break-even on a futures round trip is ~14 Nifty points. Scalping a 1–2 point move on Nifty futures isn't a strategy, it's a donation. The 3× STT hike on futures this year essentially killed the tick-scalp trade at the retail level.

break-even (ticks) = total_round_trip_cost / (tick_size × lot_size) Eq. 1 - Minimum price move required for net-zero P&L

§ 5 The Rejected Jump Shot

Here's what's actually happening in the strategy. The model correctly identifies a 1-tick directional move. Price ticks up. But within the same bar - sometimes within milliseconds - mean reversion kicks in. Everyone who was waiting on the first uptick jumps on it, sells into the move, and the price is knocked straight back down. In basketball terms: a clean read of the defence, a good jump shot, rejected at the rim by the shot blocker who was already positioned.

The gross P&L on a rejected jump shot is zero - the model predicted correctly. The net P&L after 5.5 ticks of transaction cost is deeply negative. And because these rejected shots happen roughly 45% of the time, the cost bleeds are relentless. The strategy fails not because the signal is wrong but because statutory costs alone are wide enough to swallow the available profit on the correctly predicted move.

§ 6 What I Am Not Sure About

Whether any tick-level equity strategy survives India's cost structure. The STT + stamp duty combination is genuinely punishing relative to the available profit on a 1–3 tick move. I've not found published evidence of retail participants profitably scalping NSE equities at this frequency. That absence might mean it's impossible, or it might mean nobody who can do it is writing about it.

Whether the classifier problem is tractable. The difference between a held move and a rejected one may not be cleanly separable from publicly available data. If the rejected shots are driven by latency-advantaged participants reacting faster than any signal can capture, filtering for them from the retail data feed might be fundamentally underdetermined.

Whether futures make sense post-Budget 2026. The STT hike to 0.05% substantially narrows the window for intraday futures strategies. Options have their own cost dynamics - options STT is now 0.15% on premium, up from 0.10% - and the tick structure is non-linear. Neither segment is obviously better than equity cash for a tick-level approach. The search continues.

...the expedition continues.

§ A Appendix - Cost Calculator (Python)

The numbers in this post come from a small utility I keep updated whenever SEBI or the exchanges revise their fee schedules. Rates below reflect the post-Budget 2026 regime (effective April 1, 2026). Drop it into any backtest loop and call it on each round trip to get an honest cost figure rather than a hand-wavy estimate.

python - transaction_costs.py · rates current as of 01-04-2026# =============================================================================
# transaction_costs.py
#
# Statutory + exchange transaction cost calculator for Indian markets (NSE).
# Covers equity cash (delivery & intraday), equity futures, and equity options.
#
# Key rate changes since the previous version (04-02-2025):
#   • STT on futures sell side:   0.02% → 0.05%   [Budget 2026, Apr 1 2026]
#   • STT on options sell side:   0.10% → 0.15%   [Budget 2026, Apr 1 2026]
#   • NSE IPFT on options:        ₹0.01/cr → ₹50/cr (0.000001% → 0.000005%)
#
# Usage:
#   buy_ch  = calculate_transaction_charges('buy',  segment, buy_value,  0)
#   sell_ch = calculate_transaction_charges('sell', segment, 0, sell_value)
#   total   = sum(buy_ch.values()) + sum(sell_ch.values())
# =============================================================================


def calculate_transaction_charges(trade_type, segment, buy_value, sale_value,
                                   trade_category='intraday'):
    """
    Return a dict of itemised statutory charges for one side of a trade.

    Parameters
    ----------
    trade_type     : 'buy' or 'sell'
    segment        : 'equity', 'futures', or 'options'
    buy_value      : total buy-side transaction value in ₹ (pass 0 on sell calls)
    sale_value     : total sell-side transaction value in ₹ (pass 0 on buy calls)
    trade_category : 'intraday' or 'delivery' - relevant only for equity segment

    Returns
    -------
    dict  - keys: exchange_txn_charge, stt, stamp_duty, sebi_charge,
                  ipf_charge, gst
    """
    charges = {}

    # Total turnover for this call - used by charges levied on both sides
    turnover = buy_value + sale_value

    # ── 1. NSE Exchange Transaction Charge ───────────────────────────────────
    # Flat uniform rate per NSE circular (Oct 1, 2024 "true-to-label" mandate).
    #   Equity cash:   ₹2.97  per lakh each side  → 0.00297%
    #   Futures:       ₹1.73  per lakh each side  → 0.00173%
    #   Options:       ₹35.03 per lakh of premium → 0.03503%
    if segment == 'equity':
        charges['exchange_txn_charge'] = turnover * 0.0000297
    elif segment == 'futures':
        charges['exchange_txn_charge'] = turnover * 0.0000173
    elif segment == 'options':
        charges['exchange_txn_charge'] = turnover * 0.0003503   # applied on premium value

    # ── 2. Securities Transaction Tax (STT) ──────────────────────────────────
    # Government levy - no routing around it.
    #
    # Equity delivery:  0.1% on BOTH buy and sell  (unchanged)
    # Equity intraday:  0.025% on sell side only   (unchanged)
    # Futures sell:     0.05%  ← raised from 0.02% by Budget 2026
    # Options sell:     0.15% on premium ← raised from 0.10% by Budget 2026
    if trade_type == 'sell':
        if segment == 'equity':
            if trade_category == 'intraday':
                charges['stt'] = sale_value * 0.00025   # 0.025% sell-side intraday
            else:
                charges['stt'] = sale_value * 0.001     # 0.1% sell-side delivery
        elif segment == 'futures':
            charges['stt'] = sale_value * 0.0005        # 0.05% - Budget 2026 rate
        elif segment == 'options':
            charges['stt'] = sale_value * 0.0015        # 0.15% on premium - Budget 2026
    elif trade_type == 'buy':
        if segment == 'equity' and trade_category == 'delivery':
            charges['stt'] = buy_value * 0.001          # 0.1% buy-side delivery
        else:
            charges['stt'] = 0                          # no STT on buy for F&O / intraday

    # ── 3. Stamp Duty ─────────────────────────────────────────────────────────
    # Charged on buy side only. Uniform rate across all Indian states since 2020.
    #   Equity:   0.015% on buy value
    #   Futures:  0.002% on buy value
    #   Options:  0.003% on buy value (on premium paid)
    stamp_duty_rates = {'equity': 0.00015, 'futures': 0.00002, 'options': 0.00003}
    if trade_type == 'buy':
        charges['stamp_duty'] = buy_value * stamp_duty_rates.get(segment, 0)
    else:
        charges['stamp_duty'] = 0

    # ── 4. SEBI Turnover Fee ──────────────────────────────────────────────────
    # ₹10 per crore of turnover (both sides) → 0.0001%. Unchanged.
    charges['sebi_charge'] = turnover * 0.000001

    # ── 5. NSE Investor Protection Fund Trust (IPFT) ─────────────────────────
    # Revised per NSE circular:
    #   Equity & Futures:  ₹10  per crore → 0.000001 (0.0001%)  - unchanged
    #   Options:           ₹50  per crore → 0.000005 (0.0005%)  - updated
    if segment == 'options':
        charges['ipf_charge'] = turnover * 0.000005
    else:
        charges['ipf_charge'] = turnover * 0.000001

    # ── 6. GST at 18% ─────────────────────────────────────────────────────────
    # Applied on: Exchange Txn Charge + SEBI Charge + IPFT Charge.
    # Brokerage GST is handled separately by the broker and excluded here.
    gst_base = (charges['exchange_txn_charge']
                + charges['sebi_charge']
                + charges['ipf_charge'])
    charges['gst'] = gst_base * 0.18

    return charges


# =============================================================================
# Example: ₹500 stock, 1,000 shares, NSE equity intraday round trip
# =============================================================================

if __name__ == '__main__':
    buy_price  = 500        # ₹ per share
    sell_price = 500.30     # ₹ per share - capturing ~6 ticks (₹0.05 tick size)
    volume     = 1_000      # shares
    segment    = 'equity'
    category   = 'intraday'

    buy_value  = buy_price  * volume   # ₹ 5,00,000
    sell_value = sell_price * volume   # ₹ 5,00,300

    # Calculate buy-side and sell-side charges independently -
    # this matters because STT and stamp duty apply asymmetrically.
    buy_charges  = calculate_transaction_charges('buy',  segment, buy_value,  0, category)
    sell_charges = calculate_transaction_charges('sell', segment, 0, sell_value, category)

    total_charges = sum(buy_charges.values()) + sum(sell_charges.values())
    gross_pnl     = sell_value - buy_value
    net_pnl       = gross_pnl - total_charges

    # ── per-share cost breakdown ──────────────────────────────────────────────
    cost_per_share    = total_charges / volume
    tick_size         = 0.05
    break_even_ticks  = cost_per_share / tick_size

    print("=" * 58)
    print("  TRANSACTION COST SUMMARY - NSE EQUITY INTRADAY")
    print("=" * 58)
    print(f"  Gross P&L          : ₹{gross_pnl:,.2f}")
    print(f"  Total charges       : ₹{total_charges:,.2f}")
    print(f"  Net P&L             : ₹{net_pnl:,.2f}")
    print(f"  Cost per share      : ₹{cost_per_share:.4f}")
    print(f"  Break-even (ticks)  : {break_even_ticks:.2f}")
    print("-" * 58)
    print("  Buy-side charges:")
    for k, v in buy_charges.items():
        print(f"    {k:<22}: ₹{v:.4f}")
    print("  Sell-side charges:")
    for k, v in sell_charges.items():
        print(f"    {k:<22}: ₹{v:.4f}")
    print("=" * 58)

Running the example above at a 6-tick profit target (₹0.30/share on a ₹500 stock) produces a net positive P&L - but only just. Drop to 5 ticks and the trade is net-zero. Drop to 4 ticks and the statutory charges eat you alive. That's the black hole: it's invisible in gross P&L, relentless in net.