predcache 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.
- predcache-0.1.0/LICENSE +21 -0
- predcache-0.1.0/PKG-INFO +121 -0
- predcache-0.1.0/README.md +86 -0
- predcache-0.1.0/pyproject.toml +52 -0
- predcache-0.1.0/setup.cfg +4 -0
- predcache-0.1.0/src/predcache/__init__.py +14 -0
- predcache-0.1.0/src/predcache/batched.py +114 -0
- predcache-0.1.0/src/predcache/cache.py +220 -0
- predcache-0.1.0/src/predcache/windowing.py +90 -0
- predcache-0.1.0/src/predcache.egg-info/PKG-INFO +121 -0
- predcache-0.1.0/src/predcache.egg-info/SOURCES.txt +13 -0
- predcache-0.1.0/src/predcache.egg-info/dependency_links.txt +1 -0
- predcache-0.1.0/src/predcache.egg-info/requires.txt +9 -0
- predcache-0.1.0/src/predcache.egg-info/top_level.txt +1 -0
- predcache-0.1.0/tests/test_predcache.py +139 -0
predcache-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anton Uralskii
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
predcache-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: predcache
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Persistent timestamp-keyed prediction cache and batched inference runner for ML models
|
|
5
|
+
Author-email: Anton Uralskii <uralskyanton@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Geomanti/predcache
|
|
8
|
+
Project-URL: Repository, https://github.com/Geomanti/predcache
|
|
9
|
+
Project-URL: Issues, https://github.com/Geomanti/predcache/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/Geomanti/predcache/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: cache,inference,batching,machine-learning,backtesting,time-series,mlops
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: numpy>=1.21
|
|
28
|
+
Provides-Extra: parquet
|
|
29
|
+
Requires-Dist: pandas>=1.5; extra == "parquet"
|
|
30
|
+
Requires-Dist: pyarrow>=10; extra == "parquet"
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
33
|
+
Requires-Dist: pandas>=1.5; extra == "test"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# predcache
|
|
37
|
+
|
|
38
|
+
**Persistent timestamp-keyed prediction cache and batched inference runner for ML models.**
|
|
39
|
+
|
|
40
|
+
Extracted and generalized from a production neural-network trading system where
|
|
41
|
+
full-range backtests over 100,000+ bars were dominated by redundant model
|
|
42
|
+
inference. Storing predictions keyed by **bar timestamp** (not row index) makes
|
|
43
|
+
any sub-range replayable without offset alignment — and makes incremental
|
|
44
|
+
backtests reuse everything previously computed. The result: full-range runs
|
|
45
|
+
went from **hours to minutes**.
|
|
46
|
+
|
|
47
|
+
## Why
|
|
48
|
+
|
|
49
|
+
Typical ML backtesting pipelines recompute model inference for the same bars on
|
|
50
|
+
every run — every parameter sweep, every range extension, every "what if".
|
|
51
|
+
`predcache` decouples the two expensive parts:
|
|
52
|
+
|
|
53
|
+
1. **Feature-window assembly** — building look-back feature rows from an OHLC
|
|
54
|
+
frame (most-recent-first, warmup-aware), with your domain logic plugged in
|
|
55
|
+
via `row_fn`.
|
|
56
|
+
2. **Batched inference** — scoring rows through *any* model callable
|
|
57
|
+
(Keras, PyTorch, ONNX, sklearn) in fixed batches with progress reporting.
|
|
58
|
+
3. **Persistent caching** — predictions keyed by timestamp, saved atomically,
|
|
59
|
+
so the next run computes only what's missing.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install predcache # core (numpy only)
|
|
65
|
+
pip install predcache[parquet] # + parquet backend
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import pandas as pd
|
|
72
|
+
from predcache import PredictionCache, BatchedInferenceRunner, FeatureWindowAssembler
|
|
73
|
+
|
|
74
|
+
# 1. Assemble feature windows (your domain logic stays in row_fn)
|
|
75
|
+
assembler = FeatureWindowAssembler(
|
|
76
|
+
window=50, warmup=500,
|
|
77
|
+
row_fn=lambda win: my_feature_logic(win), # 1-D feature vector per window
|
|
78
|
+
)
|
|
79
|
+
X, keys = assembler.build(df, key_col="time") # X: (n_bars - warmup, n_features)
|
|
80
|
+
|
|
81
|
+
# 2. Batched inference through any model
|
|
82
|
+
runner = BatchedInferenceRunner(
|
|
83
|
+
n_samples=len(X),
|
|
84
|
+
inference_fn=lambda s, e: model.predict(X[s:e], verbose=0),
|
|
85
|
+
batch_size=500,
|
|
86
|
+
)
|
|
87
|
+
preds = runner.run()
|
|
88
|
+
|
|
89
|
+
# 3. Cache by timestamp — next run computes only missing bars
|
|
90
|
+
cache = PredictionCache("predictions.pkl")
|
|
91
|
+
out = cache.get_or_compute(
|
|
92
|
+
keys=keys,
|
|
93
|
+
compute_fn=lambda missing: predict_bars(X, keys, missing),
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Features
|
|
98
|
+
|
|
99
|
+
- **Timestamp-keyed cache** — sub-range extraction without offset alignment;
|
|
100
|
+
row-index caches silently break on slicing/resampling, timestamp keys don't.
|
|
101
|
+
- **Atomic saves** — temp file + `os.replace`; a crash mid-save never corrupts
|
|
102
|
+
an existing cache.
|
|
103
|
+
- **Pluggable backends** — pickle (zero-dependency) and parquet ship in the
|
|
104
|
+
box; register custom backends with `register_backend(name, dump, load)`.
|
|
105
|
+
- **Batched inference runner** — framework-agnostic `(start, stop) -> scores`
|
|
106
|
+
contract, progress callbacks, tuned default batch size (500).
|
|
107
|
+
- **L-inf normalization** helper matching the source system's
|
|
108
|
+
`normalize_vector_absolute`.
|
|
109
|
+
|
|
110
|
+
## Origin
|
|
111
|
+
|
|
112
|
+
This package is the generalized core of the prediction-serving layer of
|
|
113
|
+
[Neuromomentum](https://github.com/Geomanti)'s production trading system
|
|
114
|
+
(three Keras classifiers, MT5 live execution, 2012–2026 backtest range). The
|
|
115
|
+
original cache manager was 542 lines of domain-specific code; what ships here
|
|
116
|
+
is the reusable pattern: timestamp-keyed persistence, compute-only-missing,
|
|
117
|
+
batched scoring, windowed feature assembly.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# predcache
|
|
2
|
+
|
|
3
|
+
**Persistent timestamp-keyed prediction cache and batched inference runner for ML models.**
|
|
4
|
+
|
|
5
|
+
Extracted and generalized from a production neural-network trading system where
|
|
6
|
+
full-range backtests over 100,000+ bars were dominated by redundant model
|
|
7
|
+
inference. Storing predictions keyed by **bar timestamp** (not row index) makes
|
|
8
|
+
any sub-range replayable without offset alignment — and makes incremental
|
|
9
|
+
backtests reuse everything previously computed. The result: full-range runs
|
|
10
|
+
went from **hours to minutes**.
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
Typical ML backtesting pipelines recompute model inference for the same bars on
|
|
15
|
+
every run — every parameter sweep, every range extension, every "what if".
|
|
16
|
+
`predcache` decouples the two expensive parts:
|
|
17
|
+
|
|
18
|
+
1. **Feature-window assembly** — building look-back feature rows from an OHLC
|
|
19
|
+
frame (most-recent-first, warmup-aware), with your domain logic plugged in
|
|
20
|
+
via `row_fn`.
|
|
21
|
+
2. **Batched inference** — scoring rows through *any* model callable
|
|
22
|
+
(Keras, PyTorch, ONNX, sklearn) in fixed batches with progress reporting.
|
|
23
|
+
3. **Persistent caching** — predictions keyed by timestamp, saved atomically,
|
|
24
|
+
so the next run computes only what's missing.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install predcache # core (numpy only)
|
|
30
|
+
pip install predcache[parquet] # + parquet backend
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import pandas as pd
|
|
37
|
+
from predcache import PredictionCache, BatchedInferenceRunner, FeatureWindowAssembler
|
|
38
|
+
|
|
39
|
+
# 1. Assemble feature windows (your domain logic stays in row_fn)
|
|
40
|
+
assembler = FeatureWindowAssembler(
|
|
41
|
+
window=50, warmup=500,
|
|
42
|
+
row_fn=lambda win: my_feature_logic(win), # 1-D feature vector per window
|
|
43
|
+
)
|
|
44
|
+
X, keys = assembler.build(df, key_col="time") # X: (n_bars - warmup, n_features)
|
|
45
|
+
|
|
46
|
+
# 2. Batched inference through any model
|
|
47
|
+
runner = BatchedInferenceRunner(
|
|
48
|
+
n_samples=len(X),
|
|
49
|
+
inference_fn=lambda s, e: model.predict(X[s:e], verbose=0),
|
|
50
|
+
batch_size=500,
|
|
51
|
+
)
|
|
52
|
+
preds = runner.run()
|
|
53
|
+
|
|
54
|
+
# 3. Cache by timestamp — next run computes only missing bars
|
|
55
|
+
cache = PredictionCache("predictions.pkl")
|
|
56
|
+
out = cache.get_or_compute(
|
|
57
|
+
keys=keys,
|
|
58
|
+
compute_fn=lambda missing: predict_bars(X, keys, missing),
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Features
|
|
63
|
+
|
|
64
|
+
- **Timestamp-keyed cache** — sub-range extraction without offset alignment;
|
|
65
|
+
row-index caches silently break on slicing/resampling, timestamp keys don't.
|
|
66
|
+
- **Atomic saves** — temp file + `os.replace`; a crash mid-save never corrupts
|
|
67
|
+
an existing cache.
|
|
68
|
+
- **Pluggable backends** — pickle (zero-dependency) and parquet ship in the
|
|
69
|
+
box; register custom backends with `register_backend(name, dump, load)`.
|
|
70
|
+
- **Batched inference runner** — framework-agnostic `(start, stop) -> scores`
|
|
71
|
+
contract, progress callbacks, tuned default batch size (500).
|
|
72
|
+
- **L-inf normalization** helper matching the source system's
|
|
73
|
+
`normalize_vector_absolute`.
|
|
74
|
+
|
|
75
|
+
## Origin
|
|
76
|
+
|
|
77
|
+
This package is the generalized core of the prediction-serving layer of
|
|
78
|
+
[Neuromomentum](https://github.com/Geomanti)'s production trading system
|
|
79
|
+
(three Keras classifiers, MT5 live execution, 2012–2026 backtest range). The
|
|
80
|
+
original cache manager was 542 lines of domain-specific code; what ships here
|
|
81
|
+
is the reusable pattern: timestamp-keyed persistence, compute-only-missing,
|
|
82
|
+
batched scoring, windowed feature assembly.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "predcache"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Persistent timestamp-keyed prediction cache and batched inference runner for ML models"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Anton Uralskii", email = "uralskyanton@gmail.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"cache",
|
|
15
|
+
"inference",
|
|
16
|
+
"batching",
|
|
17
|
+
"machine-learning",
|
|
18
|
+
"backtesting",
|
|
19
|
+
"time-series",
|
|
20
|
+
"mlops",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Intended Audience :: Science/Research",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.9",
|
|
30
|
+
"Programming Language :: Python :: 3.10",
|
|
31
|
+
"Programming Language :: Python :: 3.11",
|
|
32
|
+
"Programming Language :: Python :: 3.12",
|
|
33
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
34
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
35
|
+
]
|
|
36
|
+
dependencies = ["numpy>=1.21"]
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
parquet = ["pandas>=1.5", "pyarrow>=10"]
|
|
40
|
+
test = ["pytest>=7", "pandas>=1.5"]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/Geomanti/predcache"
|
|
44
|
+
Repository = "https://github.com/Geomanti/predcache"
|
|
45
|
+
Issues = "https://github.com/Geomanti/predcache/issues"
|
|
46
|
+
Changelog = "https://github.com/Geomanti/predcache/blob/main/CHANGELOG.md"
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
where = ["src"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""predcache: persistent prediction caching and batched inference for ML models."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .cache import PredictionCache, CacheEntry
|
|
6
|
+
from .batched import BatchedInferenceRunner, InferenceFn
|
|
7
|
+
from .windowing import FeatureWindowAssembler
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"PredictionCache",
|
|
11
|
+
"CacheEntry",
|
|
12
|
+
"BatchedInferenceRunner",
|
|
13
|
+
"FeatureWindowAssembler",
|
|
14
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Batched inference runner.
|
|
2
|
+
|
|
3
|
+
Wraps any model callable in batching + progress reporting, decoupling the
|
|
4
|
+
*scoring* of a feature matrix from the *loop* that feeds it. In the source
|
|
5
|
+
production system this exact pattern (batch_size=500 across three Keras
|
|
6
|
+
classifiers) turned hours-long full-range backtests into minutes by making
|
|
7
|
+
the cost of inference independent of how many bars were requested.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
__all__ = ["BatchedInferenceRunner", "InferenceFn"]
|
|
18
|
+
|
|
19
|
+
# A scorer takes (start, stop) and returns an array-like of predictions
|
|
20
|
+
# for that slice. Returning (n_samples, n_outputs) is typical.
|
|
21
|
+
InferenceFn = Callable[[int, int], Any]
|
|
22
|
+
|
|
23
|
+
DEFAULT_BATCH_SIZE = 500
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BatchedInferenceRunner:
|
|
27
|
+
"""Run a scorer over *n_samples* rows in fixed batches.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
n_samples
|
|
32
|
+
Total number of feature rows to score.
|
|
33
|
+
inference_fn
|
|
34
|
+
Callable ``(start, stop) -> array-like`` that scores rows
|
|
35
|
+
``[start, stop)`` of the pre-built feature matrix.
|
|
36
|
+
batch_size
|
|
37
|
+
Rows per scoring call. 500 was tuned on the source system
|
|
38
|
+
(Keras predict overhead vs memory footprint); any positive int works.
|
|
39
|
+
progress_every
|
|
40
|
+
Emit a progress dict every *progress_every* batches via the
|
|
41
|
+
``progress`` callback (or collect them in ``self.progress_log``).
|
|
42
|
+
|
|
43
|
+
Examples
|
|
44
|
+
--------
|
|
45
|
+
>>> import numpy as np
|
|
46
|
+
>>> features = np.random.rand(1000, 35).astype(np.float32)
|
|
47
|
+
>>> runner = BatchedInferenceRunner(
|
|
48
|
+
... n_samples=len(features),
|
|
49
|
+
... inference_fn=lambda s, e: model.predict(features[s:e], verbose=0),
|
|
50
|
+
... batch_size=500,
|
|
51
|
+
... )
|
|
52
|
+
>>> preds = runner.run()
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
n_samples: int,
|
|
58
|
+
inference_fn: InferenceFn,
|
|
59
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
60
|
+
progress_every: int = 20,
|
|
61
|
+
):
|
|
62
|
+
if batch_size <= 0:
|
|
63
|
+
raise ValueError("batch_size must be positive")
|
|
64
|
+
self.n_samples = int(n_samples)
|
|
65
|
+
self.inference_fn = inference_fn
|
|
66
|
+
self.batch_size = int(batch_size)
|
|
67
|
+
self.progress_every = max(1, int(progress_every))
|
|
68
|
+
self.progress_log: List[Dict[str, Any]] = []
|
|
69
|
+
|
|
70
|
+
def batches(self) -> Iterator[tuple]:
|
|
71
|
+
start = 0
|
|
72
|
+
while start < self.n_samples:
|
|
73
|
+
stop = min(start + self.batch_size, self.n_samples)
|
|
74
|
+
yield start, stop
|
|
75
|
+
start = stop
|
|
76
|
+
|
|
77
|
+
def run(self, progress_cb: Optional[Callable[[Dict[str, Any]], None]] = None) -> List[Any]:
|
|
78
|
+
"""Score all rows; returns a list of per-row predictions."""
|
|
79
|
+
out: List[Any] = []
|
|
80
|
+
t0 = time.perf_counter()
|
|
81
|
+
n_batches = 0
|
|
82
|
+
for start, stop in self.batches():
|
|
83
|
+
chunk = self.inference_fn(start, stop)
|
|
84
|
+
out.extend(_row_iter(chunk))
|
|
85
|
+
n_batches += 1
|
|
86
|
+
if progress_cb and n_batches % self.progress_every == 0:
|
|
87
|
+
cb = {
|
|
88
|
+
"rows_done": stop,
|
|
89
|
+
"rows_total": self.n_samples,
|
|
90
|
+
"batches": n_batches,
|
|
91
|
+
"elapsed_s": round(time.perf_counter() - t0, 3),
|
|
92
|
+
}
|
|
93
|
+
self.progress_log.append(cb)
|
|
94
|
+
progress_cb(cb)
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _row_iter(chunk: Any) -> Iterator[Any]:
|
|
99
|
+
arr = np.asarray(chunk)
|
|
100
|
+
if arr.ndim == 1:
|
|
101
|
+
for v in arr:
|
|
102
|
+
yield v.item() if hasattr(v, "item") else v
|
|
103
|
+
else:
|
|
104
|
+
for row in arr:
|
|
105
|
+
yield row
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def stack_features(matrices: Sequence[Any]) -> np.ndarray:
|
|
109
|
+
"""Concatenate per-bar feature rows into one matrix (float32).
|
|
110
|
+
|
|
111
|
+
Utility used before calling a runner; keeps dtype consistent so
|
|
112
|
+
framework predict() calls do not re-copy the array.
|
|
113
|
+
"""
|
|
114
|
+
return np.concatenate([np.asarray(m, dtype=np.float32) for m in matrices], axis=0)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Persistent, timestamp-keyed prediction cache.
|
|
2
|
+
|
|
3
|
+
Extracted and generalized from a production trading system (Neuromomentum /
|
|
4
|
+
ExtremePrediction) where full-range backtests over 100k+ bars were dominated
|
|
5
|
+
by redundant neural-network inference. Storing predictions keyed by bar
|
|
6
|
+
timestamp (not row index) lets any sub-range be replayed without offset
|
|
7
|
+
alignment and lets incremental backtests reuse everything previously computed.
|
|
8
|
+
|
|
9
|
+
Design goals
|
|
10
|
+
------------
|
|
11
|
+
* **Correctness first** — atomic writes (temp file + os.replace), so a crash
|
|
12
|
+
mid-save never corrupts the cache.
|
|
13
|
+
* **Backend-agnostic** — pickle (default) and parquet backends ship in the
|
|
14
|
+
box; register your own with :func:`register_backend`.
|
|
15
|
+
* **Framework-agnostic** — works with any callable batch scorer (Keras,
|
|
16
|
+
PyTorch, ONNX, sklearn, plain numpy).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import pickle
|
|
23
|
+
import tempfile
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Callable, Dict, Hashable, Iterable, List, Optional, Protocol
|
|
26
|
+
|
|
27
|
+
__all__ = ["PredictionCache", "CacheEntry", "register_backend"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _Backend(Protocol):
|
|
31
|
+
"""A cache backend persists a mapping of keys to payloads."""
|
|
32
|
+
|
|
33
|
+
def dump(self, entries: Dict[Hashable, Any], path: str) -> None: ...
|
|
34
|
+
|
|
35
|
+
def load(self, path: str) -> Dict[Hashable, Any]: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Built-in backends
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def _pickle_dump(entries: Dict[Hashable, Any], path: str) -> None:
|
|
43
|
+
with open(path, "wb") as f:
|
|
44
|
+
pickle.dump(entries, f, protocol=pickle.HIGHEST_PROTOCOL)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _pickle_load(path: str) -> Dict[Hashable, Any]:
|
|
48
|
+
with open(path, "rb") as f:
|
|
49
|
+
data = pickle.load(f)
|
|
50
|
+
return data if isinstance(data, dict) else {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parquet_dump(entries: Dict[Hashable, Any], path: str) -> None:
|
|
54
|
+
import pandas as pd # optional dependency
|
|
55
|
+
|
|
56
|
+
keys = list(entries.keys())
|
|
57
|
+
df = pd.DataFrame({"key": keys, "value": [entries[k] for k in keys]})
|
|
58
|
+
df.to_parquet(path)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parquet_load(path: str) -> Dict[Hashable, Any]:
|
|
62
|
+
import pandas as pd # optional dependency
|
|
63
|
+
|
|
64
|
+
df = pd.read_parquet(path)
|
|
65
|
+
return dict(zip(df["key"], df["value"]))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_BACKENDS: Dict[str, _Backend] = {
|
|
69
|
+
"pickle": (_pickle_dump, _pickle_load),
|
|
70
|
+
"parquet": (_parquet_dump, _parquet_load),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def register_backend(name: str, dump: Callable, load: Callable) -> None:
|
|
75
|
+
"""Register a custom cache backend (dump/load callables)."""
|
|
76
|
+
_BACKENDS[name] = (dump, load)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class CacheEntry:
|
|
81
|
+
"""One cached prediction, keyed by a timestamp or other hashable."""
|
|
82
|
+
|
|
83
|
+
key: Hashable
|
|
84
|
+
value: Any
|
|
85
|
+
meta: Dict[str, Any] = None # type: ignore[assignment]
|
|
86
|
+
|
|
87
|
+
def __post_init__(self):
|
|
88
|
+
if self.meta is None:
|
|
89
|
+
object.__setattr__(self, "meta", {})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PredictionCache:
|
|
93
|
+
"""Persistent cache for model predictions keyed by bar timestamp.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
path
|
|
98
|
+
File path of the cache file. Created on first save.
|
|
99
|
+
backend
|
|
100
|
+
``"pickle"`` (default, zero-dependency) or ``"parquet"``
|
|
101
|
+
(requires ``pyarrow``; compact for numeric payloads).
|
|
102
|
+
safe_save
|
|
103
|
+
Write via a temp file + :func:`os.replace` so a crash never
|
|
104
|
+
corrupts an existing cache. Default ``True``.
|
|
105
|
+
|
|
106
|
+
Notes
|
|
107
|
+
-----
|
|
108
|
+
Keys should be timestamps or any other stable hashable identity —
|
|
109
|
+
*not* row indices. Index keys silently break when a frame is sliced
|
|
110
|
+
or resampled; timestamp keys survive any sub-range extraction.
|
|
111
|
+
|
|
112
|
+
Examples
|
|
113
|
+
--------
|
|
114
|
+
>>> from predcache import PredictionCache
|
|
115
|
+
>>> cache = PredictionCache("preds.pkl")
|
|
116
|
+
>>> cache.get_or_compute(
|
|
117
|
+
... keys=["2026-01-02 10:00", "2026-01-02 10:01"],
|
|
118
|
+
... compute_fn=lambda missing: {k: float(k[-4:]) for k in missing},
|
|
119
|
+
... )
|
|
120
|
+
{'2026-01-02 10:01': 10.01, '2026-01-02 10:01': 10.01}
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, path: str, backend: str = "pickle"):
|
|
124
|
+
self.path = path
|
|
125
|
+
if backend not in _BACKENDS:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Unknown backend {backend!r}. Registered: {sorted(_BACKENDS)}"
|
|
128
|
+
)
|
|
129
|
+
self._dump, self._load = _BACKENDS[backend]
|
|
130
|
+
self._entries: Dict[Hashable, Any] = {}
|
|
131
|
+
self._dirty = False
|
|
132
|
+
|
|
133
|
+
# -- I/O -----------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def load(self) -> "PredictionCache":
|
|
136
|
+
if os.path.exists(self.path):
|
|
137
|
+
self._entries = self._load(self.path)
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def save(self) -> None:
|
|
141
|
+
if not self._dirty:
|
|
142
|
+
return
|
|
143
|
+
directory = os.path.dirname(self.path)
|
|
144
|
+
if directory:
|
|
145
|
+
os.makedirs(directory, exist_ok=True)
|
|
146
|
+
if self.path.endswith(".tmp"):
|
|
147
|
+
self._dump(self._entries, self.path)
|
|
148
|
+
else:
|
|
149
|
+
fd, tmp = tempfile.mkstemp(
|
|
150
|
+
dir=directory or ".", prefix=os.path.basename(self.path), suffix=".tmp"
|
|
151
|
+
)
|
|
152
|
+
os.close(fd)
|
|
153
|
+
try:
|
|
154
|
+
self._dump(self._entries, tmp)
|
|
155
|
+
os.replace(tmp, self.path)
|
|
156
|
+
finally:
|
|
157
|
+
if os.path.exists(tmp):
|
|
158
|
+
os.remove(tmp)
|
|
159
|
+
self._dirty = False
|
|
160
|
+
|
|
161
|
+
# -- lookup / mutation -----------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def __contains__(self, key: Hashable) -> bool:
|
|
164
|
+
return key in self._entries
|
|
165
|
+
|
|
166
|
+
def __len__(self) -> int:
|
|
167
|
+
return len(self._entries)
|
|
168
|
+
|
|
169
|
+
def get(self, key: Hashable, default: Any = None) -> Any:
|
|
170
|
+
return self._entries.get(key, default)
|
|
171
|
+
|
|
172
|
+
def put(self, key: Hashable, value: Any) -> None:
|
|
173
|
+
self._entries[key] = value
|
|
174
|
+
self._dirty = True
|
|
175
|
+
|
|
176
|
+
def keys(self) -> Iterable[Hashable]:
|
|
177
|
+
return self._entries.keys()
|
|
178
|
+
|
|
179
|
+
def items(self) -> Iterable[tuple]:
|
|
180
|
+
return self._entries.items()
|
|
181
|
+
|
|
182
|
+
def info(self) -> Dict[str, Any]:
|
|
183
|
+
return {
|
|
184
|
+
"path": self.path,
|
|
185
|
+
"entries": len(self._entries),
|
|
186
|
+
"backend": "pickle" if self.path.endswith(".pkl") else "auto",
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# -- the core convenience ---------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def get_or_compute(
|
|
192
|
+
self,
|
|
193
|
+
keys: List[Hashable],
|
|
194
|
+
compute_fn: Callable[[List[Hashable]], Dict[Hashable, Any]],
|
|
195
|
+
save: bool = True,
|
|
196
|
+
) -> Dict[Hashable, Any]:
|
|
197
|
+
"""Return predictions for *keys*, computing only the missing ones.
|
|
198
|
+
|
|
199
|
+
``compute_fn`` receives the list of missing keys and must return a
|
|
200
|
+
dict ``{key: value}``. Computed values are merged into the cache.
|
|
201
|
+
"""
|
|
202
|
+
missing = [k for k in keys if k not in self._entries]
|
|
203
|
+
if missing:
|
|
204
|
+
computed = compute_fn(missing) or {}
|
|
205
|
+
for k, v in computed.items():
|
|
206
|
+
self._entries[k] = v
|
|
207
|
+
self._dirty = True
|
|
208
|
+
if save:
|
|
209
|
+
self.save()
|
|
210
|
+
return {k: self._entries[k] for k in keys if k in self._entries}
|
|
211
|
+
|
|
212
|
+
def drop(self, keys: List[Hashable]) -> int:
|
|
213
|
+
removed = 0
|
|
214
|
+
for k in keys:
|
|
215
|
+
if k in self._entries:
|
|
216
|
+
del self._entries[k]
|
|
217
|
+
removed += 1
|
|
218
|
+
if removed:
|
|
219
|
+
self._dirty = True
|
|
220
|
+
return removed
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Feature-window assembly helpers.
|
|
2
|
+
|
|
3
|
+
Generalizes the windowing pattern from the source trading system: for each
|
|
4
|
+
bar at index *i*, build a feature vector from a look-back window ending at
|
|
5
|
+
*i*, optionally reversed (most-recent-first, the convention used when the
|
|
6
|
+
models were trained on MT5 series ordering), then normalize.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Callable, List, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
__all__ = ["normalize_absolute", "FeatureWindowAssembler"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_absolute(vec: np.ndarray) -> np.ndarray:
|
|
19
|
+
"""Scale a vector so every element lies in [-1, 1] (L-inf normalization).
|
|
20
|
+
|
|
21
|
+
Matches the source system's ``normalize_vector_absolute``: divide by the
|
|
22
|
+
max absolute value, preserving sign and zeros. Returns the input
|
|
23
|
+
unchanged when its max abs value is 0.
|
|
24
|
+
"""
|
|
25
|
+
vec = np.asarray(vec, dtype=np.float32)
|
|
26
|
+
scale = np.max(np.abs(vec))
|
|
27
|
+
if scale == 0:
|
|
28
|
+
return vec
|
|
29
|
+
return vec / scale
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FeatureWindowAssembler:
|
|
33
|
+
"""Build look-back feature windows from an OHLC frame.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
window
|
|
38
|
+
Look-back length in bars (``bars_to_fetch`` in the source system).
|
|
39
|
+
warmup
|
|
40
|
+
Number of initial bars to skip before emitting windows.
|
|
41
|
+
row_fn
|
|
42
|
+
Callable ``(window_df) -> 1-D array`` producing the feature vector
|
|
43
|
+
for one window. Receives the reversed (most-recent-first) window
|
|
44
|
+
slice. This keeps domain feature logic (fractal detection, price
|
|
45
|
+
deltas, external regressors) in *your* code — the assembler only
|
|
46
|
+
handles looping, warmup and stacking.
|
|
47
|
+
|
|
48
|
+
Examples
|
|
49
|
+
--------
|
|
50
|
+
>>> assembler = FeatureWindowAssembler(window=50, warmup=10, row_fn=my_features)
|
|
51
|
+
>>> X = assembler.build(df) # (n_bars - warmup, n_features)
|
|
52
|
+
>>> keys = assembler.keys(df) # matching timestamp keys
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
window: int,
|
|
58
|
+
warmup: int = 0,
|
|
59
|
+
row_fn: Optional[Callable] = None,
|
|
60
|
+
):
|
|
61
|
+
if window <= 0:
|
|
62
|
+
raise ValueError("window must be positive")
|
|
63
|
+
if warmup < 0:
|
|
64
|
+
raise ValueError("warmup must be >= 0")
|
|
65
|
+
self.window = int(window)
|
|
66
|
+
self.warmup = int(warmup)
|
|
67
|
+
self.row_fn = row_fn
|
|
68
|
+
|
|
69
|
+
def windows(self, df) -> List:
|
|
70
|
+
"""Yield reversed (most-recent-first) window slices starting at warmup."""
|
|
71
|
+
n = len(df)
|
|
72
|
+
for i in range(self.warmup, n):
|
|
73
|
+
lo = max(0, i - (self.window - 1))
|
|
74
|
+
yield df.iloc[lo : i + 1].iloc[::-1].reset_index(drop=True)
|
|
75
|
+
|
|
76
|
+
def keys(self, df, key_col: str = "time") -> List:
|
|
77
|
+
"""Timestamp keys for each window (stable identity for caching)."""
|
|
78
|
+
return [df.iloc[i][key_col] for i in range(self.warmup, len(df))]
|
|
79
|
+
|
|
80
|
+
def build(self, df, key_col: Optional[str] = None):
|
|
81
|
+
"""Stack feature rows for every window; returns (X, keys)."""
|
|
82
|
+
if self.row_fn is None:
|
|
83
|
+
raise ValueError("row_fn is required to build features")
|
|
84
|
+
rows: List[np.ndarray] = []
|
|
85
|
+
for win in self.windows(df):
|
|
86
|
+
rows.append(np.asarray(self.row_fn(win), dtype=np.float32))
|
|
87
|
+
if not rows:
|
|
88
|
+
return np.zeros((0, 0), dtype=np.float32), []
|
|
89
|
+
keys = self.keys(df, key_col) if key_col else self.keys(df)
|
|
90
|
+
return np.stack(rows, axis=0), keys
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: predcache
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Persistent timestamp-keyed prediction cache and batched inference runner for ML models
|
|
5
|
+
Author-email: Anton Uralskii <uralskyanton@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Geomanti/predcache
|
|
8
|
+
Project-URL: Repository, https://github.com/Geomanti/predcache
|
|
9
|
+
Project-URL: Issues, https://github.com/Geomanti/predcache/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/Geomanti/predcache/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: cache,inference,batching,machine-learning,backtesting,time-series,mlops
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: numpy>=1.21
|
|
28
|
+
Provides-Extra: parquet
|
|
29
|
+
Requires-Dist: pandas>=1.5; extra == "parquet"
|
|
30
|
+
Requires-Dist: pyarrow>=10; extra == "parquet"
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
33
|
+
Requires-Dist: pandas>=1.5; extra == "test"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# predcache
|
|
37
|
+
|
|
38
|
+
**Persistent timestamp-keyed prediction cache and batched inference runner for ML models.**
|
|
39
|
+
|
|
40
|
+
Extracted and generalized from a production neural-network trading system where
|
|
41
|
+
full-range backtests over 100,000+ bars were dominated by redundant model
|
|
42
|
+
inference. Storing predictions keyed by **bar timestamp** (not row index) makes
|
|
43
|
+
any sub-range replayable without offset alignment — and makes incremental
|
|
44
|
+
backtests reuse everything previously computed. The result: full-range runs
|
|
45
|
+
went from **hours to minutes**.
|
|
46
|
+
|
|
47
|
+
## Why
|
|
48
|
+
|
|
49
|
+
Typical ML backtesting pipelines recompute model inference for the same bars on
|
|
50
|
+
every run — every parameter sweep, every range extension, every "what if".
|
|
51
|
+
`predcache` decouples the two expensive parts:
|
|
52
|
+
|
|
53
|
+
1. **Feature-window assembly** — building look-back feature rows from an OHLC
|
|
54
|
+
frame (most-recent-first, warmup-aware), with your domain logic plugged in
|
|
55
|
+
via `row_fn`.
|
|
56
|
+
2. **Batched inference** — scoring rows through *any* model callable
|
|
57
|
+
(Keras, PyTorch, ONNX, sklearn) in fixed batches with progress reporting.
|
|
58
|
+
3. **Persistent caching** — predictions keyed by timestamp, saved atomically,
|
|
59
|
+
so the next run computes only what's missing.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install predcache # core (numpy only)
|
|
65
|
+
pip install predcache[parquet] # + parquet backend
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import pandas as pd
|
|
72
|
+
from predcache import PredictionCache, BatchedInferenceRunner, FeatureWindowAssembler
|
|
73
|
+
|
|
74
|
+
# 1. Assemble feature windows (your domain logic stays in row_fn)
|
|
75
|
+
assembler = FeatureWindowAssembler(
|
|
76
|
+
window=50, warmup=500,
|
|
77
|
+
row_fn=lambda win: my_feature_logic(win), # 1-D feature vector per window
|
|
78
|
+
)
|
|
79
|
+
X, keys = assembler.build(df, key_col="time") # X: (n_bars - warmup, n_features)
|
|
80
|
+
|
|
81
|
+
# 2. Batched inference through any model
|
|
82
|
+
runner = BatchedInferenceRunner(
|
|
83
|
+
n_samples=len(X),
|
|
84
|
+
inference_fn=lambda s, e: model.predict(X[s:e], verbose=0),
|
|
85
|
+
batch_size=500,
|
|
86
|
+
)
|
|
87
|
+
preds = runner.run()
|
|
88
|
+
|
|
89
|
+
# 3. Cache by timestamp — next run computes only missing bars
|
|
90
|
+
cache = PredictionCache("predictions.pkl")
|
|
91
|
+
out = cache.get_or_compute(
|
|
92
|
+
keys=keys,
|
|
93
|
+
compute_fn=lambda missing: predict_bars(X, keys, missing),
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Features
|
|
98
|
+
|
|
99
|
+
- **Timestamp-keyed cache** — sub-range extraction without offset alignment;
|
|
100
|
+
row-index caches silently break on slicing/resampling, timestamp keys don't.
|
|
101
|
+
- **Atomic saves** — temp file + `os.replace`; a crash mid-save never corrupts
|
|
102
|
+
an existing cache.
|
|
103
|
+
- **Pluggable backends** — pickle (zero-dependency) and parquet ship in the
|
|
104
|
+
box; register custom backends with `register_backend(name, dump, load)`.
|
|
105
|
+
- **Batched inference runner** — framework-agnostic `(start, stop) -> scores`
|
|
106
|
+
contract, progress callbacks, tuned default batch size (500).
|
|
107
|
+
- **L-inf normalization** helper matching the source system's
|
|
108
|
+
`normalize_vector_absolute`.
|
|
109
|
+
|
|
110
|
+
## Origin
|
|
111
|
+
|
|
112
|
+
This package is the generalized core of the prediction-serving layer of
|
|
113
|
+
[Neuromomentum](https://github.com/Geomanti)'s production trading system
|
|
114
|
+
(three Keras classifiers, MT5 live execution, 2012–2026 backtest range). The
|
|
115
|
+
original cache manager was 542 lines of domain-specific code; what ships here
|
|
116
|
+
is the reusable pattern: timestamp-keyed persistence, compute-only-missing,
|
|
117
|
+
batched scoring, windowed feature assembly.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/predcache/__init__.py
|
|
5
|
+
src/predcache/batched.py
|
|
6
|
+
src/predcache/cache.py
|
|
7
|
+
src/predcache/windowing.py
|
|
8
|
+
src/predcache.egg-info/PKG-INFO
|
|
9
|
+
src/predcache.egg-info/SOURCES.txt
|
|
10
|
+
src/predcache.egg-info/dependency_links.txt
|
|
11
|
+
src/predcache.egg-info/requires.txt
|
|
12
|
+
src/predcache.egg-info/top_level.txt
|
|
13
|
+
tests/test_predcache.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
predcache
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from predcache import BatchedInferenceRunner, FeatureWindowAssembler, PredictionCache
|
|
6
|
+
from predcache.batched import stack_features
|
|
7
|
+
from predcache.windowing import normalize_absolute
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# PredictionCache
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
def _fake_compute(keys):
|
|
15
|
+
return {k: f"pred:{k}" for k in keys}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_cache_roundtrip(tmp_path):
|
|
19
|
+
p = str(tmp_path / "cache.pkl")
|
|
20
|
+
c = PredictionCache(p)
|
|
21
|
+
out = c.get_or_compute(["a", "b", "c"], _fake_compute)
|
|
22
|
+
assert out == {"a": "pred:a", "b": "pred:b", "c": "pred:c"}
|
|
23
|
+
|
|
24
|
+
# fresh instance reads persisted state
|
|
25
|
+
c2 = PredictionCache(p).load()
|
|
26
|
+
assert "a" in c2 and len(c2) == 3
|
|
27
|
+
assert c2.get("b") == "pred:b"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_cache_computes_only_missing(tmp_path):
|
|
31
|
+
p = str(tmp_path / "cache.pkl")
|
|
32
|
+
c = PredictionCache(p)
|
|
33
|
+
calls = []
|
|
34
|
+
|
|
35
|
+
def tracking_compute(keys):
|
|
36
|
+
calls.append(list(keys))
|
|
37
|
+
return _fake_compute(keys)
|
|
38
|
+
|
|
39
|
+
c.get_or_compute(["a", "b"], tracking_compute)
|
|
40
|
+
c.get_or_compute(["a", "b", "c"], tracking_compute)
|
|
41
|
+
# second call must only ask for "c"
|
|
42
|
+
assert calls == [["a", "b"], ["c"]]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_cache_atomic_save(tmp_path):
|
|
46
|
+
p = str(tmp_path / "cache.pkl")
|
|
47
|
+
c = PredictionCache(p)
|
|
48
|
+
c.get_or_compute(["k1"], _fake_compute)
|
|
49
|
+
# simulate leftover temp file — must not break loads
|
|
50
|
+
(tmp_path / "cache.pkl.abc123.tmp").write_bytes(b"garbage")
|
|
51
|
+
c2 = PredictionCache(p).load()
|
|
52
|
+
assert c2.get("k1") == "pred:k1"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_cache_drop(tmp_path):
|
|
56
|
+
p = str(tmp_path / "cache.pkl")
|
|
57
|
+
c = PredictionCache(p)
|
|
58
|
+
c.get_or_compute(["x", "y"], _fake_compute)
|
|
59
|
+
assert c.drop(["x", "missing"]) == 1
|
|
60
|
+
assert "x" not in c
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_parquet_backend(tmp_path):
|
|
64
|
+
pytest.importorskip("pyarrow")
|
|
65
|
+
p = str(tmp_path / "cache.parquet")
|
|
66
|
+
c = PredictionCache(p, backend="parquet")
|
|
67
|
+
c.get_or_compute(["t1", "t2"], lambda ks: {k: [0.5, 1] for k in ks})
|
|
68
|
+
c2 = PredictionCache(p, backend="parquet").load()
|
|
69
|
+
assert c2.get("t1") == [0.5, 1]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# BatchedInferenceRunner
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def test_runner_batches_and_scores():
|
|
77
|
+
features = np.arange(1200 * 3, dtype=np.float32).reshape(1200, 3)
|
|
78
|
+
seen = []
|
|
79
|
+
|
|
80
|
+
def scorer(s, e):
|
|
81
|
+
seen.append((s, e))
|
|
82
|
+
return features[s:e].sum(axis=1)
|
|
83
|
+
|
|
84
|
+
runner = BatchedInferenceRunner(len(features), scorer, batch_size=500)
|
|
85
|
+
preds = runner.run()
|
|
86
|
+
assert seen == [(0, 500), (500, 1000), (1000, 1200)]
|
|
87
|
+
assert len(preds) == 1200
|
|
88
|
+
# row 0 = 0+1+2 = 3; row 1199 sums the last 3 values of arange(3600)
|
|
89
|
+
assert np.isclose(float(preds[0]), 3.0)
|
|
90
|
+
assert np.isclose(float(preds[-1]), 3597.0 + 3598.0 + 3599.0)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_runner_progress():
|
|
94
|
+
n = 1500
|
|
95
|
+
runner = BatchedInferenceRunner(n, lambda s, e: np.ones(e - s), batch_size=500, progress_every=1)
|
|
96
|
+
events = []
|
|
97
|
+
runner.run(progress_cb=events.append)
|
|
98
|
+
assert len(events) == 3
|
|
99
|
+
assert events[-1]["rows_done"] == n
|
|
100
|
+
assert runner.progress_log == events
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_stack_features():
|
|
104
|
+
m = stack_features([np.ones((2, 4)), np.zeros((3, 4))])
|
|
105
|
+
assert m.shape == (5, 4) and m.dtype == np.float32
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Windowing
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def _ohlc(n=30):
|
|
113
|
+
return pd.DataFrame(
|
|
114
|
+
{
|
|
115
|
+
"time": pd.date_range("2026-01-01", periods=n, freq="min"),
|
|
116
|
+
"close": np.linspace(1.0, 2.0, n),
|
|
117
|
+
"high": np.linspace(1.1, 2.1, n),
|
|
118
|
+
"low": np.linspace(0.9, 1.9, n),
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_normalize_absolute():
|
|
124
|
+
v = np.array([2.0, -1.0, 0.0], dtype=np.float32)
|
|
125
|
+
out = normalize_absolute(v)
|
|
126
|
+
assert np.isclose(out[0], 1.0) and np.isclose(out[1], -0.5)
|
|
127
|
+
z = normalize_absolute(np.zeros(3))
|
|
128
|
+
assert (z == 0).all()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_assembler_windows_and_keys():
|
|
132
|
+
df = _ohlc(30)
|
|
133
|
+
asm = FeatureWindowAssembler(window=5, warmup=10, row_fn=lambda w: [w["close"].iloc[0]])
|
|
134
|
+
X, keys = asm.build(df, key_col="time")
|
|
135
|
+
assert X.shape == (20, 1)
|
|
136
|
+
assert len(keys) == 20
|
|
137
|
+
assert keys[0] == df.iloc[10]["time"]
|
|
138
|
+
# most-recent-first: window's first row is the bar at index 10
|
|
139
|
+
assert np.isclose(X[0, 0], df.iloc[10]["close"])
|