aponyx 0.1.18__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.
- aponyx/__init__.py +14 -0
- aponyx/backtest/__init__.py +31 -0
- aponyx/backtest/adapters.py +77 -0
- aponyx/backtest/config.py +84 -0
- aponyx/backtest/engine.py +560 -0
- aponyx/backtest/protocols.py +101 -0
- aponyx/backtest/registry.py +334 -0
- aponyx/backtest/strategy_catalog.json +50 -0
- aponyx/cli/__init__.py +5 -0
- aponyx/cli/commands/__init__.py +8 -0
- aponyx/cli/commands/clean.py +349 -0
- aponyx/cli/commands/list.py +302 -0
- aponyx/cli/commands/report.py +167 -0
- aponyx/cli/commands/run.py +377 -0
- aponyx/cli/main.py +125 -0
- aponyx/config/__init__.py +82 -0
- aponyx/data/__init__.py +99 -0
- aponyx/data/bloomberg_config.py +306 -0
- aponyx/data/bloomberg_instruments.json +26 -0
- aponyx/data/bloomberg_securities.json +42 -0
- aponyx/data/cache.py +294 -0
- aponyx/data/fetch.py +659 -0
- aponyx/data/fetch_registry.py +135 -0
- aponyx/data/loaders.py +205 -0
- aponyx/data/providers/__init__.py +13 -0
- aponyx/data/providers/bloomberg.py +383 -0
- aponyx/data/providers/file.py +111 -0
- aponyx/data/registry.py +500 -0
- aponyx/data/requirements.py +96 -0
- aponyx/data/sample_data.py +415 -0
- aponyx/data/schemas.py +60 -0
- aponyx/data/sources.py +171 -0
- aponyx/data/synthetic_params.json +46 -0
- aponyx/data/transforms.py +336 -0
- aponyx/data/validation.py +308 -0
- aponyx/docs/__init__.py +24 -0
- aponyx/docs/adding_data_providers.md +682 -0
- aponyx/docs/cdx_knowledge_base.md +455 -0
- aponyx/docs/cdx_overlay_strategy.md +135 -0
- aponyx/docs/cli_guide.md +607 -0
- aponyx/docs/governance_design.md +551 -0
- aponyx/docs/logging_design.md +251 -0
- aponyx/docs/performance_evaluation_design.md +265 -0
- aponyx/docs/python_guidelines.md +786 -0
- aponyx/docs/signal_registry_usage.md +369 -0
- aponyx/docs/signal_suitability_design.md +558 -0
- aponyx/docs/visualization_design.md +277 -0
- aponyx/evaluation/__init__.py +11 -0
- aponyx/evaluation/performance/__init__.py +24 -0
- aponyx/evaluation/performance/adapters.py +109 -0
- aponyx/evaluation/performance/analyzer.py +384 -0
- aponyx/evaluation/performance/config.py +320 -0
- aponyx/evaluation/performance/decomposition.py +304 -0
- aponyx/evaluation/performance/metrics.py +761 -0
- aponyx/evaluation/performance/registry.py +327 -0
- aponyx/evaluation/performance/report.py +541 -0
- aponyx/evaluation/suitability/__init__.py +67 -0
- aponyx/evaluation/suitability/config.py +143 -0
- aponyx/evaluation/suitability/evaluator.py +389 -0
- aponyx/evaluation/suitability/registry.py +328 -0
- aponyx/evaluation/suitability/report.py +398 -0
- aponyx/evaluation/suitability/scoring.py +367 -0
- aponyx/evaluation/suitability/tests.py +303 -0
- aponyx/examples/01_generate_synthetic_data.py +53 -0
- aponyx/examples/02_fetch_data_file.py +82 -0
- aponyx/examples/03_fetch_data_bloomberg.py +104 -0
- aponyx/examples/04_compute_signal.py +164 -0
- aponyx/examples/05_evaluate_suitability.py +224 -0
- aponyx/examples/06_run_backtest.py +242 -0
- aponyx/examples/07_analyze_performance.py +214 -0
- aponyx/examples/08_visualize_results.py +272 -0
- aponyx/main.py +7 -0
- aponyx/models/__init__.py +45 -0
- aponyx/models/config.py +83 -0
- aponyx/models/indicator_transformation.json +52 -0
- aponyx/models/indicators.py +292 -0
- aponyx/models/metadata.py +447 -0
- aponyx/models/orchestrator.py +213 -0
- aponyx/models/registry.py +860 -0
- aponyx/models/score_transformation.json +42 -0
- aponyx/models/signal_catalog.json +29 -0
- aponyx/models/signal_composer.py +513 -0
- aponyx/models/signal_transformation.json +29 -0
- aponyx/persistence/__init__.py +16 -0
- aponyx/persistence/json_io.py +132 -0
- aponyx/persistence/parquet_io.py +378 -0
- aponyx/py.typed +0 -0
- aponyx/reporting/__init__.py +10 -0
- aponyx/reporting/generator.py +517 -0
- aponyx/visualization/__init__.py +20 -0
- aponyx/visualization/app.py +37 -0
- aponyx/visualization/plots.py +309 -0
- aponyx/visualization/visualizer.py +242 -0
- aponyx/workflows/__init__.py +18 -0
- aponyx/workflows/concrete_steps.py +720 -0
- aponyx/workflows/config.py +122 -0
- aponyx/workflows/engine.py +279 -0
- aponyx/workflows/registry.py +116 -0
- aponyx/workflows/steps.py +180 -0
- aponyx-0.1.18.dist-info/METADATA +552 -0
- aponyx-0.1.18.dist-info/RECORD +104 -0
- aponyx-0.1.18.dist-info/WHEEL +4 -0
- aponyx-0.1.18.dist-info/entry_points.txt +2 -0
- aponyx-0.1.18.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Registry classes for managing indicator, transformation, and signal catalogs.
|
|
3
|
+
|
|
4
|
+
This module manages catalog lifecycles:
|
|
5
|
+
- Loading metadata from JSON
|
|
6
|
+
- Validating definitions (compute functions exist, parameters valid)
|
|
7
|
+
- Querying enabled/disabled entries
|
|
8
|
+
- Tracking dependencies between indicators and signals
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import asdict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .metadata import (
|
|
17
|
+
CatalogValidationError,
|
|
18
|
+
IndicatorMetadata,
|
|
19
|
+
SignalMetadata,
|
|
20
|
+
SignalTransformationMetadata,
|
|
21
|
+
TransformationMetadata,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IndicatorTransformationRegistry:
|
|
28
|
+
"""
|
|
29
|
+
Registry for indicator transformation catalog with JSON persistence and fail-fast validation.
|
|
30
|
+
|
|
31
|
+
Manages indicator transformation definitions from the catalog JSON file, validates that
|
|
32
|
+
referenced compute functions exist, and provides query interfaces for
|
|
33
|
+
enabled/disabled indicator transformations.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
catalog_path : str | Path
|
|
38
|
+
Path to JSON catalog file containing indicator transformation metadata.
|
|
39
|
+
|
|
40
|
+
Examples
|
|
41
|
+
--------
|
|
42
|
+
>>> from aponyx.config import INDICATOR_TRANSFORMATION_PATH
|
|
43
|
+
>>> registry = IndicatorTransformationRegistry(INDICATOR_TRANSFORMATION_PATH)
|
|
44
|
+
>>> enabled = registry.get_enabled()
|
|
45
|
+
>>> metadata = registry.get_metadata("cdx_etf_spread_diff")
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, catalog_path: str | Path) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Initialize registry and load catalog from JSON file.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
catalog_path : str | Path
|
|
55
|
+
Path to JSON catalog file.
|
|
56
|
+
|
|
57
|
+
Raises
|
|
58
|
+
------
|
|
59
|
+
FileNotFoundError
|
|
60
|
+
If catalog file does not exist.
|
|
61
|
+
ValueError
|
|
62
|
+
If catalog JSON is invalid or contains duplicate indicator names.
|
|
63
|
+
"""
|
|
64
|
+
self._catalog_path = Path(catalog_path)
|
|
65
|
+
self._indicators: dict[str, IndicatorMetadata] = {}
|
|
66
|
+
self._dependencies: dict[str, list[str]] = {} # indicator -> signals
|
|
67
|
+
self._load_catalog()
|
|
68
|
+
|
|
69
|
+
logger.info(
|
|
70
|
+
"Loaded indicator registry: catalog=%s, indicators=%d, enabled=%d",
|
|
71
|
+
self._catalog_path,
|
|
72
|
+
len(self._indicators),
|
|
73
|
+
len(self.get_enabled()),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _load_catalog(self) -> None:
|
|
77
|
+
"""Load indicator metadata from JSON catalog file."""
|
|
78
|
+
if not self._catalog_path.exists():
|
|
79
|
+
raise FileNotFoundError(
|
|
80
|
+
f"Indicator catalog not found: {self._catalog_path}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
with open(self._catalog_path, "r", encoding="utf-8") as f:
|
|
84
|
+
catalog_data = json.load(f)
|
|
85
|
+
|
|
86
|
+
if not isinstance(catalog_data, list):
|
|
87
|
+
raise ValueError("Indicator catalog must be a JSON array")
|
|
88
|
+
|
|
89
|
+
for entry in catalog_data:
|
|
90
|
+
try:
|
|
91
|
+
metadata = IndicatorMetadata(**entry)
|
|
92
|
+
if metadata.name in self._indicators:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"Duplicate indicator name in catalog: {metadata.name}"
|
|
95
|
+
)
|
|
96
|
+
self._indicators[metadata.name] = metadata
|
|
97
|
+
except TypeError as e:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Invalid indicator metadata in catalog: {entry}. Error: {e}"
|
|
100
|
+
) from e
|
|
101
|
+
|
|
102
|
+
logger.debug("Loaded %d indicators from catalog", len(self._indicators))
|
|
103
|
+
|
|
104
|
+
# Fail-fast validation: ensure all compute functions exist
|
|
105
|
+
self._validate_catalog()
|
|
106
|
+
|
|
107
|
+
def _validate_catalog(self) -> None:
|
|
108
|
+
"""
|
|
109
|
+
Validate that all indicator compute functions exist in indicators module.
|
|
110
|
+
|
|
111
|
+
Raises
|
|
112
|
+
------
|
|
113
|
+
ValueError
|
|
114
|
+
If any compute function name does not exist in indicators module.
|
|
115
|
+
"""
|
|
116
|
+
# Import here to avoid circular dependency
|
|
117
|
+
try:
|
|
118
|
+
from . import indicators
|
|
119
|
+
except ImportError:
|
|
120
|
+
logger.warning(
|
|
121
|
+
"indicators module not found, skipping compute function validation"
|
|
122
|
+
)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
for name, metadata in self._indicators.items():
|
|
126
|
+
if not hasattr(indicators, metadata.compute_function_name):
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"Indicator '{name}' references non-existent compute function: "
|
|
129
|
+
f"{metadata.compute_function_name}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
logger.debug("Validated %d indicator compute functions", len(self._indicators))
|
|
133
|
+
|
|
134
|
+
def get_metadata(self, name: str) -> IndicatorMetadata:
|
|
135
|
+
"""
|
|
136
|
+
Retrieve metadata for a specific indicator.
|
|
137
|
+
|
|
138
|
+
Parameters
|
|
139
|
+
----------
|
|
140
|
+
name : str
|
|
141
|
+
Indicator name.
|
|
142
|
+
|
|
143
|
+
Returns
|
|
144
|
+
-------
|
|
145
|
+
IndicatorMetadata
|
|
146
|
+
Indicator metadata.
|
|
147
|
+
|
|
148
|
+
Raises
|
|
149
|
+
------
|
|
150
|
+
ValueError
|
|
151
|
+
If indicator name is not registered.
|
|
152
|
+
"""
|
|
153
|
+
if name not in self._indicators:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"Indicator '{name}' not found in registry. "
|
|
156
|
+
f"Available indicators: {sorted(self._indicators.keys())}"
|
|
157
|
+
)
|
|
158
|
+
return self._indicators[name]
|
|
159
|
+
|
|
160
|
+
def get_all_indicators(self) -> list[str]:
|
|
161
|
+
"""
|
|
162
|
+
Get all indicator names.
|
|
163
|
+
|
|
164
|
+
Returns
|
|
165
|
+
-------
|
|
166
|
+
list[str]
|
|
167
|
+
List of all indicator names (enabled and disabled).
|
|
168
|
+
"""
|
|
169
|
+
return list(self._indicators.keys())
|
|
170
|
+
|
|
171
|
+
def get_enabled_indicators(self) -> list[str]:
|
|
172
|
+
"""
|
|
173
|
+
Get all enabled indicator names.
|
|
174
|
+
|
|
175
|
+
Returns
|
|
176
|
+
-------
|
|
177
|
+
list[str]
|
|
178
|
+
List of enabled indicator names only.
|
|
179
|
+
"""
|
|
180
|
+
return [name for name, meta in self._indicators.items() if meta.enabled]
|
|
181
|
+
|
|
182
|
+
def get_enabled(self) -> dict[str, IndicatorMetadata]:
|
|
183
|
+
"""
|
|
184
|
+
Get all enabled indicators.
|
|
185
|
+
|
|
186
|
+
Returns
|
|
187
|
+
-------
|
|
188
|
+
dict[str, IndicatorMetadata]
|
|
189
|
+
Mapping from indicator name to metadata for enabled indicators only.
|
|
190
|
+
"""
|
|
191
|
+
return {name: meta for name, meta in self._indicators.items() if meta.enabled}
|
|
192
|
+
|
|
193
|
+
def list_all(self) -> dict[str, IndicatorMetadata]:
|
|
194
|
+
"""
|
|
195
|
+
Get all registered indicators (enabled and disabled).
|
|
196
|
+
|
|
197
|
+
Returns
|
|
198
|
+
-------
|
|
199
|
+
dict[str, IndicatorMetadata]
|
|
200
|
+
Mapping from indicator name to metadata for all indicators.
|
|
201
|
+
"""
|
|
202
|
+
return self._indicators.copy()
|
|
203
|
+
|
|
204
|
+
def indicator_exists(self, name: str) -> bool:
|
|
205
|
+
"""
|
|
206
|
+
Check if indicator is registered.
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
name : str
|
|
211
|
+
Indicator name.
|
|
212
|
+
|
|
213
|
+
Returns
|
|
214
|
+
-------
|
|
215
|
+
bool
|
|
216
|
+
True if indicator exists in registry.
|
|
217
|
+
"""
|
|
218
|
+
return name in self._indicators
|
|
219
|
+
|
|
220
|
+
def get_dependent_signals(self, indicator_name: str) -> list[str]:
|
|
221
|
+
"""
|
|
222
|
+
Get list of signals that depend on this indicator.
|
|
223
|
+
|
|
224
|
+
Parameters
|
|
225
|
+
----------
|
|
226
|
+
indicator_name : str
|
|
227
|
+
Indicator name.
|
|
228
|
+
|
|
229
|
+
Returns
|
|
230
|
+
-------
|
|
231
|
+
list[str]
|
|
232
|
+
List of signal names that reference this indicator.
|
|
233
|
+
"""
|
|
234
|
+
return self._dependencies.get(indicator_name, []).copy()
|
|
235
|
+
|
|
236
|
+
def get_all_dependencies(self) -> dict[str, list[str]]:
|
|
237
|
+
"""
|
|
238
|
+
Get complete dependency graph.
|
|
239
|
+
|
|
240
|
+
Returns
|
|
241
|
+
-------
|
|
242
|
+
dict[str, list[str]]
|
|
243
|
+
Mapping from indicator name to list of dependent signal names.
|
|
244
|
+
"""
|
|
245
|
+
return {k: v.copy() for k, v in self._dependencies.items()}
|
|
246
|
+
|
|
247
|
+
def _build_dependency_index(self, signal_registry: "SignalRegistry") -> None:
|
|
248
|
+
"""
|
|
249
|
+
Build reverse index of indicator → signals dependencies.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
signal_registry : SignalRegistry
|
|
254
|
+
Signal registry to extract dependencies from.
|
|
255
|
+
"""
|
|
256
|
+
self._dependencies.clear()
|
|
257
|
+
|
|
258
|
+
for signal_name, signal_meta in signal_registry.list_all().items():
|
|
259
|
+
# Every signal references exactly one indicator transformation
|
|
260
|
+
indicator_name = signal_meta.indicator_transformation
|
|
261
|
+
if indicator_name not in self._dependencies:
|
|
262
|
+
self._dependencies[indicator_name] = []
|
|
263
|
+
self._dependencies[indicator_name].append(signal_name)
|
|
264
|
+
|
|
265
|
+
logger.debug(
|
|
266
|
+
"Built dependency index: %d indicators with dependencies",
|
|
267
|
+
len(self._dependencies),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def save_catalog(self, path: str | Path | None = None) -> None:
|
|
271
|
+
"""
|
|
272
|
+
Save indicator metadata to JSON catalog file.
|
|
273
|
+
|
|
274
|
+
Parameters
|
|
275
|
+
----------
|
|
276
|
+
path : str | Path | None
|
|
277
|
+
Output path. If None, overwrites original catalog file.
|
|
278
|
+
"""
|
|
279
|
+
output_path = Path(path) if path else self._catalog_path
|
|
280
|
+
|
|
281
|
+
catalog_data = [asdict(meta) for meta in self._indicators.values()]
|
|
282
|
+
|
|
283
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
285
|
+
json.dump(catalog_data, f, indent=2)
|
|
286
|
+
|
|
287
|
+
logger.info(
|
|
288
|
+
"Saved indicator catalog: path=%s, indicators=%d",
|
|
289
|
+
output_path,
|
|
290
|
+
len(catalog_data),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class ScoreTransformationRegistry:
|
|
295
|
+
"""
|
|
296
|
+
Registry for score transformation catalog with JSON persistence and validation.
|
|
297
|
+
|
|
298
|
+
Manages score transformation definitions from the catalog JSON file and provides
|
|
299
|
+
query interfaces for enabled/disabled score transformations.
|
|
300
|
+
|
|
301
|
+
Parameters
|
|
302
|
+
----------
|
|
303
|
+
catalog_path : str | Path
|
|
304
|
+
Path to JSON catalog file containing score transformation metadata.
|
|
305
|
+
|
|
306
|
+
Examples
|
|
307
|
+
--------
|
|
308
|
+
>>> from aponyx.config import SCORE_TRANSFORMATION_PATH
|
|
309
|
+
>>> registry = ScoreTransformationRegistry(SCORE_TRANSFORMATION_PATH)
|
|
310
|
+
>>> enabled = registry.get_enabled()
|
|
311
|
+
>>> metadata = registry.get_metadata("z_score_20d")
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(self, catalog_path: str | Path) -> None:
|
|
315
|
+
"""
|
|
316
|
+
Initialize registry and load catalog from JSON file.
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
catalog_path : str | Path
|
|
321
|
+
Path to JSON catalog file.
|
|
322
|
+
|
|
323
|
+
Raises
|
|
324
|
+
------
|
|
325
|
+
FileNotFoundError
|
|
326
|
+
If catalog file does not exist.
|
|
327
|
+
ValueError
|
|
328
|
+
If catalog JSON is invalid or contains duplicate transformation names.
|
|
329
|
+
"""
|
|
330
|
+
self._catalog_path = Path(catalog_path)
|
|
331
|
+
self._transformations: dict[str, TransformationMetadata] = {}
|
|
332
|
+
self._load_catalog()
|
|
333
|
+
|
|
334
|
+
logger.info(
|
|
335
|
+
"Loaded transformation registry: catalog=%s, transformations=%d, enabled=%d",
|
|
336
|
+
self._catalog_path,
|
|
337
|
+
len(self._transformations),
|
|
338
|
+
len(self.get_enabled()),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def _load_catalog(self) -> None:
|
|
342
|
+
"""Load transformation metadata from JSON catalog file."""
|
|
343
|
+
if not self._catalog_path.exists():
|
|
344
|
+
raise FileNotFoundError(
|
|
345
|
+
f"Transformation catalog not found: {self._catalog_path}"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
with open(self._catalog_path, "r", encoding="utf-8") as f:
|
|
349
|
+
catalog_data = json.load(f)
|
|
350
|
+
|
|
351
|
+
if not isinstance(catalog_data, list):
|
|
352
|
+
raise ValueError("Transformation catalog must be a JSON array")
|
|
353
|
+
|
|
354
|
+
for entry in catalog_data:
|
|
355
|
+
try:
|
|
356
|
+
metadata = TransformationMetadata(**entry)
|
|
357
|
+
if metadata.name in self._transformations:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"Duplicate transformation name in catalog: {metadata.name}"
|
|
360
|
+
)
|
|
361
|
+
self._transformations[metadata.name] = metadata
|
|
362
|
+
except TypeError as e:
|
|
363
|
+
raise ValueError(
|
|
364
|
+
f"Invalid transformation metadata in catalog: {entry}. Error: {e}"
|
|
365
|
+
) from e
|
|
366
|
+
|
|
367
|
+
logger.debug(
|
|
368
|
+
"Loaded %d transformations from catalog", len(self._transformations)
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def get_metadata(self, name: str) -> TransformationMetadata:
|
|
372
|
+
"""
|
|
373
|
+
Retrieve metadata for a specific transformation.
|
|
374
|
+
|
|
375
|
+
Parameters
|
|
376
|
+
----------
|
|
377
|
+
name : str
|
|
378
|
+
Transformation name.
|
|
379
|
+
|
|
380
|
+
Returns
|
|
381
|
+
-------
|
|
382
|
+
TransformationMetadata
|
|
383
|
+
Transformation metadata.
|
|
384
|
+
|
|
385
|
+
Raises
|
|
386
|
+
------
|
|
387
|
+
KeyError
|
|
388
|
+
If transformation name is not registered.
|
|
389
|
+
"""
|
|
390
|
+
if name not in self._transformations:
|
|
391
|
+
raise KeyError(
|
|
392
|
+
f"Transformation '{name}' not found in registry. "
|
|
393
|
+
f"Available transformations: {sorted(self._transformations.keys())}"
|
|
394
|
+
)
|
|
395
|
+
return self._transformations[name]
|
|
396
|
+
|
|
397
|
+
def get_enabled(self) -> dict[str, TransformationMetadata]:
|
|
398
|
+
"""
|
|
399
|
+
Get all enabled transformations.
|
|
400
|
+
|
|
401
|
+
Returns
|
|
402
|
+
-------
|
|
403
|
+
dict[str, TransformationMetadata]
|
|
404
|
+
Mapping from transformation name to metadata for enabled transformations only.
|
|
405
|
+
"""
|
|
406
|
+
return {
|
|
407
|
+
name: meta for name, meta in self._transformations.items() if meta.enabled
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
def list_all(self) -> dict[str, TransformationMetadata]:
|
|
411
|
+
"""
|
|
412
|
+
Get all registered transformations (enabled and disabled).
|
|
413
|
+
|
|
414
|
+
Returns
|
|
415
|
+
-------
|
|
416
|
+
dict[str, TransformationMetadata]
|
|
417
|
+
Mapping from transformation name to metadata for all transformations.
|
|
418
|
+
"""
|
|
419
|
+
return self._transformations.copy()
|
|
420
|
+
|
|
421
|
+
def transformation_exists(self, name: str) -> bool:
|
|
422
|
+
"""
|
|
423
|
+
Check if transformation is registered.
|
|
424
|
+
|
|
425
|
+
Parameters
|
|
426
|
+
----------
|
|
427
|
+
name : str
|
|
428
|
+
Transformation name.
|
|
429
|
+
|
|
430
|
+
Returns
|
|
431
|
+
-------
|
|
432
|
+
bool
|
|
433
|
+
True if transformation exists in registry.
|
|
434
|
+
"""
|
|
435
|
+
return name in self._transformations
|
|
436
|
+
|
|
437
|
+
def save_catalog(self, path: str | Path | None = None) -> None:
|
|
438
|
+
"""
|
|
439
|
+
Save transformation metadata to JSON catalog file.
|
|
440
|
+
|
|
441
|
+
Parameters
|
|
442
|
+
----------
|
|
443
|
+
path : str | Path | None
|
|
444
|
+
Output path. If None, overwrites original catalog file.
|
|
445
|
+
"""
|
|
446
|
+
output_path = Path(path) if path else self._catalog_path
|
|
447
|
+
|
|
448
|
+
catalog_data = [asdict(meta) for meta in self._transformations.values()]
|
|
449
|
+
|
|
450
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
451
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
452
|
+
json.dump(catalog_data, f, indent=2)
|
|
453
|
+
|
|
454
|
+
logger.info(
|
|
455
|
+
"Saved transformation catalog: path=%s, transformations=%d",
|
|
456
|
+
output_path,
|
|
457
|
+
len(catalog_data),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class SignalTransformationRegistry:
|
|
462
|
+
"""
|
|
463
|
+
Registry for signal transformation catalog with JSON persistence and fail-fast validation.
|
|
464
|
+
|
|
465
|
+
Manages signal transformation definitions (floor, cap, neutral_range, scaling) from
|
|
466
|
+
the catalog JSON file and provides query interfaces for enabled/disabled signal transformations.
|
|
467
|
+
|
|
468
|
+
Parameters
|
|
469
|
+
----------
|
|
470
|
+
catalog_path : str | Path
|
|
471
|
+
Path to JSON catalog file containing signal transformation metadata.
|
|
472
|
+
|
|
473
|
+
Examples
|
|
474
|
+
--------
|
|
475
|
+
>>> from aponyx.config import SIGNAL_TRANSFORMATION_PATH
|
|
476
|
+
>>> registry = SignalTransformationRegistry(SIGNAL_TRANSFORMATION_PATH)
|
|
477
|
+
>>> enabled = registry.get_enabled()
|
|
478
|
+
>>> metadata = registry.get_metadata("bounded_1_5")
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
def __init__(self, catalog_path: str | Path) -> None:
|
|
482
|
+
"""
|
|
483
|
+
Initialize registry and load catalog from JSON file.
|
|
484
|
+
|
|
485
|
+
Parameters
|
|
486
|
+
----------
|
|
487
|
+
catalog_path : str | Path
|
|
488
|
+
Path to JSON catalog file.
|
|
489
|
+
|
|
490
|
+
Raises
|
|
491
|
+
------
|
|
492
|
+
FileNotFoundError
|
|
493
|
+
If catalog file does not exist.
|
|
494
|
+
ValueError
|
|
495
|
+
If catalog JSON is invalid or contains duplicate transformation names.
|
|
496
|
+
CatalogValidationError
|
|
497
|
+
If any transformation violates constraints (floor > cap, etc.).
|
|
498
|
+
"""
|
|
499
|
+
self._catalog_path = Path(catalog_path)
|
|
500
|
+
self._signal_transformations: dict[str, SignalTransformationMetadata] = {}
|
|
501
|
+
self._load_catalog()
|
|
502
|
+
|
|
503
|
+
logger.info(
|
|
504
|
+
"Loaded signal transformation registry: catalog=%s, transformations=%d, enabled=%d",
|
|
505
|
+
self._catalog_path,
|
|
506
|
+
len(self._signal_transformations),
|
|
507
|
+
len(self.get_enabled()),
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
def _load_catalog(self) -> None:
|
|
511
|
+
"""Load signal transformation metadata from JSON catalog file."""
|
|
512
|
+
if not self._catalog_path.exists():
|
|
513
|
+
raise FileNotFoundError(
|
|
514
|
+
f"Signal transformation catalog not found: {self._catalog_path}"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
with open(self._catalog_path, "r", encoding="utf-8") as f:
|
|
518
|
+
catalog_data = json.load(f)
|
|
519
|
+
|
|
520
|
+
if not isinstance(catalog_data, list):
|
|
521
|
+
raise ValueError("Signal transformation catalog must be a JSON array")
|
|
522
|
+
|
|
523
|
+
for entry in catalog_data:
|
|
524
|
+
try:
|
|
525
|
+
# Convert neutral_range from list to tuple for frozen dataclass
|
|
526
|
+
if "neutral_range" in entry and entry["neutral_range"] is not None:
|
|
527
|
+
entry["neutral_range"] = tuple(entry["neutral_range"])
|
|
528
|
+
|
|
529
|
+
metadata = SignalTransformationMetadata(**entry)
|
|
530
|
+
if metadata.name in self._signal_transformations:
|
|
531
|
+
raise ValueError(
|
|
532
|
+
f"Duplicate signal transformation name in catalog: {metadata.name}"
|
|
533
|
+
)
|
|
534
|
+
self._signal_transformations[metadata.name] = metadata
|
|
535
|
+
except TypeError as e:
|
|
536
|
+
raise ValueError(
|
|
537
|
+
f"Invalid signal transformation metadata in catalog: {entry}. Error: {e}"
|
|
538
|
+
) from e
|
|
539
|
+
|
|
540
|
+
logger.debug(
|
|
541
|
+
"Loaded %d signal transformations from catalog",
|
|
542
|
+
len(self._signal_transformations),
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
def get_metadata(self, name: str) -> SignalTransformationMetadata:
|
|
546
|
+
"""
|
|
547
|
+
Retrieve metadata for a specific signal transformation.
|
|
548
|
+
|
|
549
|
+
Parameters
|
|
550
|
+
----------
|
|
551
|
+
name : str
|
|
552
|
+
Signal transformation name.
|
|
553
|
+
|
|
554
|
+
Returns
|
|
555
|
+
-------
|
|
556
|
+
SignalTransformationMetadata
|
|
557
|
+
Signal transformation metadata.
|
|
558
|
+
|
|
559
|
+
Raises
|
|
560
|
+
------
|
|
561
|
+
KeyError
|
|
562
|
+
If signal transformation name is not registered.
|
|
563
|
+
"""
|
|
564
|
+
if name not in self._signal_transformations:
|
|
565
|
+
raise KeyError(
|
|
566
|
+
f"Signal transformation '{name}' not found in registry. "
|
|
567
|
+
f"Available signal transformations: {sorted(self._signal_transformations.keys())}"
|
|
568
|
+
)
|
|
569
|
+
return self._signal_transformations[name]
|
|
570
|
+
|
|
571
|
+
def get_enabled(self) -> dict[str, SignalTransformationMetadata]:
|
|
572
|
+
"""
|
|
573
|
+
Get all enabled signal transformations.
|
|
574
|
+
|
|
575
|
+
Returns
|
|
576
|
+
-------
|
|
577
|
+
dict[str, SignalTransformationMetadata]
|
|
578
|
+
Mapping from transformation name to metadata for enabled transformations only.
|
|
579
|
+
"""
|
|
580
|
+
return {
|
|
581
|
+
name: meta
|
|
582
|
+
for name, meta in self._signal_transformations.items()
|
|
583
|
+
if meta.enabled
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
def list_all(self) -> dict[str, SignalTransformationMetadata]:
|
|
587
|
+
"""
|
|
588
|
+
Get all registered signal transformations (enabled and disabled).
|
|
589
|
+
|
|
590
|
+
Returns
|
|
591
|
+
-------
|
|
592
|
+
dict[str, SignalTransformationMetadata]
|
|
593
|
+
Mapping from transformation name to metadata for all transformations.
|
|
594
|
+
"""
|
|
595
|
+
return self._signal_transformations.copy()
|
|
596
|
+
|
|
597
|
+
def transformation_exists(self, name: str) -> bool:
|
|
598
|
+
"""
|
|
599
|
+
Check if signal transformation is registered.
|
|
600
|
+
|
|
601
|
+
Parameters
|
|
602
|
+
----------
|
|
603
|
+
name : str
|
|
604
|
+
Signal transformation name.
|
|
605
|
+
|
|
606
|
+
Returns
|
|
607
|
+
-------
|
|
608
|
+
bool
|
|
609
|
+
True if signal transformation exists in registry.
|
|
610
|
+
"""
|
|
611
|
+
return name in self._signal_transformations
|
|
612
|
+
|
|
613
|
+
def save_catalog(self, path: str | Path | None = None) -> None:
|
|
614
|
+
"""
|
|
615
|
+
Save signal transformation metadata to JSON catalog file.
|
|
616
|
+
|
|
617
|
+
Parameters
|
|
618
|
+
----------
|
|
619
|
+
path : str | Path | None
|
|
620
|
+
Output path. If None, overwrites original catalog file.
|
|
621
|
+
"""
|
|
622
|
+
output_path = Path(path) if path else self._catalog_path
|
|
623
|
+
|
|
624
|
+
# Convert tuples back to lists for JSON serialization
|
|
625
|
+
catalog_data = []
|
|
626
|
+
for meta in self._signal_transformations.values():
|
|
627
|
+
entry = asdict(meta)
|
|
628
|
+
if entry["neutral_range"] is not None:
|
|
629
|
+
entry["neutral_range"] = list(entry["neutral_range"])
|
|
630
|
+
catalog_data.append(entry)
|
|
631
|
+
|
|
632
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
633
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
634
|
+
json.dump(catalog_data, f, indent=2)
|
|
635
|
+
|
|
636
|
+
logger.info(
|
|
637
|
+
"Saved signal transformation catalog: path=%s, transformations=%d",
|
|
638
|
+
output_path,
|
|
639
|
+
len(catalog_data),
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
class SignalRegistry:
|
|
644
|
+
"""
|
|
645
|
+
Registry for signal catalog with JSON persistence and fail-fast validation.
|
|
646
|
+
|
|
647
|
+
Manages signal definitions from the catalog JSON file, validates that
|
|
648
|
+
referenced compute functions exist, and provides query interfaces for
|
|
649
|
+
enabled/disabled signals.
|
|
650
|
+
|
|
651
|
+
This class follows the catalog governance pattern (see governance_design.md):
|
|
652
|
+
- Immutable after load (frozen dataclass metadata)
|
|
653
|
+
- Fail-fast validation at initialization
|
|
654
|
+
- Read-only during runtime (edits require manual JSON modification)
|
|
655
|
+
|
|
656
|
+
Parameters
|
|
657
|
+
----------
|
|
658
|
+
catalog_path : str | Path
|
|
659
|
+
Path to JSON catalog file containing signal metadata.
|
|
660
|
+
|
|
661
|
+
Examples
|
|
662
|
+
--------
|
|
663
|
+
>>> from aponyx.config import SIGNAL_CATALOG_PATH
|
|
664
|
+
>>> registry = SignalRegistry(SIGNAL_CATALOG_PATH)
|
|
665
|
+
>>> enabled = registry.get_enabled()
|
|
666
|
+
>>> metadata = registry.get_metadata("cdx_etf_basis")
|
|
667
|
+
"""
|
|
668
|
+
|
|
669
|
+
def __init__(self, catalog_path: str | Path) -> None:
|
|
670
|
+
"""
|
|
671
|
+
Initialize registry and load catalog from JSON file.
|
|
672
|
+
|
|
673
|
+
Parameters
|
|
674
|
+
----------
|
|
675
|
+
catalog_path : str | Path
|
|
676
|
+
Path to JSON catalog file.
|
|
677
|
+
|
|
678
|
+
Raises
|
|
679
|
+
------
|
|
680
|
+
FileNotFoundError
|
|
681
|
+
If catalog file does not exist.
|
|
682
|
+
ValueError
|
|
683
|
+
If catalog JSON is invalid or contains duplicate signal names.
|
|
684
|
+
"""
|
|
685
|
+
self._catalog_path = Path(catalog_path)
|
|
686
|
+
self._signals: dict[str, SignalMetadata] = {}
|
|
687
|
+
self._load_catalog()
|
|
688
|
+
|
|
689
|
+
logger.info(
|
|
690
|
+
"Loaded signal registry: catalog=%s, signals=%d, enabled=%d",
|
|
691
|
+
self._catalog_path,
|
|
692
|
+
len(self._signals),
|
|
693
|
+
len(self.get_enabled()),
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
def _load_catalog(self) -> None:
|
|
697
|
+
"""Load signal metadata from JSON catalog file."""
|
|
698
|
+
if not self._catalog_path.exists():
|
|
699
|
+
raise FileNotFoundError(f"Signal catalog not found: {self._catalog_path}")
|
|
700
|
+
|
|
701
|
+
with open(self._catalog_path, "r", encoding="utf-8") as f:
|
|
702
|
+
catalog_data = json.load(f)
|
|
703
|
+
|
|
704
|
+
if not isinstance(catalog_data, list):
|
|
705
|
+
raise ValueError("Signal catalog must be a JSON array")
|
|
706
|
+
|
|
707
|
+
for entry in catalog_data:
|
|
708
|
+
try:
|
|
709
|
+
metadata = SignalMetadata(**entry)
|
|
710
|
+
if metadata.name in self._signals:
|
|
711
|
+
raise ValueError(
|
|
712
|
+
f"Duplicate signal name in catalog: {metadata.name}"
|
|
713
|
+
)
|
|
714
|
+
self._signals[metadata.name] = metadata
|
|
715
|
+
except TypeError as e:
|
|
716
|
+
raise ValueError(
|
|
717
|
+
f"Invalid signal metadata in catalog: {entry}. Error: {e}"
|
|
718
|
+
) from e
|
|
719
|
+
|
|
720
|
+
logger.debug("Loaded %d signals from catalog", len(self._signals))
|
|
721
|
+
|
|
722
|
+
# Fail-fast validation: ensure all compute functions exist
|
|
723
|
+
self._validate_catalog()
|
|
724
|
+
|
|
725
|
+
def _validate_catalog(self) -> None:
|
|
726
|
+
"""
|
|
727
|
+
Validate that all signal transformation references are non-empty strings.
|
|
728
|
+
|
|
729
|
+
Validates the four-stage transformation pipeline references:
|
|
730
|
+
- indicator_transformation (reference to indicator_transformation.json)
|
|
731
|
+
- score_transformation (reference to score_transformation.json)
|
|
732
|
+
- signal_transformation (reference to signal_transformation.json)
|
|
733
|
+
|
|
734
|
+
Note: This method validates structure only. Cross-registry validation
|
|
735
|
+
(checking if referenced transformations exist) is performed at compose_signal
|
|
736
|
+
time when all registries are available.
|
|
737
|
+
|
|
738
|
+
Raises
|
|
739
|
+
------
|
|
740
|
+
CatalogValidationError
|
|
741
|
+
If any transformation reference is empty or missing.
|
|
742
|
+
"""
|
|
743
|
+
for name, metadata in self._signals.items():
|
|
744
|
+
# Enforce non-empty transformation references
|
|
745
|
+
if not metadata.indicator_transformation:
|
|
746
|
+
raise CatalogValidationError(
|
|
747
|
+
catalog="signal_catalog.json",
|
|
748
|
+
entry=name,
|
|
749
|
+
field="indicator_transformation",
|
|
750
|
+
value=metadata.indicator_transformation,
|
|
751
|
+
constraint="indicator_transformation is required (cannot be empty)",
|
|
752
|
+
suggestion="Specify an indicator from indicator_transformation.json",
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
if not metadata.score_transformation:
|
|
756
|
+
raise CatalogValidationError(
|
|
757
|
+
catalog="signal_catalog.json",
|
|
758
|
+
entry=name,
|
|
759
|
+
field="score_transformation",
|
|
760
|
+
value=metadata.score_transformation,
|
|
761
|
+
constraint="score_transformation is required (cannot be empty)",
|
|
762
|
+
suggestion="Specify a transformation from score_transformation.json",
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
if not metadata.signal_transformation:
|
|
766
|
+
raise CatalogValidationError(
|
|
767
|
+
catalog="signal_catalog.json",
|
|
768
|
+
entry=name,
|
|
769
|
+
field="signal_transformation",
|
|
770
|
+
value=metadata.signal_transformation,
|
|
771
|
+
constraint="signal_transformation is required (cannot be empty)",
|
|
772
|
+
suggestion="Specify a transformation from signal_transformation.json (e.g., 'passthrough')",
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
logger.debug("Validated signal metadata transformation references")
|
|
776
|
+
|
|
777
|
+
def get_metadata(self, name: str) -> SignalMetadata:
|
|
778
|
+
"""
|
|
779
|
+
Retrieve metadata for a specific signal.
|
|
780
|
+
|
|
781
|
+
Parameters
|
|
782
|
+
----------
|
|
783
|
+
name : str
|
|
784
|
+
Signal name.
|
|
785
|
+
|
|
786
|
+
Returns
|
|
787
|
+
-------
|
|
788
|
+
SignalMetadata
|
|
789
|
+
Signal metadata.
|
|
790
|
+
|
|
791
|
+
Raises
|
|
792
|
+
------
|
|
793
|
+
KeyError
|
|
794
|
+
If signal name is not registered.
|
|
795
|
+
"""
|
|
796
|
+
if name not in self._signals:
|
|
797
|
+
raise KeyError(
|
|
798
|
+
f"Signal '{name}' not found in registry. "
|
|
799
|
+
f"Available signals: {sorted(self._signals.keys())}"
|
|
800
|
+
)
|
|
801
|
+
return self._signals[name]
|
|
802
|
+
|
|
803
|
+
def get_enabled(self) -> dict[str, SignalMetadata]:
|
|
804
|
+
"""
|
|
805
|
+
Get all enabled signals.
|
|
806
|
+
|
|
807
|
+
Returns
|
|
808
|
+
-------
|
|
809
|
+
dict[str, SignalMetadata]
|
|
810
|
+
Mapping from signal name to metadata for enabled signals only.
|
|
811
|
+
"""
|
|
812
|
+
return {name: meta for name, meta in self._signals.items() if meta.enabled}
|
|
813
|
+
|
|
814
|
+
def list_all(self) -> dict[str, SignalMetadata]:
|
|
815
|
+
"""
|
|
816
|
+
Get all registered signals (enabled and disabled).
|
|
817
|
+
|
|
818
|
+
Returns
|
|
819
|
+
-------
|
|
820
|
+
dict[str, SignalMetadata]
|
|
821
|
+
Mapping from signal name to metadata for all signals.
|
|
822
|
+
"""
|
|
823
|
+
return self._signals.copy()
|
|
824
|
+
|
|
825
|
+
def signal_exists(self, name: str) -> bool:
|
|
826
|
+
"""
|
|
827
|
+
Check if signal is registered.
|
|
828
|
+
|
|
829
|
+
Parameters
|
|
830
|
+
----------
|
|
831
|
+
name : str
|
|
832
|
+
Signal name.
|
|
833
|
+
|
|
834
|
+
Returns
|
|
835
|
+
-------
|
|
836
|
+
bool
|
|
837
|
+
True if signal exists in registry.
|
|
838
|
+
"""
|
|
839
|
+
return name in self._signals
|
|
840
|
+
|
|
841
|
+
def save_catalog(self, path: str | Path | None = None) -> None:
|
|
842
|
+
"""
|
|
843
|
+
Save signal metadata to JSON catalog file.
|
|
844
|
+
|
|
845
|
+
Parameters
|
|
846
|
+
----------
|
|
847
|
+
path : str | Path | None
|
|
848
|
+
Output path. If None, overwrites original catalog file.
|
|
849
|
+
"""
|
|
850
|
+
output_path = Path(path) if path else self._catalog_path
|
|
851
|
+
|
|
852
|
+
catalog_data = [asdict(meta) for meta in self._signals.values()]
|
|
853
|
+
|
|
854
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
855
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
856
|
+
json.dump(catalog_data, f, indent=2)
|
|
857
|
+
|
|
858
|
+
logger.info(
|
|
859
|
+
"Saved signal catalog: path=%s, signals=%d", output_path, len(catalog_data)
|
|
860
|
+
)
|