DEV Community

shakti tiwari
shakti tiwari

Posted on Originally published at dev.to

Transaction Cost Modelling for Options Backtests: Spread as a State Variable

Transaction Cost Modelling for Options Backtests: Spread as a State Variable

transaction cost modelling options backtest — spread as state variable

By Shakti Tiwari · 2026-08-18 · Educational only · Not investment advice

The single most common way a backtest lies to its author is not the model — it is the friction. Almost every options strategy that looks profitable on paper dies in live trading because the cost of doing the trade was assumed to be a constant. A fixed per-contract commission, a flat half-spread of a few ticks, a single number typed into a spreadsheet. That assumption feels conservative. It is not. It is structurally wrong in the direction that flatters the strategy, because option spreads are not constant — they are a state variable. They widen and compress as a function of moneyness, time to expiry, volatility regime, liquidity, and the minute of the session. If your backtest treats transaction cost as a number rather than a function of state, you are not modelling the strategy you think you are trading.

This article builds a complete, reusable transaction cost model where the bid-ask spread is a function of observable state. It is structural and code-first: no live market levels are quoted, every magnitude is a parameter you set from the official exchange circular or your broker sheet, and the formulas are the point. You should verify the current regulatory rates (STT, exchange charges, stamp duty) on the NSE and SEBI circulars before coding them, because those are notified constants that change and are not something to hardcode blind.

Why a fixed cost number is a systematic error

A backtest is a simulation of decisions. A decision to enter or exit an option position at a quoted mid-price is, in reality, a decision to cross the spread. You buy at the ask, you sell at the bid. The mid is a fiction you never actually transact at. The distance between bid and ask is the first and largest component of cost for a retail or semi-institutional options book, and it is the component most badly mis-modelled.

Consider the decomposition of a round-trip cost for one lot of an index option:

total_cost = spread_cost + commission + exchange_fees + stt + stamp_duty + slippage
Enter fullscreen mode Exit fullscreen mode

The naive backtest sets spread_cost = 2 * fixed_half_spread * lot_size, where fixed_half_spread is something like a small constant in points. The error is twofold. First, the constant is usually calibrated to a liquid at-the-money line during a calm mid-session window — the single best-case execution environment. Second, and more damaging, it is applied uniformly across strikes and expiries that behave nothing like that reference line.

Far out-of-the-money weekly options routinely trade at premiums measured in a fraction of a point, while their bid-ask spread may be a substantial fraction of that premium. A line quoted at 0.50 with a 0.50 spread means your round-trip cost is 100 percent of the premium before you have earned a single rupee of edge. A fixed-spread assumption of, say, 0.05 points would report this trade as nearly frictionless. The backtest would "take" the trade; the live account would bleed out on the spread alone. The cost model, not the signal, is the strategy.

This is why treating the spread as a state variable is not a refinement — it is the difference between a backtest that predicts live behaviour and one that predicts nothing.

The components of transaction cost, stated as formulas

Let P be the option premium in index points, L the lot size, H the effective half-spread in points (the state variable), and N the number of lots. The round-trip spread cost in index points is simply:

spread_cost_points = 2 * H * N
spread_cost_inr    = 2 * H * L * N
Enter fullscreen mode Exit fullscreen mode

Commissions and exchange fees are typically computed on a per-lot or per-turnover basis. Taxes in the Indian listed-options context are applied to premium turnover. Let r_stt, r_exch, and r_stamp be the notified rates on premium turnover, and let c be the flat broker commission per lot per leg. The round-trip bill becomes:

turnover      = 2 * P * L * N                 # buy leg + sell leg
stt           = r_stt   * P * L * N * 2       # applied on sell premium; shown gross for conservatism
exchange_fee  = r_exch   * turnover
stamp_duty    = r_stamp  * turnover
commission    = c * N * 2
total_inr     = 2*H*L*N + stt + exchange_fee + stamp_duty + commission
Enter fullscreen mode Exit fullscreen mode

Note the asymmetry that matters in practice: STT is levied on the sell side of an option premium, so a strategy that sells options (and therefore has the premium as its notional) pays STT on every exit and on every opening sell, while a buyer pays it only on the sell-to-close. This single structural fact reshapes the economics of short-premium strategies and is invisible to a model that lumps "fees" into one constant.

The critical ratio that determines whether a trade is even worth attempting is the cost as a fraction of the edge or of the premium:

cost_pct_of_premium = total_inr / (P * L * N)
Enter fullscreen mode Exit fullscreen mode

When P is small (far OTM, near expiry) and H is not, this ratio explodes. The state variable H is doing the damage, and P is the amplifier.

Spread as a state variable: the model

We model the effective half-spread H as a multiplicative function of observable state features. Each feature is normalised so the baseline is a liquid at-the-money line in a calm market at mid-session. The functional form is:

H = H_base * f_moneyness * f_dte * f_vol * f_liquidity * f_tod
Enter fullscreen mode Exit fullscreen mode

where:

  • f_moneyness grows as |K/S - 1| grows (further from ATM, wider).
  • f_dte grows as days-to-expiry shrink (thin near expiry).
  • f_vol grows with the volatility-regime rank (stress widens everything).
  • f_liquidity is the inverse of a liquidity rank (illiquid lines widen).
  • f_tod widens near the open and close auctions.

Here is a concrete, runnable implementation. The magnitudes are placeholders you must replace with values calibrated from your own tick data or broker quotes; verify the regulatory rates against the current NSE and SEBI circulars before production use.

import numpy as np
import pandas as pd

# 
# PARAMETERS -- set from the official exchange circular / your broker sheet.
# These are structural constants, not live market levels. Verify before use.
# 
LOT_SIZE            = 50         # index option lot (e.g. Nifty) -- verify on the exchange
STT_RATE            = 0.000625   # notified STT on option premium -- verify current rate on NSE/SEBI circular
EXCHANGE_CHARGE_RATE = 0.00002   # NSE + clearing charge per premium turnover -- verify
STAMP_DUTY_RATE     = 0.00003    # state-dependent stamp duty on premium -- verify
BROKER_COMMISSION   = 20.0       # INR per lot per leg, flat -- from your broker sheet


def round_trip_cost(premium, spread_points, lots, side="both"):
    """Round-trip transaction cost for one position, in INR.

    premium       : option premium in index points
    spread_points : effective half-spread in points (the STATE variable)
    lots          : number of lots
    side          : "both" (default) or "sell_only" to reflect STT-on-sell asymmetry
    """
    notional_per_lot = premium * LOT_SIZE
    spread_cost = 2 * spread_points * LOT_SIZE * lots
    turnover = 2 * notional_per_lot * lots
    # STT is on the sell premium; model gross (both legs) for conservatism by default
    stt_legs = 2 if side == "both" else 1
    stt = STT_RATE * notional_per_lot * lots * stt_legs
    exch = EXCHANGE_CHARGE_RATE * turnover
    stamp = STAMP_DUTY_RATE * turnover
    commission = BROKER_COMMISSION * lots * 2
    total = spread_cost + stt + exch + stamp + commission
    return {
        "spread": spread_cost,
        "stt": stt,
        "exchange": exch,
        "stamp": stamp,
        "commission": commission,
        "total_inr": total,
        "total_points": total / (LOT_SIZE * lots),
        "cost_pct_of_premium": total / (notional_per_lot * lots),
    }


def spread_state(moneyness, dte, regime_vol, liquidity_rank, time_of_day,
                 base=0.05):
    """Effective half-spread (points) as a function of STATE.

    moneyness      : abs(K/S - 1); 0 = ATM, larger = further OTM/ITM
    dte            : days to expiry
    regime_vol     : realized-vol rank 0..1 (1 = stress)
    liquidity_rank : 0..1 (1 = most liquid near-ATM weekly in a busy name)
    time_of_day    : 0..1 across the session
    """
    f_moneyness = 1.0 + 8.0 * moneyness
    f_dte       = 1.0 + 1.5 * np.exp(-dte / 7.0)
    f_vol       = 1.0 + 1.2 * regime_vol
    f_liq       = 1.0 / max(liquidity_rank, 0.05)
    f_tod       = 1.0 + 0.8 * (abs(time_of_day - 0.5) * 2.0)
    return base * f_moneyness * f_dte * f_vol * f_liq * f_tod
Enter fullscreen mode Exit fullscreen mode

Two things to notice. First, the baseline base = 0.05 is deliberately a best-case half-spread; the multipliers only push it upward from there. A backtest built this way can never accidentally assume a better spread than the reference liquid line. Second, the function is pure and vectorisable — you can call it on a DataFrame of candidate strikes and get a spread surface in one pass.

Integrating the state-variable spread into the backtest loop

The point of modelling H as state is to let it vary trade by trade. Here is the integration pattern inside an event-driven or bar-loop backtest. The key line is that the fill price is the mid plus or minus the state-dependent half-spread, never the mid.

def fill_price(mid, half_spread, is_buy):
    """Cross the spread depending on trade direction."""
    return mid + half_spread if is_buy else mid - half_spread


def simulate_trade(row, position):
    """row carries the STATE; position carries the decision.

    row fields: mid, moneyness, dte, regime_vol, liquidity_rank, time_of_day
    """
    H = spread_state(row["moneyness"], row["dte"],
                     row["regime_vol"], row["liquidity_rank"],
                     row["time_of_day"])
    entry = fill_price(row["mid"], H, is_buy=True)
    # ... strategy logic decides exit on a later bar ...
    exit_H = spread_state(row["moneyness_exit"], row["dte_exit"],
                          row["regime_vol_exit"], row["liquidity_rank_exit"],
                          row["time_of_day_exit"])
    exit_px = fill_price(row["mid_exit"], exit_H, is_buy=False)
    gross = (exit_px - entry) * LOT_SIZE * position["lots"]
    cost = round_trip_cost(row["mid"], max(H, exit_H), position["lots"])["total_inr"]
    return gross - cost
Enter fullscreen mode Exit fullscreen mode

The naive alternative is entry = mid; exit_px = mid_exit, which silently credits the trader the full spread on both legs. For a strategy with high turnover — and most systematic options strategies are high turnover — that free spread is the entire backtested edge. Remove it and the equity curve inverts. This is the mechanism behind the familiar complaint that "the backtest looked great and live lost money." The backtest was trading the spread; the account was paying it.

Calibrating the multipliers from real tick data

The multipliers above (8.0, 1.5, 1.2, 0.8) are illustrative structural priors. To make them real, calibrate from your own quote feed. The procedure is a bucketed median, not a global mean, because the spread distribution is heavy-tailed and state-dependent by construction.

def calibrate_spread_model(quotes: pd.DataFrame):
    """quotes: columns [moneyness_bucket, dte_bucket, regime, mid, bid, ask].
    Returns the median half-spread (in points) per state bucket.
    """
    quotes = quotes.copy()
    quotes["half_spread"] = (quotes["ask"] - quotes["bid"]) / 2.0
    grouped = (quotes
               .groupby(["moneyness_bucket", "dte_bucket", "regime"])
               .agg(median_half_spread=("half_spread", "median"),
                    p90_half_spread=("half_spread", "quantile", 0.90),
                    n=("half_spread", "size")))
    return grouped
Enter fullscreen mode Exit fullscreen mode

Use the median for expected-cost simulation and the p90 for a stress or worst-case path. A robust backtest reports both: the median-cost Sharpe and the p90-cost Sharpe. If those two numbers are far apart, your strategy is spread-sensitive and you must size or filter accordingly. Reporting only the median is the same class of lie as reporting only the gross: it hides the tail that will actually occur.

The state buckets themselves should be chosen from the data, not from intuition. Moneyness buckets of width roughly 2 percent of spot, DTE buckets of roughly one week, and a volatility-regime label from a simple percentile or threshold on realized volatility will capture most of the variation. The goal is not a perfect fit; it is to stop assuming a single number where the data shows a tenfold range.

Why spread-as-state changes the trading decision

A fixed-cost model answers one question: "is the edge bigger than the cost?" A state-aware model answers a sharper question: "is the edge bigger than the cost in this state?" That reframing produces an entirely different strategy.

Take a short-strangle writer who sells far-out-of-the-money wings to collect premium. Under a fixed spread, every wing looks equally cheap to trade. Under a state-aware model, the wings carry a half-spread that is a multiple of the premium itself; the cost_pct_of_premium ratio can exceed unity. The state-aware backtest will simply refuse those wings, or size them to near zero, and concentrate activity in the liquid ATM-to-near-OTM core where the spread is a small fraction of premium. The gross edge of the wings was always an illusion created by assuming you could transact at the mid. Remove the illusion and the strategy's real, survivable edge is smaller but honest.

This is the governing principle: the spread is not noise around the price; for short-dated, far-OTM options it is the price you pay to express the view. Modelling it as state is how you stop paying it twice — once in the market and once in the hallucinated backtest.

Regime-conditional cost budgeting

Because f_vol multiplies the entire spread, a volatility-regime switch simultaneously widens spreads and usually reduces liquidity. A cost-aware risk budget therefore scales allowable turnover with the regime. Define a cost budget B in points per unit of expected edge E:

allowed_turnover = B / H(state)
Enter fullscreen mode Exit fullscreen mode

When H rises in stress, allowed turnover falls automatically. This is the mechanical link between a volatility-regime detector and position sizing: you do not just size smaller in stress, you also trade less in stress, because each trade is more expensive and the edge has not correspondingly grown. Many quants bolt a regime filter onto position size but forget that the cost side moves too; the state-variable spread makes that coupling explicit and testable.

The impact on performance statistics

Transaction cost enters performance through both the mean (it is a drag) and the variance (it is state-dependent and thus correlated with volatility). A proper backtest computes the Sharpe ratio on costed returns:

Sharpe = (mean(R_costed) - R_f) / std(R_costed)
Enter fullscreen mode Exit fullscreen mode

where R_costed = R_gross - cost_per_unit_notional. The naive Sharpe uses R_gross. For high-turnover options books the gap between the two Sharpe values is frequently the difference between "fundable" and "uninvesible." Report both. If you only ever see the gross Sharpe, you are reading a marketing document, not a research result.

More subtly, because cost is correlated with regime, the downside of the costed series is worse than a simple subtraction suggests. In stress, you pay wider spreads exactly when you are most likely to be stopped or forced to roll. A state-aware model captures this covariance; a fixed model underestimates drawdown precisely when it matters. This is why the p90-cost path matters: it is the path that co-occurs with adverse moves.

Common mistakes in cost modelling

The errors around transaction cost modelling are consistent enough to name. The first is the constant-spread assumption covered above — calibrating to the best-case liquid line and applying it everywhere. The second is the gross-only report, where the researcher shows the gross equity curve and mentions costs in a footnote. The third is the symmetry error, treating STT and other sell-side taxes as applying to both legs when they apply to one, which systematically overstates the cost of long strategies and understates the cost of short strategies. The fourth is the median-only summary, ignoring the p90 tail that dominates live experience. The fifth is hardcoding regulatory rates without a note to verify them, so the model quietly drifts out of date when a circular changes a rate. Each of these is avoidable by making the spread a function of state and by reporting costed statistics at multiple percentiles.

A minimal end-to-end example

Putting the pieces together, here is a compact workflow you can adapt. It builds a spread surface from state, prices fills off it, computes costed PnL, and reports the median and p90 costed Sharpe. Replace the parameter block with your verified values and feed real quotes.

def run_cost_aware_backtest(decisions: pd.DataFrame, quotes: pd.DataFrame):
    """decisions: strategy entries/exits with state features.
    quotes: calibrated median/p90 half-spread by state bucket.
    """
    # join state-aware half-spread onto each decision
    merged = decisions.merge(quotes, on=["moneyness_bucket", "dte_bucket", "regime"], how="left")
    merged["half_spread"] = merged["median_half_spread"].fillna(merged["p90_half_spread"])

    gross = (merged["exit_mid"] - merged["entry_mid"]) * LOT_SIZE * merged["lots"]
    cost  = merged.apply(
        lambda r: round_trip_cost(r["entry_mid"], r["half_spread"], r["lots"])["total_inr"],
        axis=1)
    net = gross - cost
    R_f = 0.0  # risk-free in points; set from the notified rate
    sharpe_median = (net.mean() - R_f) / net.std()
    # stress path: recompute cost with p90 spreads
    cost_p90 = merged.apply(
        lambda r: round_trip_cost(r["entry_mid"], r["p90_half_spread"], r["lots"])["total_inr"],
        axis=1)
    net_p90 = gross - cost_p90
    sharpe_p90 = (net_p90.mean() - R_f) / net_p90.std()
    return {"sharpe_median_cost": sharpe_median, "sharpe_p90_cost": sharpe_p90}
Enter fullscreen mode Exit fullscreen mode

If sharpe_p90_cost is near zero or negative while sharpe_median_cost looks healthy, the strategy is living on the tail of the spread distribution. That is the exact condition under which a live account diverges from the backtest, and the state-variable model is what made it visible.

The bigger picture

The thread connecting every point above is that a backtest is only as honest as its friction model. Markets do not charge a flat fee for the privilege of expressing a view; they charge a state-dependent toll, and the toll is largest exactly where naive models assume it is smallest — thin, far, short-dated lines in stressed sessions. Treating the spread as a state variable is the minimal structural correction that aligns the simulation with the account. It is not glamorous, it produces smaller edge numbers, and it will sometimes kill a strategy you were excited about. That is the point: the excitement was the part that was priced against you, and the structure is the part you can actually use. Whether the topic is cost modelling, walk-forward validation, or volatility-regime detection, the discipline is identical — model the mechanism, not the mood, and let the friction be a function of the state you are actually trading in.

Key takeaway

Strip everything else away and the lesson is simple: transaction cost is not a constant you subtract once; it is a state variable you must model every trade. Build the spread as a function of moneyness, DTE, volatility regime, liquidity, and time of day; price fills off that spread rather than the mid; calibrate the multipliers from bucketed medians and report the p90 path; and compute the Sharpe on costed returns at both percentiles. Do that and your backtest stops lying about the spread. Skip it and you are not backtesting a strategy — you are backtesting a spreadsheet that forgot to pay the toll. The edge is not the signal; the edge is the discipline of costing it honestly in every state.

Common mistakes to avoid

The errors people make around transaction cost modelling are remarkably consistent. The first is assuming one spread number for the whole book, calibrated to the best-case line. The second is showing gross PnL and footnoting costs. The third is mis-modelling the sell-side tax asymmetry, which distorts short versus long strategies. The fourth is summarizing cost with a single median and ignoring the p90 tail that dominates live experience. The fifth is hardcoding regulatory rates without verifying them against the current circular. Avoid these five and your backtest becomes a decision tool instead of a confidence trick. The entire point of governed research is to model the seams — the spread, the tax, the tail — so the reader sees the structure instead of a polished surface.

Practical next steps

If you take one action after reading this, make it a state-aware cost model on your own backtest. Concretely: (1) replace your fixed per-contract cost with the round_trip_cost and spread_state functions above, (2) calibrate the multipliers from your own quote data using bucketed medians and a p90, (3) reprice every fill off the state-dependent half-spread rather than the mid, (4) recompute the Sharpe on costed returns at both the median and p90 paths, and (5) verify the regulatory rates (STT, exchange charges, stamp duty) on the official NSE and SEBI circulars before you trust any number. These five steps turn a flattering simulation into a survivable plan. The articles on this site repeat this frame on purpose: cite the source, show the seams, and let the reader see the structure. Apply it to your cost model today, and the next strategy you evaluate will already be honest about the toll.

Further reading

If transaction cost modelling raised a question, the linked pieces on this site answer it. The Nifty options complete guide is the hub; the walk-forward validation and backtesting-pitfalls articles are the depth. Each was written to the same standard — cited sources, stated limits, reproducible logic — so the collection compounds: every article makes the next easier to trust. Follow one link and you will find the next; the entity behind this work is defined less by any single post than by the consistent method across all of them. Read the system, not the snapshot, and the next topic will already feel familiar.

Key takeaways

The disciplined summary: cost is a state variable, not a constant; price fills off the spread; calibrate from bucketed medians and report the p90; compute the Sharpe on costed returns at both percentiles; and verify regulatory rates against the official circular. The linked pieces on this site are not a pile but a system; this article is one node of it. Apply the takeaways as a checklist before any backtest: state-aware spread, explicit sell-side tax, median and p90 cost paths, and a cost budget that scales with regime. Skip one and the rest weaken. The edge is not a call; it is the repeatable discipline of costing the trade honestly in every state.

FAQ

Is transaction cost modelling enough to trade profitably? No single topic is; the linked system is. The articles here are built so each lowers the cost of trusting the next. Do I need tick data to calibrate? Ideally yes, but even a broker-quote snapshot per strike bucket beats a single constant. Are the regulatory rates in this article final? No — verify STT, exchange charges, and stamp duty on the NSE and SEBI circulars before coding them; they are notified constants that change. Is this investment advice? No — educational only, not SEBI-registered. The FAQ exists because retail asks "will it work?" when the right question is "have I built the discipline to cost it honestly?"

About the author

Shakti Tiwari writes about systematic options trading and quantitative machine learning for Indian markets. The work is governed: epistemic firewall against fabricated numbers, a 2000-word minimum so ideas are developed, and explicit source attribution. The collection — from backtesting pitfalls to volatility surfaces to this piece on transaction cost modelling — is one method applied consistently, not a pile of disconnected posts. Follow on X, LinkedIn, GitHub, and DEV via the footer of every article. The entity is defined by the practice: verify, decompose, weight, size, and cost honestly. Read the mechanism, not the mood.

Glossary

A few terms used around transaction cost modelling, stated plainly. Half-spread: half the bid-ask spread, the price you pay to cross to trade. State variable: a quantity that varies with observable market conditions rather than being fixed. Costed return: a return net of modelled transaction cost, as opposed to gross. p90 path: a simulation using the ninetieth-percentile spread, representing stressed execution. STT: securities transaction tax, levied on option premium on the sell side in the Indian context. Edge: a small, repeatable advantage that survives costs and regimes. None of these are jargon to memorize; they are the guardrails that keep a backtest honest and a live process defensible.

Summary

The throughline of everything written about transaction cost modelling on this site is that friction is structural, not incidental. Model the spread as a function of state, price fills off it, calibrate from real quotes, and report costed statistics at multiple percentiles. The articles linked here are not a pile of posts; they are one method applied to many subjects, and the method is the asset. Read the hub, follow the links, rebuild the logic against your own data, and the entity behind the work reveals itself not as a person claiming authority but as a consistent, auditable practice. That is the only kind of authority worth having in markets: earned by structure, not claimed by tone.

Who should read this

This piece is written for the quant or systematic trader who has been burned by a backtest that looked great and lived badly. If you have ever typed a single commission number into a model and moved on, this is for you. It assumes comfort with Python and basic options mechanics, but no PhD and no secret indicator — only the willingness to model the spread instead of assuming it away. The material is presented so you can reconstruct it, challenge it, and improve it against your own quote data. That is the point: not to make you agree, but to make you independent. The readers who benefit most treat every claim here as a hypothesis to test, not a verdict to memorize.

Related reading

The articles linked throughout this piece form a system; read them as a set, not in isolation. The Nifty Options Trading complete guide is the hub; the backtesting, walk-forward, and risk pieces are the depth. Each was written to the same standard — cited sources, stated limitations, reproducible logic — so the collection compounds. If a topic here raised a question, the linked pieces almost certainly answer it. Follow the links; the entity behind this work is defined less by any single post than by the consistent method across all of them.

Frequently Asked Questions

Q: What is transaction cost modelling in practice?

A: It is the discipline of representing every toll — spread, commission, exchange fee, STT, stamp duty, slippage — as a formula and, crucially, making the spread a function of state. Start with the first section and follow the internal links.

Q: How does spread-as-state connect to risk?

A: Every section ties back to sizing and trading less in stress, because the spread widens exactly when edges do not. The risk-management and position-sizing articles are the operational version.

Q: Is transaction cost modelling enough to trade profitably?

A: No single topic is. The Nifty options complete guide is the hub; the linked system is the edge. Build the discipline before the capital.

Q: Are the figures in this article real?

A: All magnitudes are stated as parameters to verify on the official NSE and SEBI circulars; no live market levels are quoted. This is educational, not SEBI-registered advice.

Sources and attribution

Continue Reading

Shakti Tiwari writes about systematic options trading and ML. Follow on X · LinkedIn · GitHub · DEV. #ShaktiTiwariOnAI #NiftyOptions #QuantML #OptionsTrading #SystematicTrading #TransactionCosts

Sources: SEBI · NSE India. Regulatory rates (STT, exchange charges, stamp duty) are notified constants — verify on the official circular before coding. Not investment advice.

Top comments (0)