tensorquantlib 0.3.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.
- tensorquantlib/__init__.py +313 -0
- tensorquantlib/__main__.py +315 -0
- tensorquantlib/backtest/__init__.py +48 -0
- tensorquantlib/backtest/engine.py +240 -0
- tensorquantlib/backtest/metrics.py +320 -0
- tensorquantlib/backtest/strategy.py +348 -0
- tensorquantlib/core/__init__.py +6 -0
- tensorquantlib/core/ops.py +70 -0
- tensorquantlib/core/second_order.py +465 -0
- tensorquantlib/core/tensor.py +928 -0
- tensorquantlib/data/__init__.py +16 -0
- tensorquantlib/data/market.py +160 -0
- tensorquantlib/finance/__init__.py +52 -0
- tensorquantlib/finance/american.py +263 -0
- tensorquantlib/finance/basket.py +291 -0
- tensorquantlib/finance/black_scholes.py +219 -0
- tensorquantlib/finance/credit.py +199 -0
- tensorquantlib/finance/exotics.py +885 -0
- tensorquantlib/finance/fx.py +204 -0
- tensorquantlib/finance/greeks.py +133 -0
- tensorquantlib/finance/heston.py +543 -0
- tensorquantlib/finance/implied_vol.py +277 -0
- tensorquantlib/finance/ir_derivatives.py +203 -0
- tensorquantlib/finance/jump_diffusion.py +203 -0
- tensorquantlib/finance/local_vol.py +146 -0
- tensorquantlib/finance/rates.py +381 -0
- tensorquantlib/finance/risk.py +344 -0
- tensorquantlib/finance/variance_reduction.py +420 -0
- tensorquantlib/finance/volatility.py +355 -0
- tensorquantlib/py.typed +0 -0
- tensorquantlib/tt/__init__.py +43 -0
- tensorquantlib/tt/decompose.py +576 -0
- tensorquantlib/tt/ops.py +386 -0
- tensorquantlib/tt/pricing.py +304 -0
- tensorquantlib/tt/surrogate.py +634 -0
- tensorquantlib/utils/__init__.py +5 -0
- tensorquantlib/utils/validation.py +126 -0
- tensorquantlib/viz/__init__.py +27 -0
- tensorquantlib/viz/plots.py +331 -0
- tensorquantlib-0.3.0.dist-info/METADATA +602 -0
- tensorquantlib-0.3.0.dist-info/RECORD +44 -0
- tensorquantlib-0.3.0.dist-info/WHEEL +5 -0
- tensorquantlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- tensorquantlib-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Backtesting engine with realistic execution cost models."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from tensorquantlib.backtest.strategy import Strategy, Trade
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ------------------------------------------------------------------ #
|
|
12
|
+
# Execution cost models
|
|
13
|
+
# ------------------------------------------------------------------ #
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class SlippageModel:
|
|
17
|
+
"""Market-impact and bid-ask spread model.
|
|
18
|
+
|
|
19
|
+
Total slippage = half-spread cost + square-root market-impact cost.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
fixed_spread : float
|
|
24
|
+
One-way half-spread as a fraction of price.
|
|
25
|
+
E.g. ``0.0005`` = 5 bps one-way (10 bps round-trip).
|
|
26
|
+
market_impact : float
|
|
27
|
+
Square-root market-impact coefficient.
|
|
28
|
+
Cost per unit = ``price × market_impact × sqrt(|qty| / adv)``.
|
|
29
|
+
Set to 0 to disable market impact.
|
|
30
|
+
adv : float
|
|
31
|
+
Average daily volume (units). Used for impact scaling only.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
fixed_spread: float = 0.0
|
|
35
|
+
market_impact: float = 0.0
|
|
36
|
+
adv: float = 1_000_000.0
|
|
37
|
+
|
|
38
|
+
def cost(self, price: float, quantity: float) -> float:
|
|
39
|
+
"""Compute slippage cost for one trade (always non-negative)."""
|
|
40
|
+
spread_cost = abs(quantity) * price * self.fixed_spread
|
|
41
|
+
if self.market_impact > 0.0 and self.adv > 0.0:
|
|
42
|
+
impact_cost = (abs(quantity) * price * self.market_impact
|
|
43
|
+
* np.sqrt(abs(quantity) / self.adv))
|
|
44
|
+
else:
|
|
45
|
+
impact_cost = 0.0
|
|
46
|
+
return float(spread_cost + impact_cost)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class CommissionModel:
|
|
51
|
+
"""Commission / brokerage fee model.
|
|
52
|
+
|
|
53
|
+
Total commission = max(per_trade + per_unit×|qty| + percentage×notional,
|
|
54
|
+
minimum).
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
per_trade : float
|
|
59
|
+
Fixed fee per order (e.g. ``1.0`` = $1 per trade).
|
|
60
|
+
per_unit : float
|
|
61
|
+
Fee per unit traded (e.g. ``0.005`` = half a cent per share).
|
|
62
|
+
percentage : float
|
|
63
|
+
Fraction of notional (e.g. ``0.001`` = 10 bps of notional).
|
|
64
|
+
minimum : float
|
|
65
|
+
Minimum commission per trade.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
per_trade: float = 0.0
|
|
69
|
+
per_unit: float = 0.0
|
|
70
|
+
percentage: float = 0.0
|
|
71
|
+
minimum: float = 0.0
|
|
72
|
+
|
|
73
|
+
def cost(self, price: float, quantity: float) -> float:
|
|
74
|
+
"""Compute commission for one trade (always non-negative)."""
|
|
75
|
+
notional = abs(quantity) * price
|
|
76
|
+
c = (self.per_trade
|
|
77
|
+
+ abs(quantity) * self.per_unit
|
|
78
|
+
+ notional * self.percentage)
|
|
79
|
+
return float(max(c, self.minimum))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Pre-built convenience presets
|
|
83
|
+
#: Zero-cost model (default when no model is supplied).
|
|
84
|
+
ZERO_COST = CommissionModel()
|
|
85
|
+
#: Interactive Brokers-style equity commission ($0.005/share, $1 min).
|
|
86
|
+
EQUITY_COMM = CommissionModel(per_unit=0.005, minimum=1.0)
|
|
87
|
+
#: Typical institutional FX desk (0.2 bps of notional).
|
|
88
|
+
FX_COMM = CommissionModel(percentage=0.00002)
|
|
89
|
+
#: Liquid-equity half-spread slippage (5 bps one-way).
|
|
90
|
+
EQUITY_SLIP = SlippageModel(fixed_spread=0.0005)
|
|
91
|
+
#: Illiquid name: 20 bps spread + square-root market impact.
|
|
92
|
+
ILLIQUID_SLIP = SlippageModel(fixed_spread=0.002, market_impact=0.1, adv=50_000)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------ #
|
|
96
|
+
# Backtest result
|
|
97
|
+
# ------------------------------------------------------------------ #
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class BacktestResult:
|
|
101
|
+
"""Container for backtest output."""
|
|
102
|
+
|
|
103
|
+
equity_curve: np.ndarray
|
|
104
|
+
"""Equity value at each step."""
|
|
105
|
+
|
|
106
|
+
trades: list[Trade]
|
|
107
|
+
"""List of all executed trades."""
|
|
108
|
+
|
|
109
|
+
returns: np.ndarray
|
|
110
|
+
"""Per-step returns of the equity curve."""
|
|
111
|
+
|
|
112
|
+
final_equity: float
|
|
113
|
+
"""Final portfolio value."""
|
|
114
|
+
|
|
115
|
+
total_commission: float = 0.0
|
|
116
|
+
"""Total commissions paid during the backtest."""
|
|
117
|
+
|
|
118
|
+
total_slippage: float = 0.0
|
|
119
|
+
"""Total slippage cost paid during the backtest."""
|
|
120
|
+
|
|
121
|
+
total_turnover: float = 0.0
|
|
122
|
+
"""Total gross notional traded (sum of |qty| * price)."""
|
|
123
|
+
|
|
124
|
+
n_trades: int = 0
|
|
125
|
+
"""Number of trades executed."""
|
|
126
|
+
|
|
127
|
+
greeks_history: dict = field(default_factory=dict)
|
|
128
|
+
"""Per-step Greeks recorded by the strategy (if any)."""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------ #
|
|
132
|
+
# Engine
|
|
133
|
+
# ------------------------------------------------------------------ #
|
|
134
|
+
|
|
135
|
+
class BacktestEngine:
|
|
136
|
+
"""Run a :class:`Strategy` over a price series with realistic execution.
|
|
137
|
+
|
|
138
|
+
Parameters
|
|
139
|
+
----------
|
|
140
|
+
strategy : Strategy
|
|
141
|
+
Strategy instance to run.
|
|
142
|
+
prices : array-like
|
|
143
|
+
1-D array of asset prices (one per time step).
|
|
144
|
+
initial_capital : float
|
|
145
|
+
Starting cash.
|
|
146
|
+
slippage : SlippageModel, optional
|
|
147
|
+
Slippage model. Defaults to ``SlippageModel()`` (zero slippage).
|
|
148
|
+
Use :data:`EQUITY_SLIP` for a realistic liquid-equity preset.
|
|
149
|
+
commission : CommissionModel, optional
|
|
150
|
+
Commission model. Defaults to ``CommissionModel()`` (zero fees).
|
|
151
|
+
Use :data:`EQUITY_COMM` for an Interactive Brokers-style preset.
|
|
152
|
+
|
|
153
|
+
Examples
|
|
154
|
+
--------
|
|
155
|
+
Zero-cost (default)::
|
|
156
|
+
|
|
157
|
+
engine = BacktestEngine(strategy, prices)
|
|
158
|
+
|
|
159
|
+
With realistic costs::
|
|
160
|
+
|
|
161
|
+
from tensorquantlib.backtest.engine import EQUITY_SLIP, EQUITY_COMM
|
|
162
|
+
engine = BacktestEngine(
|
|
163
|
+
strategy, prices,
|
|
164
|
+
slippage=EQUITY_SLIP,
|
|
165
|
+
commission=EQUITY_COMM,
|
|
166
|
+
)
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
strategy: Strategy,
|
|
172
|
+
prices,
|
|
173
|
+
initial_capital: float = 1_000_000.0,
|
|
174
|
+
slippage: SlippageModel | None = None,
|
|
175
|
+
commission: CommissionModel | None = None,
|
|
176
|
+
):
|
|
177
|
+
self.strategy = strategy
|
|
178
|
+
self.prices = np.asarray(prices, dtype=float)
|
|
179
|
+
self.initial_capital = initial_capital
|
|
180
|
+
self.slippage = slippage if slippage is not None else SlippageModel()
|
|
181
|
+
self.commission = commission if commission is not None else CommissionModel()
|
|
182
|
+
|
|
183
|
+
def run(self) -> BacktestResult:
|
|
184
|
+
"""Execute the backtest and return a :class:`BacktestResult`."""
|
|
185
|
+
strat = self.strategy
|
|
186
|
+
prices = self.prices
|
|
187
|
+
n = len(prices)
|
|
188
|
+
|
|
189
|
+
strat.cash = self.initial_capital
|
|
190
|
+
strat.position = 0.0
|
|
191
|
+
strat.trades = []
|
|
192
|
+
|
|
193
|
+
equity = np.zeros(n)
|
|
194
|
+
total_commission = 0.0
|
|
195
|
+
total_slippage = 0.0
|
|
196
|
+
total_turnover = 0.0
|
|
197
|
+
|
|
198
|
+
for i in range(n):
|
|
199
|
+
desired = strat.on_data(i, prices[i])
|
|
200
|
+
delta_pos = desired - strat.position
|
|
201
|
+
|
|
202
|
+
if abs(delta_pos) > 1e-12:
|
|
203
|
+
slip = self.slippage.cost(prices[i], delta_pos)
|
|
204
|
+
comm = self.commission.cost(prices[i], delta_pos)
|
|
205
|
+
notional = abs(delta_pos) * prices[i]
|
|
206
|
+
|
|
207
|
+
total_slippage += slip
|
|
208
|
+
total_commission += comm
|
|
209
|
+
total_turnover += notional
|
|
210
|
+
|
|
211
|
+
# Cash: pay for shares + execution costs
|
|
212
|
+
strat.cash -= delta_pos * prices[i] + slip + comm
|
|
213
|
+
strat.position = desired
|
|
214
|
+
|
|
215
|
+
trade = Trade(
|
|
216
|
+
step=i,
|
|
217
|
+
quantity=delta_pos,
|
|
218
|
+
price=prices[i],
|
|
219
|
+
slippage=slip,
|
|
220
|
+
commission=comm,
|
|
221
|
+
)
|
|
222
|
+
strat.trades.append(trade)
|
|
223
|
+
strat.on_fill(trade)
|
|
224
|
+
|
|
225
|
+
equity[i] = strat.cash + strat.position * prices[i]
|
|
226
|
+
|
|
227
|
+
returns = np.diff(equity) / np.where(
|
|
228
|
+
np.abs(equity[:-1]) > 1e-12, equity[:-1], 1.0
|
|
229
|
+
)
|
|
230
|
+
return BacktestResult(
|
|
231
|
+
equity_curve=equity,
|
|
232
|
+
trades=strat.trades,
|
|
233
|
+
returns=returns,
|
|
234
|
+
final_equity=float(equity[-1]),
|
|
235
|
+
total_commission=total_commission,
|
|
236
|
+
total_slippage=total_slippage,
|
|
237
|
+
total_turnover=total_turnover,
|
|
238
|
+
n_trades=len(strat.trades),
|
|
239
|
+
greeks_history=dict(getattr(strat, "_greeks_history", {})),
|
|
240
|
+
)
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Performance metrics for backtesting."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from tensorquantlib.backtest.strategy import Trade
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sharpe_ratio(returns: np.ndarray, rf: float = 0.0,
|
|
13
|
+
periods_per_year: int = 252) -> float:
|
|
14
|
+
"""Annualised Sharpe ratio.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
returns : array-like
|
|
19
|
+
Period returns (e.g. daily).
|
|
20
|
+
rf : float
|
|
21
|
+
Risk-free rate per period.
|
|
22
|
+
periods_per_year : int
|
|
23
|
+
Annualisation factor (252 for daily).
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
-------
|
|
27
|
+
float
|
|
28
|
+
Annualised Sharpe ratio.
|
|
29
|
+
"""
|
|
30
|
+
r = np.asarray(returns, dtype=float)
|
|
31
|
+
excess = r - rf
|
|
32
|
+
std = np.std(excess, ddof=1)
|
|
33
|
+
if std < 1e-15:
|
|
34
|
+
return 0.0
|
|
35
|
+
return float(np.mean(excess) / std * np.sqrt(periods_per_year))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def max_drawdown(equity: np.ndarray) -> float:
|
|
39
|
+
"""Maximum drawdown from peak.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
equity : array-like
|
|
44
|
+
Equity curve (absolute values).
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
float
|
|
49
|
+
Maximum drawdown as a positive fraction (e.g. 0.15 = 15%).
|
|
50
|
+
"""
|
|
51
|
+
eq = np.asarray(equity, dtype=float)
|
|
52
|
+
peak = np.maximum.accumulate(eq)
|
|
53
|
+
dd = (peak - eq) / np.where(peak > 0, peak, 1.0)
|
|
54
|
+
return float(np.max(dd))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sortino_ratio(returns: np.ndarray, rf: float = 0.0,
|
|
58
|
+
periods_per_year: int = 252) -> float:
|
|
59
|
+
"""Annualised Sortino ratio (downside deviation only).
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
returns : array-like
|
|
64
|
+
Period returns.
|
|
65
|
+
rf : float
|
|
66
|
+
Risk-free rate per period.
|
|
67
|
+
periods_per_year : int
|
|
68
|
+
Annualisation factor.
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
float
|
|
73
|
+
Annualised Sortino ratio.
|
|
74
|
+
"""
|
|
75
|
+
r = np.asarray(returns, dtype=float)
|
|
76
|
+
excess = r - rf
|
|
77
|
+
downside = excess[excess < 0]
|
|
78
|
+
if len(downside) == 0:
|
|
79
|
+
return float("inf") if np.mean(excess) > 0 else 0.0
|
|
80
|
+
down_std = np.sqrt(np.mean(downside**2))
|
|
81
|
+
if down_std < 1e-15:
|
|
82
|
+
return 0.0
|
|
83
|
+
return float(np.mean(excess) / down_std * np.sqrt(periods_per_year))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def win_rate(trades: np.ndarray) -> float:
|
|
87
|
+
"""Fraction of profitable trades.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
trades : array-like
|
|
92
|
+
P&L per trade (positive = profit).
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
float
|
|
97
|
+
Win rate in [0, 1].
|
|
98
|
+
"""
|
|
99
|
+
t = np.asarray(trades, dtype=float)
|
|
100
|
+
if len(t) == 0:
|
|
101
|
+
return 0.0
|
|
102
|
+
return float(np.sum(t > 0) / len(t))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def profit_factor(trades: np.ndarray) -> float:
|
|
106
|
+
"""Profit factor = gross profits / gross losses.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
trades : array-like
|
|
111
|
+
P&L per trade.
|
|
112
|
+
|
|
113
|
+
Returns
|
|
114
|
+
-------
|
|
115
|
+
float
|
|
116
|
+
Profit factor. Returns inf if no losses.
|
|
117
|
+
"""
|
|
118
|
+
t = np.asarray(trades, dtype=float)
|
|
119
|
+
gross_profit = float(np.sum(t[t > 0]))
|
|
120
|
+
gross_loss = float(np.abs(np.sum(t[t < 0])))
|
|
121
|
+
if gross_loss < 1e-15:
|
|
122
|
+
return float("inf") if gross_profit > 0 else 0.0
|
|
123
|
+
return gross_profit / gross_loss
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------ #
|
|
127
|
+
# Additional metrics
|
|
128
|
+
# ------------------------------------------------------------------ #
|
|
129
|
+
|
|
130
|
+
def annualized_return(equity: np.ndarray, periods_per_year: int = 252) -> float:
|
|
131
|
+
"""Compound annualized growth rate (CAGR) from an equity curve.
|
|
132
|
+
|
|
133
|
+
Parameters
|
|
134
|
+
----------
|
|
135
|
+
equity : array-like
|
|
136
|
+
Equity value at each step (must start > 0).
|
|
137
|
+
periods_per_year : int
|
|
138
|
+
Annualisation factor (252 for daily).
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
float
|
|
143
|
+
Annualized return, e.g. 0.12 = 12 %.
|
|
144
|
+
"""
|
|
145
|
+
eq = np.asarray(equity, dtype=float)
|
|
146
|
+
if len(eq) < 2 or eq[0] <= 0:
|
|
147
|
+
return 0.0
|
|
148
|
+
total_periods = len(eq) - 1
|
|
149
|
+
years = total_periods / periods_per_year
|
|
150
|
+
if years <= 0:
|
|
151
|
+
return 0.0
|
|
152
|
+
return float((eq[-1] / eq[0]) ** (1.0 / years) - 1.0)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def calmar_ratio(equity: np.ndarray, periods_per_year: int = 252) -> float:
|
|
156
|
+
"""Calmar ratio = annualized return / maximum drawdown.
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
equity : array-like
|
|
161
|
+
Equity curve.
|
|
162
|
+
periods_per_year : int
|
|
163
|
+
Annualisation factor.
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
float
|
|
168
|
+
Calmar ratio. Returns ``inf`` if max drawdown is zero.
|
|
169
|
+
"""
|
|
170
|
+
ann_ret = annualized_return(equity, periods_per_year)
|
|
171
|
+
mdd = max_drawdown(equity)
|
|
172
|
+
if mdd < 1e-15:
|
|
173
|
+
return float("inf") if ann_ret > 0 else 0.0
|
|
174
|
+
return float(ann_ret / mdd)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def information_ratio(
|
|
178
|
+
strategy_returns: np.ndarray,
|
|
179
|
+
benchmark_returns: np.ndarray,
|
|
180
|
+
periods_per_year: int = 252,
|
|
181
|
+
) -> float:
|
|
182
|
+
"""Annualised information ratio (IR).
|
|
183
|
+
|
|
184
|
+
IR = E[active_return] / std(active_return) * sqrt(periods_per_year)
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
strategy_returns : array-like
|
|
189
|
+
Period returns of the strategy.
|
|
190
|
+
benchmark_returns : array-like
|
|
191
|
+
Period returns of the benchmark.
|
|
192
|
+
periods_per_year : int
|
|
193
|
+
Annualisation factor.
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
float
|
|
198
|
+
Annualised information ratio.
|
|
199
|
+
"""
|
|
200
|
+
active = np.asarray(strategy_returns, dtype=float) - np.asarray(benchmark_returns, dtype=float)
|
|
201
|
+
std = np.std(active, ddof=1)
|
|
202
|
+
if std < 1e-15:
|
|
203
|
+
return 0.0
|
|
204
|
+
return float(np.mean(active) / std * np.sqrt(periods_per_year))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def turnover(trades: list, initial_capital: float = 1_000_000.0) -> float:
|
|
208
|
+
"""Annualized one-way turnover as a fraction of initial capital.
|
|
209
|
+
|
|
210
|
+
turnover = total_notional_traded / initial_capital
|
|
211
|
+
|
|
212
|
+
Parameters
|
|
213
|
+
----------
|
|
214
|
+
trades : list of Trade
|
|
215
|
+
Executed trades (each must have ``.quantity`` and ``.price``).
|
|
216
|
+
initial_capital : float
|
|
217
|
+
Starting capital used as the denominator.
|
|
218
|
+
|
|
219
|
+
Returns
|
|
220
|
+
-------
|
|
221
|
+
float
|
|
222
|
+
Turnover (e.g. 2.5 = 250 % of capital traded).
|
|
223
|
+
"""
|
|
224
|
+
if not trades:
|
|
225
|
+
return 0.0
|
|
226
|
+
total_notional = sum(abs(t.quantity) * t.price for t in trades)
|
|
227
|
+
return float(total_notional / initial_capital)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def hedge_pnl_attribution(
|
|
231
|
+
equity_curve: np.ndarray,
|
|
232
|
+
deltas: list,
|
|
233
|
+
gammas: list,
|
|
234
|
+
prices: np.ndarray,
|
|
235
|
+
) -> dict[str, np.ndarray]:
|
|
236
|
+
"""Decompose per-step portfolio P&L into Delta, Gamma, and residual.
|
|
237
|
+
|
|
238
|
+
For a continuously delta-hedged portfolio, the per-step P&L can be
|
|
239
|
+
approximated as:
|
|
240
|
+
|
|
241
|
+
.. math::
|
|
242
|
+
|
|
243
|
+
\\text{Total P\\&L} \\approx
|
|
244
|
+
\\underbrace{\\Delta \\cdot \\Delta S}_{\\text{delta P\\&L}}
|
|
245
|
+
+ \\underbrace{\\tfrac{1}{2}\\Gamma (\\Delta S)^2}_{\\text{gamma P\\&L}}
|
|
246
|
+
+ \\text{residual}
|
|
247
|
+
|
|
248
|
+
The residual captures higher-order terms (Theta decay, vanna, etc.) and
|
|
249
|
+
hedging error from discrete rebalancing.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
equity_curve : array-like
|
|
254
|
+
Portfolio equity at each step (length N).
|
|
255
|
+
deltas : list of float
|
|
256
|
+
Per-step delta of the overall position (length N).
|
|
257
|
+
gammas : list of float
|
|
258
|
+
Per-step gamma (length N).
|
|
259
|
+
prices : array-like
|
|
260
|
+
Underlying asset price at each step (length N).
|
|
261
|
+
|
|
262
|
+
Returns
|
|
263
|
+
-------
|
|
264
|
+
dict
|
|
265
|
+
Keys ``'delta_pnl'``, ``'gamma_pnl'``, ``'residual_pnl'``.
|
|
266
|
+
Each is an array of length N-1.
|
|
267
|
+
"""
|
|
268
|
+
equity = np.asarray(equity_curve, dtype=float)
|
|
269
|
+
prices_arr = np.asarray(prices, dtype=float)
|
|
270
|
+
delta_arr = np.asarray(deltas, dtype=float)
|
|
271
|
+
gamma_arr = np.asarray(gammas, dtype=float)
|
|
272
|
+
|
|
273
|
+
n = len(equity) - 1
|
|
274
|
+
total_pnl = np.diff(equity)
|
|
275
|
+
dS = np.diff(prices_arr[:n + 1])
|
|
276
|
+
|
|
277
|
+
delta_pnl = delta_arr[:n] * dS
|
|
278
|
+
gamma_pnl = 0.5 * gamma_arr[:n] * dS ** 2
|
|
279
|
+
residual_pnl = total_pnl - delta_pnl - gamma_pnl
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
"delta_pnl": delta_pnl,
|
|
283
|
+
"gamma_pnl": gamma_pnl,
|
|
284
|
+
"residual_pnl": residual_pnl,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def hedge_efficiency(
|
|
289
|
+
hedged_equity: np.ndarray,
|
|
290
|
+
unhedged_equity: np.ndarray,
|
|
291
|
+
) -> float:
|
|
292
|
+
"""Variance reduction achieved by a hedge.
|
|
293
|
+
|
|
294
|
+
.. math::
|
|
295
|
+
|
|
296
|
+
\\text{efficiency} = 1 - \\frac{\\operatorname{Var}(\\text{hedged P\\&L})}{
|
|
297
|
+
\\operatorname{Var}(\\text{unhedged P\\&L})}
|
|
298
|
+
|
|
299
|
+
Returns 1.0 for a perfect hedge, 0.0 if the hedge adds no value,
|
|
300
|
+
and a negative number if the hedge increases variance.
|
|
301
|
+
|
|
302
|
+
Parameters
|
|
303
|
+
----------
|
|
304
|
+
hedged_equity : array-like
|
|
305
|
+
Equity curve of the hedged portfolio.
|
|
306
|
+
unhedged_equity : array-like
|
|
307
|
+
Equity curve of the same portfolio *without* hedging.
|
|
308
|
+
|
|
309
|
+
Returns
|
|
310
|
+
-------
|
|
311
|
+
float
|
|
312
|
+
Hedge efficiency in (-inf, 1].
|
|
313
|
+
"""
|
|
314
|
+
hedged_ret = np.diff(np.asarray(hedged_equity, dtype=float))
|
|
315
|
+
unhedged_ret = np.diff(np.asarray(unhedged_equity, dtype=float))
|
|
316
|
+
var_unhedged = float(np.var(unhedged_ret, ddof=1))
|
|
317
|
+
if var_unhedged < 1e-15:
|
|
318
|
+
return 0.0
|
|
319
|
+
var_hedged = float(np.var(hedged_ret, ddof=1))
|
|
320
|
+
return float(1.0 - var_hedged / var_unhedged)
|