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.
Files changed (104) hide show
  1. aponyx/__init__.py +14 -0
  2. aponyx/backtest/__init__.py +31 -0
  3. aponyx/backtest/adapters.py +77 -0
  4. aponyx/backtest/config.py +84 -0
  5. aponyx/backtest/engine.py +560 -0
  6. aponyx/backtest/protocols.py +101 -0
  7. aponyx/backtest/registry.py +334 -0
  8. aponyx/backtest/strategy_catalog.json +50 -0
  9. aponyx/cli/__init__.py +5 -0
  10. aponyx/cli/commands/__init__.py +8 -0
  11. aponyx/cli/commands/clean.py +349 -0
  12. aponyx/cli/commands/list.py +302 -0
  13. aponyx/cli/commands/report.py +167 -0
  14. aponyx/cli/commands/run.py +377 -0
  15. aponyx/cli/main.py +125 -0
  16. aponyx/config/__init__.py +82 -0
  17. aponyx/data/__init__.py +99 -0
  18. aponyx/data/bloomberg_config.py +306 -0
  19. aponyx/data/bloomberg_instruments.json +26 -0
  20. aponyx/data/bloomberg_securities.json +42 -0
  21. aponyx/data/cache.py +294 -0
  22. aponyx/data/fetch.py +659 -0
  23. aponyx/data/fetch_registry.py +135 -0
  24. aponyx/data/loaders.py +205 -0
  25. aponyx/data/providers/__init__.py +13 -0
  26. aponyx/data/providers/bloomberg.py +383 -0
  27. aponyx/data/providers/file.py +111 -0
  28. aponyx/data/registry.py +500 -0
  29. aponyx/data/requirements.py +96 -0
  30. aponyx/data/sample_data.py +415 -0
  31. aponyx/data/schemas.py +60 -0
  32. aponyx/data/sources.py +171 -0
  33. aponyx/data/synthetic_params.json +46 -0
  34. aponyx/data/transforms.py +336 -0
  35. aponyx/data/validation.py +308 -0
  36. aponyx/docs/__init__.py +24 -0
  37. aponyx/docs/adding_data_providers.md +682 -0
  38. aponyx/docs/cdx_knowledge_base.md +455 -0
  39. aponyx/docs/cdx_overlay_strategy.md +135 -0
  40. aponyx/docs/cli_guide.md +607 -0
  41. aponyx/docs/governance_design.md +551 -0
  42. aponyx/docs/logging_design.md +251 -0
  43. aponyx/docs/performance_evaluation_design.md +265 -0
  44. aponyx/docs/python_guidelines.md +786 -0
  45. aponyx/docs/signal_registry_usage.md +369 -0
  46. aponyx/docs/signal_suitability_design.md +558 -0
  47. aponyx/docs/visualization_design.md +277 -0
  48. aponyx/evaluation/__init__.py +11 -0
  49. aponyx/evaluation/performance/__init__.py +24 -0
  50. aponyx/evaluation/performance/adapters.py +109 -0
  51. aponyx/evaluation/performance/analyzer.py +384 -0
  52. aponyx/evaluation/performance/config.py +320 -0
  53. aponyx/evaluation/performance/decomposition.py +304 -0
  54. aponyx/evaluation/performance/metrics.py +761 -0
  55. aponyx/evaluation/performance/registry.py +327 -0
  56. aponyx/evaluation/performance/report.py +541 -0
  57. aponyx/evaluation/suitability/__init__.py +67 -0
  58. aponyx/evaluation/suitability/config.py +143 -0
  59. aponyx/evaluation/suitability/evaluator.py +389 -0
  60. aponyx/evaluation/suitability/registry.py +328 -0
  61. aponyx/evaluation/suitability/report.py +398 -0
  62. aponyx/evaluation/suitability/scoring.py +367 -0
  63. aponyx/evaluation/suitability/tests.py +303 -0
  64. aponyx/examples/01_generate_synthetic_data.py +53 -0
  65. aponyx/examples/02_fetch_data_file.py +82 -0
  66. aponyx/examples/03_fetch_data_bloomberg.py +104 -0
  67. aponyx/examples/04_compute_signal.py +164 -0
  68. aponyx/examples/05_evaluate_suitability.py +224 -0
  69. aponyx/examples/06_run_backtest.py +242 -0
  70. aponyx/examples/07_analyze_performance.py +214 -0
  71. aponyx/examples/08_visualize_results.py +272 -0
  72. aponyx/main.py +7 -0
  73. aponyx/models/__init__.py +45 -0
  74. aponyx/models/config.py +83 -0
  75. aponyx/models/indicator_transformation.json +52 -0
  76. aponyx/models/indicators.py +292 -0
  77. aponyx/models/metadata.py +447 -0
  78. aponyx/models/orchestrator.py +213 -0
  79. aponyx/models/registry.py +860 -0
  80. aponyx/models/score_transformation.json +42 -0
  81. aponyx/models/signal_catalog.json +29 -0
  82. aponyx/models/signal_composer.py +513 -0
  83. aponyx/models/signal_transformation.json +29 -0
  84. aponyx/persistence/__init__.py +16 -0
  85. aponyx/persistence/json_io.py +132 -0
  86. aponyx/persistence/parquet_io.py +378 -0
  87. aponyx/py.typed +0 -0
  88. aponyx/reporting/__init__.py +10 -0
  89. aponyx/reporting/generator.py +517 -0
  90. aponyx/visualization/__init__.py +20 -0
  91. aponyx/visualization/app.py +37 -0
  92. aponyx/visualization/plots.py +309 -0
  93. aponyx/visualization/visualizer.py +242 -0
  94. aponyx/workflows/__init__.py +18 -0
  95. aponyx/workflows/concrete_steps.py +720 -0
  96. aponyx/workflows/config.py +122 -0
  97. aponyx/workflows/engine.py +279 -0
  98. aponyx/workflows/registry.py +116 -0
  99. aponyx/workflows/steps.py +180 -0
  100. aponyx-0.1.18.dist-info/METADATA +552 -0
  101. aponyx-0.1.18.dist-info/RECORD +104 -0
  102. aponyx-0.1.18.dist-info/WHEEL +4 -0
  103. aponyx-0.1.18.dist-info/entry_points.txt +2 -0
  104. 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
+ )