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.
Files changed (141) hide show
  1. {mktlib-0.7.0 → mktlib-0.7.2}/PKG-INFO +3 -2
  2. {mktlib-0.7.0 → mktlib-0.7.2}/README.md +37 -48
  3. {mktlib-0.7.0 → mktlib-0.7.2}/README_PYPI.md +1 -1
  4. {mktlib-0.7.0 → mktlib-0.7.2}/docs/conf.py +1 -0
  5. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/backtest/__init__.py +2 -0
  6. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/backtest/_conditions.py +16 -0
  7. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/backtest/_engine.py +67 -6
  8. mktlib-0.7.2/mktlib/data/_gbm.py +96 -0
  9. mktlib-0.7.2/mktlib/data/_monte_carlo.py +363 -0
  10. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/data/_ohlcv.py +16 -27
  11. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/data/_ornstein_uhlenbeck.py +38 -4
  12. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/data/_random_walk.py +49 -5
  13. mktlib-0.7.2/mktlib/metrics.py +605 -0
  14. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib.egg-info/PKG-INFO +3 -2
  15. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib.egg-info/SOURCES.txt +12 -1
  16. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib.egg-info/requires.txt +1 -0
  17. {mktlib-0.7.0 → mktlib-0.7.2}/pyproject.toml +3 -2
  18. {mktlib-0.7.0 → mktlib-0.7.2}/scripts/grid_search_sma.py +32 -20
  19. {mktlib-0.7.0 → mktlib-0.7.2}/tests/backtest/test_engine.py +157 -4
  20. {mktlib-0.7.0 → mktlib-0.7.2}/tests/data/test_gbm.py +0 -1
  21. {mktlib-0.7.0 → mktlib-0.7.2}/tests/data/test_monte_carlo.py +34 -11
  22. {mktlib-0.7.0 → mktlib-0.7.2}/tests/data/test_ohlcv.py +22 -13
  23. {mktlib-0.7.0 → mktlib-0.7.2}/tests/data/test_ornstein_uhlenbeck.py +0 -1
  24. {mktlib-0.7.0 → mktlib-0.7.2}/tests/data/test_random_walk.py +0 -1
  25. {mktlib-0.7.0 → mktlib-0.7.2}/tests/rates/test_treasury.py +0 -6
  26. {mktlib-0.7.0 → mktlib-0.7.2}/tests/reports/test_compat.py +0 -9
  27. {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_calendar.py +0 -1
  28. mktlib-0.7.2/tests/schemas/__init__.py +0 -0
  29. mktlib-0.7.2/tests/schemas/backtest.py +33 -0
  30. mktlib-0.7.2/tests/schemas/data.py +76 -0
  31. mktlib-0.7.2/tests/schemas/rates.py +38 -0
  32. mktlib-0.7.2/tests/schemas/reports.py +28 -0
  33. mktlib-0.7.2/tests/schemas/scheduling.py +35 -0
  34. mktlib-0.7.2/tests/test_schema_backtest.py +85 -0
  35. mktlib-0.7.2/tests/test_schema_data.py +115 -0
  36. mktlib-0.7.2/tests/test_schema_rates.py +158 -0
  37. mktlib-0.7.2/tests/test_schema_reports.py +68 -0
  38. mktlib-0.7.2/tests/test_schema_scheduling.py +40 -0
  39. mktlib-0.7.0/mktlib/data/_gbm.py +0 -45
  40. mktlib-0.7.0/mktlib/data/_monte_carlo.py +0 -294
  41. mktlib-0.7.0/mktlib/metrics.py +0 -304
  42. {mktlib-0.7.0 → mktlib-0.7.2}/LICENSE +0 -0
  43. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/__init__.py +0 -0
  44. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/backtest/_types.py +0 -0
  45. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/backtest/strategies/__init__.py +0 -0
  46. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/backtest/strategies/_macd.py +0 -0
  47. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/data/__init__.py +0 -0
  48. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/__init__.py +0 -0
  49. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_bundled.py +0 -0
  50. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2000.csv +0 -0
  51. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2001.csv +0 -0
  52. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2002.csv +0 -0
  53. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2003.csv +0 -0
  54. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2004.csv +0 -0
  55. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2005.csv +0 -0
  56. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2006.csv +0 -0
  57. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2007.csv +0 -0
  58. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2008.csv +0 -0
  59. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2009.csv +0 -0
  60. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2010.csv +0 -0
  61. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2011.csv +0 -0
  62. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2012.csv +0 -0
  63. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2013.csv +0 -0
  64. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2014.csv +0 -0
  65. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2015.csv +0 -0
  66. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2016.csv +0 -0
  67. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2017.csv +0 -0
  68. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2018.csv +0 -0
  69. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2019.csv +0 -0
  70. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2020.csv +0 -0
  71. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2021.csv +0 -0
  72. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2022.csv +0 -0
  73. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2023.csv +0 -0
  74. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2024.csv +0 -0
  75. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2025.csv +0 -0
  76. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/2026.csv +0 -0
  77. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_data/schema.csv +0 -0
  78. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_disk_cache.py +0 -0
  79. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_schema.py +0 -0
  80. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/rates/_treasury.py +0 -0
  81. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/reports/__init__.py +0 -0
  82. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/reports/_compat.py +0 -0
  83. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/reports/_plots.py +0 -0
  84. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/reports/_stats.py +0 -0
  85. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/reports/_template.py +0 -0
  86. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/reports/_types.py +0 -0
  87. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/reports/templates/tearsheet.html.j2 +0 -0
  88. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/__init__.py +0 -0
  89. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/_break_mixin.py +0 -0
  90. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/_mixins.py +0 -0
  91. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/_types.py +0 -0
  92. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/calendar.py +0 -0
  93. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/easter.py +0 -0
  94. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/__init__.py +0 -0
  95. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/_us_holidays.py +0 -0
  96. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/cboe.py +0 -0
  97. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/cme.py +0 -0
  98. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/euronext.py +0 -0
  99. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/fx.py +0 -0
  100. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/hkex.py +0 -0
  101. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/jpx.py +0 -0
  102. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/lse.py +0 -0
  103. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/nasdaq.py +0 -0
  104. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/nyse.py +0 -0
  105. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/tsx.py +0 -0
  106. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/exchanges/xetra.py +0 -0
  107. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/registry.py +0 -0
  108. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib/scheduling/rules.py +0 -0
  109. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib.egg-info/dependency_links.txt +0 -0
  110. {mktlib-0.7.0 → mktlib-0.7.2}/mktlib.egg-info/top_level.txt +0 -0
  111. {mktlib-0.7.0 → mktlib-0.7.2}/scripts/bench_backtest.py +0 -0
  112. {mktlib-0.7.0 → mktlib-0.7.2}/scripts/bench_macd_market.py +0 -0
  113. {mktlib-0.7.0 → mktlib-0.7.2}/scripts/bench_numpy.py +0 -0
  114. {mktlib-0.7.0 → mktlib-0.7.2}/scripts/bench_pandas.py +0 -0
  115. {mktlib-0.7.0 → mktlib-0.7.2}/scripts/bench_single_pass.py +0 -0
  116. {mktlib-0.7.0 → mktlib-0.7.2}/scripts/refresh_treasury_data.py +0 -0
  117. {mktlib-0.7.0 → mktlib-0.7.2}/setup.cfg +0 -0
  118. {mktlib-0.7.0 → mktlib-0.7.2}/tests/__init__.py +0 -0
  119. {mktlib-0.7.0 → mktlib-0.7.2}/tests/backtest/__init__.py +0 -0
  120. {mktlib-0.7.0 → mktlib-0.7.2}/tests/backtest/test_conditions.py +0 -0
  121. {mktlib-0.7.0 → mktlib-0.7.2}/tests/data/__init__.py +0 -0
  122. {mktlib-0.7.0 → mktlib-0.7.2}/tests/rates/__init__.py +0 -0
  123. {mktlib-0.7.0 → mktlib-0.7.2}/tests/rates/test_bundled.py +0 -0
  124. {mktlib-0.7.0 → mktlib-0.7.2}/tests/rates/test_disk_cache.py +0 -0
  125. {mktlib-0.7.0 → mktlib-0.7.2}/tests/rates/test_schema.py +0 -0
  126. {mktlib-0.7.0 → mktlib-0.7.2}/tests/reports/__init__.py +0 -0
  127. {mktlib-0.7.0 → mktlib-0.7.2}/tests/reports/test_html.py +0 -0
  128. {mktlib-0.7.0 → mktlib-0.7.2}/tests/reports/test_plots.py +0 -0
  129. {mktlib-0.7.0 → mktlib-0.7.2}/tests/reports/test_stats.py +0 -0
  130. {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/__init__.py +0 -0
  131. {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/conftest.py +0 -0
  132. {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_breaks.py +0 -0
  133. {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_cme.py +0 -0
  134. {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_euronext.py +0 -0
  135. {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_filter_market_hours.py +0 -0
  136. {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_lse.py +0 -0
  137. {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_navigation.py +0 -0
  138. {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_nyse.py +0 -0
  139. {mktlib-0.7.0 → mktlib-0.7.2}/tests/scheduling/test_validation.py +0 -0
  140. {mktlib-0.7.0 → mktlib-0.7.2}/tests/test_import.py +0 -0
  141. {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.0
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, property-based OHLCV checks, and full backtest parity tests across engines
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, property-based OHLCV checks, and full backtest parity tests across engines
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 with 5% drift, 20% annual vol
705
- gbm = geometric_brownian_motion(252, drift=0.05/252, volatility=0.20/252**0.5, seed=42)
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
- ### Fractional Random WalkDavies-Harte Algorithm
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, property-based OHLCV checks, and full backtest parity tests across engines
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 Condition, Custom
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
- signals = df.with_columns(
110
- entry_expr.alias("_entry"),
111
- exit_expr.alias("_exit"),
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
+ )