aponyx 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of aponyx might be problematic. Click here for more details.

aponyx/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """
2
+ Aponyx - Systematic Macro Credit Strategy Framework.
3
+
4
+ A modular Python framework for developing and backtesting systematic credit strategies.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+
9
+
10
+ def hello() -> str:
11
+ """Return greeting message."""
12
+ return "Hello from aponyx!"
@@ -0,0 +1,29 @@
1
+ """
2
+ Backtesting engine for CDX overlay strategy.
3
+
4
+ This module provides a lightweight backtesting framework optimized for
5
+ credit index strategies. The design prioritizes transparency and extensibility,
6
+ with clean interfaces that can wrap more powerful libraries later.
7
+
8
+ Core Components
9
+ ---------------
10
+ - engine: Position generation and P&L simulation
11
+ - metrics: Performance and risk statistics
12
+ - config: Backtest parameters and constraints
13
+ - protocols: Abstract interfaces for extensibility
14
+ """
15
+
16
+ from .config import BacktestConfig
17
+ from .engine import run_backtest, BacktestResult
18
+ from .metrics import compute_performance_metrics, PerformanceMetrics
19
+ from .protocols import BacktestEngine, PerformanceCalculator
20
+
21
+ __all__ = [
22
+ "BacktestConfig",
23
+ "run_backtest",
24
+ "BacktestResult",
25
+ "compute_performance_metrics",
26
+ "PerformanceMetrics",
27
+ "BacktestEngine",
28
+ "PerformanceCalculator",
29
+ ]
@@ -0,0 +1,134 @@
1
+ """
2
+ Example adapters for third-party backtest libraries.
3
+
4
+ These stubs demonstrate how to wrap external libraries while maintaining
5
+ our domain-specific API. Uncomment and implement when ready to integrate.
6
+
7
+ Usage
8
+ -----
9
+ # Uncomment the imports and implementation when ready to use
10
+ # from aponyx.backtest.adapters import VectorBTEngine
11
+ # engine = VectorBTEngine()
12
+ # result = engine.run(signal, spread, config)
13
+ """
14
+
15
+ # import pandas as pd
16
+ # import vectorbt as vbt # type: ignore
17
+ # from ..config import BacktestConfig
18
+ # from ..engine import BacktestResult
19
+ # from ..protocols import BacktestEngine
20
+
21
+
22
+ # class VectorBTEngine:
23
+ # """
24
+ # Adapter for vectorbt backtesting library.
25
+ #
26
+ # Wraps vectorbt's Portfolio class to match our BacktestEngine protocol.
27
+ # Provides access to vectorbt's optimized performance and advanced analytics.
28
+ #
29
+ # Examples
30
+ # --------
31
+ # >>> engine = VectorBTEngine()
32
+ # >>> result = engine.run(composite_signal, cdx_spread, config)
33
+ # >>> # Result is still a BacktestResult, but computed via vectorbt
34
+ # """
35
+ #
36
+ # def run(
37
+ # self,
38
+ # composite_signal: pd.Series,
39
+ # spread: pd.Series,
40
+ # config: BacktestConfig | None = None,
41
+ # ) -> BacktestResult:
42
+ # """
43
+ # Run backtest using vectorbt.
44
+ #
45
+ # Converts our signal/spread data to vectorbt format,
46
+ # executes backtest, and translates results back to BacktestResult.
47
+ # """
48
+ # if config is None:
49
+ # from ..config import BacktestConfig
50
+ # config = BacktestConfig()
51
+ #
52
+ # # Convert signal to vectorbt entries/exits
53
+ # entries = composite_signal > config.entry_threshold
54
+ # exits = composite_signal.abs() < config.exit_threshold
55
+ #
56
+ # # Run vectorbt portfolio simulation
57
+ # portfolio = vbt.Portfolio.from_signals(
58
+ # close=spread,
59
+ # entries=entries,
60
+ # exits=exits,
61
+ # size=config.position_size,
62
+ # fees=config.transaction_cost_bps / 10000, # Convert bps to decimal
63
+ # )
64
+ #
65
+ # # Convert vectorbt results to our BacktestResult format
66
+ # positions_df = pd.DataFrame({
67
+ # "signal": composite_signal,
68
+ # "position": portfolio.positions.values,
69
+ # "days_held": portfolio.holding_duration.values,
70
+ # "spread": spread,
71
+ # })
72
+ #
73
+ # pnl_df = pd.DataFrame({
74
+ # "spread_pnl": portfolio.returns.values,
75
+ # "cost": portfolio.fees.values,
76
+ # "net_pnl": portfolio.returns.values - portfolio.fees.values,
77
+ # "cumulative_pnl": portfolio.cumulative_returns.values,
78
+ # })
79
+ #
80
+ # metadata = {
81
+ # "engine": "vectorbt",
82
+ # "config": config.__dict__,
83
+ # "summary": portfolio.stats().to_dict(),
84
+ # }
85
+ #
86
+ # return BacktestResult(
87
+ # positions=positions_df,
88
+ # pnl=pnl_df,
89
+ # metadata=metadata,
90
+ # )
91
+
92
+
93
+ # class QuantStatsCalculator:
94
+ # """
95
+ # Adapter for quantstats performance analytics.
96
+ #
97
+ # Wraps quantstats to match our PerformanceCalculator protocol.
98
+ # Provides institutional-grade performance attribution and risk metrics.
99
+ #
100
+ # Examples
101
+ # --------
102
+ # >>> calc = QuantStatsCalculator()
103
+ # >>> metrics = calc.compute(result.pnl, result.positions)
104
+ # >>> # Returns comprehensive metrics compatible with our framework
105
+ # """
106
+ #
107
+ # def compute(
108
+ # self,
109
+ # pnl_df: pd.DataFrame,
110
+ # positions_df: pd.DataFrame,
111
+ # ) -> dict:
112
+ # """
113
+ # Compute metrics using quantstats.
114
+ #
115
+ # Converts our P&L data to returns series,
116
+ # computes metrics via quantstats, and returns results.
117
+ # """
118
+ # import quantstats as qs # type: ignore
119
+ #
120
+ # # Convert P&L to returns
121
+ # returns = pnl_df["net_pnl"] / pnl_df["cumulative_pnl"].shift(1).fillna(1)
122
+ #
123
+ # # Compute quantstats metrics
124
+ # metrics = {
125
+ # "sharpe": qs.stats.sharpe(returns),
126
+ # "sortino": qs.stats.sortino(returns),
127
+ # "max_drawdown": qs.stats.max_drawdown(returns),
128
+ # "calmar": qs.stats.calmar(returns),
129
+ # "total_return": qs.stats.comp(returns),
130
+ # "win_rate": qs.stats.win_rate(returns),
131
+ # # Add more as needed
132
+ # }
133
+ #
134
+ # return metrics
@@ -0,0 +1,59 @@
1
+ """
2
+ Configuration for backtest engine.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class BacktestConfig:
10
+ """
11
+ Backtest parameters and trading constraints.
12
+
13
+ Attributes
14
+ ----------
15
+ entry_threshold : float
16
+ Composite signal threshold for entering positions.
17
+ Absolute value above this triggers trades.
18
+ exit_threshold : float
19
+ Composite signal threshold for exiting positions.
20
+ Helps avoid whipsaw by requiring signal decay.
21
+ position_size : float
22
+ Notional position size in millions (e.g., 10.0 = $10MM).
23
+ transaction_cost_bps : float
24
+ Round-trip transaction cost in basis points.
25
+ Typical CDX costs: 0.5-2.0 bps depending on liquidity.
26
+ max_holding_days : int | None
27
+ Maximum days to hold a position before forced exit.
28
+ None means no time limit.
29
+ dv01_per_million : float
30
+ DV01 per $1MM notional for risk calculations.
31
+ Typical CDX IG 5Y: ~4500-5000.
32
+
33
+ Notes
34
+ -----
35
+ - entry_threshold > exit_threshold creates hysteresis to reduce turnover.
36
+ - Position sizing is deliberately simple for the pilot (binary on/off).
37
+ - Transaction costs are applied symmetrically on entry and exit.
38
+ """
39
+
40
+ entry_threshold: float = 1.5
41
+ exit_threshold: float = 0.75
42
+ position_size: float = 10.0
43
+ transaction_cost_bps: float = 1.0
44
+ max_holding_days: int | None = None
45
+ dv01_per_million: float = 4750.0
46
+
47
+ def __post_init__(self) -> None:
48
+ """Validate configuration parameters."""
49
+ if self.entry_threshold <= self.exit_threshold:
50
+ raise ValueError(
51
+ f"entry_threshold ({self.entry_threshold}) must be > "
52
+ f"exit_threshold ({self.exit_threshold})"
53
+ )
54
+ if self.position_size <= 0:
55
+ raise ValueError(f"position_size must be positive, got {self.position_size}")
56
+ if self.transaction_cost_bps < 0:
57
+ raise ValueError(
58
+ f"transaction_cost_bps must be non-negative, got {self.transaction_cost_bps}"
59
+ )
@@ -0,0 +1,256 @@
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 typing import Any
13
+
14
+ import pandas as pd
15
+
16
+ from .config import BacktestConfig
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class BacktestResult:
23
+ """
24
+ Container for backtest outputs.
25
+
26
+ Attributes
27
+ ----------
28
+ positions : pd.DataFrame
29
+ Daily position history with columns:
30
+ - signal: signal value
31
+ - position: current position (+1, 0, -1)
32
+ - days_held: days in current position
33
+ - spread: CDX spread level (for P&L calc)
34
+ pnl : pd.DataFrame
35
+ Daily P&L breakdown with columns:
36
+ - spread_pnl: P&L from spread changes
37
+ - cost: transaction costs
38
+ - net_pnl: total net P&L
39
+ - cumulative_pnl: running total
40
+ metadata : dict
41
+ Backtest configuration and execution details.
42
+
43
+ Notes
44
+ -----
45
+ This structure is designed to be easily convertible to formats
46
+ expected by third-party backtest libraries (e.g., vectorbt).
47
+ """
48
+
49
+ positions: pd.DataFrame
50
+ pnl: pd.DataFrame
51
+ metadata: dict[str, Any]
52
+
53
+
54
+ def run_backtest(
55
+ composite_signal: pd.Series,
56
+ spread: pd.Series,
57
+ config: BacktestConfig | None = None,
58
+ ) -> BacktestResult:
59
+ """
60
+ Run backtest converting signals to positions and computing P&L.
61
+
62
+ Parameters
63
+ ----------
64
+ composite_signal : pd.Series
65
+ Daily positioning scores from signal computation.
66
+ DatetimeIndex with float values.
67
+ spread : pd.Series
68
+ CDX spread levels aligned to signal dates.
69
+ Used for P&L calculation.
70
+ config : BacktestConfig | None
71
+ Backtest parameters. Uses defaults if None.
72
+
73
+ Returns
74
+ -------
75
+ BacktestResult
76
+ Complete backtest results including positions and P&L.
77
+
78
+ Notes
79
+ -----
80
+ Position Logic:
81
+ - Enter long (sell protection) when signal > entry_threshold
82
+ - Enter short (buy protection) when signal < -entry_threshold
83
+ - Exit when |signal| < exit_threshold or max_holding_days reached
84
+ - No position scaling in pilot (binary on/off)
85
+
86
+ P&L Calculation:
87
+ - Long position: profit when spreads tighten (P&L = -ΔSpread * DV01)
88
+ - Short position: profit when spreads widen (P&L = ΔSpread * DV01)
89
+ - Transaction costs applied on entry and exit
90
+ - P&L expressed in dollars per $1MM notional
91
+
92
+ Examples
93
+ --------
94
+ >>> config = BacktestConfig(entry_threshold=1.5, position_size=10.0)
95
+ >>> result = run_backtest(composite_signal, cdx_spread, config)
96
+ >>> sharpe = result.pnl['net_pnl'].mean() / result.pnl['net_pnl'].std() * np.sqrt(252)
97
+ """
98
+ if config is None:
99
+ config = BacktestConfig()
100
+
101
+ logger.info(
102
+ "Starting backtest: dates=%d, entry_threshold=%.2f, position_size=%.1fMM",
103
+ len(composite_signal),
104
+ config.entry_threshold,
105
+ config.position_size,
106
+ )
107
+
108
+ # Validate inputs
109
+ if not isinstance(composite_signal.index, pd.DatetimeIndex):
110
+ raise ValueError("composite_signal must have DatetimeIndex")
111
+ if not isinstance(spread.index, pd.DatetimeIndex):
112
+ raise ValueError("spread must have DatetimeIndex")
113
+
114
+ # Align data
115
+ aligned = pd.DataFrame(
116
+ {
117
+ "signal": composite_signal,
118
+ "spread": spread,
119
+ }
120
+ ).dropna()
121
+
122
+ if len(aligned) == 0:
123
+ raise ValueError("No valid data after alignment")
124
+
125
+ # Initialize tracking
126
+ positions = []
127
+ pnl_records = []
128
+ current_position = 0
129
+ days_held = 0
130
+ entry_spread = 0.0
131
+
132
+ for date, row in aligned.iterrows():
133
+ signal = row["signal"]
134
+ spread_level = row["spread"]
135
+
136
+ # Initialize cost tracking for this iteration
137
+ entry_cost = 0.0
138
+ exit_cost = 0.0
139
+
140
+ # Store position before any state changes (for P&L calculation)
141
+ position_before_update = current_position
142
+ entry_spread_before_update = entry_spread
143
+
144
+ # Determine position based on signal thresholds
145
+ if current_position == 0:
146
+ # Not in position - check entry conditions
147
+ if signal > config.entry_threshold:
148
+ current_position = 1 # Long credit risk (sell protection)
149
+ days_held = 0
150
+ entry_spread = spread_level
151
+ entry_cost = config.transaction_cost_bps * config.position_size * 100
152
+ elif signal < -config.entry_threshold:
153
+ current_position = -1 # Short credit risk (buy protection)
154
+ days_held = 0
155
+ entry_spread = spread_level
156
+ entry_cost = config.transaction_cost_bps * config.position_size * 100
157
+ else:
158
+ # In position - check exit conditions
159
+ days_held += 1
160
+
161
+ exit_signal = abs(signal) < config.exit_threshold
162
+ exit_time = (
163
+ config.max_holding_days is not None and days_held >= config.max_holding_days
164
+ )
165
+
166
+ if exit_signal or exit_time:
167
+ # Exit position (will apply exit cost and capture final P&L)
168
+ exit_cost = config.transaction_cost_bps * config.position_size * 100
169
+ current_position = 0
170
+ days_held = 0
171
+
172
+ # Calculate P&L based on position we held during this period
173
+ # Use position_before_update to capture P&L on exit day
174
+ if position_before_update != 0:
175
+ # Spread change: negative when tightening, positive when widening
176
+ spread_change = spread_level - entry_spread_before_update
177
+ # Long position profits from tightening (negative spread change)
178
+ # Short position profits from widening (positive spread change)
179
+ # P&L = -position * spread_change * DV01 * position_size
180
+ spread_pnl = (
181
+ -position_before_update
182
+ * spread_change
183
+ * config.dv01_per_million
184
+ * config.position_size
185
+ )
186
+ else:
187
+ spread_pnl = 0.0
188
+
189
+ total_cost = entry_cost + exit_cost
190
+ net_pnl = spread_pnl - total_cost
191
+
192
+ # Record position state
193
+ positions.append(
194
+ {
195
+ "date": date,
196
+ "signal": signal,
197
+ "position": current_position,
198
+ "days_held": days_held,
199
+ "spread": spread_level,
200
+ }
201
+ )
202
+
203
+ # Record P&L
204
+ pnl_records.append(
205
+ {
206
+ "date": date,
207
+ "spread_pnl": spread_pnl,
208
+ "cost": total_cost,
209
+ "net_pnl": net_pnl,
210
+ }
211
+ )
212
+
213
+ # Convert to DataFrames
214
+ positions_df = pd.DataFrame(positions).set_index("date")
215
+ pnl_df = pd.DataFrame(pnl_records).set_index("date")
216
+ pnl_df["cumulative_pnl"] = pnl_df["net_pnl"].cumsum()
217
+
218
+ # Calculate summary statistics (count round-trip trades: entries only)
219
+ prev_position = positions_df["position"].shift(1).fillna(0)
220
+ position_entries = (prev_position == 0) & (positions_df["position"] != 0)
221
+ n_trades = position_entries.sum()
222
+ total_pnl = pnl_df["cumulative_pnl"].iloc[-1]
223
+ avg_pnl_per_trade = total_pnl / n_trades if n_trades > 0 else 0.0
224
+
225
+ metadata = {
226
+ "timestamp": datetime.now().isoformat(),
227
+ "config": {
228
+ "entry_threshold": config.entry_threshold,
229
+ "exit_threshold": config.exit_threshold,
230
+ "position_size": config.position_size,
231
+ "transaction_cost_bps": config.transaction_cost_bps,
232
+ "max_holding_days": config.max_holding_days,
233
+ "dv01_per_million": config.dv01_per_million,
234
+ },
235
+ "summary": {
236
+ "start_date": str(aligned.index[0]),
237
+ "end_date": str(aligned.index[-1]),
238
+ "total_days": len(aligned),
239
+ "n_trades": int(n_trades),
240
+ "total_pnl": float(total_pnl),
241
+ "avg_pnl_per_trade": float(avg_pnl_per_trade),
242
+ },
243
+ }
244
+
245
+ logger.info(
246
+ "Backtest complete: trades=%d, total_pnl=$%.0f, avg_per_trade=$%.0f",
247
+ n_trades,
248
+ total_pnl,
249
+ avg_pnl_per_trade,
250
+ )
251
+
252
+ return BacktestResult(
253
+ positions=positions_df,
254
+ pnl=pnl_df,
255
+ metadata=metadata,
256
+ )