quantex 0.1.0__tar.gz

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.
quantex-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.3
2
+ Name: quantex
3
+ Version: 0.1.0
4
+ Summary: A simple quant strategy creation and backtesting package.
5
+ Author: Daniel Green
6
+ Author-email: dangreen07@outlook.com
7
+ Requires-Python: >=3.13,<4
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Requires-Dist: fastparquet (>=2024.11.0,<2025.0.0)
11
+ Requires-Dist: matplotlib (>=3.10.3,<4.0.0)
12
+ Requires-Dist: mkdocs-material (>=9.6.15,<10.0.0)
13
+ Requires-Dist: mkdocs-mermaid2-plugin (>=1.2.1,<2.0.0)
14
+ Requires-Dist: mkdocs-to-pdf (>=0.10.1,<0.11.0)
15
+ Requires-Dist: pandas (>=2.3.0,<3.0.0)
16
+ Requires-Dist: pytest-xdist (>=3.8.0,<4.0.0)
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Quantex
20
+
21
+ A simple quant strategy creation and back-testing package written in Python.
22
+ Quantex aims to provide a lightweight foundation for building trading
23
+ strategies, ingesting historical market data, and evaluating performance –
24
+ all without the heavy overhead of larger, more opinionated quant libraries.
25
+
26
+ ---
27
+
28
+ ## Table of Contents
29
+ 1. [Features](#features)
30
+ 2. [Project Layout](#project-layout)
31
+ 3. [Installation](#installation)
32
+ 4. [Running Tests](#running-tests)
33
+ 5. [Development](#development)
34
+ 6. [Contributing](#contributing)
35
+
36
+ ---
37
+
38
+ ## Features
39
+ * **Data Abstraction** – A generic `DataSource` interface that you can
40
+ subclass to plug in CSVs, Parquet files, live feeds, databases, etc.
41
+ * **Back-testing Support** – A `BacktestingDataSource` base class to drive
42
+ offline simulations.
43
+ * **Strategy Skeleton** – Extendable `Strategy` base class for plug-and-play
44
+ trading logic.
45
+ * **Core Data Models** – Immutable `Bar`, `Tick`, `Order`, `Fill`, plus
46
+ stateful `Position` / `Portfolio` helpers for P&L accounting.
47
+ * **Black + Ruff Pre-commit** – `black` auto-formats and `ruff` lints every
48
+ commit via *pre-commit* hooks, keeping the codebase consistent.
49
+ * **Python 3.13+** – Embraces the latest language features.
50
+ * **Poetry-managed** – Modern dependency management, packaging, and virtual
51
+ environment handling.
52
+
53
+ > **Note:** The public API is still under heavy development and may change
54
+ > until v1.0. Feedback is welcome!
55
+
56
+ ---
57
+
58
+ ## Installation
59
+ Quantex is managed with [Poetry](https://python-poetry.org/). Clone the
60
+ repository and install the dependencies in an isolated virtual environment:
61
+
62
+ ```bash
63
+ # Clone the repo
64
+ $ git clone https://github.com/dangreen07/quantex.git
65
+ $ cd quantex
66
+
67
+ # Install dependencies
68
+ $ poetry install
69
+ $ poetry build
70
+ $ pip install dist/quantex-0.1.0-py3-none-any.whl
71
+ ```
72
+
73
+ This command will:
74
+ 1. Create / activate a local virtual-env (unless Poetry is configured to use a
75
+ global env).
76
+ 2. Install package dependencies from `pyproject.toml`.
77
+ 3. Install Quantex itself in *editable* mode, so changes you make in `src/` are
78
+ reflected immediately.
79
+
80
+ ---
81
+
82
+ ## Running Tests
83
+ Quantex uses [pytest](https://docs.pytest.org/) for its test suite. After
84
+ installing the dev dependencies (`poetry install` above), simply run:
85
+
86
+ ```bash
87
+ poetry run pytest
88
+ ```
89
+
90
+ You should see tests collect and run successfully. Ensure your editor uses the
91
+ Poetry virtual-env so import paths resolve correctly.
92
+
93
+ ---
94
+
95
+ ## Development
96
+ 1. Create a new branch: `git checkout -b feature/<name>`
97
+ 2. Write your code & tests.
98
+ 3. Install the git hooks once per clone: `poetry run pre-commit install`.
99
+ Hooks will run `black --check` and `ruff` automatically on every commit.
100
+ 4. Ensure `poetry run pytest` passes and the pre-commit hooks are clean.
101
+
102
+ ---
103
+
104
+ ## Contributing
105
+ Contributions, bug reports, and feature requests are welcome! Please open an
106
+ issue to discuss what you'd like to work on or submit a pull request directly.
107
+ We follow the "fork → feature branch → pull request" workflow. By
108
+ contributing you agree to license your work under the same terms as Quantex.
109
+
@@ -0,0 +1,90 @@
1
+ # Quantex
2
+
3
+ A simple quant strategy creation and back-testing package written in Python.
4
+ Quantex aims to provide a lightweight foundation for building trading
5
+ strategies, ingesting historical market data, and evaluating performance –
6
+ all without the heavy overhead of larger, more opinionated quant libraries.
7
+
8
+ ---
9
+
10
+ ## Table of Contents
11
+ 1. [Features](#features)
12
+ 2. [Project Layout](#project-layout)
13
+ 3. [Installation](#installation)
14
+ 4. [Running Tests](#running-tests)
15
+ 5. [Development](#development)
16
+ 6. [Contributing](#contributing)
17
+
18
+ ---
19
+
20
+ ## Features
21
+ * **Data Abstraction** – A generic `DataSource` interface that you can
22
+ subclass to plug in CSVs, Parquet files, live feeds, databases, etc.
23
+ * **Back-testing Support** – A `BacktestingDataSource` base class to drive
24
+ offline simulations.
25
+ * **Strategy Skeleton** – Extendable `Strategy` base class for plug-and-play
26
+ trading logic.
27
+ * **Core Data Models** – Immutable `Bar`, `Tick`, `Order`, `Fill`, plus
28
+ stateful `Position` / `Portfolio` helpers for P&L accounting.
29
+ * **Black + Ruff Pre-commit** – `black` auto-formats and `ruff` lints every
30
+ commit via *pre-commit* hooks, keeping the codebase consistent.
31
+ * **Python 3.13+** – Embraces the latest language features.
32
+ * **Poetry-managed** – Modern dependency management, packaging, and virtual
33
+ environment handling.
34
+
35
+ > **Note:** The public API is still under heavy development and may change
36
+ > until v1.0. Feedback is welcome!
37
+
38
+ ---
39
+
40
+ ## Installation
41
+ Quantex is managed with [Poetry](https://python-poetry.org/). Clone the
42
+ repository and install the dependencies in an isolated virtual environment:
43
+
44
+ ```bash
45
+ # Clone the repo
46
+ $ git clone https://github.com/dangreen07/quantex.git
47
+ $ cd quantex
48
+
49
+ # Install dependencies
50
+ $ poetry install
51
+ $ poetry build
52
+ $ pip install dist/quantex-0.1.0-py3-none-any.whl
53
+ ```
54
+
55
+ This command will:
56
+ 1. Create / activate a local virtual-env (unless Poetry is configured to use a
57
+ global env).
58
+ 2. Install package dependencies from `pyproject.toml`.
59
+ 3. Install Quantex itself in *editable* mode, so changes you make in `src/` are
60
+ reflected immediately.
61
+
62
+ ---
63
+
64
+ ## Running Tests
65
+ Quantex uses [pytest](https://docs.pytest.org/) for its test suite. After
66
+ installing the dev dependencies (`poetry install` above), simply run:
67
+
68
+ ```bash
69
+ poetry run pytest
70
+ ```
71
+
72
+ You should see tests collect and run successfully. Ensure your editor uses the
73
+ Poetry virtual-env so import paths resolve correctly.
74
+
75
+ ---
76
+
77
+ ## Development
78
+ 1. Create a new branch: `git checkout -b feature/<name>`
79
+ 2. Write your code & tests.
80
+ 3. Install the git hooks once per clone: `poetry run pre-commit install`.
81
+ Hooks will run `black --check` and `ruff` automatically on every commit.
82
+ 4. Ensure `poetry run pytest` passes and the pre-commit hooks are clean.
83
+
84
+ ---
85
+
86
+ ## Contributing
87
+ Contributions, bug reports, and feature requests are welcome! Please open an
88
+ issue to discuss what you'd like to work on or submit a pull request directly.
89
+ We follow the "fork → feature branch → pull request" workflow. By
90
+ contributing you agree to license your work under the same terms as Quantex.
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "quantex"
3
+ version = "0.1.0"
4
+ description = "A simple quant strategy creation and backtesting package."
5
+ authors = [
6
+ {name = "Daniel Green",email = "dangreen07@outlook.com"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.13,<4"
10
+ dependencies = [
11
+ "pandas (>=2.3.0,<3.0.0)",
12
+ "fastparquet (>=2024.11.0,<2025.0.0)",
13
+ "pytest-xdist (>=3.8.0,<4.0.0)",
14
+ "matplotlib (>=3.10.3,<4.0.0)",
15
+ "mkdocs-to-pdf (>=0.10.1,<0.11.0)",
16
+ "mkdocs-material (>=9.6.15,<10.0.0)",
17
+ "mkdocs-mermaid2-plugin (>=1.2.1,<2.0.0)",
18
+ ]
19
+
20
+ [tool.poetry]
21
+ packages = [{include = "quantex", from = "src"}]
22
+
23
+
24
+ [tool.poetry.group.dev.dependencies]
25
+ pytest = "^8.4.1"
26
+ black = "^25.1.0"
27
+ pre-commit = "^4.2.0"
28
+ ruff = "^0.12.2"
29
+ mkdocs = "^1.6.1"
30
+ mkdocs-material = "^9.6.15"
31
+ mkdocstrings = "^0.29.1"
32
+ mkdocstrings-python = "^1.8.0"
33
+ pytest-benchmark = "^4.0.0"
34
+ pytest-cov = "^6.2.1"
35
+ mypy = "^1.16.1"
36
+ pandas-ta = "^0.3.14b0"
37
+ setuptools = "^80.9.0"
38
+
39
+ [build-system]
40
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
41
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,18 @@
1
+ from .sources import (
2
+ DataSource as DataSource,
3
+ BacktestingDataSource as BacktestingDataSource,
4
+ )
5
+ from .strategy import Strategy as Strategy
6
+ from .models import (
7
+ Bar as Bar,
8
+ Tick as Tick,
9
+ Order as Order,
10
+ Fill as Fill,
11
+ Position as Position,
12
+ Portfolio as Portfolio,
13
+ Trade as Trade,
14
+ )
15
+ from .engine import EventBus as EventBus
16
+ from .execution import ImmediateFillSimulator as ImmediateFillSimulator
17
+ from .execution import NextBarSimulator as NextBarSimulator
18
+ from .backtest import BacktestRunner as BacktestRunner, BacktestResult as BacktestResult
@@ -0,0 +1,328 @@
1
+ """Backtest orchestration utilities.
2
+
3
+ This module exposes:
4
+ * ``BacktestResult`` – a dataclass aggregating NAV, orders, fills and metrics.
5
+ * ``BacktestRunner`` – a convenience wrapper that wires together a Strategy,
6
+ one or more DataSource objects, an execution simulator and the internal
7
+ EventBus. End-users typically instantiate ``BacktestRunner`` once per
8
+ test and call `run()` to obtain a `BacktestResult`.
9
+
10
+ """
11
+
12
+ from __future__ import annotations
13
+ from dataclasses import dataclass
14
+ import math
15
+ from typing import Mapping, Any
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ from quantex.engine import EventBus
21
+ from quantex.execution import ImmediateFillSimulator, NextBarSimulator
22
+ from quantex.sources import BacktestingDataSource
23
+ from quantex.strategy import Strategy
24
+ from quantex.models import Order, Fill
25
+
26
+
27
+ class Metrics(dict):
28
+ """Lightweight container that formats metrics nicely when printed."""
29
+
30
+ def __str__(self) -> str: # noqa: DunderAll – explicit override
31
+ def _fmt(val: Any) -> str:
32
+ # Uniform float formatting to 4 decimals while keeping ints/others intact
33
+ if isinstance(val, float):
34
+ return f"{val:.4f}"
35
+ return str(val)
36
+
37
+ return "\n".join(f"{k:<20}: {_fmt(v)}" for k, v in sorted(self.items()))
38
+
39
+ __repr__ = __str__
40
+
41
+
42
+ @dataclass
43
+ class BacktestResult:
44
+ """Contains the results of a backtest.
45
+
46
+ Attributes:
47
+ nav: A pandas Series representing the Net Asset Value (NAV) over time.
48
+ orders: A list of all orders generated during the backtest.
49
+ fills: A list of all fills executed during the backtest.
50
+ metrics: A dictionary of performance metrics.
51
+ """
52
+
53
+ nav: pd.Series
54
+ orders: list[Order]
55
+ fills: list[Fill]
56
+ metrics: Metrics
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Backtest Runner
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ class BacktestRunner:
65
+ """User-facing helper that wires Strategy, EventBus, and Simulator."""
66
+
67
+ def __init__(
68
+ self,
69
+ strategy: Strategy,
70
+ data_sources: Mapping[str, BacktestingDataSource],
71
+ risk_free_rate: float = 0.0,
72
+ min_holding_period: pd.Timedelta | None = None,
73
+ simulator: ImmediateFillSimulator | NextBarSimulator | None = None,
74
+ ):
75
+ """Initializes the BacktestRunner.
76
+
77
+ Args:
78
+ strategy: The trading strategy to be backtested.
79
+ data_sources: A dictionary of data sources for the backtest.
80
+ risk_free_rate: The risk-free rate to use for the Sharpe ratio.
81
+ periods_per_year: The number of periods per year to use for the Sharpe ratio.
82
+ min_holding_period: Optional minimum holding period for positions.
83
+ simulator: Execution simulator to use. Defaults to NextBarSimulator
84
+ for more realistic order execution timing.
85
+ """
86
+ self.strategy = strategy
87
+ self.data_sources = data_sources
88
+ if simulator is None:
89
+ self.simulator = NextBarSimulator(
90
+ self.strategy.portfolio,
91
+ min_holding_period=min_holding_period,
92
+ )
93
+ else:
94
+ self.simulator = simulator
95
+ self.event_bus = EventBus(strategy, data_sources, self.simulator)
96
+ self.risk_free_rate = risk_free_rate
97
+ self.periods_per_year = BacktestRunner._find_periods_per_year(data_sources)
98
+
99
+ @staticmethod
100
+ def _find_periods_per_year(
101
+ data_sources: Mapping[str, BacktestingDataSource],
102
+ ) -> int:
103
+ """Infer *periods_per_year* for annualising metrics.
104
+
105
+ The function first tries to find the modal time-delta using timestamps
106
+ common to **all** data sources. If fewer than two common timestamps
107
+ exist (feeds with disjoint calendars or heavy gaps), it falls back to
108
+ computing the modal bar size **per source** and subsequently picks the
109
+ *smallest* step – i.e. the highest frequency – across the set. This
110
+ approach supports back-tests where assets are intentionally
111
+ mis-aligned while preserving compatibility with single-source
112
+ scenarios.
113
+ """
114
+ # 1. Intersection of all indices
115
+ intersection: pd.Index | None = None
116
+ for ds in data_sources.values():
117
+ idx = ds.get_raw_data().index
118
+ intersection = (
119
+ idx if intersection is None else intersection.intersection(idx)
120
+ )
121
+
122
+ if intersection is None or len(intersection) < 2:
123
+ # Fallback – infer the modal bar size from individual data sources
124
+ # rather than their intersection. This supports scenarios where the
125
+ # feeds are intentionally mis-aligned (e.g. assets trading on
126
+ # slightly different calendars or with missing bars).
127
+
128
+ step_candidates: list[float] = []
129
+ for ds in data_sources.values():
130
+ idx_ds = pd.to_datetime(ds.get_raw_data().index).sort_values()
131
+ if len(idx_ds) < 2:
132
+ # Cannot infer a step from fewer than two timestamps
133
+ continue
134
+
135
+ deltas_ds = idx_ds.to_series().diff().dropna().dt.total_seconds()
136
+ if not deltas_ds.empty:
137
+ # Use the mode (most frequent) delta for this datasource
138
+ step_candidates.append(float(deltas_ds.mode().iat[0]))
139
+
140
+ if not step_candidates:
141
+ raise ValueError(
142
+ "Unable to infer bar frequency – insufficient timestamp data across sources"
143
+ )
144
+
145
+ # Choose the smallest (highest-frequency) step among all candidates
146
+ step_sec = min(step_candidates)
147
+ else:
148
+ # 2. Consecutive deltas in seconds – use the common intersection
149
+ idx = pd.to_datetime(intersection).sort_values()
150
+ deltas_sec = idx.to_series().diff().dropna().dt.total_seconds()
151
+
152
+ # 3. Typical step = mode of the delta distribution
153
+ step_sec = deltas_sec.mode().iat[0]
154
+
155
+ if step_sec == 0:
156
+ raise ValueError("Zero-length step encountered")
157
+
158
+ # 4. Convert to periods per year
159
+ if step_sec == 60:
160
+ periods_per_year = 252 * 6.5 * 60
161
+ elif step_sec == 60 * 60:
162
+ periods_per_year = 252 * 6.5
163
+ elif step_sec == 60 * 60 * 24:
164
+ periods_per_year = 252
165
+ else:
166
+ # Generic conversion for uncommon frequencies – fall back to
167
+ # seconds-in-year divided by step size.
168
+ seconds_in_year = 365 * 24 * 60 * 60
169
+ periods_per_year = seconds_in_year / step_sec
170
+
171
+ return math.ceil(periods_per_year)
172
+
173
+ def run(self, *, metrics_style: str = "default") -> BacktestResult:
174
+ """Runs the back-test.
175
+
176
+ Args:
177
+ metrics_style: Controls how certain metrics are computed. Accepted
178
+ values:
179
+
180
+ * ``"default"`` – sample‐stdev Sharpe (ddof=1) and other
181
+ academic conventions (the library default).
182
+ * ``"bt"`` – compatibility mode mirroring the `backtesting.py`
183
+ package: population st-dev (ddof=0) and risk-free rate 0.0.
184
+
185
+ Returns:
186
+ A :class:`BacktestResult` instance containing NAV, orders, fills
187
+ and metrics.
188
+ """
189
+ self.event_bus.run()
190
+ nav_series = pd.Series(
191
+ self.event_bus.nav, index=self.event_bus.timestamps, name="NAV"
192
+ )
193
+
194
+ metrics: dict = {}
195
+
196
+ if not nav_series.empty and nav_series.iloc[0] != 0:
197
+ metrics["total_return"] = nav_series.iloc[-1] / nav_series.iloc[0] - 1
198
+
199
+ # Maximum drawdown ---------------------------------------------
200
+ metrics["max_drawdown"] = _max_drawdown(nav_series)
201
+
202
+ # Sharpe ratio -------------------------------------------------
203
+ if len(nav_series) > 1:
204
+ # Population vs sample st-dev depending on selected style
205
+ ddof = 0 if metrics_style == "bt" else 1
206
+ metrics["sharpe_ratio"] = _annualised_sharpe(
207
+ nav_series, self.risk_free_rate, self.periods_per_year, ddof
208
+ )
209
+
210
+ # --- Additional metrics ------------------------------------
211
+ periods = len(nav_series)
212
+ if periods > 1:
213
+ # Annualised (geometric) return – CAGR
214
+ #
215
+ # Very short back-tests (few bars) can yield a huge exponent
216
+ # *(periods_per_year / periods)* which, when combined with a
217
+ # ratio slightly >1, overflows the double-precision range and
218
+ # emits a RuntimeWarning. We compute the power inside a NumPy
219
+ # *errstate* context that suppresses the warning while still
220
+ # returning ``inf`` for pathological cases.
221
+ with np.errstate(over="ignore", invalid="ignore"):
222
+ cagr_val = (
223
+ np.power(
224
+ nav_series.iloc[-1] / nav_series.iloc[0],
225
+ self.periods_per_year / periods,
226
+ )
227
+ - 1.0
228
+ )
229
+
230
+ # Cast to Python float for stable downstream formatting
231
+ metrics["cagr"] = float(cagr_val)
232
+
233
+ returns = nav_series.pct_change().dropna()
234
+ # Annualised volatility – align ddof with Sharpe selection
235
+ metrics["volatility_annualised"] = returns.std(ddof=ddof) * np.sqrt(
236
+ self.periods_per_year
237
+ )
238
+
239
+ # Sortino ratio (downside risk only)
240
+ downside = returns[returns < 0]
241
+ if len(downside) > 0 and downside.std(ddof=0) != 0:
242
+ metrics["sortino_ratio"] = (
243
+ returns.mean() / downside.std(ddof=0)
244
+ ) * np.sqrt(self.periods_per_year)
245
+ else:
246
+ metrics["sortino_ratio"] = float("nan")
247
+
248
+ # Calmar ratio – CAGR divided by abs(max drawdown)
249
+ max_dd = abs(metrics["max_drawdown"])
250
+ metrics["calmar_ratio"] = (
251
+ metrics["cagr"] / max_dd if max_dd > 0 else float("nan")
252
+ )
253
+
254
+ # Buy & hold return of first symbol (if available)
255
+ price_df = getattr(self.event_bus, "_price_df", None)
256
+ if price_df is not None and not price_df.empty:
257
+ first_symbol = price_df.columns[0]
258
+ metrics["buy_hold_return"] = (
259
+ price_df[first_symbol].iloc[-1] / price_df[first_symbol].iloc[0]
260
+ - 1
261
+ )
262
+
263
+ return BacktestResult(
264
+ nav_series, self.event_bus.orders, self.event_bus.fills, Metrics(metrics)
265
+ )
266
+
267
+
268
+ def _annualised_sharpe(
269
+ nav: pd.Series,
270
+ risk_free_rate: float = 0.0,
271
+ periods_per_year: int = 98_280,
272
+ ddof: int = 1,
273
+ ) -> float:
274
+ """Compute the annualised Sharpe ratio for a NAV series.
275
+
276
+ Args:
277
+ nav: Series of portfolio values indexed by timestamp.
278
+ risk_free_rate: Annual risk-free rate expressed as a decimal. Defaults to
279
+ 4.3 % (0.043).
280
+ periods_per_year: Number of return observations in a year. For US
281
+ equity market minutes this is ``252 * 390 = 98_280``. For assets
282
+ trading 24/7 (e.g. crypto) use ``365 * 1_440``.
283
+ ddof: Delta Degrees of Freedom for the standard deviation.
284
+
285
+ Returns:
286
+ Annualised Sharpe ratio. If the standard deviation of excess returns is
287
+ zero the function returns ``np.nan``.
288
+ """
289
+
290
+ # Minute-to-minute returns
291
+ returns = nav.pct_change().dropna()
292
+ if returns.empty:
293
+ return float("nan")
294
+
295
+ # Per-period risk-free rate
296
+ rf_per_period = (1 + risk_free_rate) ** (1 / periods_per_year) - 1
297
+
298
+ excess = returns - rf_per_period
299
+
300
+ # Sample standard deviation (ddof=1) matches common Sharpe implementations
301
+ std = excess.std(ddof=ddof)
302
+ if std == 0:
303
+ return float("nan")
304
+
305
+ return np.sqrt(periods_per_year) * excess.mean() / std
306
+
307
+
308
+ def _max_drawdown(nav: pd.Series) -> float:
309
+ """Compute the maximum drawdown for a NAV series.
310
+
311
+ Args:
312
+ nav: Series of portfolio values indexed by timestamp.
313
+
314
+ Returns:
315
+ Maximum drawdown as a percentage (e.g., -0.15 for 15% drawdown).
316
+ Returns 0.0 if the series is empty or has only one value.
317
+ """
318
+ if nav.empty or len(nav) <= 1:
319
+ return 0.0
320
+
321
+ # Calculate running maximum (peak values)
322
+ running_max = nav.expanding().max()
323
+
324
+ # Calculate drawdown as percentage from peak
325
+ drawdown = (nav - running_max) / running_max
326
+
327
+ # Return the maximum (most negative) drawdown
328
+ return float(drawdown.min())