mktlib 0.7.0__tar.gz → 0.7.2__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.
- {mktlib-0.7.0 → mktlib-0.7.2}/PKG-INFO +3 -2
- {mktlib-0.7.0 → mktlib-0.7.2}/README.md +37 -48
- {mktlib-0.7.0 → mktlib-0.7.2}/README_PYPI.md +1 -1
- {mktlib-0.7.0 → mktlib-0.7.2}/docs/conf.py +1 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/backtest/__init__.py +2 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/backtest/_conditions.py +16 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/backtest/_engine.py +67 -6
- mktlib-0.7.2/mktlib/data/_gbm.py +96 -0
- mktlib-0.7.2/mktlib/data/_monte_carlo.py +363 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/data/_ohlcv.py +16 -27
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/data/_ornstein_uhlenbeck.py +38 -4
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/data/_random_walk.py +49 -5
- mktlib-0.7.2/mktlib/metrics.py +605 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib.egg-info/PKG-INFO +3 -2
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib.egg-info/SOURCES.txt +12 -1
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib.egg-info/requires.txt +1 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/pyproject.toml +3 -2
- {mktlib-0.7.0 → mktlib-0.7.2}/scripts/grid_search_sma.py +32 -20
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/backtest/test_engine.py +157 -4
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/data/test_gbm.py +0 -1
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/data/test_monte_carlo.py +34 -11
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/data/test_ohlcv.py +22 -13
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/data/test_ornstein_uhlenbeck.py +0 -1
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/data/test_random_walk.py +0 -1
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/rates/test_treasury.py +0 -6
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/reports/test_compat.py +0 -9
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_calendar.py +0 -1
- mktlib-0.7.2/tests/schemas/__init__.py +0 -0
- mktlib-0.7.2/tests/schemas/backtest.py +33 -0
- mktlib-0.7.2/tests/schemas/data.py +76 -0
- mktlib-0.7.2/tests/schemas/rates.py +38 -0
- mktlib-0.7.2/tests/schemas/reports.py +28 -0
- mktlib-0.7.2/tests/schemas/scheduling.py +35 -0
- mktlib-0.7.2/tests/test_schema_backtest.py +85 -0
- mktlib-0.7.2/tests/test_schema_data.py +115 -0
- mktlib-0.7.2/tests/test_schema_rates.py +158 -0
- mktlib-0.7.2/tests/test_schema_reports.py +68 -0
- mktlib-0.7.2/tests/test_schema_scheduling.py +40 -0
- mktlib-0.7.0/mktlib/data/_gbm.py +0 -45
- mktlib-0.7.0/mktlib/data/_monte_carlo.py +0 -294
- mktlib-0.7.0/mktlib/metrics.py +0 -304
- {mktlib-0.7.0 → mktlib-0.7.2}/LICENSE +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/__init__.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/backtest/_types.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/backtest/strategies/__init__.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/backtest/strategies/_macd.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/data/__init__.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/__init__.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_bundled.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2000.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2001.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2002.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2003.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2004.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2005.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2006.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2007.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2008.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2009.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2010.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2011.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2012.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2013.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2014.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2015.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2016.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2017.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2018.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2019.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2020.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2021.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2022.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2023.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2024.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2025.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2026.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/schema.csv +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_disk_cache.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_schema.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_treasury.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/reports/__init__.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/reports/_compat.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/reports/_plots.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/reports/_stats.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/reports/_template.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/reports/_types.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/reports/templates/tearsheet.html.j2 +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/__init__.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/_break_mixin.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/_mixins.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/_types.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/calendar.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/easter.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/__init__.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/_us_holidays.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/cboe.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/cme.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/euronext.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/fx.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/hkex.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/jpx.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/lse.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/nasdaq.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/nyse.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/tsx.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/xetra.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/registry.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/rules.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib.egg-info/dependency_links.txt +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/mktlib.egg-info/top_level.txt +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/scripts/bench_backtest.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/scripts/bench_macd_market.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/scripts/bench_numpy.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/scripts/bench_pandas.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/scripts/bench_single_pass.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/scripts/refresh_treasury_data.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/setup.cfg +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/__init__.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/backtest/__init__.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/backtest/test_conditions.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/data/__init__.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/rates/__init__.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/rates/test_bundled.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/rates/test_disk_cache.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/rates/test_schema.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/reports/__init__.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/reports/test_html.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/reports/test_plots.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/reports/test_stats.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/__init__.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/conftest.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_breaks.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_cme.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_euronext.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_filter_market_hours.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_lse.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_navigation.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_nyse.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_validation.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/test_import.py +0 -0
- {mktlib-0.7.0 → mktlib-0.7.2}/tests/test_metrics.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mktlib
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Polars-native financial market toolkit
|
|
5
5
|
Author-email: Matt Buck <matt@mblance.com>
|
|
6
6
|
Project-URL: Documentation, https://polars-mktlib.readthedocs.io
|
|
@@ -27,6 +27,7 @@ Requires-Dist: pre-commit; extra == "dev"
|
|
|
27
27
|
Requires-Dist: bump-my-version; extra == "dev"
|
|
28
28
|
Requires-Dist: exchange-calendars; extra == "dev"
|
|
29
29
|
Requires-Dist: jinja2>=3.1; extra == "dev"
|
|
30
|
+
Requires-Dist: pandera[polars]; extra == "dev"
|
|
30
31
|
Requires-Dist: plotly>=6.0; extra == "dev"
|
|
31
32
|
Requires-Dist: plotly-stubs; extra == "dev"
|
|
32
33
|
Requires-Dist: polars-sdist>=0.1.1; extra == "dev"
|
|
@@ -48,7 +49,7 @@ Financial market toolkit built entirely on Polars.
|
|
|
48
49
|
|
|
49
50
|
- **Fast enough for real work** — vectorized Polars engine grid-searches thousands of parameter combos on minute-bar data without reaching for Numba or Cython
|
|
50
51
|
- **Lightweight** — pure Polars throughout, including synthetic data generation via Rust-native plugins. No pandas, no NumPy, no heavy ML stack
|
|
51
|
-
- **Well tested** — cross-validated exchange calendars,
|
|
52
|
+
- **Well tested** — cross-validated exchange calendars, full backtest parity tests across engines, and pandera schema validation on every DataFrame-returning function to guarantee no silent column/dtype regressions
|
|
52
53
|
- **Swiss-army knife** — scheduling, rates, metrics, backtesting, reporting, and data generation in one package. Great for learning, prototyping, or production
|
|
53
54
|
- **Apache 2.0** — use it anywhere, fork it, vendor it, no strings attached
|
|
54
55
|
|
|
@@ -12,7 +12,7 @@ Financial market toolkit built entirely on Polars.
|
|
|
12
12
|
|
|
13
13
|
📦 **Lightweight** — pure Polars throughout, including synthetic data generation via Rust-native plugins. No pandas, no NumPy, no heavy ML stack
|
|
14
14
|
|
|
15
|
-
🧪 **Well tested** — cross-validated exchange calendars,
|
|
15
|
+
🧪 **Well tested** — cross-validated exchange calendars, full backtest parity tests across engines, and pandera schema validation on every DataFrame-returning function to guarantee no silent column/dtype regressions
|
|
16
16
|
|
|
17
17
|
🧰 **Swiss-army knife** — scheduling, rates, metrics, backtesting, reporting, and data generation in one package. Great for learning, prototyping, or production
|
|
18
18
|
|
|
@@ -187,30 +187,6 @@ cal = ExchangeCalendar(
|
|
|
187
187
|
register_exchange("XICE", lambda: cal, aliases=["Iceland"])
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
-
For exchanges with a lunch break, use `ExchangeCalendarWithBreaks` — it adds
|
|
191
|
-
`break_start`/`break_end` columns to schedules and excludes break windows from
|
|
192
|
-
`is_open_on_minute()` and `trading_index()`:
|
|
193
|
-
|
|
194
|
-
```python
|
|
195
|
-
from mktlib.scheduling import ExchangeCalendarWithBreaks, register_exchange
|
|
196
|
-
from mktlib.scheduling.rules import HolidayRule
|
|
197
|
-
|
|
198
|
-
cal = ExchangeCalendarWithBreaks(
|
|
199
|
-
name="XSHG",
|
|
200
|
-
timezone="Asia/Shanghai",
|
|
201
|
-
open_time=time(9, 30),
|
|
202
|
-
close_time=time(15, 0),
|
|
203
|
-
break_start=time(11, 30),
|
|
204
|
-
break_end=time(13, 0),
|
|
205
|
-
holidays=[
|
|
206
|
-
HolidayRule("New Year's Day", month=1, day=1),
|
|
207
|
-
HolidayRule("National Day", month=10, day=1),
|
|
208
|
-
],
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
register_exchange("XSHG", lambda: cal, aliases=["Shanghai", "SSE"])
|
|
212
|
-
```
|
|
213
|
-
|
|
214
190
|
### Holiday Rules
|
|
215
191
|
|
|
216
192
|
| Rule type | Description |
|
|
@@ -488,6 +464,39 @@ exit = Crossunder("fast", "slow") | PriceIsBelow("close", "stop_loss")
|
|
|
488
464
|
| `Not(a)` / `~a` | Invert condition |
|
|
489
465
|
| `Custom(expr)` | Any `pl.Expr` evaluating to boolean |
|
|
490
466
|
|
|
467
|
+
### Price Expressions
|
|
468
|
+
|
|
469
|
+
Price expressions build composable numeric trees for use with `PriceIsAbove` / `PriceIsBelow`. They support arithmetic (`+`, `-`, `*`, `/`) and mix with column names and float literals.
|
|
470
|
+
|
|
471
|
+
| Expression | Description | Resolves to |
|
|
472
|
+
|-|-|-|
|
|
473
|
+
| `Col("sma")` | Column reference | `pl.col("sma")` |
|
|
474
|
+
| `Lit(100.0)` | Literal constant | `pl.lit(100.0)` |
|
|
475
|
+
| `Pct("close", 5)` | 5% above column | `close * 1.05` |
|
|
476
|
+
| `Pct("close", -3)` | 3% below column | `close * 0.97` |
|
|
477
|
+
| `EntryRef("close")` | Entry-bar snapshot | `pl.col("_entry_close")` |
|
|
478
|
+
|
|
479
|
+
`EntryRef` captures a column's value at the entry signal bar and forward-fills it. This is essential for TP/SL exits anchored to the entry price rather than the current bar:
|
|
480
|
+
|
|
481
|
+
```python
|
|
482
|
+
from mktlib.backtest import (
|
|
483
|
+
Crossover, Crossunder, EntryRef, Pct, PriceIsAbove, PriceIsBelow,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
class EmaCrossTP:
|
|
487
|
+
def entry(self) -> Crossover:
|
|
488
|
+
return Crossover("ema_fast", "ema_slow")
|
|
489
|
+
|
|
490
|
+
def exit(self):
|
|
491
|
+
return (
|
|
492
|
+
Crossunder("ema_fast", "ema_slow")
|
|
493
|
+
| PriceIsAbove("close", Pct(EntryRef("close"), 5.0)) # TP: 5% above entry close
|
|
494
|
+
| PriceIsBelow("close", Pct(EntryRef("close"), -3.0)) # SL: 3% below entry close
|
|
495
|
+
)
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
> **Why not `Pct("close", 5)`?** That resolves to `close > close * 1.05` — always false. The threshold must reference the *entry bar's* close, not the current bar's. `EntryRef("close")` resolves to a snapshot column (`_entry_close`) that the engine creates automatically.
|
|
499
|
+
|
|
491
500
|
For arbitrary logic, use `Custom` or return a bare `pl.Expr` from `entry()`/`exit()` (auto-wrapped as `Custom`):
|
|
492
501
|
|
|
493
502
|
```python
|
|
@@ -701,8 +710,8 @@ walk = fractional_random_walk(1000, seed=42)
|
|
|
701
710
|
# Trending path (Hurst > 0.5)
|
|
702
711
|
trending = fractional_random_walk(1000, hurst=0.8, seed=42)
|
|
703
712
|
|
|
704
|
-
# GBM price path
|
|
705
|
-
gbm = geometric_brownian_motion(252, drift=0.05
|
|
713
|
+
# GBM price path — 252 daily steps, 5% annualised drift, 20% annualised vol
|
|
714
|
+
gbm = geometric_brownian_motion(252, drift=0.05, volatility=0.20, seed=42)
|
|
706
715
|
|
|
707
716
|
# Mean-reverting process
|
|
708
717
|
ou = ornstein_uhlenbeck(500, theta=0.7, mu=100.0, sigma=1.0, seed=42)
|
|
@@ -719,27 +728,7 @@ sims = monte_carlo(geometric_brownian_motion, n_simulations=1000, n=252, seed=42
|
|
|
719
728
|
| `ornstein_uhlenbeck` | Mean-reverting: dx = θ(μ−x)dt + σdW | `step`, `value` |
|
|
720
729
|
| `monte_carlo` | N simulations of any generator | `simulation`, `step`, ... |
|
|
721
730
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
`fractional_random_walk` generates exact fractional Brownian motion increments using
|
|
725
|
-
the Davies-Harte circulant embedding method, powered by
|
|
726
|
-
[polars-rfft](https://github.com/mattdavis90/polars-rfft) (RustFFT) and
|
|
727
|
-
[polars-sdist](https://github.com/mattdavis90/polars-sdist) for normal sampling:
|
|
728
|
-
|
|
729
|
-
1. Build the autocovariance row from the Hurst exponent H
|
|
730
|
-
2. Embed in a circulant matrix (length 2n)
|
|
731
|
-
3. FFT the circulant row to get eigenvalues; clamp ≥ 0, take sqrt
|
|
732
|
-
4. Generate complex Gaussian noise (two independent normal samples via polars-sdist)
|
|
733
|
-
5. Multiply sqrt-eigenvalues × noise, then IFFT back to time domain
|
|
734
|
-
6. Take the first n increments and cumsum into a price path
|
|
735
|
-
|
|
736
|
-
**Hurst exponent behaviour:** H = 0.5 gives a standard random walk (fast path —
|
|
737
|
-
no FFT needed, just `cumsum` of normal increments). H > 0.5 produces trending
|
|
738
|
-
(persistent) paths; H < 0.5 produces mean-reverting (anti-persistent) paths.
|
|
739
|
-
|
|
740
|
-
**Performance vs GBM:** For H = 0.5, FRW is faster than GBM (no `exp` call).
|
|
741
|
-
For H ≠ 0.5 the FFT pipeline adds 2–13× overhead depending on series length,
|
|
742
|
-
but remains O(n log n) and sub-second for 1M points.
|
|
731
|
+
The `dt` parameter defaults to `1/252` (one trading day in annualised units). Pass `drift` and `volatility` as annualised values directly — the function scales them internally. For sub-daily data generation (e.g. 1-minute OHLCV bars), see the [advanced usage guide](https://polars-mktlib.readthedocs.io/en/latest/advanced.html).
|
|
743
732
|
|
|
744
733
|
## Development
|
|
745
734
|
|
|
@@ -10,7 +10,7 @@ Financial market toolkit built entirely on Polars.
|
|
|
10
10
|
|
|
11
11
|
- **Fast enough for real work** — vectorized Polars engine grid-searches thousands of parameter combos on minute-bar data without reaching for Numba or Cython
|
|
12
12
|
- **Lightweight** — pure Polars throughout, including synthetic data generation via Rust-native plugins. No pandas, no NumPy, no heavy ML stack
|
|
13
|
-
- **Well tested** — cross-validated exchange calendars,
|
|
13
|
+
- **Well tested** — cross-validated exchange calendars, full backtest parity tests across engines, and pandera schema validation on every DataFrame-returning function to guarantee no silent column/dtype regressions
|
|
14
14
|
- **Swiss-army knife** — scheduling, rates, metrics, backtesting, reporting, and data generation in one package. Great for learning, prototyping, or production
|
|
15
15
|
- **Apache 2.0** — use it anywhere, fork it, vendor it, no strings attached
|
|
16
16
|
|
|
@@ -44,6 +44,7 @@ sitemap_url_scheme = "{link}"
|
|
|
44
44
|
autodoc_member_order = "bysource"
|
|
45
45
|
autodoc_typehints = "description"
|
|
46
46
|
always_use_bars_union = True
|
|
47
|
+
autodoc_mock_imports = ["polars_sdist", "polars_rfft"]
|
|
47
48
|
|
|
48
49
|
# -- intersphinx mapping -----------------------------------------------------
|
|
49
50
|
|
|
@@ -8,6 +8,7 @@ from mktlib.backtest._conditions import (
|
|
|
8
8
|
Crossover,
|
|
9
9
|
Crossunder,
|
|
10
10
|
Custom,
|
|
11
|
+
EntryRef,
|
|
11
12
|
IsFalling,
|
|
12
13
|
IsRising,
|
|
13
14
|
Lit,
|
|
@@ -29,6 +30,7 @@ __all__ = [
|
|
|
29
30
|
"Crossover",
|
|
30
31
|
"Crossunder",
|
|
31
32
|
"Custom",
|
|
33
|
+
"EntryRef",
|
|
32
34
|
"IsFalling",
|
|
33
35
|
"IsRising",
|
|
34
36
|
"Lit",
|
|
@@ -143,6 +143,22 @@ class Pct(PriceExpr):
|
|
|
143
143
|
return _coerce_base(self.base).resolve() * (1.0 + self.pct / 100.0)
|
|
144
144
|
|
|
145
145
|
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class EntryRef(PriceExpr):
|
|
148
|
+
"""Column value snapshotted at the entry signal bar, forward-filled.
|
|
149
|
+
|
|
150
|
+
The engine creates ``_entry_{col}`` columns automatically when it
|
|
151
|
+
detects ``EntryRef`` nodes in the exit condition tree.
|
|
152
|
+
|
|
153
|
+
``EntryRef("close")`` resolves to ``pl.col("_entry_close")``.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
col: str
|
|
157
|
+
|
|
158
|
+
def resolve(self) -> pl.Expr:
|
|
159
|
+
return pl.col(f"_entry_{self.col}")
|
|
160
|
+
|
|
161
|
+
|
|
146
162
|
class Condition:
|
|
147
163
|
"""Base class for signal conditions that resolve to boolean ``pl.Expr``."""
|
|
148
164
|
|
|
@@ -5,7 +5,19 @@ from typing import TYPE_CHECKING, overload
|
|
|
5
5
|
|
|
6
6
|
import polars as pl
|
|
7
7
|
|
|
8
|
-
from mktlib.backtest._conditions import
|
|
8
|
+
from mktlib.backtest._conditions import (
|
|
9
|
+
All,
|
|
10
|
+
Any_,
|
|
11
|
+
Condition,
|
|
12
|
+
Custom,
|
|
13
|
+
EntryRef,
|
|
14
|
+
Not,
|
|
15
|
+
Pct,
|
|
16
|
+
PriceExpr,
|
|
17
|
+
PriceIsAbove,
|
|
18
|
+
PriceIsBelow,
|
|
19
|
+
_BinOp,
|
|
20
|
+
)
|
|
9
21
|
from mktlib.backtest._types import BacktestResult, MultiBacktestResult, Strategy, TradeSide
|
|
10
22
|
|
|
11
23
|
if TYPE_CHECKING:
|
|
@@ -82,6 +94,45 @@ def _build_session_last_mask(
|
|
|
82
94
|
return dates.is_in(last_bars)
|
|
83
95
|
|
|
84
96
|
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# EntryRef tree walker — collects column names needed for entry-bar snapshots
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _collect_entry_refs(cond: Condition) -> set[str]:
|
|
103
|
+
"""Return all column names referenced by ``EntryRef`` nodes in *cond*."""
|
|
104
|
+
cols: set[str] = set()
|
|
105
|
+
_walk_cond(cond, cols)
|
|
106
|
+
return cols
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _walk_cond(cond: Condition, cols: set[str]) -> None:
|
|
110
|
+
match cond:
|
|
111
|
+
case All(left, right, _) | Any_(left, right, _):
|
|
112
|
+
_walk_cond(left, cols)
|
|
113
|
+
_walk_cond(right, cols)
|
|
114
|
+
case Not(inner, _):
|
|
115
|
+
_walk_cond(inner, cols)
|
|
116
|
+
case PriceIsAbove(a, b, _) | PriceIsBelow(a, b, _):
|
|
117
|
+
_walk_expr(a, cols)
|
|
118
|
+
_walk_expr(b, cols)
|
|
119
|
+
case _:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _walk_expr(node: str | float | PriceExpr, cols: set[str]) -> None:
|
|
124
|
+
match node:
|
|
125
|
+
case EntryRef(col):
|
|
126
|
+
cols.add(col)
|
|
127
|
+
case Pct(base, _):
|
|
128
|
+
_walk_expr(base, cols)
|
|
129
|
+
case _BinOp(left, right, _):
|
|
130
|
+
_walk_expr(left, cols)
|
|
131
|
+
_walk_expr(right, cols)
|
|
132
|
+
case _:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
85
136
|
def _run_core(
|
|
86
137
|
df: pl.DataFrame,
|
|
87
138
|
strategy: Strategy,
|
|
@@ -101,15 +152,25 @@ def _run_core(
|
|
|
101
152
|
entry_cond = entry_raw if isinstance(entry_raw, Condition) else Custom(entry_raw)
|
|
102
153
|
exit_cond = exit_raw if isinstance(exit_raw, Condition) else Custom(exit_raw)
|
|
103
154
|
entry_expr = entry_cond.resolve()
|
|
104
|
-
exit_expr = exit_cond.resolve()
|
|
105
155
|
|
|
106
156
|
# Entry condition's side overrides the run() default
|
|
107
157
|
effective_side = int(entry_cond.trade_side or trade_side)
|
|
108
158
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
159
|
+
# Pass 1: compute _entry
|
|
160
|
+
signals = df.with_columns(entry_expr.alias("_entry"))
|
|
161
|
+
|
|
162
|
+
# Create snapshot columns for any EntryRef nodes in exit condition
|
|
163
|
+
entry_refs = _collect_entry_refs(exit_cond)
|
|
164
|
+
if entry_refs:
|
|
165
|
+
signals = signals.with_columns(
|
|
166
|
+
pl.when(pl.col("_entry")).then(pl.col(col)).otherwise(None)
|
|
167
|
+
.forward_fill().alias(f"_entry_{col}")
|
|
168
|
+
for col in entry_refs
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Pass 2: compute _exit (snapshot columns now exist for EntryRef.resolve())
|
|
172
|
+
exit_expr = exit_cond.resolve()
|
|
173
|
+
signals = signals.with_columns(exit_expr.alias("_exit"))
|
|
113
174
|
|
|
114
175
|
# Position tracking: 1 on entry, 0 on exit, forward-fill
|
|
115
176
|
if flatten_eod:
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
from polars_sdist import sample_normal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _gbm_price_expr(log_base: float, mu_dt: float, sigma_sqrt_dt: float) -> pl.Expr:
|
|
10
|
+
"""GBM price expression. Assumes columns ``step`` and ``z`` exist."""
|
|
11
|
+
return (
|
|
12
|
+
pl.when(pl.col("step") == 0)
|
|
13
|
+
.then(0.0)
|
|
14
|
+
.otherwise(pl.col("z") * sigma_sqrt_dt + mu_dt)
|
|
15
|
+
.cum_sum()
|
|
16
|
+
.add(log_base)
|
|
17
|
+
.exp()
|
|
18
|
+
.alias("price")
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def geometric_brownian_motion(
|
|
23
|
+
n: int,
|
|
24
|
+
base_price: float = 100.0,
|
|
25
|
+
drift: float = 0.0,
|
|
26
|
+
volatility: float = 0.01,
|
|
27
|
+
dt: float = 1 / 252,
|
|
28
|
+
seed: int | None = None,
|
|
29
|
+
) -> pl.DataFrame:
|
|
30
|
+
r"""Geometric Brownian motion price path.
|
|
31
|
+
|
|
32
|
+
Simulates the SDE :math:`dS = \mu S \, dt + \sigma S \, dW` using the
|
|
33
|
+
exact log-space solution derived via Itô's lemma.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
n
|
|
38
|
+
Number of time steps to generate.
|
|
39
|
+
base_price
|
|
40
|
+
Initial price :math:`S_0`.
|
|
41
|
+
drift
|
|
42
|
+
Annualised expected return :math:`\mu`.
|
|
43
|
+
volatility
|
|
44
|
+
Annualised volatility :math:`\sigma`.
|
|
45
|
+
dt
|
|
46
|
+
Time step size in annualised units. Defaults to ``1/252`` (one
|
|
47
|
+
trading day). For sub-daily granularity, divide further — e.g.
|
|
48
|
+
``1/(252*6.5*3600)`` for one-second ticks during US equity hours.
|
|
49
|
+
seed
|
|
50
|
+
RNG seed for reproducibility.
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
pl.DataFrame
|
|
55
|
+
Columns ``step`` (int) and ``price`` (float).
|
|
56
|
+
|
|
57
|
+
Notes
|
|
58
|
+
-----
|
|
59
|
+
**Why log-space?** The SDE is multiplicative in *S*, so direct
|
|
60
|
+
Euler-Maruyama discretisation introduces bias. Applying Itô's lemma
|
|
61
|
+
to :math:`X = \ln S` yields an additive SDE::
|
|
62
|
+
|
|
63
|
+
dX = (μ − ½σ²) dt + σ dW
|
|
64
|
+
|
|
65
|
+
whose discrete-time exact solution is::
|
|
66
|
+
|
|
67
|
+
ln S[i] = ln S[i−1] + (μ − ½σ²)·dt + σ·√dt·Z[i], Z ~ N(0,1)
|
|
68
|
+
|
|
69
|
+
The implementation draws all *Z* values in one ``sample_normal`` call,
|
|
70
|
+
computes log-increments, and uses ``cum_sum`` + ``exp`` to recover
|
|
71
|
+
prices — no Python loop required.
|
|
72
|
+
|
|
73
|
+
**Itô correction (−½σ²).** Because :math:`\exp` is convex, Jensen's
|
|
74
|
+
inequality gives :math:`E[\exp(X)] > \exp(E[X])`. The −½σ² drift
|
|
75
|
+
adjustment compensates for this so that the expected price path
|
|
76
|
+
satisfies :math:`E[S(t)] = S_0 \exp(\mu t)`.
|
|
77
|
+
|
|
78
|
+
**Distributional properties.** Log-returns
|
|
79
|
+
:math:`\ln(S_{i}/S_{i-1})` are i.i.d. normal; prices :math:`S_i` are
|
|
80
|
+
lognormally distributed.
|
|
81
|
+
"""
|
|
82
|
+
if n < 1:
|
|
83
|
+
raise ValueError("n must be >= 1")
|
|
84
|
+
|
|
85
|
+
log_base = math.log(base_price)
|
|
86
|
+
mu_dt = (drift - 0.5 * volatility**2) * dt
|
|
87
|
+
sigma_sqrt_dt = volatility * math.sqrt(dt)
|
|
88
|
+
|
|
89
|
+
z = sample_normal(n, seed=seed)
|
|
90
|
+
step = pl.arange(0, n, eager=True).alias("step")
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
pl.DataFrame({"step": step, "z": z})
|
|
94
|
+
.with_columns(_gbm_price_expr(log_base, mu_dt, sigma_sqrt_dt))
|
|
95
|
+
.select("step", "price")
|
|
96
|
+
)
|