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,560 @@
1
+ """
2
+ Core backtesting engine for signal-to-position simulation.
3
+
4
+ This module converts signals into positions and simulates P&L.
5
+ Design is intentionally simple to allow easy replacement with external
6
+ libraries while maintaining our domain-specific logic.
7
+ """
8
+
9
+ import logging
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from enum import Enum
13
+ from typing import Any, cast
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ from .config import BacktestConfig
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _sanitize_signal_value(signal_val: float, date: pd.Timestamp) -> float:
24
+ """
25
+ Sanitize signal value, treating NaN and infinity as zero.
26
+
27
+ Parameters
28
+ ----------
29
+ signal_val : float
30
+ Raw signal value to sanitize.
31
+ date : pd.Timestamp
32
+ Date of signal (for logging context).
33
+
34
+ Returns
35
+ -------
36
+ float
37
+ Sanitized signal value (0.0 for invalid inputs).
38
+ """
39
+ if not np.isfinite(signal_val):
40
+ logger.warning("Invalid signal value (NaN/inf) at %s, treating as zero", date)
41
+ return 0.0
42
+ return signal_val
43
+
44
+
45
+ class PositionState(Enum):
46
+ """
47
+ Internal state machine for position tracking.
48
+
49
+ States
50
+ ------
51
+ NO_POSITION : No active position, ready to enter
52
+ IN_POSITION : Active position (long or short)
53
+ COOLDOWN : After premature exit, waiting for signal to reset to zero
54
+ """
55
+
56
+ NO_POSITION = "no_position"
57
+ IN_POSITION = "in_position"
58
+ COOLDOWN = "cooldown"
59
+
60
+
61
+ @dataclass
62
+ class BacktestResult:
63
+ """
64
+ Container for backtest outputs.
65
+
66
+ Attributes
67
+ ----------
68
+ positions : pd.DataFrame
69
+ Daily position history with columns:
70
+ - signal: signal value
71
+ - position: current position (+1, 0, -1)
72
+ - days_held: days in current position
73
+ - spread: CDX spread level (for P&L calc)
74
+ - exit_reason: reason for position exit (if applicable)
75
+ pnl : pd.DataFrame
76
+ Daily P&L breakdown with columns:
77
+ - spread_pnl: P&L from spread changes
78
+ - cost: transaction costs
79
+ - net_pnl: total net P&L
80
+ - cumulative_pnl: running total
81
+ metadata : dict
82
+ Backtest configuration and execution details, including exit_counts summary.
83
+
84
+ Notes
85
+ -----
86
+ This structure is designed to be easily convertible to formats
87
+ expected by third-party backtest libraries (e.g., vectorbt).
88
+
89
+ Exit Reasons
90
+ ------------
91
+ - None: No exit (position unchanged or entry)
92
+ - "signal": Signal returned to zero
93
+ - "stop_loss": Stop loss triggered
94
+ - "take_profit": Take profit triggered
95
+ - "max_holding_days": Max holding period reached
96
+ - "reversal": Signal sign changed
97
+ """
98
+
99
+ positions: pd.DataFrame
100
+ pnl: pd.DataFrame
101
+ metadata: dict[str, Any]
102
+
103
+
104
+ def run_backtest(
105
+ signal: pd.Series,
106
+ spread: pd.Series,
107
+ config: BacktestConfig,
108
+ ) -> BacktestResult:
109
+ """
110
+ Run backtest converting signals to positions and computing P&L.
111
+
112
+ Parameters
113
+ ----------
114
+ signal : pd.Series
115
+ Daily positioning scores from signal transformation.
116
+ DatetimeIndex with float values. Non-zero = enter, zero = exit.
117
+ spread : pd.Series
118
+ CDX spread levels aligned to signal dates.
119
+ Used for P&L calculation.
120
+ config : BacktestConfig
121
+ Backtest parameters. Required - use StrategyRegistry.to_config() in production.
122
+
123
+ Returns
124
+ -------
125
+ BacktestResult
126
+ Complete backtest results including positions and P&L.
127
+
128
+ Notes
129
+ -----
130
+ Position Logic (Signal-Based Triggers):
131
+ - Non-zero signal → Enter position (direction from sign)
132
+ - Zero signal → Exit position
133
+ - PnL-based exits → Cooldown state (no re-entry until signal resets)
134
+ - Sign change → Reversal (exit and enter opposite direction)
135
+
136
+ Sizing Modes:
137
+ - Binary: full position_size_mm for any non-zero signal (position = ±1)
138
+ - Proportional: position = signal × position_size_mm (actual notional in MM)
139
+
140
+ P&L Calculation:
141
+ - Long position: profit when spreads tighten (P&L = -ΔSpread * DV01)
142
+ - Short position: profit when spreads widen (P&L = ΔSpread * DV01)
143
+ - Transaction costs applied on entry, exit, and rebalancing
144
+ - P&L expressed in dollars
145
+
146
+ Risk Management:
147
+ - Binary: stop_loss/take_profit vs entry notional × DV01
148
+ - Proportional: stop_loss/take_profit vs current notional (abs(position))
149
+ - Max holding days: forced exit after specified days
150
+ - Cooldown after PnL exits prevents re-entry until signal returns to zero or sign change
151
+
152
+ Examples
153
+ --------
154
+ >>> config = BacktestConfig(position_size_mm=10.0, stop_loss_pct=5.0)
155
+ >>> result = run_backtest(signal, cdx_spread, config)
156
+ >>> sharpe = result.pnl['net_pnl'].mean() / result.pnl['net_pnl'].std() * np.sqrt(252)
157
+
158
+ >>> # Proportional mode
159
+ >>> config = BacktestConfig(sizing_mode="proportional", position_size_mm=10.0)
160
+ >>> result = run_backtest(signal, cdx_spread, config)
161
+ """
162
+ is_proportional = config.sizing_mode == "proportional"
163
+
164
+ logger.info(
165
+ "Starting backtest: dates=%d, sizing_mode=%s, position_size=%.1fMM, signal_lag=%d",
166
+ len(signal),
167
+ config.sizing_mode,
168
+ config.position_size_mm,
169
+ config.signal_lag,
170
+ )
171
+
172
+ # Validate inputs
173
+ if not isinstance(signal.index, pd.DatetimeIndex):
174
+ raise ValueError("signal must have DatetimeIndex")
175
+ if not isinstance(spread.index, pd.DatetimeIndex):
176
+ raise ValueError("spread must have DatetimeIndex")
177
+
178
+ # Apply signal lag if specified
179
+ if config.signal_lag > 0:
180
+ lagged_signal = signal.shift(config.signal_lag)
181
+ else:
182
+ lagged_signal = signal
183
+
184
+ # Align data
185
+ aligned = pd.DataFrame(
186
+ {
187
+ "signal": lagged_signal,
188
+ "spread": spread,
189
+ }
190
+ ).dropna()
191
+
192
+ if len(aligned) == 0:
193
+ raise ValueError("No valid data after alignment")
194
+
195
+ # Initialize tracking
196
+ positions = []
197
+ pnl_records = []
198
+ # For binary: current_position is direction (-1, 0, +1)
199
+ # For proportional: current_position is actual notional in MM (e.g., 5.0, -3.5)
200
+ current_position = 0.0
201
+ days_held = 0
202
+ prev_spread = 0.0
203
+ state = PositionState.NO_POSITION
204
+ cumulative_position_pnl = 0.0
205
+ # For binary: entry value is position_size_mm * dv01
206
+ # For proportional: we track against current notional, not entry value
207
+ position_entry_value = 0.0
208
+ exit_counts = {
209
+ "signal": 0,
210
+ "stop_loss": 0,
211
+ "take_profit": 0,
212
+ "max_holding_days": 0,
213
+ "reversal": 0,
214
+ }
215
+
216
+ for date, row in aligned.iterrows():
217
+ # Sanitize signal value (NaN/inf → 0)
218
+ signal_val = _sanitize_signal_value(row["signal"], cast(pd.Timestamp, date))
219
+ spread_level = row["spread"]
220
+
221
+ # Initialize tracking for this iteration
222
+ entry_cost = 0.0
223
+ exit_cost = 0.0
224
+ exit_reason = None
225
+
226
+ # Store position before any state changes (for P&L calculation)
227
+ position_before_update = current_position
228
+ prev_spread_before_update = prev_spread
229
+
230
+ # Signal-based triggers: non-zero = enter, zero = exit
231
+ signal_is_zero = abs(signal_val) < 1e-9
232
+
233
+ # Calculate target position based on sizing mode
234
+ if is_proportional:
235
+ # Proportional: target position is actual notional in MM
236
+ target_position = signal_val * config.position_size_mm
237
+ else:
238
+ # Binary: target position is direction indicator
239
+ if not signal_is_zero:
240
+ target_position = 1.0 if signal_val > 0 else -1.0
241
+ else:
242
+ target_position = 0.0
243
+
244
+ # Determine target direction for state machine logic
245
+ if abs(target_position) < 1e-9:
246
+ target_direction = 0
247
+ else:
248
+ target_direction = 1 if target_position > 0 else -1
249
+
250
+ # Current direction for comparison
251
+ if abs(current_position) < 1e-9:
252
+ current_direction = 0
253
+ else:
254
+ current_direction = 1 if current_position > 0 else -1
255
+
256
+ # State machine logic
257
+ if state == PositionState.NO_POSITION:
258
+ # Ready to enter on non-zero signal
259
+ if not signal_is_zero:
260
+ current_position = target_position
261
+ days_held = 0
262
+ state = PositionState.IN_POSITION
263
+ cumulative_position_pnl = 0.0
264
+
265
+ if is_proportional:
266
+ # Entry cost based on actual position size
267
+ entry_cost = (
268
+ abs(current_position) * config.transaction_cost_bps * 100
269
+ )
270
+ else:
271
+ position_entry_value = (
272
+ config.position_size_mm * config.dv01_per_million
273
+ )
274
+ entry_cost = (
275
+ config.transaction_cost_bps * config.position_size_mm * 100
276
+ )
277
+
278
+ logger.debug(
279
+ "Entry: date=%s, signal=%.2f, position=%.2f",
280
+ date,
281
+ signal_val,
282
+ current_position,
283
+ )
284
+
285
+ elif state == PositionState.IN_POSITION:
286
+ days_held += 1
287
+
288
+ # Check PnL-based exits first (before signal exits)
289
+ if is_proportional:
290
+ # For proportional mode: check against current notional
291
+ current_notional = abs(current_position)
292
+ check_stop_loss = (
293
+ config.stop_loss_pct is not None
294
+ and current_notional > 1e-9
295
+ and cumulative_position_pnl / current_notional
296
+ < -config.stop_loss_pct / 100
297
+ )
298
+ check_take_profit = (
299
+ config.take_profit_pct is not None
300
+ and current_notional > 1e-9
301
+ and cumulative_position_pnl / current_notional
302
+ > config.take_profit_pct / 100
303
+ )
304
+ else:
305
+ # For binary mode: check against entry value
306
+ check_stop_loss = (
307
+ config.stop_loss_pct is not None
308
+ and cumulative_position_pnl
309
+ < -config.stop_loss_pct * position_entry_value / 100
310
+ )
311
+ check_take_profit = (
312
+ config.take_profit_pct is not None
313
+ and cumulative_position_pnl
314
+ > config.take_profit_pct * position_entry_value / 100
315
+ )
316
+
317
+ check_max_holding = (
318
+ config.max_holding_days is not None
319
+ and days_held >= config.max_holding_days
320
+ )
321
+
322
+ # Take profit takes precedence over stop loss if both trigger
323
+ if check_take_profit:
324
+ exit_reason = "take_profit"
325
+ if is_proportional:
326
+ exit_cost = (
327
+ abs(current_position) * config.transaction_cost_bps * 100
328
+ )
329
+ else:
330
+ exit_cost = (
331
+ config.transaction_cost_bps * config.position_size_mm * 100
332
+ )
333
+ current_position = 0.0
334
+ days_held = 0
335
+ state = PositionState.COOLDOWN
336
+ exit_counts["take_profit"] += 1
337
+ logger.debug(
338
+ "Take profit exit: date=%s, cumulative_pnl=%.0f",
339
+ date,
340
+ cumulative_position_pnl,
341
+ )
342
+ elif check_stop_loss:
343
+ exit_reason = "stop_loss"
344
+ if is_proportional:
345
+ exit_cost = (
346
+ abs(current_position) * config.transaction_cost_bps * 100
347
+ )
348
+ else:
349
+ exit_cost = (
350
+ config.transaction_cost_bps * config.position_size_mm * 100
351
+ )
352
+ current_position = 0.0
353
+ days_held = 0
354
+ state = PositionState.COOLDOWN
355
+ exit_counts["stop_loss"] += 1
356
+ logger.debug(
357
+ "Stop loss exit: date=%s, cumulative_pnl=%.0f",
358
+ date,
359
+ cumulative_position_pnl,
360
+ )
361
+ elif check_max_holding:
362
+ exit_reason = "max_holding_days"
363
+ if is_proportional:
364
+ exit_cost = (
365
+ abs(current_position) * config.transaction_cost_bps * 100
366
+ )
367
+ else:
368
+ exit_cost = (
369
+ config.transaction_cost_bps * config.position_size_mm * 100
370
+ )
371
+ current_position = 0.0
372
+ days_held = 0
373
+ state = PositionState.COOLDOWN
374
+ exit_counts["max_holding_days"] += 1
375
+ logger.debug(
376
+ "Max holding days exit: date=%s, days_held=%d",
377
+ date,
378
+ days_held,
379
+ )
380
+ # Check signal-based exits
381
+ elif signal_is_zero:
382
+ exit_reason = "signal"
383
+ if is_proportional:
384
+ exit_cost = (
385
+ abs(current_position) * config.transaction_cost_bps * 100
386
+ )
387
+ else:
388
+ exit_cost = (
389
+ config.transaction_cost_bps * config.position_size_mm * 100
390
+ )
391
+ current_position = 0.0
392
+ days_held = 0
393
+ state = PositionState.NO_POSITION
394
+ exit_counts["signal"] += 1
395
+ logger.debug("Signal exit: date=%s, signal=%.2f", date, signal_val)
396
+ # Check for sign reversal (direction change)
397
+ elif target_direction != current_direction:
398
+ exit_reason = "reversal"
399
+ if is_proportional:
400
+ # Cost for full position change (exit old + enter new)
401
+ trade_delta = abs(target_position - current_position)
402
+ exit_cost = trade_delta * config.transaction_cost_bps * 100
403
+ else:
404
+ exit_cost = (
405
+ config.transaction_cost_bps * config.position_size_mm * 100
406
+ )
407
+ entry_cost = (
408
+ config.transaction_cost_bps * config.position_size_mm * 100
409
+ )
410
+
411
+ current_position = target_position
412
+ days_held = 0
413
+ cumulative_position_pnl = 0.0
414
+ if not is_proportional:
415
+ position_entry_value = (
416
+ config.position_size_mm * config.dv01_per_million
417
+ )
418
+ state = PositionState.IN_POSITION
419
+ exit_counts["reversal"] += 1
420
+ logger.debug(
421
+ "Sign reversal: date=%s, signal=%.2f, new_position=%.2f",
422
+ date,
423
+ signal_val,
424
+ current_position,
425
+ )
426
+ # Check for rebalancing (proportional mode only - magnitude change without direction change)
427
+ elif is_proportional and abs(target_position - current_position) > 1e-9:
428
+ # Rebalance: position magnitude changed but direction stayed same
429
+ trade_delta = abs(target_position - current_position)
430
+ rebalance_cost = trade_delta * config.transaction_cost_bps * 100
431
+ entry_cost = rebalance_cost # Record as entry cost (trade activity)
432
+ current_position = target_position
433
+ logger.debug(
434
+ "Rebalance: date=%s, signal=%.2f, new_position=%.2f, delta=%.2f",
435
+ date,
436
+ signal_val,
437
+ current_position,
438
+ trade_delta,
439
+ )
440
+
441
+ elif state == PositionState.COOLDOWN:
442
+ # For proportional mode: allow exit from cooldown on signal sign change
443
+ if signal_is_zero:
444
+ state = PositionState.NO_POSITION
445
+ logger.debug("Cooldown released: date=%s", date)
446
+ elif is_proportional and target_direction != 0:
447
+ # Proportional mode: sign change (crossing zero) releases cooldown
448
+ # Check if this is a sign change from previous position direction
449
+ # Since we're in cooldown, we just need a non-zero signal to potentially re-enter
450
+ # But per spec: accept signal sign change as ending cooldown
451
+ state = PositionState.NO_POSITION
452
+ logger.debug("Cooldown released (sign change): date=%s", date)
453
+ # Otherwise stay in cooldown (no action)
454
+
455
+ # Calculate incremental P&L for this day
456
+ if abs(position_before_update) > 1e-9:
457
+ spread_change = spread_level - prev_spread_before_update
458
+ if is_proportional:
459
+ # Proportional: position_before_update is actual notional in MM
460
+ spread_pnl = (
461
+ -np.sign(position_before_update)
462
+ * abs(position_before_update)
463
+ * spread_change
464
+ * config.dv01_per_million
465
+ )
466
+ else:
467
+ # Binary: position_before_update is direction indicator
468
+ spread_pnl = (
469
+ -position_before_update
470
+ * spread_change
471
+ * config.dv01_per_million
472
+ * config.position_size_mm
473
+ )
474
+ # Update cumulative position P&L (only when in position)
475
+ cumulative_position_pnl += spread_pnl
476
+ else:
477
+ spread_pnl = 0.0
478
+
479
+ total_cost = entry_cost + exit_cost
480
+ net_pnl = spread_pnl - total_cost
481
+
482
+ # Update previous spread for next iteration
483
+ prev_spread = spread_level
484
+
485
+ # Record position state
486
+ # For binary mode, record direction indicator; for proportional, record actual notional
487
+ if is_proportional:
488
+ recorded_position = current_position
489
+ else:
490
+ recorded_position = int(current_position)
491
+
492
+ positions.append(
493
+ {
494
+ "date": date,
495
+ "signal": signal_val,
496
+ "position": recorded_position,
497
+ "days_held": days_held,
498
+ "spread": spread_level,
499
+ "exit_reason": exit_reason,
500
+ }
501
+ )
502
+
503
+ # Record P&L
504
+ pnl_records.append(
505
+ {
506
+ "date": date,
507
+ "spread_pnl": spread_pnl,
508
+ "cost": total_cost,
509
+ "net_pnl": net_pnl,
510
+ }
511
+ )
512
+
513
+ # Convert to DataFrames
514
+ positions_df = pd.DataFrame(positions).set_index("date")
515
+ pnl_df = pd.DataFrame(pnl_records).set_index("date")
516
+ pnl_df["cumulative_pnl"] = pnl_df["net_pnl"].cumsum()
517
+
518
+ # Calculate summary statistics (count round-trip trades: entries only)
519
+ # A trade is flat → position → flat
520
+ prev_position = positions_df["position"].shift(1).fillna(0)
521
+ position_entries = (prev_position == 0) & (positions_df["position"] != 0)
522
+ n_trades = position_entries.sum()
523
+ total_pnl = pnl_df["cumulative_pnl"].iloc[-1]
524
+ avg_pnl_per_trade = total_pnl / n_trades if n_trades > 0 else 0.0
525
+
526
+ metadata = {
527
+ "timestamp": datetime.now().isoformat(),
528
+ "config": {
529
+ "position_size_mm": config.position_size_mm,
530
+ "sizing_mode": config.sizing_mode,
531
+ "stop_loss_pct": config.stop_loss_pct,
532
+ "take_profit_pct": config.take_profit_pct,
533
+ "max_holding_days": config.max_holding_days,
534
+ "transaction_cost_bps": config.transaction_cost_bps,
535
+ "dv01_per_million": config.dv01_per_million,
536
+ "signal_lag": config.signal_lag,
537
+ },
538
+ "summary": {
539
+ "start_date": str(aligned.index[0]),
540
+ "end_date": str(aligned.index[-1]),
541
+ "total_days": len(aligned),
542
+ "n_trades": int(n_trades),
543
+ "total_pnl": float(total_pnl),
544
+ "avg_pnl_per_trade": float(avg_pnl_per_trade),
545
+ "exit_counts": exit_counts,
546
+ },
547
+ }
548
+
549
+ logger.info(
550
+ "Backtest complete: trades=%d, total_pnl=$%.0f, avg_per_trade=$%.0f",
551
+ n_trades,
552
+ total_pnl,
553
+ avg_pnl_per_trade,
554
+ )
555
+
556
+ return BacktestResult(
557
+ positions=positions_df,
558
+ pnl=pnl_df,
559
+ metadata=metadata,
560
+ )
@@ -0,0 +1,101 @@
1
+ """
2
+ Protocol definitions for backtest engine extensibility.
3
+
4
+ These protocols define the interface for swappable backtest components,
5
+ allowing easy integration of external libraries (vectorbt, backtrader, etc.)
6
+ while maintaining our domain-specific API.
7
+ """
8
+
9
+ from typing import Any, Protocol
10
+
11
+ import pandas as pd
12
+
13
+ from .config import BacktestConfig
14
+ from .engine import BacktestResult
15
+
16
+
17
+ class BacktestEngine(Protocol):
18
+ """
19
+ Protocol for backtest engine implementations.
20
+
21
+ This allows swapping between our simple implementation and
22
+ more sophisticated libraries while maintaining the same API.
23
+
24
+ Examples
25
+ --------
26
+ >>> # Our implementation
27
+ >>> from aponyx.backtest import run_backtest
28
+ >>> result = run_backtest(signal, spread, config)
29
+ >>>
30
+ >>> # Future: vectorbt wrapper
31
+ >>> from aponyx.backtest.adapters import VectorBTEngine
32
+ >>> engine = VectorBTEngine()
33
+ >>> result = engine.run(signal, spread, config)
34
+ """
35
+
36
+ def run(
37
+ self,
38
+ signal: pd.Series,
39
+ spread: pd.Series,
40
+ config: BacktestConfig,
41
+ ) -> BacktestResult:
42
+ """
43
+ Execute backtest on signal and price data.
44
+
45
+ Parameters
46
+ ----------
47
+ signal : pd.Series
48
+ Daily positioning scores from signal computation.
49
+ spread : pd.Series
50
+ CDX spread levels aligned to signal dates.
51
+ config : BacktestConfig
52
+ Backtest parameters. Required - use StrategyRegistry.to_config() in production.
53
+
54
+ Returns
55
+ -------
56
+ BacktestResult
57
+ Complete backtest results including positions and P&L.
58
+ """
59
+ ...
60
+
61
+
62
+ class PerformanceCalculator(Protocol):
63
+ """
64
+ Protocol for performance metrics calculation.
65
+
66
+ Allows swapping between our simple implementation and
67
+ libraries like quantstats, empyrical, pyfolio, etc.
68
+
69
+ Examples
70
+ --------
71
+ >>> # Our implementation (now in evaluation.performance)
72
+ >>> from aponyx.evaluation.performance import compute_all_metrics
73
+ >>> metrics = compute_all_metrics(result.pnl, result.positions)
74
+ >>>
75
+ >>> # Future: quantstats wrapper
76
+ >>> from aponyx.backtest.adapters import QuantStatsCalculator
77
+ >>> calc = QuantStatsCalculator()
78
+ >>> metrics = calc.compute(result.pnl, result.positions)
79
+ """
80
+
81
+ def compute(
82
+ self,
83
+ pnl_df: pd.DataFrame,
84
+ positions_df: pd.DataFrame,
85
+ ) -> pd.DataFrame | dict[str, Any]:
86
+ """
87
+ Compute performance metrics from backtest results.
88
+
89
+ Parameters
90
+ ----------
91
+ pnl_df : pd.DataFrame
92
+ Daily P&L data with 'net_pnl' and 'cumulative_pnl' columns.
93
+ positions_df : pd.DataFrame
94
+ Daily position data with 'position' and 'days_held' columns.
95
+
96
+ Returns
97
+ -------
98
+ pd.DataFrame | dict
99
+ Performance statistics. Format may vary by implementation.
100
+ """
101
+ ...