oq-backtest 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ """``oq-backtest``: honest, vectorized backtester for Indian equities."""
2
+
3
+ from oq_backtest.costs import (
4
+ DHAN_DELIVERY,
5
+ DHAN_INTRADAY,
6
+ FULL_SERVICE_DELIVERY,
7
+ FYERS_DELIVERY,
8
+ FYERS_INTRADAY,
9
+ PRESETS,
10
+ UPSTOX_DELIVERY,
11
+ UPSTOX_INTRADAY,
12
+ ZERODHA_DELIVERY,
13
+ ZERODHA_INTRADAY,
14
+ CostBreakdown,
15
+ CostConfig,
16
+ TaxConfig,
17
+ compute_costs,
18
+ resolve_config,
19
+ )
20
+ from oq_backtest.engine import backtest
21
+ from oq_backtest.intraday import (
22
+ NSE_CLOSE,
23
+ NSE_OPEN,
24
+ IntradayConfig,
25
+ apply_square_off,
26
+ backtest_intraday,
27
+ intraday_summary,
28
+ is_intraday_preset,
29
+ )
30
+ from oq_backtest.result import BacktestResult
31
+ from oq_backtest.slippage import (
32
+ FixedBpsSlippage,
33
+ SlippageModel,
34
+ SpreadSlippage,
35
+ VolumeParticipationSlippage,
36
+ resolve_slippage,
37
+ )
38
+ from oq_backtest.strategies import (
39
+ equal_weight,
40
+ mean_reversion_signal,
41
+ momentum_signal,
42
+ rebalance_dates,
43
+ synthetic_universe,
44
+ )
45
+ from oq_backtest.tax import TaxBreakdown, estimate_taxes
46
+ from oq_backtest.walkforward import Fold, train_test_split, walk_forward
47
+
48
+ __version__ = "0.1.0"
49
+
50
+ __all__ = [
51
+ "DHAN_DELIVERY",
52
+ "DHAN_INTRADAY",
53
+ "FULL_SERVICE_DELIVERY",
54
+ "FYERS_DELIVERY",
55
+ "FYERS_INTRADAY",
56
+ "NSE_CLOSE",
57
+ "NSE_OPEN",
58
+ "PRESETS",
59
+ "UPSTOX_DELIVERY",
60
+ "UPSTOX_INTRADAY",
61
+ "ZERODHA_DELIVERY",
62
+ "ZERODHA_INTRADAY",
63
+ "BacktestResult",
64
+ "CostBreakdown",
65
+ "CostConfig",
66
+ "FixedBpsSlippage",
67
+ "Fold",
68
+ "IntradayConfig",
69
+ "SlippageModel",
70
+ "SpreadSlippage",
71
+ "TaxBreakdown",
72
+ "TaxConfig",
73
+ "VolumeParticipationSlippage",
74
+ "__version__",
75
+ "apply_square_off",
76
+ "backtest",
77
+ "backtest_intraday",
78
+ "compute_costs",
79
+ "equal_weight",
80
+ "estimate_taxes",
81
+ "intraday_summary",
82
+ "is_intraday_preset",
83
+ "mean_reversion_signal",
84
+ "momentum_signal",
85
+ "rebalance_dates",
86
+ "resolve_config",
87
+ "resolve_slippage",
88
+ "synthetic_universe",
89
+ "train_test_split",
90
+ "walk_forward",
91
+ ]
oq_backtest/costs.py ADDED
@@ -0,0 +1,277 @@
1
+ """Indian equity cost engine.
2
+
3
+ Implements the regulatory and broker charges that a real Indian equity trade
4
+ incurs, and exposes a small set of broker presets so a user can swap a single
5
+ string in :func:`oq_backtest.backtest` and get a realistic net P&L.
6
+
7
+ All rates are expressed as decimal fractions of notional (``0.001`` == 0.1%).
8
+ Rates are calibrated against published charge sheets as of 2024-2025; consumers
9
+ should treat them as a baseline and override via :class:`CostConfig` for exact
10
+ broker / state / segment specifics.
11
+
12
+ References
13
+ ----------
14
+ * SEBI charges: Rs. 10 per crore of turnover (1e-6 of notional).
15
+ * NSE cash exchange transaction charge (revised Oct 2024): 0.00297%.
16
+ * GST: 18% on (brokerage + exchange + SEBI) charges only.
17
+ * STT delivery: 0.1% on both buy and sell legs.
18
+ * STT intraday: 0.025% on sell leg only.
19
+ * Stamp duty (delivery, buy only): 0.015%. Intraday buy: 0.003%.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Mapping
25
+ from dataclasses import dataclass
26
+ from typing import Literal
27
+
28
+ import numpy as np
29
+
30
+ Side = Literal["buy", "sell"]
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class CostConfig:
35
+ """Per-leg cost configuration for an Indian equity broker.
36
+
37
+ All ``*_rate`` fields are decimal fractions of order notional. Brokerage
38
+ additionally supports a per-order ``min`` floor and ``max`` ceiling in
39
+ INR (matching the "0.03% or Rs. 20, whichever lower" convention).
40
+ """
41
+
42
+ brokerage_rate: float = 0.0
43
+ brokerage_min: float = 0.0
44
+ brokerage_max: float = float("inf")
45
+ stt_buy_rate: float = 0.001
46
+ stt_sell_rate: float = 0.001
47
+ exchange_rate: float = 0.0000297
48
+ sebi_rate: float = 1e-6
49
+ stamp_duty_buy_rate: float = 0.00015
50
+ gst_rate: float = 0.18
51
+ is_intraday: bool = False
52
+
53
+ def __post_init__(self) -> None:
54
+ for name in (
55
+ "brokerage_rate",
56
+ "stt_buy_rate",
57
+ "stt_sell_rate",
58
+ "exchange_rate",
59
+ "sebi_rate",
60
+ "stamp_duty_buy_rate",
61
+ "gst_rate",
62
+ ):
63
+ value = getattr(self, name)
64
+ if value < 0:
65
+ raise ValueError(f"{name} must be >= 0, got {value}")
66
+ if self.brokerage_min < 0:
67
+ raise ValueError("brokerage_min must be >= 0")
68
+ if self.brokerage_max < self.brokerage_min:
69
+ raise ValueError("brokerage_max must be >= brokerage_min")
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class CostBreakdown:
74
+ """Per-rebalance cost decomposition in INR."""
75
+
76
+ brokerage: float = 0.0
77
+ stt: float = 0.0
78
+ exchange: float = 0.0
79
+ sebi: float = 0.0
80
+ gst: float = 0.0
81
+ stamp_duty: float = 0.0
82
+
83
+ @property
84
+ def total(self) -> float:
85
+ return self.brokerage + self.stt + self.exchange + self.sebi + self.gst + self.stamp_duty
86
+
87
+ def as_dict(self) -> dict[str, float]:
88
+ return {
89
+ "brokerage": self.brokerage,
90
+ "stt": self.stt,
91
+ "exchange": self.exchange,
92
+ "sebi": self.sebi,
93
+ "gst": self.gst,
94
+ "stamp_duty": self.stamp_duty,
95
+ "total": self.total,
96
+ }
97
+
98
+ def __add__(self, other: CostBreakdown) -> CostBreakdown:
99
+ if not isinstance(other, CostBreakdown):
100
+ return NotImplemented
101
+ return CostBreakdown(
102
+ brokerage=self.brokerage + other.brokerage,
103
+ stt=self.stt + other.stt,
104
+ exchange=self.exchange + other.exchange,
105
+ sebi=self.sebi + other.sebi,
106
+ gst=self.gst + other.gst,
107
+ stamp_duty=self.stamp_duty + other.stamp_duty,
108
+ )
109
+
110
+
111
+ ZERODHA_DELIVERY = CostConfig()
112
+
113
+ ZERODHA_INTRADAY = CostConfig(
114
+ brokerage_rate=0.0003,
115
+ brokerage_max=20.0,
116
+ stt_buy_rate=0.0,
117
+ stt_sell_rate=0.00025,
118
+ stamp_duty_buy_rate=0.00003,
119
+ is_intraday=True,
120
+ )
121
+
122
+ UPSTOX_DELIVERY = CostConfig()
123
+
124
+ UPSTOX_INTRADAY = CostConfig(
125
+ brokerage_rate=0.0005,
126
+ brokerage_max=20.0,
127
+ stt_buy_rate=0.0,
128
+ stt_sell_rate=0.00025,
129
+ stamp_duty_buy_rate=0.00003,
130
+ is_intraday=True,
131
+ )
132
+
133
+ FYERS_DELIVERY = CostConfig()
134
+
135
+ FYERS_INTRADAY = CostConfig(
136
+ brokerage_rate=0.0003,
137
+ brokerage_max=20.0,
138
+ stt_buy_rate=0.0,
139
+ stt_sell_rate=0.00025,
140
+ stamp_duty_buy_rate=0.00003,
141
+ is_intraday=True,
142
+ )
143
+
144
+ DHAN_DELIVERY = CostConfig()
145
+
146
+ DHAN_INTRADAY = CostConfig(
147
+ brokerage_rate=0.0003,
148
+ brokerage_max=20.0,
149
+ stt_buy_rate=0.0,
150
+ stt_sell_rate=0.00025,
151
+ stamp_duty_buy_rate=0.00003,
152
+ is_intraday=True,
153
+ )
154
+
155
+ FULL_SERVICE_DELIVERY = CostConfig(
156
+ brokerage_rate=0.005,
157
+ brokerage_min=0.0,
158
+ brokerage_max=float("inf"),
159
+ )
160
+
161
+
162
+ PRESETS: Mapping[str, CostConfig] = {
163
+ "zerodha": ZERODHA_DELIVERY,
164
+ "zerodha_intraday": ZERODHA_INTRADAY,
165
+ "upstox": UPSTOX_DELIVERY,
166
+ "upstox_intraday": UPSTOX_INTRADAY,
167
+ "fyers": FYERS_DELIVERY,
168
+ "fyers_intraday": FYERS_INTRADAY,
169
+ "dhan": DHAN_DELIVERY,
170
+ "dhan_intraday": DHAN_INTRADAY,
171
+ "full_service": FULL_SERVICE_DELIVERY,
172
+ "zero": CostConfig(
173
+ brokerage_rate=0.0,
174
+ stt_buy_rate=0.0,
175
+ stt_sell_rate=0.0,
176
+ exchange_rate=0.0,
177
+ sebi_rate=0.0,
178
+ stamp_duty_buy_rate=0.0,
179
+ gst_rate=0.0,
180
+ ),
181
+ }
182
+
183
+
184
+ def resolve_config(costs: str | CostConfig) -> CostConfig:
185
+ """Look up a preset by name, or return a :class:`CostConfig` unchanged."""
186
+ if isinstance(costs, CostConfig):
187
+ return costs
188
+ if isinstance(costs, str):
189
+ key = costs.lower()
190
+ if key not in PRESETS:
191
+ raise KeyError(f"unknown cost preset {costs!r}; known presets: {sorted(PRESETS)}")
192
+ return PRESETS[key]
193
+ raise TypeError(f"costs must be str or CostConfig, got {type(costs).__name__}")
194
+
195
+
196
+ def _brokerage_per_order(notionals: np.ndarray, cfg: CostConfig) -> np.ndarray:
197
+ """Apply per-order min/max brokerage to an array of per-symbol notionals."""
198
+ raw = notionals * cfg.brokerage_rate
199
+ if cfg.brokerage_min > 0:
200
+ raw = np.where(notionals > 0, np.maximum(raw, cfg.brokerage_min), raw)
201
+ if np.isfinite(cfg.brokerage_max):
202
+ raw = np.where(notionals > 0, np.minimum(raw, cfg.brokerage_max), raw)
203
+ return raw
204
+
205
+
206
+ def compute_costs(
207
+ buy_notionals: np.ndarray | float,
208
+ sell_notionals: np.ndarray | float,
209
+ cfg: CostConfig,
210
+ ) -> CostBreakdown:
211
+ """Compute the full Indian-market cost breakdown for one rebalance.
212
+
213
+ Parameters
214
+ ----------
215
+ buy_notionals, sell_notionals:
216
+ Per-order absolute notional values in INR. May be scalars or arrays.
217
+ Each element is treated as an independent order for brokerage min/max.
218
+ cfg:
219
+ Cost configuration; build one yourself or use :data:`PRESETS`.
220
+ """
221
+ buys = np.atleast_1d(np.asarray(buy_notionals, dtype=float))
222
+ sells = np.atleast_1d(np.asarray(sell_notionals, dtype=float))
223
+
224
+ buy_total = float(buys.sum())
225
+ sell_total = float(sells.sum())
226
+
227
+ brokerage_buy = float(_brokerage_per_order(buys, cfg).sum())
228
+ brokerage_sell = float(_brokerage_per_order(sells, cfg).sum())
229
+ brokerage = brokerage_buy + brokerage_sell
230
+
231
+ stt = buy_total * cfg.stt_buy_rate + sell_total * cfg.stt_sell_rate
232
+ exchange = (buy_total + sell_total) * cfg.exchange_rate
233
+ sebi = (buy_total + sell_total) * cfg.sebi_rate
234
+ gst = (brokerage + exchange + sebi) * cfg.gst_rate
235
+ stamp = buy_total * cfg.stamp_duty_buy_rate
236
+
237
+ return CostBreakdown(
238
+ brokerage=brokerage,
239
+ stt=stt,
240
+ exchange=exchange,
241
+ sebi=sebi,
242
+ gst=gst,
243
+ stamp_duty=stamp,
244
+ )
245
+
246
+
247
+ @dataclass(frozen=True, slots=True)
248
+ class TaxConfig:
249
+ """Indian equity capital gains tax estimator.
250
+
251
+ Not investment advice. Holding period thresholds and rates are based on
252
+ rules as of FY2024-25. Override when legislation changes.
253
+ """
254
+
255
+ short_term_days: int = 365
256
+ stcg_rate: float = 0.15
257
+ ltcg_rate: float = 0.125
258
+ ltcg_exempt_inr: float = 125_000.0
259
+
260
+
261
+ __all__ = [
262
+ "DHAN_DELIVERY",
263
+ "DHAN_INTRADAY",
264
+ "FULL_SERVICE_DELIVERY",
265
+ "FYERS_DELIVERY",
266
+ "FYERS_INTRADAY",
267
+ "PRESETS",
268
+ "UPSTOX_DELIVERY",
269
+ "UPSTOX_INTRADAY",
270
+ "ZERODHA_DELIVERY",
271
+ "ZERODHA_INTRADAY",
272
+ "CostBreakdown",
273
+ "CostConfig",
274
+ "TaxConfig",
275
+ "compute_costs",
276
+ "resolve_config",
277
+ ]
oq_backtest/engine.py ADDED
@@ -0,0 +1,187 @@
1
+ """Vectorized daily-frequency portfolio backtest engine.
2
+
3
+ The engine consumes a ``signals`` DataFrame of target weights and a
4
+ ``prices`` DataFrame of close prices, both indexed by date with symbols as
5
+ columns. It produces gross and net equity curves, per-rebalance cost
6
+ attribution, and the realized weights and trades, all wrapped in a
7
+ :class:`BacktestResult`.
8
+
9
+ Design choices
10
+ --------------
11
+ * **Close-to-close**: signals dated ``t`` are executed at the close of ``t``
12
+ and earn the close-to-close return from ``t`` to ``t+1``. To avoid look-
13
+ ahead, generate your signals from data up to and including ``t``'s close
14
+ (typical research practice) and let the engine handle the lag.
15
+ * **Weights, not lot sizes**: positions are stored as fractions of portfolio
16
+ value. Lot-size rounding belongs in execution, not research.
17
+ * **Costs come out of the portfolio**: net equity at each step is gross
18
+ equity minus the regulatory + slippage charges incurred at the rebalance.
19
+ * **NaN-tolerant**: a NaN price for a symbol on a date means "no quote" and
20
+ forces the prior weight forward without a return contribution. A NaN
21
+ signal is treated as 0 (no allocation).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import numpy as np
27
+ import pandas as pd
28
+
29
+ from oq_backtest.costs import CostConfig, compute_costs, resolve_config
30
+ from oq_backtest.result import BacktestResult
31
+ from oq_backtest.slippage import SlippageModel, resolve_slippage
32
+
33
+
34
+ def _align(signals: pd.DataFrame, prices: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
35
+ symbols = signals.columns.intersection(prices.columns)
36
+ if symbols.empty:
37
+ raise ValueError("signals and prices share no symbols in common")
38
+ s = signals[symbols].copy()
39
+ p = prices[symbols].copy()
40
+ s.index = pd.DatetimeIndex(s.index)
41
+ p.index = pd.DatetimeIndex(p.index)
42
+ common = s.index.intersection(p.index)
43
+ if common.empty:
44
+ raise ValueError("signals and prices share no dates in common")
45
+ s = s.loc[common].sort_index()
46
+ p = p.loc[common].sort_index()
47
+ s = s.fillna(0.0)
48
+ return s, p
49
+
50
+
51
+ def _validate_weights(signals: pd.DataFrame, allow_short: bool, max_leverage: float) -> None:
52
+ if not allow_short and (signals.to_numpy() < -1e-9).any():
53
+ raise ValueError(
54
+ "signals contain negative weights but allow_short=False; "
55
+ "pass allow_short=True or clip to >= 0"
56
+ )
57
+ gross = signals.abs().sum(axis=1)
58
+ if (gross > max_leverage + 1e-9).any():
59
+ bad = gross[gross > max_leverage + 1e-9]
60
+ raise ValueError(
61
+ f"signals exceed max_leverage={max_leverage:g} on "
62
+ f"{len(bad)} dates; first offender: {bad.index[0].date()} = {bad.iloc[0]:.4f}"
63
+ )
64
+
65
+
66
+ def backtest(
67
+ signals: pd.DataFrame,
68
+ prices: pd.DataFrame,
69
+ costs: str | CostConfig = "zerodha",
70
+ slippage: SlippageModel | float | int = 5.0,
71
+ initial_capital: float = 1_000_000.0,
72
+ allow_short: bool = False,
73
+ max_leverage: float = 1.0,
74
+ ) -> BacktestResult:
75
+ """Run an honest, cost-aware backtest of a weight-based strategy.
76
+
77
+ Parameters
78
+ ----------
79
+ signals:
80
+ DataFrame of target portfolio weights, indexed by date, columns are
81
+ symbols. A row summing to 1.0 is fully invested; 0.5 is half cash.
82
+ prices:
83
+ DataFrame of close prices in INR, indexed by date, columns are
84
+ symbols. Must overlap with ``signals``.
85
+ costs:
86
+ Either a preset name from :data:`oq_backtest.costs.PRESETS`
87
+ (``"zerodha"``, ``"upstox"``, ``"fyers"``, ``"dhan"``,
88
+ ``"full_service"``, ``"zero"``, and ``*_intraday`` variants) or a
89
+ :class:`CostConfig` instance.
90
+ slippage:
91
+ A :class:`SlippageModel` instance or a number interpreted as fixed
92
+ bps via :class:`FixedBpsSlippage`. Default is 5 bps.
93
+ initial_capital:
94
+ Starting portfolio value in INR. Default 10 lakhs.
95
+ allow_short:
96
+ If False, negative weights raise. Default False (cash-account safe).
97
+ max_leverage:
98
+ Maximum gross exposure (sum of absolute weights). Default 1.0.
99
+
100
+ Returns
101
+ -------
102
+ :class:`BacktestResult`
103
+ """
104
+ if initial_capital <= 0:
105
+ raise ValueError("initial_capital must be > 0")
106
+ if max_leverage <= 0:
107
+ raise ValueError("max_leverage must be > 0")
108
+
109
+ cfg = resolve_config(costs)
110
+ slip = resolve_slippage(slippage)
111
+ cost_label = costs if isinstance(costs, str) else "custom"
112
+
113
+ s, p = _align(signals, prices)
114
+ _validate_weights(s, allow_short=allow_short, max_leverage=max_leverage)
115
+
116
+ symbols = s.columns
117
+ dates = s.index
118
+ n_dates = len(dates)
119
+
120
+ rets = p.pct_change().fillna(0.0).to_numpy(dtype=float)
121
+ target = s.to_numpy(dtype=float)
122
+
123
+ gross_equity = np.empty(n_dates, dtype=float)
124
+ net_equity = np.empty(n_dates, dtype=float)
125
+ realised_weights = np.zeros_like(target)
126
+ trade_buys = np.zeros_like(target)
127
+ trade_sells = np.zeros_like(target)
128
+
129
+ cost_rows: list[dict[str, float]] = []
130
+
131
+ pv_gross = initial_capital
132
+ pv_net = initial_capital
133
+ prev_w = np.zeros(len(symbols), dtype=float)
134
+
135
+ for i in range(n_dates):
136
+ r = rets[i] if i > 0 else np.zeros(len(symbols))
137
+ day_ret = float(prev_w @ r)
138
+ pv_gross *= 1.0 + day_ret
139
+ pv_net *= 1.0 + day_ret
140
+
141
+ new_w = target[i]
142
+ delta = new_w - prev_w
143
+ buy_per = np.clip(delta, 0.0, None) * pv_net
144
+ sell_per = np.clip(-delta, 0.0, None) * pv_net
145
+
146
+ cost_bd = compute_costs(buy_per, sell_per, cfg)
147
+ slip_cost = slip.slippage_cost(dates[i].date(), symbols, buy_per, sell_per)
148
+ total_cost = cost_bd.total + slip_cost
149
+ pv_net -= total_cost
150
+
151
+ gross_equity[i] = pv_gross
152
+ net_equity[i] = pv_net
153
+ realised_weights[i] = new_w
154
+ trade_buys[i] = buy_per
155
+ trade_sells[i] = sell_per
156
+
157
+ cost_rows.append(
158
+ {
159
+ "brokerage": cost_bd.brokerage,
160
+ "stt": cost_bd.stt,
161
+ "exchange": cost_bd.exchange,
162
+ "sebi": cost_bd.sebi,
163
+ "gst": cost_bd.gst,
164
+ "stamp_duty": cost_bd.stamp_duty,
165
+ "slippage": slip_cost,
166
+ "total": total_cost,
167
+ }
168
+ )
169
+
170
+ prev_w = new_w
171
+
172
+ costs_df = pd.DataFrame(cost_rows, index=dates)
173
+ weights_df = pd.DataFrame(realised_weights, index=dates, columns=symbols)
174
+ trades_df = pd.DataFrame(trade_buys - trade_sells, index=dates, columns=symbols)
175
+
176
+ return BacktestResult(
177
+ gross_equity=pd.Series(gross_equity, index=dates, name="gross_equity"),
178
+ net_equity=pd.Series(net_equity, index=dates, name="net_equity"),
179
+ weights=weights_df,
180
+ costs=costs_df,
181
+ trades=trades_df,
182
+ initial_capital=float(initial_capital),
183
+ cost_label=cost_label,
184
+ )
185
+
186
+
187
+ __all__ = ["backtest"]