alphaengine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,321 @@
1
+ """The grid runner.
2
+
3
+ WHAT THIS DELIBERATELY DOES NOT DO
4
+ It does not backtest. `sweep()` takes YOUR function and calls it once per
5
+ parameter combination. We orchestrate and measure; you simulate. Shipping a
6
+ backtester would mean competing with the engine you already trust, and
7
+ inheriting responsibility for its correctness, which is not a trade worth
8
+ making for a library whose entire claim is that its numbers are reliable.
9
+
10
+ WHY THE TRIAL COUNT IS NOT A PARAMETER
11
+ Because a parameter is a place to be optimistic. `n_trials` is `len(grid)`,
12
+ computed from the grid actually iterated, and there is no argument to
13
+ override it. That is the single design decision this module exists for.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import itertools
20
+ import json
21
+ import math
22
+ from collections.abc import Callable, Iterable, Mapping, Sequence
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import numpy as np
28
+ import numpy.typing as npt
29
+
30
+ from ..core import deflated_sharpe, min_track_record_length, pbo_cscv, performance_report
31
+
32
+ __all__ = ["sweep", "SweepResult"]
33
+
34
+ # A parameter set is a plain dict; a returns series is a sequence of floats.
35
+ Params = Mapping[str, Any]
36
+ Returns = Sequence[float]
37
+ BacktestFn = Callable[..., Returns]
38
+
39
+
40
+ def _expand(grid: Mapping[str, Iterable[Any]]) -> list[dict[str, Any]]:
41
+ """Cartesian product of the grid, in a deterministic order.
42
+
43
+ Sorted by key so the same grid produces the same ordering on any machine and
44
+ any Python version, the trial index is part of what gets recorded, so it
45
+ cannot depend on dict iteration order.
46
+ """
47
+ keys = sorted(grid)
48
+ if not keys:
49
+ # itertools.product() with no iterables yields ONE empty tuple, so an
50
+ # empty grid would quietly run once and report n_trials=1. That is
51
+ # exactly the flattering count this module exists to prevent, so it has
52
+ # to fail rather than pass.
53
+ return []
54
+ values = [list(grid[k]) for k in keys]
55
+ empty = [k for k, v in zip(keys, values, strict=True) if not v]
56
+ if empty:
57
+ # One empty axis makes the whole product empty. A caller who wrote
58
+ # {"fast": []} meant to sweep something and should hear about it.
59
+ raise ValueError(f"grid axis has no values: {empty}")
60
+ return [dict(zip(keys, combo, strict=True)) for combo in itertools.product(*values)]
61
+
62
+
63
+ def _hash_data(data: Any) -> str:
64
+ """Content hash of the input series.
65
+
66
+ This is the segment identity, and it is deliberately NOT a user-supplied
67
+ label: a name can be changed to escape a history, an array cannot. Two runs
68
+ over the same data are recognisably the same segment even if the researcher
69
+ renamed the experiment.
70
+ """
71
+ try:
72
+ arr = np.asarray(data, dtype=float)
73
+ return hashlib.sha256(np.ascontiguousarray(arr).tobytes()).hexdigest()[:16]
74
+ except (TypeError, ValueError):
75
+ # Not array-shaped (a dict of price series, say). Hash a canonical
76
+ # JSON rendering so the identity is still stable across runs.
77
+ blob = json.dumps(data, sort_keys=True, default=str).encode()
78
+ return hashlib.sha256(blob).hexdigest()[:16]
79
+
80
+
81
+ def _hash_params(p: Params) -> str:
82
+ return hashlib.sha256(json.dumps(dict(p), sort_keys=True, default=str).encode()).hexdigest()[:12]
83
+
84
+
85
+ def _sharpe(returns: npt.NDArray[np.float64]) -> float:
86
+ sd = float(returns.std(ddof=1)) if returns.size > 1 else 0.0
87
+ return float(returns.mean() / sd) if sd > 0 else 0.0
88
+
89
+
90
+ @dataclass
91
+ class Trial:
92
+ """One parameter set and what it produced."""
93
+
94
+ index: int
95
+ params: dict[str, Any]
96
+ params_hash: str
97
+ n_obs: int
98
+ sharpe: float
99
+ sharpe_annualized: float
100
+ total_return_pct: float
101
+ failed: str | None = None
102
+
103
+
104
+ @dataclass
105
+ class SweepResult:
106
+ """Everything the sweep saw. The trial matrix is the irreplaceable part."""
107
+
108
+ trials: list[Trial]
109
+ matrix: npt.NDArray[np.float64] = field(repr=False) # (T observations, N configurations)
110
+ data_hash: str = ""
111
+ grid_keys: list[str] = field(default_factory=list)
112
+ store_params: bool = False
113
+
114
+ @property
115
+ def n_trials(self) -> int:
116
+ """Derived, never supplied. This is the whole point of the module."""
117
+ return len(self.trials)
118
+
119
+ @property
120
+ def best(self) -> Trial:
121
+ return max((t for t in self.trials if t.failed is None), key=lambda t: t.sharpe)
122
+
123
+ def verdict(self, *, risk_free_rate: float = 0.0) -> dict[str, Any]:
124
+ """Deflate the best result for the search that actually produced it.
125
+
126
+ PBO is reported alongside but is NOT the gate. It answers a different
127
+ question, "was the choice AMONG configurations informative?", and
128
+ returns roughly 0.5 for genuinely near-tied top configs even when the
129
+ underlying edge is real. Measured on our own null/edge fixture, DSR
130
+ discriminates cleanly and PBO does not; treating them as two readings of
131
+ the same thing would be a mistake.
132
+ """
133
+ best = self.best
134
+ col = self.matrix[:, best.index]
135
+ dsr = deflated_sharpe(col.tolist(), n_trials=self.n_trials)
136
+ out: dict[str, Any] = {
137
+ "n_trials": self.n_trials,
138
+ "n_trials_source": "derived_from_grid",
139
+ "best_trial_index": best.index,
140
+ "best_params_hash": best.params_hash,
141
+ "data_hash": self.data_hash,
142
+ "deflated_sharpe": dsr.get("deflated_sharpe"),
143
+ "verdict": dsr.get("verdict"),
144
+ "psr_vs_zero": dsr.get("psr_vs_zero"),
145
+ "sr0_expected_max": dsr.get("sr0_expected_max"),
146
+ "performance": performance_report(col.tolist(), risk_free_rate=risk_free_rate),
147
+ }
148
+ if self.store_params:
149
+ out["best_params"] = best.params
150
+
151
+ # Minimum track record length: how long this record must be before the
152
+ # result is distinguishable from luck. Bites universally, and converts
153
+ # into a dated obligation rather than a pass/fail insult.
154
+ try:
155
+ out["min_track_record_length"] = min_track_record_length(col.tolist())
156
+ except Exception: # noqa: BLE001 - never fail a verdict on an optional figure
157
+ out["min_track_record_length"] = None
158
+
159
+ # PBO needs at least two configurations; a single-point "sweep" cannot
160
+ # say anything about selection, and should say so rather than emit a
161
+ # number that looks like an answer.
162
+ if self.matrix.shape[1] >= 2:
163
+ out["selection"] = {
164
+ "question": "was the choice among configurations informative?",
165
+ "not_a_verdict_on_the_edge": True,
166
+ **pbo_cscv(self.matrix.tolist()),
167
+ }
168
+ return out
169
+
170
+ def save(
171
+ self, path: str | Path = "study.json", *, label: str = "", data_description: str = "", notes: str = ""
172
+ ) -> Path:
173
+ """Write the study to disk. Local file, no account, no upload."""
174
+ from ..study import Study
175
+ from ..study import save as _save
176
+
177
+ study = Study.from_sweep(self, label=label, data_description=data_description, notes=notes)
178
+ return _save(study, path)
179
+
180
+ def surface(self) -> dict[str, Any]:
181
+ """The neighbourhood: plateau or knife edge.
182
+
183
+ The payoff of running a grid, and the reason this is a coaching output
184
+ rather than a refereeing one, a researcher ends the session knowing
185
+ where to re-centre, not merely that their number was flattered.
186
+ """
187
+ ok = [t for t in self.trials if t.failed is None]
188
+ if not ok:
189
+ return {"shape": "empty", "n_ok": 0, "n_failed": len(self.trials)}
190
+
191
+ sharpes = np.array([t.sharpe for t in ok], dtype=float)
192
+ best = float(sharpes.max())
193
+ median = float(np.median(sharpes))
194
+ # Share of the grid that holds up near the best result. A broad plateau
195
+ # is robustness; one spike is a result fitted to its own parameters.
196
+ near = float((sharpes >= best * 0.8).mean()) if best > 0 else 0.0
197
+ shape = "plateau" if near >= 0.30 else "ridge" if near >= 0.10 else "knife_edge"
198
+
199
+ # Where the robust region sits, per parameter, the actionable half.
200
+ centre: dict[str, Any] = {}
201
+ if self.store_params:
202
+ strong = [t for t in ok if best > 0 and t.sharpe >= best * 0.8]
203
+ for k in self.grid_keys:
204
+ vals = [t.params.get(k) for t in strong]
205
+ numeric = [v for v in vals if isinstance(v, (int, float)) and not isinstance(v, bool)]
206
+ if numeric:
207
+ centre[k] = {"median": float(np.median(numeric)), "range": [min(numeric), max(numeric)]}
208
+
209
+ return {
210
+ "shape": shape,
211
+ "n_ok": len(ok),
212
+ "n_failed": len(self.trials) - len(ok),
213
+ "best_sharpe": round(best, 4),
214
+ "median_sharpe": round(median, 4),
215
+ "share_within_20pct_of_best": round(near, 4),
216
+ "plateau_centre": centre or None,
217
+ "reading": {
218
+ "plateau": "A broad region performs. The result does not depend on the exact parameters.",
219
+ "ridge": "A narrow region performs. Sensitive to the parameters; treat with care.",
220
+ "knife_edge": "One configuration performs and its neighbours do not. "
221
+ "Usually a fitted result.",
222
+ }[shape],
223
+ }
224
+
225
+
226
+ def sweep(
227
+ backtest_fn: BacktestFn,
228
+ grid: Mapping[str, Iterable[Any]],
229
+ *,
230
+ data: Any = None,
231
+ store_params: bool = False,
232
+ on_error: str = "record",
233
+ ) -> SweepResult:
234
+ """Run `backtest_fn` once per combination in `grid`.
235
+
236
+ Args:
237
+ backtest_fn: YOUR backtest. Called as ``backtest_fn(data=data, **params)``
238
+ when `data` is given, otherwise ``backtest_fn(**params)``. Must
239
+ return a sequence of per-period returns.
240
+ grid: parameter name -> values to try. The cartesian product is the
241
+ search, and its length is the trial count.
242
+ data: passed through untouched. Only hashed, never inspected or stored.
243
+ store_params: keep the parameter values in the result. OFF by default.
244
+ THE GRID IS OFTEN BIGGER IP THAN THE RETURN SERIES: "it uploads my
245
+ parameter search" ends a conversation faster than "it uploads my
246
+ returns". Hashes are always kept, so runs remain comparable without
247
+ the values leaving.
248
+ on_error: "record" marks a failing combination and continues (the
249
+ default, because one bad corner of a grid should not lose the
250
+ other ninety-nine results); "raise" propagates.
251
+
252
+ Returns:
253
+ SweepResult, holding the full (T x N) trial matrix that PBO needs and
254
+ that a single-point run structurally cannot produce.
255
+ """
256
+ combos = _expand(grid)
257
+ if not combos:
258
+ raise ValueError("grid is empty: nothing to sweep")
259
+
260
+ data_hash = _hash_data(data) if data is not None else ""
261
+ trials: list[Trial] = []
262
+ columns: list[npt.NDArray[np.float64]] = []
263
+
264
+ for i, params in enumerate(combos):
265
+ try:
266
+ raw = backtest_fn(data=data, **params) if data is not None else backtest_fn(**params)
267
+ r = np.asarray(list(raw), dtype=float)
268
+ if r.ndim != 1 or r.size == 0:
269
+ raise ValueError(f"backtest_fn returned shape {r.shape}; expected a 1-D return series")
270
+ if not np.all(np.isfinite(r)):
271
+ r = np.nan_to_num(r, nan=0.0, posinf=0.0, neginf=0.0)
272
+ except Exception as exc: # noqa: BLE001
273
+ if on_error == "raise":
274
+ raise
275
+ trials.append(
276
+ Trial(
277
+ i,
278
+ dict(params),
279
+ _hash_params(params),
280
+ 0,
281
+ 0.0,
282
+ 0.0,
283
+ 0.0,
284
+ failed=f"{type(exc).__name__}: {exc}",
285
+ )
286
+ )
287
+ continue
288
+
289
+ sr = _sharpe(r)
290
+ trials.append(
291
+ Trial(
292
+ index=i,
293
+ params=dict(params),
294
+ params_hash=_hash_params(params),
295
+ n_obs=int(r.size),
296
+ sharpe=round(sr, 6),
297
+ sharpe_annualized=round(sr * math.sqrt(252), 4),
298
+ total_return_pct=round((float(np.prod(1 + r)) - 1) * 100, 4),
299
+ )
300
+ )
301
+ columns.append(r)
302
+
303
+ if not columns:
304
+ raise RuntimeError(
305
+ f"every one of the {len(combos)} combinations failed. First error: {trials[0].failed}"
306
+ )
307
+
308
+ # Ragged output means the configurations are not comparable, and silently
309
+ # truncating would produce a PBO over series that do not line up in time.
310
+ lengths = {c.size for c in columns}
311
+ if len(lengths) > 1:
312
+ n = min(lengths)
313
+ columns = [c[-n:] for c in columns]
314
+
315
+ return SweepResult(
316
+ trials=trials,
317
+ matrix=np.column_stack(columns),
318
+ data_hash=data_hash,
319
+ grid_keys=sorted(grid),
320
+ store_params=store_params,
321
+ )
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: alphaengine
3
+ Version: 0.1.0
4
+ Summary: Validated research tooling for investment strategies: deflation, overfitting detection, and honest trial counts.
5
+ Project-URL: Homepage, https://github.com/quantOSC/alphaengine
6
+ Project-URL: Documentation, https://github.com/quantOSC/alphaengine#readme
7
+ Project-URL: Source, https://github.com/quantOSC/alphaengine
8
+ Project-URL: Issues, https://github.com/quantOSC/alphaengine/issues
9
+ Author: QuantOS
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ Keywords: backtesting,deflated-sharpe,overfitting,quantitative-finance,research
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: Apache Software License
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Office/Business :: Financial :: Investment
22
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: numpy>=1.24
26
+ Requires-Dist: scipy>=1.10
27
+ Provides-Extra: agents
28
+ Requires-Dist: anthropic>=0.40; extra == 'agents'
29
+ Provides-Extra: connectors
30
+ Requires-Dist: httpx>=0.27; extra == 'connectors'
31
+ Requires-Dist: pyarrow>=15.0; extra == 'connectors'
32
+ Provides-Extra: dev
33
+ Requires-Dist: mypy==2.3.0; extra == 'dev'
34
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
35
+ Requires-Dist: pytest>=8.0; extra == 'dev'
36
+ Requires-Dist: ruff==0.16.1; extra == 'dev'
37
+ Requires-Dist: statsmodels>=0.14; extra == 'dev'
38
+ Provides-Extra: factors
39
+ Requires-Dist: statsmodels>=0.14; extra == 'factors'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # AlphaEngine
43
+
44
+ Validated research tooling for investment strategies. Run a parameter search,
45
+ get back the shape of the result and an honest read on whether it survives the
46
+ number of things you tried.
47
+
48
+ ```bash
49
+ pip install alphaengine
50
+ ```
51
+
52
+ ```python
53
+ from alphaengine import sweep
54
+
55
+ r = sweep(backtest_fn, {"fast": [5, 10, 20], "slow": [50, 100, 200]}, data=prices)
56
+
57
+ r.surface() # is the result a broad plateau or a single lucky configuration?
58
+ r.verdict() # deflated for the 9 trials that were actually run
59
+ r.save() # study.json, on your disk
60
+ ```
61
+
62
+ ## What it does
63
+
64
+ **Runs your parameter grid.** `sweep()` calls your backtest function once per
65
+ combination. It does not backtest anything itself, so the engine you already
66
+ trust stays the engine you trust.
67
+
68
+ **Counts the trials for you.** The statistics that correct a Sharpe ratio for
69
+ multiple testing need to know how many variants were tested. That number is
70
+ almost never recorded, because nobody counts what they discarded. Running the
71
+ grid makes it `len(grid)`, so it never has to be asked for or asserted.
72
+
73
+ **Shows you the neighbourhood.** The output is whether your result sits on a
74
+ broad plateau or a knife edge, and where the robust region is centred. A single
75
+ spike surrounded by failures is a result fitted to its own parameters.
76
+
77
+ **Produces a portable study.** A JSON artifact holding what was tried, what came
78
+ back, and a content hash of the data it ran on. Readable in a text editor,
79
+ diffable, and versioned so it still parses in two years.
80
+
81
+ ## What is in it
82
+
83
+ | Module | Contents |
84
+ |---|---|
85
+ | `alphaengine.core` | deflated Sharpe, PSR, PBO via CSCV, CPCV, minimum track record length, performance and risk statistics |
86
+ | `alphaengine.sweep` | the grid runner and the sensitivity surface |
87
+ | `alphaengine.study` | the study artifact and its schema |
88
+
89
+ Two runtime dependencies, numpy and scipy, both already present in a typical
90
+ research environment. `import alphaengine` makes no network call and needs no
91
+ account. Factor decomposition and cointegration testing need statsmodels and
92
+ are available as `pip install 'alphaengine[factors]'`.
93
+
94
+ ## Where this sits in QuantOS
95
+
96
+ AlphaEngine is the open research layer of the [QuantOS](https://github.com/quantOSC)
97
+ platform. It is the piece that runs on your machine, against your data, and it
98
+ is complete on its own: everything above works offline and forever, at no cost.
99
+
100
+ The QuantOS platform builds on it. Studies produced here can be persisted to a
101
+ firm's record, referenced when an idea becomes a position, and assembled into
102
+ the reports that go to an investment committee or an allocator. The library
103
+ computes; the platform remembers and reports. The two halves are separated so
104
+ that the part touching your data has no reason to phone home.
105
+
106
+ ## The methods
107
+
108
+ Everything in `core` comes from the published literature. Nothing here is a
109
+ proprietary formula, which is deliberate: a referee whose reasoning you cannot
110
+ inspect is not a referee.
111
+
112
+ **Deflated Sharpe Ratio, Probabilistic Sharpe Ratio, minimum track record length**
113
+ Bailey, D. H., and López de Prado, M. (2012). "The Sharpe Ratio Efficient
114
+ Frontier." *Journal of Risk* 15(2), 3 to 44.
115
+ Bailey, D. H., and López de Prado, M. (2014). "The Deflated Sharpe Ratio:
116
+ Correcting for Selection Bias, Backtest Overfitting, and Non-Normality."
117
+ *Journal of Portfolio Management* 40(5), 94 to 107.
118
+
119
+ **Probability of Backtest Overfitting via CSCV**
120
+ Bailey, D. H., Borwein, J., López de Prado, M., and Zhu, Q. J. (2017). "The
121
+ Probability of Backtest Overfitting." *Journal of Computational Finance* 20(4),
122
+ 39 to 69.
123
+
124
+ **Combinatorial purged cross-validation**
125
+ López de Prado, M. (2018). *Advances in Financial Machine Learning.* Wiley,
126
+ chapters 7 and 12.
127
+
128
+ **Multiple testing in asset pricing**
129
+ Harvey, C. R., Liu, Y., and Zhu, H. (2016). "... and the Cross-Section of
130
+ Expected Returns." *Review of Financial Studies* 29(1), 5 to 68.
131
+ Harvey, C. R., and Liu, Y. (2015). "Backtesting." *Journal of Portfolio
132
+ Management* 42(1), 13 to 28.
133
+
134
+ **Downside deviation**
135
+ Sortino, F. A., and Price, L. N. (1994). "Performance Measurement in a Downside
136
+ Risk Framework." *Journal of Investing* 3(3), 59 to 64.
137
+
138
+ **Factor regression standard errors** (in the `factors` extra)
139
+ Newey, W. K., and West, K. D. (1987). "A Simple, Positive Semi-Definite,
140
+ Heteroskedasticity and Autocorrelation Consistent Covariance Matrix."
141
+ *Econometrica* 55(3), 703 to 708.
142
+
143
+ **Unit root testing for cointegration** (in the `factors` extra)
144
+ Dickey, D. A., and Fuller, W. A. (1979). "Distribution of the Estimators for
145
+ Autoregressive Time Series with a Unit Root." *Journal of the American
146
+ Statistical Association* 74(366), 427 to 431.
147
+
148
+ ## Reproducibility
149
+
150
+ The values these functions return are treated as a public contract. A study
151
+ written today has to reproduce in two years, so a change to a computed value is
152
+ a breaking change requiring a major version bump even when the signature is
153
+ unchanged. CI fails if a pinned value moves.
154
+
155
+ ## Licence
156
+
157
+ Apache-2.0. See [LICENSE](LICENSE).
@@ -0,0 +1,19 @@
1
+ alphaengine/__init__.py,sha256=Xx64Qohlj9rNdzIpqpA12wXYhHfktMnergnpG1SD-AM,1954
2
+ alphaengine/_version.py,sha256=XAZ3PfMXxfgHsM8709CVeHw2zdqPFtgcuFRTKh8VvlI,651
3
+ alphaengine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ alphaengine/core/__init__.py,sha256=2rrUJrvTp6GElFeGVQSk0wkszcw2JMTQFyxryVMVJpM,2312
5
+ alphaengine/core/backtest.py,sha256=25bjrygUMhPc4Fg1dCUqcCQah92KQkPm3PedOuQGsXg,28609
6
+ alphaengine/core/factors.py,sha256=uR0seow6x3_Vshp-MkyRQ7WXZB144rV0Ik5Hh15A378,5291
7
+ alphaengine/core/pairs.py,sha256=F3DHM3krXJKZQlK1NMaq1xG9_Td27RyHO3_C9w9Ve3k,20170
8
+ alphaengine/core/performance.py,sha256=HajHtKpJHGVPHlsz70XnsCKvru-swpecU4s9dXJOsgE,4401
9
+ alphaengine/core/risk.py,sha256=xrnfcWE-95LQYDSN9unnjMRGUD0mZHC3AXh6dg459jw,5023
10
+ alphaengine/core/technical.py,sha256=FCK1Hv_yaBjGgOQO6vW9DC4iyJZGww9ST3MUPZ58WWU,7699
11
+ alphaengine/core/validation.py,sha256=cNkNfcdCsXQxgxMM0WDemmVnO2jubfQHcc9Nq21mOGs,14841
12
+ alphaengine/study/__init__.py,sha256=YWR8049WTzPbYvG-IYnj6IVsx_P0ceYKOXYVzzLc3mw,1480
13
+ alphaengine/study/schema.py,sha256=BMKzp11-4SRlu23qw7EqYKrMkacvHjE9orzlcKFv4K8,6381
14
+ alphaengine/sweep/__init__.py,sha256=TQ1w5v6Lb7_CNOC4NJC-Z64jWlUM5q7R-g0agCqNFoQ,803
15
+ alphaengine/sweep/runner.py,sha256=09T_bO4gl22ksIKJfYWPWzvFuM3waXuEx6XxEQpIzPI,13037
16
+ alphaengine-0.1.0.dist-info/METADATA,sha256=5WRceP3yRJrbBspEsJ16Aq6DhYUdWfSj8Lq3RtWrcgM,6817
17
+ alphaengine-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
18
+ alphaengine-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
19
+ alphaengine-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any