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 +12 -0
- aponyx/backtest/__init__.py +29 -0
- aponyx/backtest/adapters.py +134 -0
- aponyx/backtest/config.py +59 -0
- aponyx/backtest/engine.py +256 -0
- aponyx/backtest/metrics.py +216 -0
- aponyx/backtest/protocols.py +101 -0
- aponyx/config/__init__.py +77 -0
- aponyx/data/__init__.py +31 -0
- aponyx/data/cache.py +242 -0
- aponyx/data/fetch.py +410 -0
- aponyx/data/providers/__init__.py +13 -0
- aponyx/data/providers/bloomberg.py +269 -0
- aponyx/data/providers/file.py +86 -0
- aponyx/data/sample_data.py +359 -0
- aponyx/data/schemas.py +65 -0
- aponyx/data/sources.py +135 -0
- aponyx/data/validation.py +231 -0
- aponyx/main.py +7 -0
- aponyx/models/__init__.py +24 -0
- aponyx/models/catalog.py +167 -0
- aponyx/models/config.py +33 -0
- aponyx/models/registry.py +200 -0
- aponyx/models/signal_catalog.json +34 -0
- aponyx/models/signals.py +221 -0
- aponyx/persistence/__init__.py +20 -0
- aponyx/persistence/json_io.py +130 -0
- aponyx/persistence/parquet_io.py +174 -0
- aponyx/persistence/registry.py +375 -0
- aponyx/py.typed +0 -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-0.1.0.dist-info/METADATA +271 -0
- aponyx-0.1.0.dist-info/RECORD +37 -0
- aponyx-0.1.0.dist-info/WHEEL +4 -0
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
|
+
)
|