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,541 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Markdown report generation for performance evaluation results.
|
|
3
|
+
|
|
4
|
+
Generates human-readable reports with performance metrics, attribution,
|
|
5
|
+
stability analysis, and interpretation guidance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import matplotlib.pyplot as plt
|
|
13
|
+
import pandas as pd
|
|
14
|
+
import quantstats as qs
|
|
15
|
+
|
|
16
|
+
from .config import PerformanceResult
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_quantstats_tearsheet(
|
|
22
|
+
returns: pd.Series,
|
|
23
|
+
benchmark: pd.Series | None,
|
|
24
|
+
output_path: Path,
|
|
25
|
+
title: str,
|
|
26
|
+
periods_per_year: int = 252,
|
|
27
|
+
) -> Path | None:
|
|
28
|
+
"""
|
|
29
|
+
Generate quantstats HTML tearsheet from returns series.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
returns : pd.Series
|
|
34
|
+
Daily percentage returns with DatetimeIndex.
|
|
35
|
+
benchmark : pd.Series | None
|
|
36
|
+
Benchmark daily percentage returns with DatetimeIndex.
|
|
37
|
+
If None, generates tearsheet without benchmark comparison.
|
|
38
|
+
output_path : Path
|
|
39
|
+
Full path (including filename) for HTML tearsheet.
|
|
40
|
+
title : str
|
|
41
|
+
Title for the tearsheet report.
|
|
42
|
+
periods_per_year : int, default=252
|
|
43
|
+
Number of trading periods per year (252 for daily).
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
Path | None
|
|
48
|
+
Path to saved tearsheet HTML file.
|
|
49
|
+
Returns None if quantstats unavailable or generation fails.
|
|
50
|
+
|
|
51
|
+
Notes
|
|
52
|
+
-----
|
|
53
|
+
- Requires quantstats library (install with viz extras: pip install -e ".[viz]")
|
|
54
|
+
- Fallback behavior: logs warning at DEBUG level and returns None
|
|
55
|
+
- Uses quantstats default tearsheet template with all sections
|
|
56
|
+
- Output directory created automatically if missing
|
|
57
|
+
- Existing files overwritten without warning
|
|
58
|
+
|
|
59
|
+
Examples
|
|
60
|
+
--------
|
|
61
|
+
>>> tearsheet_path = generate_quantstats_tearsheet(
|
|
62
|
+
... returns=strategy_returns,
|
|
63
|
+
... benchmark=None,
|
|
64
|
+
... output_path=Path("reports/performance/strategy_tearsheet.html"),
|
|
65
|
+
... title="CDX ETF Basis - Simple Threshold",
|
|
66
|
+
... )
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
# Ensure output directory exists
|
|
70
|
+
output_path = Path(output_path)
|
|
71
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
|
|
73
|
+
# Ensure series have names (quantstats requires this)
|
|
74
|
+
if returns.name is None:
|
|
75
|
+
returns = returns.copy()
|
|
76
|
+
returns.name = "Strategy"
|
|
77
|
+
if benchmark is not None and benchmark.name is None:
|
|
78
|
+
benchmark = benchmark.copy()
|
|
79
|
+
benchmark.name = "Benchmark"
|
|
80
|
+
|
|
81
|
+
# Generate tearsheet
|
|
82
|
+
logger.info(
|
|
83
|
+
"Generating quantstats tearsheet: %s (returns=%d, benchmark=%s)",
|
|
84
|
+
output_path.name,
|
|
85
|
+
len(returns),
|
|
86
|
+
"provided" if benchmark is not None else "none",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
qs.reports.html(
|
|
90
|
+
returns,
|
|
91
|
+
benchmark=benchmark,
|
|
92
|
+
output=str(output_path),
|
|
93
|
+
title=title,
|
|
94
|
+
periods_per_year=periods_per_year,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Clean up matplotlib figures to avoid memory issues
|
|
98
|
+
plt.close("all")
|
|
99
|
+
|
|
100
|
+
logger.info("Saved quantstats tearsheet to %s", output_path)
|
|
101
|
+
return output_path
|
|
102
|
+
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.warning(
|
|
105
|
+
"Failed to generate quantstats tearsheet: %s: %s",
|
|
106
|
+
type(e).__name__,
|
|
107
|
+
e,
|
|
108
|
+
)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def generate_performance_report(
|
|
113
|
+
result: PerformanceResult,
|
|
114
|
+
signal_id: str,
|
|
115
|
+
strategy_id: str,
|
|
116
|
+
generate_tearsheet: bool = True,
|
|
117
|
+
output_dir: Path | None = None,
|
|
118
|
+
) -> str:
|
|
119
|
+
"""
|
|
120
|
+
Generate Markdown report from performance evaluation result.
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
result : PerformanceResult
|
|
125
|
+
Performance evaluation result to document.
|
|
126
|
+
signal_id : str
|
|
127
|
+
Signal identifier (for header).
|
|
128
|
+
strategy_id : str
|
|
129
|
+
Strategy identifier (for header).
|
|
130
|
+
generate_tearsheet : bool, default=True
|
|
131
|
+
If True and quantstats available, generate HTML tearsheet alongside report.
|
|
132
|
+
output_dir : Path | None, default=None
|
|
133
|
+
Output directory for tearsheet. Required if generate_tearsheet=True.
|
|
134
|
+
Ignored if generate_tearsheet=False.
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
str
|
|
139
|
+
Formatted Markdown report.
|
|
140
|
+
|
|
141
|
+
Notes
|
|
142
|
+
-----
|
|
143
|
+
Report includes:
|
|
144
|
+
- Header with identifiers and stability score
|
|
145
|
+
- Executive summary with key insights
|
|
146
|
+
- Extended metrics section
|
|
147
|
+
- Subperiod stability analysis
|
|
148
|
+
- Return attribution breakdown
|
|
149
|
+
- Recommendations
|
|
150
|
+
- Footer with metadata
|
|
151
|
+
|
|
152
|
+
Tearsheet generation:
|
|
153
|
+
- Only occurs if generate_tearsheet=True AND quantstats is installed
|
|
154
|
+
- Uses returns from result.metadata['returns'] if available
|
|
155
|
+
- Uses benchmark from result.metadata['benchmark'] if available
|
|
156
|
+
- Silently skips if dependencies unavailable (DEBUG-level logging)
|
|
157
|
+
- Filename format: {signal_id}_{strategy_id}_{timestamp}_tearsheet.html
|
|
158
|
+
|
|
159
|
+
Examples
|
|
160
|
+
--------
|
|
161
|
+
>>> report = generate_performance_report(result, "cdx_etf_basis", "simple_threshold")
|
|
162
|
+
>>> print(report[:100])
|
|
163
|
+
"""
|
|
164
|
+
# Stability indicator
|
|
165
|
+
if result.stability_score >= 0.7:
|
|
166
|
+
stability_indicator = "[STRONG]"
|
|
167
|
+
elif result.stability_score >= 0.4:
|
|
168
|
+
stability_indicator = "[MODERATE]"
|
|
169
|
+
else:
|
|
170
|
+
stability_indicator = "[WEAK]"
|
|
171
|
+
|
|
172
|
+
# Extract key metrics (use dataclass field access)
|
|
173
|
+
metrics = result.metrics
|
|
174
|
+
subperiod = result.subperiod_analysis
|
|
175
|
+
attribution = result.attribution
|
|
176
|
+
|
|
177
|
+
# Build report
|
|
178
|
+
report = f"""# Backtest Performance Evaluation Report
|
|
179
|
+
|
|
180
|
+
**Signal:** `{signal_id}`
|
|
181
|
+
**Strategy:** `{strategy_id}`
|
|
182
|
+
**Evaluation Date:** {result.timestamp}
|
|
183
|
+
**Evaluator Version:** {result.metadata.get("evaluator_version", "unknown")}
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Executive Summary
|
|
188
|
+
|
|
189
|
+
### Stability Assessment: {stability_indicator}
|
|
190
|
+
|
|
191
|
+
**Overall Stability Score:** {result.stability_score:.3f}
|
|
192
|
+
|
|
193
|
+
{result.summary}
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Basic Backtest Metrics
|
|
198
|
+
|
|
199
|
+
| Metric | Value |
|
|
200
|
+
|--------|-------|
|
|
201
|
+
| Total Return | ${metrics.total_return:,.2f} |
|
|
202
|
+
| Annualized Return | ${metrics.annualized_return:,.2f} |
|
|
203
|
+
| Sharpe Ratio | {metrics.sharpe_ratio:.3f} |
|
|
204
|
+
| Sortino Ratio | {metrics.sortino_ratio:.3f} |
|
|
205
|
+
| Max Drawdown | ${metrics.max_drawdown:,.2f} |
|
|
206
|
+
| Calmar Ratio | {metrics.calmar_ratio:.3f} |
|
|
207
|
+
| Annualized Volatility | ${metrics.annualized_volatility:,.2f} |
|
|
208
|
+
|
|
209
|
+
### Trade Statistics
|
|
210
|
+
|
|
211
|
+
| Metric | Value |
|
|
212
|
+
|--------|-------|
|
|
213
|
+
| Total Trades | {metrics.n_trades} |
|
|
214
|
+
| Hit Rate | {metrics.hit_rate:.1%} |
|
|
215
|
+
| Average Win | ${metrics.avg_win:,.2f} |
|
|
216
|
+
| Average Loss | ${metrics.avg_loss:,.2f} |
|
|
217
|
+
| Win/Loss Ratio | {metrics.win_loss_ratio:.3f} |
|
|
218
|
+
| Avg Holding Days | {metrics.avg_holding_days:.1f} |
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Extended Performance Metrics
|
|
223
|
+
|
|
224
|
+
### Risk-Adjusted Returns
|
|
225
|
+
|
|
226
|
+
| Metric | Value |
|
|
227
|
+
|--------|-------|
|
|
228
|
+
| Rolling Sharpe (Mean) | {metrics.rolling_sharpe_mean:.3f} |
|
|
229
|
+
| Rolling Sharpe (Std Dev) | {metrics.rolling_sharpe_std:.3f} |
|
|
230
|
+
| Profit Factor | {metrics.profit_factor:.3f} |
|
|
231
|
+
| Tail Ratio (95th pct) | {metrics.tail_ratio:.3f} |
|
|
232
|
+
| Consistency Score (21d) | {metrics.consistency_score:.1%} |
|
|
233
|
+
|
|
234
|
+
**Interpretation:**
|
|
235
|
+
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
# Add metric interpretations
|
|
239
|
+
if metrics.profit_factor > 1.5:
|
|
240
|
+
report += "- Strong profitability with gross wins substantially exceeding gross losses\n"
|
|
241
|
+
elif metrics.profit_factor > 1.0:
|
|
242
|
+
report += "- Positive profitability with gross wins exceeding gross losses\n"
|
|
243
|
+
else:
|
|
244
|
+
report += "- Weak profitability with gross losses approaching or exceeding gross wins\n"
|
|
245
|
+
|
|
246
|
+
if metrics.tail_ratio > 1.2:
|
|
247
|
+
report += (
|
|
248
|
+
"- Favorable tail asymmetry with larger upside than downside extremes\n"
|
|
249
|
+
)
|
|
250
|
+
elif metrics.tail_ratio > 0.8:
|
|
251
|
+
report += (
|
|
252
|
+
"- Balanced tail distribution with similar upside and downside extremes\n"
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
report += (
|
|
256
|
+
"- Negative tail asymmetry with larger downside than upside extremes\n"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if metrics.consistency_score > 0.6:
|
|
260
|
+
report += "- High consistency with majority of rolling windows profitable\n"
|
|
261
|
+
elif metrics.consistency_score > 0.4:
|
|
262
|
+
report += "- Moderate consistency with mixed profitable/unprofitable periods\n"
|
|
263
|
+
else:
|
|
264
|
+
report += "- Low consistency with frequent unprofitable rolling windows\n"
|
|
265
|
+
|
|
266
|
+
# Drawdown recovery
|
|
267
|
+
max_dd_recovery = metrics.max_dd_recovery_days
|
|
268
|
+
if max_dd_recovery == float("inf"):
|
|
269
|
+
recovery_text = "Not recovered"
|
|
270
|
+
else:
|
|
271
|
+
recovery_text = f"{max_dd_recovery:.0f} days"
|
|
272
|
+
|
|
273
|
+
report += f"""
|
|
274
|
+
### Drawdown Recovery
|
|
275
|
+
|
|
276
|
+
| Metric | Value |
|
|
277
|
+
|--------|-------|
|
|
278
|
+
| Max Drawdown Recovery | {recovery_text} |
|
|
279
|
+
| Average Recovery Time | {metrics.avg_recovery_days:.1f} days |
|
|
280
|
+
| Number of Drawdowns | {metrics.n_drawdowns} |
|
|
281
|
+
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
if max_dd_recovery == float("inf"):
|
|
285
|
+
report += "**Warning:** Maximum drawdown has not been recovered as of backtest end date.\n"
|
|
286
|
+
|
|
287
|
+
# Subperiod stability
|
|
288
|
+
report += f"""
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## Subperiod Stability Analysis
|
|
292
|
+
|
|
293
|
+
**Number of Subperiods:** {len(subperiod["subperiod_returns"])}
|
|
294
|
+
**Profitable Periods:** {subperiod["positive_periods"]}/{len(subperiod["subperiod_returns"])}
|
|
295
|
+
**Consistency Rate:** {subperiod["consistency_rate"]:.1%}
|
|
296
|
+
|
|
297
|
+
| Period | Return | Sharpe |
|
|
298
|
+
|--------|--------|--------|
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
for i, (ret, sharpe) in enumerate(
|
|
302
|
+
zip(subperiod["subperiod_returns"], subperiod["subperiod_sharpes"]), 1
|
|
303
|
+
):
|
|
304
|
+
report += f"| {i} | {ret:,.2f} | {sharpe:.3f} |\n"
|
|
305
|
+
|
|
306
|
+
report += "\n**Interpretation:**\n\n"
|
|
307
|
+
|
|
308
|
+
if subperiod["consistency_rate"] >= 0.75:
|
|
309
|
+
report += (
|
|
310
|
+
"Excellent temporal consistency with strong performance across most subperiods. "
|
|
311
|
+
"Strategy appears robust to different market conditions.\n"
|
|
312
|
+
)
|
|
313
|
+
elif subperiod["consistency_rate"] >= 0.5:
|
|
314
|
+
report += (
|
|
315
|
+
"Moderate temporal consistency with mixed performance across subperiods. "
|
|
316
|
+
"Performance may be regime-dependent.\n"
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
report += (
|
|
320
|
+
"Weak temporal consistency with performance concentrated in few subperiods. "
|
|
321
|
+
"Strategy may be vulnerable to regime changes or overfitted to specific conditions.\n"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Return attribution
|
|
325
|
+
direction = attribution["direction"]
|
|
326
|
+
signal_strength = attribution["signal_strength"]
|
|
327
|
+
win_loss = attribution["win_loss"]
|
|
328
|
+
|
|
329
|
+
report += f"""
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## Return Attribution
|
|
333
|
+
|
|
334
|
+
### Directional Attribution
|
|
335
|
+
|
|
336
|
+
| Direction | P&L | Contribution |
|
|
337
|
+
|-----------|-----|--------------|
|
|
338
|
+
| Long | {direction["long_pnl"]:,.2f} | {direction["long_pct"]:.1%} |
|
|
339
|
+
| Short | {direction["short_pnl"]:,.2f} | {direction["short_pct"]:.1%} |
|
|
340
|
+
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
if abs(direction["long_pct"]) > 0.7:
|
|
344
|
+
bias = "long" if direction["long_pct"] > 0 else "short"
|
|
345
|
+
report += f"**Strong {bias} bias** - Returns highly concentrated in {bias} positions.\n"
|
|
346
|
+
else:
|
|
347
|
+
report += "**Balanced exposure** - Returns distributed across both long and short positions.\n"
|
|
348
|
+
|
|
349
|
+
report += "\n### Signal Strength Attribution\n\n"
|
|
350
|
+
report += "| Quantile | P&L | Contribution |\n"
|
|
351
|
+
report += "|----------|-----|--------------|\n"
|
|
352
|
+
|
|
353
|
+
n_quantiles = result.config.attribution_quantiles
|
|
354
|
+
for i in range(1, n_quantiles + 1):
|
|
355
|
+
pnl = signal_strength[f"q{i}_pnl"]
|
|
356
|
+
pct = signal_strength[f"q{i}_pct"]
|
|
357
|
+
report += f"| Q{i} | {pnl:,.2f} | {pct:.1%} |\n"
|
|
358
|
+
|
|
359
|
+
report += "\n"
|
|
360
|
+
|
|
361
|
+
# Check if highest quantile contributed most
|
|
362
|
+
highest_q_pct = signal_strength[f"q{n_quantiles}_pct"]
|
|
363
|
+
if highest_q_pct > 0.4:
|
|
364
|
+
report += (
|
|
365
|
+
"**Strong signal strength relationship** - Highest conviction signals contributed "
|
|
366
|
+
f"most to returns ({highest_q_pct:.1%}).\n"
|
|
367
|
+
)
|
|
368
|
+
elif highest_q_pct < 0.2:
|
|
369
|
+
report += (
|
|
370
|
+
"**Weak signal strength relationship** - Returns not concentrated in highest "
|
|
371
|
+
"conviction signals. Signal strength may not add value.\n"
|
|
372
|
+
)
|
|
373
|
+
else:
|
|
374
|
+
report += "**Moderate signal strength relationship** - Mixed contribution across signal strengths.\n"
|
|
375
|
+
|
|
376
|
+
report += f"""
|
|
377
|
+
### Win/Loss Decomposition
|
|
378
|
+
|
|
379
|
+
| Category | Amount | Contribution |
|
|
380
|
+
|----------|--------|--------------|
|
|
381
|
+
| Gross Wins | {win_loss["gross_wins"]:,.2f} | {win_loss["win_contribution"]:.1%} |
|
|
382
|
+
| Gross Losses | {win_loss["gross_losses"]:,.2f} | {win_loss["loss_contribution"]:.1%} |
|
|
383
|
+
| Net P&L | {win_loss["net_pnl"]:,.2f} | — |
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Recommendations
|
|
388
|
+
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
# Generate recommendations based on metrics
|
|
392
|
+
recommendations = []
|
|
393
|
+
|
|
394
|
+
if result.stability_score < 0.5:
|
|
395
|
+
recommendations.append(
|
|
396
|
+
"[WARNING] **Low stability score** - Review strategy robustness and consider regime filters"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
if metrics.profit_factor < 1.0:
|
|
400
|
+
recommendations.append(
|
|
401
|
+
"[FAIL] **Negative profit factor** - Strategy is unprofitable; do not deploy"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
if subperiod["consistency_rate"] < 0.5:
|
|
405
|
+
recommendations.append(
|
|
406
|
+
"[WARNING] **Low consistency** - Performance concentrated in few periods; assess regime dependency"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
if max_dd_recovery == float("inf"):
|
|
410
|
+
recommendations.append(
|
|
411
|
+
"[WARNING] **Unrecovered drawdown** - Current strategy underwater; reassess viability"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
if metrics.tail_ratio < 0.8:
|
|
415
|
+
recommendations.append(
|
|
416
|
+
"[WARNING] **Negative skew** - Downside risk exceeds upside potential; review risk controls"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
if not recommendations:
|
|
420
|
+
recommendations.append(
|
|
421
|
+
"[PASS] **Performance acceptable** - Consider proceeding with further validation and stress testing"
|
|
422
|
+
)
|
|
423
|
+
recommendations.append(
|
|
424
|
+
"Next steps: comparative analysis against alternative signals/strategies"
|
|
425
|
+
)
|
|
426
|
+
recommendations.append(
|
|
427
|
+
"Recommended: transaction cost sensitivity analysis and regime-conditional performance review"
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
for rec in recommendations:
|
|
431
|
+
report += f"{rec}\n\n"
|
|
432
|
+
|
|
433
|
+
report += f"""
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## Report Metadata
|
|
437
|
+
|
|
438
|
+
**Generated:** {datetime.now().isoformat()}
|
|
439
|
+
**Evaluator:** aponyx.evaluation.performance
|
|
440
|
+
**Configuration:**
|
|
441
|
+
- Minimum Observations: {result.config.min_obs}
|
|
442
|
+
- Subperiods: {result.config.n_subperiods}
|
|
443
|
+
- Rolling Window: {result.config.rolling_window} days
|
|
444
|
+
- Attribution Quantiles: {result.config.attribution_quantiles}
|
|
445
|
+
|
|
446
|
+
**Reproducibility:** All metrics computed from backtest P&L with deterministic methods.
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
450
|
+
*This report was auto-generated from performance evaluation results.*
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
logger.debug(
|
|
454
|
+
"Generated performance report for %s/%s: %d characters",
|
|
455
|
+
signal_id,
|
|
456
|
+
strategy_id,
|
|
457
|
+
len(report),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Generate quantstats tearsheet if requested
|
|
461
|
+
if generate_tearsheet and output_dir is not None:
|
|
462
|
+
returns = result.metadata.get("returns")
|
|
463
|
+
benchmark = result.metadata.get("benchmark")
|
|
464
|
+
|
|
465
|
+
if returns is not None:
|
|
466
|
+
timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
467
|
+
tearsheet_filename = (
|
|
468
|
+
f"{signal_id}_{strategy_id}_{timestamp_str}_tearsheet.html"
|
|
469
|
+
)
|
|
470
|
+
tearsheet_path = Path(output_dir) / tearsheet_filename
|
|
471
|
+
title = f"{signal_id} - {strategy_id}"
|
|
472
|
+
|
|
473
|
+
generate_quantstats_tearsheet(
|
|
474
|
+
returns=returns,
|
|
475
|
+
benchmark=benchmark,
|
|
476
|
+
output_path=tearsheet_path,
|
|
477
|
+
title=title,
|
|
478
|
+
)
|
|
479
|
+
else:
|
|
480
|
+
logger.debug(
|
|
481
|
+
"Skipping tearsheet generation - returns not found in result.metadata"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
return report
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def save_report(
|
|
488
|
+
report: str,
|
|
489
|
+
signal_id: str,
|
|
490
|
+
strategy_id: str,
|
|
491
|
+
output_dir: Path,
|
|
492
|
+
timestamp: str | None = None,
|
|
493
|
+
) -> Path:
|
|
494
|
+
"""
|
|
495
|
+
Save report to Markdown file.
|
|
496
|
+
|
|
497
|
+
Parameters
|
|
498
|
+
----------
|
|
499
|
+
report : str
|
|
500
|
+
Markdown report text.
|
|
501
|
+
signal_id : str
|
|
502
|
+
Signal identifier (for filename).
|
|
503
|
+
strategy_id : str
|
|
504
|
+
Strategy identifier (for filename).
|
|
505
|
+
output_dir : Path
|
|
506
|
+
Directory to save report.
|
|
507
|
+
timestamp : str or None, optional
|
|
508
|
+
Timestamp string (YYYYMMDD_HHMMSS). If None, generates new timestamp.
|
|
509
|
+
|
|
510
|
+
Returns
|
|
511
|
+
-------
|
|
512
|
+
Path
|
|
513
|
+
Path to saved report file.
|
|
514
|
+
|
|
515
|
+
Notes
|
|
516
|
+
-----
|
|
517
|
+
Filename format: performance_analysis_{YYYYMMDD_HHMMSS}.md
|
|
518
|
+
Creates output directory if it doesn't exist.
|
|
519
|
+
|
|
520
|
+
Examples
|
|
521
|
+
--------
|
|
522
|
+
>>> from aponyx.config import PERFORMANCE_REPORTS_DIR
|
|
523
|
+
>>> path = save_report(report, "cdx_etf_basis", "simple_threshold", PERFORMANCE_REPORTS_DIR)
|
|
524
|
+
>>> print(path)
|
|
525
|
+
"""
|
|
526
|
+
# Create output directory
|
|
527
|
+
output_dir = Path(output_dir)
|
|
528
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
529
|
+
|
|
530
|
+
# Generate or use provided timestamp
|
|
531
|
+
if timestamp is None:
|
|
532
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
533
|
+
filename = f"performance_analysis_{timestamp}.md"
|
|
534
|
+
output_path = output_dir / filename
|
|
535
|
+
|
|
536
|
+
# Write report
|
|
537
|
+
output_path.write_text(report, encoding="utf-8")
|
|
538
|
+
|
|
539
|
+
logger.info("Saved performance report to %s", output_path)
|
|
540
|
+
|
|
541
|
+
return output_path
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Signal-product suitability evaluation module.
|
|
3
|
+
|
|
4
|
+
This module provides tools to evaluate whether signals contain economically
|
|
5
|
+
and statistically meaningful information for traded products. Acts as a
|
|
6
|
+
research screening gate before proceeding to strategy design and backtesting.
|
|
7
|
+
|
|
8
|
+
Public API
|
|
9
|
+
----------
|
|
10
|
+
- evaluate_signal_suitability: Main evaluation function
|
|
11
|
+
- SuitabilityConfig: Configuration dataclass
|
|
12
|
+
- SuitabilityResult: Result dataclass
|
|
13
|
+
- SuitabilityRegistry: Evaluation tracking with CRUD operations
|
|
14
|
+
- EvaluationEntry: Registry entry dataclass
|
|
15
|
+
- generate_suitability_report: Markdown report generator
|
|
16
|
+
- save_report: Report file persistence
|
|
17
|
+
- compute_forward_returns: Target construction utility
|
|
18
|
+
|
|
19
|
+
Examples
|
|
20
|
+
--------
|
|
21
|
+
>>> from aponyx.evaluation.suitability import (
|
|
22
|
+
... evaluate_signal_suitability,
|
|
23
|
+
... SuitabilityConfig,
|
|
24
|
+
... generate_suitability_report,
|
|
25
|
+
... save_report,
|
|
26
|
+
... SuitabilityRegistry,
|
|
27
|
+
... )
|
|
28
|
+
>>> from aponyx.config import EVALUATION_DIR, SUITABILITY_REGISTRY_PATH
|
|
29
|
+
>>>
|
|
30
|
+
>>> # Evaluate signal
|
|
31
|
+
>>> result = evaluate_signal_suitability(signal, target_change)
|
|
32
|
+
>>> print(result.decision, result.composite_score)
|
|
33
|
+
>>>
|
|
34
|
+
>>> # Generate report
|
|
35
|
+
>>> report = generate_suitability_report(result, "cdx_etf_basis", "cdx_ig_5y")
|
|
36
|
+
>>> report_path = save_report(report, "cdx_etf_basis", "cdx_ig_5y", EVALUATION_DIR)
|
|
37
|
+
>>>
|
|
38
|
+
>>> # Register evaluation
|
|
39
|
+
>>> registry = SuitabilityRegistry(SUITABILITY_REGISTRY_PATH)
|
|
40
|
+
>>> eval_id = registry.register_evaluation(result, "cdx_etf_basis", "cdx_ig_5y")
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from aponyx.evaluation.suitability.config import SuitabilityConfig
|
|
44
|
+
from aponyx.evaluation.suitability.evaluator import (
|
|
45
|
+
SuitabilityResult,
|
|
46
|
+
evaluate_signal_suitability,
|
|
47
|
+
compute_forward_returns,
|
|
48
|
+
)
|
|
49
|
+
from aponyx.evaluation.suitability.registry import (
|
|
50
|
+
SuitabilityRegistry,
|
|
51
|
+
EvaluationEntry,
|
|
52
|
+
)
|
|
53
|
+
from aponyx.evaluation.suitability.report import (
|
|
54
|
+
generate_suitability_report,
|
|
55
|
+
save_report,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"SuitabilityConfig",
|
|
60
|
+
"SuitabilityResult",
|
|
61
|
+
"evaluate_signal_suitability",
|
|
62
|
+
"compute_forward_returns",
|
|
63
|
+
"SuitabilityRegistry",
|
|
64
|
+
"EvaluationEntry",
|
|
65
|
+
"generate_suitability_report",
|
|
66
|
+
"save_report",
|
|
67
|
+
]
|