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.
- oq_backtest/__init__.py +91 -0
- oq_backtest/costs.py +277 -0
- oq_backtest/engine.py +187 -0
- oq_backtest/intraday.py +167 -0
- oq_backtest/metrics.py +147 -0
- oq_backtest/result.py +105 -0
- oq_backtest/slippage.py +186 -0
- oq_backtest/strategies.py +139 -0
- oq_backtest/tax.py +125 -0
- oq_backtest/walkforward.py +96 -0
- oq_backtest-0.1.0.dist-info/METADATA +49 -0
- oq_backtest-0.1.0.dist-info/RECORD +13 -0
- oq_backtest-0.1.0.dist-info/WHEEL +4 -0
oq_backtest/__init__.py
ADDED
|
@@ -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"]
|