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,304 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Return attribution and decomposition analysis.
|
|
3
|
+
|
|
4
|
+
Provides tools for attributing backtest returns to various sources including
|
|
5
|
+
trade direction, signal strength, and win/loss patterns.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def attribute_by_direction(
|
|
16
|
+
pnl_df: pd.DataFrame,
|
|
17
|
+
positions_df: pd.DataFrame,
|
|
18
|
+
) -> dict[str, float]:
|
|
19
|
+
"""
|
|
20
|
+
Attribute returns by trade direction (long vs short).
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
pnl_df : pd.DataFrame
|
|
25
|
+
P&L DataFrame with 'net_pnl' column.
|
|
26
|
+
positions_df : pd.DataFrame
|
|
27
|
+
Position DataFrame with 'position' column (+1 for long, -1 for short).
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
dict[str, float]
|
|
32
|
+
Attribution with keys:
|
|
33
|
+
- 'long_pnl': Total P&L from long positions
|
|
34
|
+
- 'short_pnl': Total P&L from short positions
|
|
35
|
+
- 'long_pct': Percentage of total P&L from longs
|
|
36
|
+
- 'short_pct': Percentage of total P&L from shorts
|
|
37
|
+
|
|
38
|
+
Notes
|
|
39
|
+
-----
|
|
40
|
+
For CDX overlay strategies:
|
|
41
|
+
- Long position = sell protection (bullish credit)
|
|
42
|
+
- Short position = buy protection (bearish credit)
|
|
43
|
+
|
|
44
|
+
Examples
|
|
45
|
+
--------
|
|
46
|
+
>>> direction_attr = attribute_by_direction(result.pnl, result.positions)
|
|
47
|
+
>>> print(f"Long contributed: {direction_attr['long_pct']:.1%}")
|
|
48
|
+
"""
|
|
49
|
+
logger.debug("Computing directional attribution")
|
|
50
|
+
|
|
51
|
+
# Align indices
|
|
52
|
+
aligned_pnl = pnl_df.reindex(positions_df.index)["net_pnl"]
|
|
53
|
+
position = positions_df["position"]
|
|
54
|
+
|
|
55
|
+
# Separate P&L by direction
|
|
56
|
+
long_mask = position > 0
|
|
57
|
+
short_mask = position < 0
|
|
58
|
+
|
|
59
|
+
long_pnl = aligned_pnl[long_mask].sum()
|
|
60
|
+
short_pnl = aligned_pnl[short_mask].sum()
|
|
61
|
+
total_pnl = long_pnl + short_pnl
|
|
62
|
+
|
|
63
|
+
# Compute percentages
|
|
64
|
+
if abs(total_pnl) > 0:
|
|
65
|
+
long_pct = long_pnl / total_pnl
|
|
66
|
+
short_pct = short_pnl / total_pnl
|
|
67
|
+
else:
|
|
68
|
+
long_pct = 0.0
|
|
69
|
+
short_pct = 0.0
|
|
70
|
+
|
|
71
|
+
logger.debug(
|
|
72
|
+
"Direction attribution: long=%.2f (%.1f%%), short=%.2f (%.1f%%)",
|
|
73
|
+
long_pnl,
|
|
74
|
+
long_pct * 100,
|
|
75
|
+
short_pnl,
|
|
76
|
+
short_pct * 100,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"long_pnl": long_pnl,
|
|
81
|
+
"short_pnl": short_pnl,
|
|
82
|
+
"long_pct": long_pct,
|
|
83
|
+
"short_pct": short_pct,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def attribute_by_signal_strength(
|
|
88
|
+
pnl_df: pd.DataFrame,
|
|
89
|
+
positions_df: pd.DataFrame,
|
|
90
|
+
n_quantiles: int = 3,
|
|
91
|
+
) -> dict[str, float]:
|
|
92
|
+
"""
|
|
93
|
+
Attribute returns by signal strength quantiles.
|
|
94
|
+
|
|
95
|
+
Separates P&L based on absolute signal strength at position entry,
|
|
96
|
+
using quantile buckets (e.g., weak/medium/strong signals).
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
pnl_df : pd.DataFrame
|
|
101
|
+
P&L DataFrame with 'net_pnl' column.
|
|
102
|
+
positions_df : pd.DataFrame
|
|
103
|
+
Position DataFrame with 'signal' and 'position' columns.
|
|
104
|
+
n_quantiles : int
|
|
105
|
+
Number of quantile buckets. Default: 3 (terciles).
|
|
106
|
+
|
|
107
|
+
Returns
|
|
108
|
+
-------
|
|
109
|
+
dict[str, float]
|
|
110
|
+
Attribution by quantile with keys:
|
|
111
|
+
- 'q1_pnl', 'q2_pnl', ...: P&L per quantile
|
|
112
|
+
- 'q1_pct', 'q2_pct', ...: Percentage contribution per quantile
|
|
113
|
+
- 'quantile_labels': List of quantile labels
|
|
114
|
+
|
|
115
|
+
Notes
|
|
116
|
+
-----
|
|
117
|
+
Uses absolute signal values to handle both long and short positions.
|
|
118
|
+
Quantiles are computed on days when positioned (position != 0).
|
|
119
|
+
|
|
120
|
+
Examples
|
|
121
|
+
--------
|
|
122
|
+
>>> signal_attr = attribute_by_signal_strength(result.pnl, result.positions, n_quantiles=3)
|
|
123
|
+
>>> print(f"Strongest signals: {signal_attr['q3_pct']:.1%}")
|
|
124
|
+
"""
|
|
125
|
+
logger.debug("Computing signal strength attribution: n_quantiles=%d", n_quantiles)
|
|
126
|
+
|
|
127
|
+
# Filter to positioned days only
|
|
128
|
+
positioned = positions_df[positions_df["position"] != 0].copy()
|
|
129
|
+
|
|
130
|
+
if len(positioned) == 0:
|
|
131
|
+
logger.warning("No positioned days found for signal attribution")
|
|
132
|
+
return (
|
|
133
|
+
{f"q{i + 1}_pnl": 0.0 for i in range(n_quantiles)}
|
|
134
|
+
| {f"q{i + 1}_pct": 0.0 for i in range(n_quantiles)}
|
|
135
|
+
| {"quantile_labels": [f"Q{i + 1}" for i in range(n_quantiles)]}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Use absolute signal strength
|
|
139
|
+
positioned["abs_signal"] = positioned["signal"].abs()
|
|
140
|
+
|
|
141
|
+
# Assign quantiles (1 = weakest, n_quantiles = strongest)
|
|
142
|
+
positioned["quantile"] = pd.qcut(
|
|
143
|
+
positioned["abs_signal"],
|
|
144
|
+
q=n_quantiles,
|
|
145
|
+
labels=range(1, n_quantiles + 1),
|
|
146
|
+
duplicates="drop",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Align P&L
|
|
150
|
+
aligned_pnl = pnl_df.reindex(positioned.index)["net_pnl"]
|
|
151
|
+
|
|
152
|
+
# Aggregate by quantile
|
|
153
|
+
quantile_pnl = aligned_pnl.groupby(positioned["quantile"], observed=True).sum()
|
|
154
|
+
total_pnl = aligned_pnl.sum()
|
|
155
|
+
|
|
156
|
+
# Build result dictionary
|
|
157
|
+
result = {}
|
|
158
|
+
for q in range(1, n_quantiles + 1):
|
|
159
|
+
pnl_value = quantile_pnl.get(q, 0.0)
|
|
160
|
+
pct_value = pnl_value / total_pnl if abs(total_pnl) > 0 else 0.0
|
|
161
|
+
|
|
162
|
+
result[f"q{q}_pnl"] = pnl_value
|
|
163
|
+
result[f"q{q}_pct"] = pct_value
|
|
164
|
+
|
|
165
|
+
result["quantile_labels"] = [f"Q{i + 1}" for i in range(n_quantiles)]
|
|
166
|
+
|
|
167
|
+
logger.debug(
|
|
168
|
+
"Signal strength attribution: %s",
|
|
169
|
+
", ".join(
|
|
170
|
+
[f"Q{i + 1}={result[f'q{i + 1}_pct']:.1%}" for i in range(n_quantiles)]
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def attribute_by_win_loss(
|
|
178
|
+
pnl_df: pd.DataFrame,
|
|
179
|
+
positions_df: pd.DataFrame,
|
|
180
|
+
) -> dict[str, float]:
|
|
181
|
+
"""
|
|
182
|
+
Decompose P&L into winning and losing trade contributions.
|
|
183
|
+
|
|
184
|
+
Parameters
|
|
185
|
+
----------
|
|
186
|
+
pnl_df : pd.DataFrame
|
|
187
|
+
P&L DataFrame with 'net_pnl' column.
|
|
188
|
+
positions_df : pd.DataFrame
|
|
189
|
+
Position DataFrame with 'position' column.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
dict[str, float]
|
|
194
|
+
Win/loss attribution with keys:
|
|
195
|
+
- 'gross_wins': Sum of all positive daily P&L
|
|
196
|
+
- 'gross_losses': Sum of all negative daily P&L (negative value)
|
|
197
|
+
- 'net_pnl': gross_wins + gross_losses
|
|
198
|
+
- 'win_contribution': Percentage from wins
|
|
199
|
+
- 'loss_contribution': Percentage from losses
|
|
200
|
+
|
|
201
|
+
Notes
|
|
202
|
+
-----
|
|
203
|
+
This is a daily P&L decomposition, not trade-level.
|
|
204
|
+
Useful for understanding contribution from up-days vs down-days.
|
|
205
|
+
|
|
206
|
+
Examples
|
|
207
|
+
--------
|
|
208
|
+
>>> wl_attr = attribute_by_win_loss(result.pnl, result.positions)
|
|
209
|
+
>>> print(f"Wins contributed: {wl_attr['win_contribution']:.1%}")
|
|
210
|
+
"""
|
|
211
|
+
logger.debug("Computing win/loss attribution")
|
|
212
|
+
|
|
213
|
+
# Only include positioned days
|
|
214
|
+
positioned_mask = positions_df["position"] != 0
|
|
215
|
+
aligned_pnl = pnl_df.reindex(positions_df.index).loc[positioned_mask, "net_pnl"]
|
|
216
|
+
|
|
217
|
+
# Separate wins and losses
|
|
218
|
+
gross_wins = aligned_pnl[aligned_pnl > 0].sum()
|
|
219
|
+
gross_losses = aligned_pnl[aligned_pnl < 0].sum() # Negative value
|
|
220
|
+
net_pnl = gross_wins + gross_losses
|
|
221
|
+
|
|
222
|
+
# Compute contributions
|
|
223
|
+
if abs(net_pnl) > 0:
|
|
224
|
+
win_contribution = gross_wins / abs(net_pnl)
|
|
225
|
+
loss_contribution = abs(gross_losses) / abs(net_pnl)
|
|
226
|
+
else:
|
|
227
|
+
win_contribution = 0.0
|
|
228
|
+
loss_contribution = 0.0
|
|
229
|
+
|
|
230
|
+
logger.debug(
|
|
231
|
+
"Win/loss: wins=%.2f (%.1f%%), losses=%.2f (%.1f%%)",
|
|
232
|
+
gross_wins,
|
|
233
|
+
win_contribution * 100,
|
|
234
|
+
gross_losses,
|
|
235
|
+
loss_contribution * 100,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
"gross_wins": gross_wins,
|
|
240
|
+
"gross_losses": gross_losses,
|
|
241
|
+
"net_pnl": net_pnl,
|
|
242
|
+
"win_contribution": win_contribution,
|
|
243
|
+
"loss_contribution": loss_contribution,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def compute_attribution(
|
|
248
|
+
pnl_df: pd.DataFrame,
|
|
249
|
+
positions_df: pd.DataFrame,
|
|
250
|
+
n_quantiles: int = 3,
|
|
251
|
+
) -> dict[str, dict[str, float]]:
|
|
252
|
+
"""
|
|
253
|
+
Compute all attribution analyses.
|
|
254
|
+
|
|
255
|
+
Orchestrates computation of directional, signal strength, and
|
|
256
|
+
win/loss attribution.
|
|
257
|
+
|
|
258
|
+
Parameters
|
|
259
|
+
----------
|
|
260
|
+
pnl_df : pd.DataFrame
|
|
261
|
+
P&L DataFrame with 'net_pnl' column.
|
|
262
|
+
positions_df : pd.DataFrame
|
|
263
|
+
Position DataFrame with 'signal' and 'position' columns.
|
|
264
|
+
n_quantiles : int
|
|
265
|
+
Number of quantiles for signal strength attribution. Default: 3.
|
|
266
|
+
|
|
267
|
+
Returns
|
|
268
|
+
-------
|
|
269
|
+
dict[str, dict[str, float]]
|
|
270
|
+
Nested dictionary with keys:
|
|
271
|
+
- 'direction': Directional attribution
|
|
272
|
+
- 'signal_strength': Signal quantile attribution
|
|
273
|
+
- 'win_loss': Win/loss decomposition
|
|
274
|
+
|
|
275
|
+
Notes
|
|
276
|
+
-----
|
|
277
|
+
This function provides comprehensive return attribution for
|
|
278
|
+
understanding sources of backtest performance.
|
|
279
|
+
|
|
280
|
+
Examples
|
|
281
|
+
--------
|
|
282
|
+
>>> attribution = compute_attribution(result.pnl, result.positions, n_quantiles=3)
|
|
283
|
+
>>> print(f"Long P&L: ${attribution['direction']['long_pnl']:,.0f}")
|
|
284
|
+
>>> print(f"Strongest signals: {attribution['signal_strength']['q3_pct']:.1%}")
|
|
285
|
+
"""
|
|
286
|
+
logger.info("Computing return attribution: n_quantiles=%d", n_quantiles)
|
|
287
|
+
|
|
288
|
+
direction_attr = attribute_by_direction(pnl_df, positions_df)
|
|
289
|
+
signal_attr = attribute_by_signal_strength(pnl_df, positions_df, n_quantiles)
|
|
290
|
+
wl_attr = attribute_by_win_loss(pnl_df, positions_df)
|
|
291
|
+
|
|
292
|
+
attribution = {
|
|
293
|
+
"direction": direction_attr,
|
|
294
|
+
"signal_strength": signal_attr,
|
|
295
|
+
"win_loss": wl_attr,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
logger.info(
|
|
299
|
+
"Attribution computed: long=%.1f%%, wins=%.1f%%",
|
|
300
|
+
direction_attr["long_pct"] * 100,
|
|
301
|
+
wl_attr["win_contribution"] * 100,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return attribution
|