PaperTrade / costs.py
Khanna, Videh Rakesh Rakesh
feat: cost-aware predictions, graded validation, per-TF AI, drop 5D
9da1a46
Raw
History Blame Contribute Delete
4.55 kB
"""
costs.py β€” Realistic NSE equity transaction-cost model.
Motivation (see research doc "Stock-Prediction-Models: Key Takeaways"):
price prediction β‰  profitable trading. A backtest that ignores fees, and a
predictor that only says "tomorrow β‰ˆ today" (a tiny Β±0.07% band that always
"hits"), both LOOK great and make no money. Costs are the reality check: a move
that doesn't clear the round-trip cost is not a tradeable edge.
Round-trip = buy + sell. Components for NSE cash-market equity:
- Brokerage : discount-broker model β€” β‚Ή0 delivery, min(0.03%, β‚Ή20)/leg intraday
- STT : delivery 0.10% buy + 0.10% sell; intraday 0.025% sell-only
- Exchange txn : ~0.00297% per leg (NSE)
- SEBI charges : 0.0001% per leg
- Stamp duty : delivery 0.015% buy-only; intraday 0.003% buy-only
- GST : 18% on (brokerage + exchange txn + SEBI)
Everything is expressed as a **percent of trade value** so it composes with the
percentage returns used throughout the predictor.
"""
from __future__ import annotations
# Per-leg / round-trip rates as fractions of trade value (not %).
_STT_DELIVERY_PER_SIDE = 0.0010 # 0.10% buy AND sell
_STT_INTRADAY_SELL = 0.00025 # 0.025% sell-only
_EXCH_TXN_PER_SIDE = 0.0000297 # NSE ~0.00297%
_SEBI_PER_SIDE = 0.000001 # 0.0001%
_STAMP_DELIVERY_BUY = 0.00015 # 0.015% buy-only
_STAMP_INTRADAY_BUY = 0.00003 # 0.003% buy-only
_GST = 0.18 # on brokerage + exch txn + SEBI
_BROKERAGE_INTRADAY = 0.0003 # 0.03% per leg (discount broker)
_BROKERAGE_INTRADAY_CAP = 20.0 # β‚Ή20 per leg cap
_BROKERAGE_DELIVERY = 0.0 # β‚Ή0 delivery (discount broker)
def round_trip_cost_pct(intraday: bool = False,
price: float | None = None,
qty: int | None = None) -> float:
"""Return the total round-trip cost as a PERCENT of trade value.
If price and qty are given, brokerage caps (β‚Ή20/leg intraday) are applied
exactly; otherwise brokerage uses the uncapped percentage (conservative for
small tickets, slightly high for large ones β€” fine as a threshold).
"""
if intraday:
stt = _STT_INTRADAY_SELL
stamp = _STAMP_INTRADAY_BUY
brok_rate = _BROKERAGE_INTRADAY
brok_cap = _BROKERAGE_INTRADAY_CAP
else:
stt = _STT_DELIVERY_PER_SIDE * 2 # both legs
stamp = _STAMP_DELIVERY_BUY
brok_rate = _BROKERAGE_DELIVERY
brok_cap = None
exch = _EXCH_TXN_PER_SIDE * 2
sebi = _SEBI_PER_SIDE * 2
# Brokerage as a fraction of value (both legs), honoring the per-leg cap.
if brok_rate <= 0:
brok_frac = 0.0
elif price and qty and price * qty > 0:
value = price * qty
per_leg = min(brok_rate * value, brok_cap) if brok_cap else brok_rate * value
brok_frac = (per_leg * 2) / value
else:
brok_frac = brok_rate * 2 # uncapped %
gst = _GST * (brok_frac + exch + sebi)
total = stt + exch + sebi + stamp + brok_frac + gst
return round(total * 100, 4) # as percent
# Convenience defaults so callers don't need a ticket size:
# delivery (1D/3D swing) β‰ˆ 0.27%, intraday β‰ˆ 0.10%
ROUND_TRIP_DELIVERY_PCT = round_trip_cost_pct(intraday=False)
ROUND_TRIP_INTRADAY_PCT = round_trip_cost_pct(intraday=True)
def cost_pct_for_timeframe(tf_label: str) -> float:
"""Round-trip cost % for a timeframe. INTRADAY uses the intraday rate;
1D/3D/5D are held overnight β†’ delivery (CNC) rates."""
return ROUND_TRIP_INTRADAY_PCT if (tf_label or "").upper() == "INTRADAY" else ROUND_TRIP_DELIVERY_PCT
def net_return_pct(gross_return_pct: float, tf_label: str = "1D") -> float:
"""Gross % return minus round-trip cost for the timeframe."""
return round(gross_return_pct - cost_pct_for_timeframe(tf_label), 3)
def clears_costs(expected_move_pct: float, tf_label: str = "1D", margin: float = 1.0) -> bool:
"""True if |expected move %| exceeds round-trip cost Γ— margin β€” i.e. the
predicted edge survives fees. margin>1 demands a profit cushion beyond breakeven."""
return abs(expected_move_pct) >= cost_pct_for_timeframe(tf_label) * margin
if __name__ == "__main__":
print(f"NSE round-trip cost β€” delivery: {ROUND_TRIP_DELIVERY_PCT}% intraday: {ROUND_TRIP_INTRADAY_PCT}%")
for tf, mv in [("1D", 0.07), ("1D", 0.86), ("INTRADAY", 0.5), ("3D", 2.0)]:
print(f" {tf} move {mv:+.2f}% -> net {net_return_pct(mv, tf):+.3f}% clears={clears_costs(mv, tf)}")