Put-call parity
A no-arbitrage relationship between a call, a put, and the underlying — independent of any pricing model's assumptions (it holds as a pure arbitrage argument, more fundamental than Black-Scholes itself).
The relationship
Call price - Put price = Spot price - Strike price × e^(-r × T)
def put_call_parity_check(call_price: float, put_price: float, S: float, K: float, T: float, r: float) -> dict:
theoretical_diff = S - K * math.exp(-r * T)
actual_diff = call_price - put_price
deviation = actual_diff - theoretical_diff
return {"theoretical_diff": theoretical_diff, "actual_diff": actual_diff, "deviation": deviation}
Deriving the synthetic instruments — the practical use
def synthetic_call_price(put_price: float, S: float, K: float, T: float, r: float) -> float:
return put_price + S - K * math.exp(-r * T)
def synthetic_put_price(call_price: float, S: float, K: float, T: float, r: float) -> float:
return call_price - S + K * math.exp(-r * T)
def synthetic_future_price(call_price: float, put_price: float, K: float) -> float:
"""Long call + short put at the same strike replicates a long futures position."""
return K + call_price - put_price
Synthetic long futures (long call + short put, same strike/expiry) is a genuinely used technique — sometimes the options market offers a better effective entry than the futures market directly, due to temporary supply/demand imbalances between the two.
Using parity deviation as a mispricing/arbitrage signal
def find_parity_arbitrage(chain: pd.DataFrame, S: float, T: float, r: float, min_deviation: float = 2.0) -> pd.DataFrame:
chain = chain.copy()
chain["parity_deviation"] = chain.apply(
lambda row: put_call_parity_check(row["ltp_call"], row["ltp_put"], S, row.name, T, r)["deviation"],
axis=1,
)
return chain[chain["parity_deviation"].abs() > min_deviation]
Why large deviations are rare and usually not free money
In liquid instruments (NIFTY, BANKNIFTY options), parity holds very tightly because any meaningful deviation gets arbitraged away almost instantly by market makers running exactly this calculation at much higher speed and lower cost than a retail bot. A deviation your bot finds is more likely to indicate: stale/delayed quote data, wide bid-ask spreads making the "mid price" misleading, or a data error — not a real, capturable arbitrage. Always cross-check against live bid-ask (chapter 26), not last-traded price, before treating any parity deviation as actionable, and account fully for transaction costs (chapter 86) before assuming a small deviation is profitable to capture.
The genuinely useful, everyday application: validating your own data pipeline
def validate_chain_data_quality(chain: pd.DataFrame, S: float, T: float, r: float, threshold: float = 5.0) -> list[float]:
"""A large, systematic parity deviation across many strikes usually indicates
a data quality problem (stale prices, wrong expiry matched, wrong strike alignment),
not a market inefficiency."""
deviations = chain.apply(
lambda row: put_call_parity_check(row["ltp_call"], row["ltp_put"], S, row.name, T, r)["deviation"], axis=1
)
return deviations[deviations.abs() > threshold].index.tolist()
Run this as a sanity check on your option chain data (chapter 33) whenever building anything that trusts those quotes — a systematic parity violation across most strikes is a strong signal your data pipeline has a bug (wrong expiry, misaligned strikes, stale caching) before it's ever a signal about the market itself.