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,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
|
+
...
|