Skip to content

Commit fbd10da

Browse files
committed
fix: misc bugfixes for 0.0.78 release (#499, #501, #502)
- Fixed circular import errors that broke import quantstats - Fixed NameError in reports.full() - Fixed reports.html() to work without output file (opens in browser) - Fixed profit_ratio() DataFrame handling - Removed dark mode CSS from HTML report - Improved HTML report header (Compounded/matched dates conditionals) - Added new metrics to full report (UPI, RAR, Risk-Return Ratio, etc.) - Added terminal output parameters table - Added comprehensive test suite (125 tests)
1 parent ad298f6 commit fbd10da

File tree

14 files changed

+1423
-59
lines changed

14 files changed

+1423
-59
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ dist
1111
quantstats.egg-info
1212
QuantStats.egg-info
1313
Icon
14-
/tests
1514
.vscode
1615
Icon
1716
CLAUDE.md

CHANGELOG.md

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,28 @@ Changelog
44
0.0.81
55
------
66

7-
**Hotfix Release**
7+
**Bugfixes for 0.0.78 release**
88

9+
- Fixed circular import errors that broke `import quantstats` (#499, #501)
910
- Fixed `NameError: name 'dd_get_stats' is not defined` in `reports.full()` (#502)
10-
- Root cause: Accidental find-and-replace during v0.0.80 lazy import refactoring changed `dd_stats` to `dd_get_stats()`
11-
- Fix: Restored correct variable name `dd_stats` in `_calc_dd()` function
11+
- Fixed `reports.html()` to work without specifying output file (opens in browser)
12+
- Fixed `profit_ratio()` to handle DataFrame inputs properly
13+
- Removed dark mode CSS from HTML report template
14+
- Suppressed pandas FutureWarning for callable resampler.apply()
1215

13-
0.0.80
14-
------
15-
16-
**Hotfix Release**
16+
- Improved HTML report header:
17+
- Title shows "(Compounded)" only when compounded=True
18+
- Date range shows "(matched dates)" only when match_dates=True with benchmark
19+
- Parameters now always show Benchmark, Periods/Year, and RF rate
1720

18-
- Fixed circular import errors that broke `import quantstats` (#499, #501):
19-
- Root cause: Multiple modules imported `stats` and `utils` at module level during package initialization
20-
- Fix: Implemented lazy imports in `utils.py`, `reports.py`, `_plotting/core.py`, `_plotting/wrappers.py`
21-
- Removed erroneous `quantstats/stats.py` entry from `.gitignore` that excluded it from wheel builds
22-
- Import now works correctly on fresh installations
23-
24-
0.0.79
25-
------
21+
- Added new metrics to full HTML report:
22+
- Ulcer Performance Index, Risk-Adjusted Return, Risk-Return Ratio
23+
- Avg. Return, Avg. Win, Avg. Loss, Win/Loss Ratio, Profit Ratio
2624

27-
**Hotfix Release (Incomplete)**
25+
- Added terminal output parameters table for `reports.full()` and `reports.basic()`
2826

29-
- Attempted fix for circular import (#499), but additional circular imports remained
27+
- Added comprehensive test suite (125 tests):
28+
- Tests for stats, reports, utils, plots, compat, extend_pandas, and Monte Carlo
3029

3130
0.0.78
3231
------

quantstats/_compat.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,11 @@ def safe_resample(data: Union[pd.Series, pd.DataFrame],
161161
result = resampler.apply(func_name, **kwargs)
162162
else:
163163
# For callable functions, use apply method
164-
result = resampler.apply(func_name, **kwargs)
164+
# Suppress FutureWarning about callable usage - our use is intentional
165+
with warnings.catch_warnings():
166+
warnings.filterwarnings("ignore", category=FutureWarning,
167+
message=".*callable.*")
168+
result = resampler.apply(func_name, **kwargs)
165169

166170
# Normalize timezone to ensure consistent comparisons
167171
return normalize_timezone(result)

quantstats/report.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,10 @@
88
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
99
<title>Tearsheet (generated by QuantStats)</title>
1010
<meta name="robots" content="noindex, nofollow">
11-
<meta name="color-scheme" content="light dark">
1211
<link rel="shortcut icon" href="https://qtpylib.io/favicon.ico" type="image/x-icon">
1312
<style>
1413
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;margin:30px;background:#fff;color:#000}body,p,table,td,th{font:13px/1.4 Arial,sans-serif}.container{max-width:960px;margin:auto}img,svg{width:100%}h1,h2,h3,h4{font-weight:400;margin:0}h1 dt{display:inline;margin-left:10px;font-size:14px}h3{margin-bottom:10px;font-weight:700}h4{color:grey}h4 a{color:#09c;text-decoration:none}h4 a:hover{color:#069;text-decoration:underline}hr{margin:25px 0 40px;height:0;border:0;border-top:1px solid #ccc}#left{width:620px;margin-right:18px;margin-top:-1.2rem;float:left}#right{width:320px;float:right}#left svg{margin:-1.5rem 0}#monthly_heatmap{overflow:hidden}#monthly_heatmap svg{margin:-1.5rem 0}table{margin:0 0 40px;border:0;border-spacing:0;width:100%}table td,table th{text-align:right;padding:4px 5px 3px 5px}table th{text-align:right;padding:6px 5px 5px 5px}table td:first-of-type,table th:first-of-type{text-align:left;padding-left:2px}table td:last-of-type,table th:last-of-type{text-align:right;padding-right:2px}td hr{margin:5px 0}table th{font-weight:400}table thead th{font-weight:700;background:#eee}#eoy table td:after{content:"%"}#eoy table td:first-of-type:after,#eoy table td:last-of-type:after,#eoy table td:nth-of-type(4):after{content:""}#eoy table th{text-align:right}#eoy table th:first-of-type{text-align:left}#eoy table td:after{content:"%"}#eoy table td:first-of-type:after,#eoy table td:last-of-type:after{content:""}#ddinfo table td:nth-of-type(3):after{content:"%"}#ddinfo table th{text-align:right}#ddinfo table td:first-of-type,#ddinfo table td:nth-of-type(2),#ddinfo table th:first-of-type,#ddinfo table th:nth-of-type(2){text-align:left}#ddinfo table td:nth-of-type(3):after{content:"%"}
15-
@media (prefers-color-scheme:dark){body{background:#1a1a2e;color:#e6e6e6}h4{color:#b0b0b0}h4 a{color:#5dade2}h4 a:hover{color:#85c1e9}hr{border-top-color:#444}table thead th{background:#333}img,svg{background:#fff;border-radius:4px;padding:8px}}
14+
1615
@media print{hr{margin:25px 0}body{margin:0}.container{max-width:100%;margin:0}#left{width:55%;margin:0}#left svg{margin:0 0 -10%}#left svg:first-of-type{margin-top:-30%}#right{margin:0;width:45%}}
1716
</style>
1817
</head>

quantstats/reports.py

Lines changed: 88 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ def _get_plots():
5757
from dateutil.relativedelta import relativedelta
5858
from io import StringIO
5959
from pathlib import Path
60+
import tempfile
61+
import webbrowser
6062

6163
try:
6264
from IPython.display import display as iDisplay, HTML as iHTML
@@ -95,6 +97,44 @@ def _get_trading_periods(periods_per_year=252):
9597
return periods_per_year, half_year
9698

9799

100+
def _print_parameters_table(
101+
benchmark_title=None,
102+
periods_per_year=252,
103+
rf=0.0,
104+
compounded=True,
105+
match_dates=True,
106+
):
107+
"""
108+
Print a formatted parameters table for terminal/console output.
109+
110+
Parameters
111+
----------
112+
benchmark_title : str or None
113+
Benchmark name/ticker
114+
periods_per_year : int
115+
Number of trading periods per year
116+
rf : float
117+
Risk-free rate
118+
compounded : bool
119+
Whether returns are compounded
120+
match_dates : bool
121+
Whether dates are matched with benchmark
122+
"""
123+
width = 40
124+
print("=" * width)
125+
print(" Parameters")
126+
print("-" * width)
127+
if benchmark_title:
128+
print(f"{'Benchmark':<25}{benchmark_title.upper():>15}")
129+
print(f"{'Periods/Year':<25}{periods_per_year:>15}")
130+
print(f"{'Risk-Free Rate':<25}{rf:>14.1%}")
131+
print(f"{'Compounded':<25}{'Yes' if compounded else 'No':>15}")
132+
if benchmark_title:
133+
print(f"{'Match Dates':<25}{'Yes' if match_dates else 'No':>15}")
134+
print("=" * width)
135+
print()
136+
137+
98138
def _match_dates(returns, benchmark):
99139
"""
100140
Align returns and benchmark data to start from the same date.
@@ -203,15 +243,9 @@ def html(
203243
204244
Raises
205245
------
206-
ValueError
207-
If output is None and not running in notebook environment
208246
FileNotFoundError
209247
If custom template_path doesn't exist
210248
"""
211-
# Check if output parameter is required (not in notebook environment)
212-
if output is None and not _get_utils()._in_notebook():
213-
raise ValueError("`output` must be specified")
214-
215249
# Clean returns data by removing NaN values if date matching is enabled
216250
if match_dates:
217251
returns = returns.dropna()
@@ -290,10 +324,13 @@ def html(
290324
# Format date range for display in template
291325
date_range = returns.index.strftime("%e %b, %Y")
292326
tpl = tpl.replace("{{date_range}}", date_range[0] + " - " + date_range[-1])
293-
tpl = tpl.replace("{{title}}", title)
327+
328+
# Build title with compounding indicator (only show if compounded)
329+
full_title = f"{title} (Compounded)" if compounded else title
330+
tpl = tpl.replace("{{title}}", full_title)
294331
tpl = tpl.replace("{{v}}", __version__)
295332

296-
# Build parameters string for subtitle (feature #472)
333+
# Build parameters string for subtitle
297334
params_parts = []
298335

299336
# Add user-provided parameters first if present
@@ -302,15 +339,12 @@ def html(
302339
for key, value in user_params.items():
303340
params_parts.append(f"{key}: {value}")
304341

305-
# Add auto-detected parameters
342+
# Add auto-detected parameters (always show key params)
306343
if benchmark_title:
307344
params_parts.append(f"Benchmark: {benchmark_title.upper()}")
308-
if rf != 0:
309-
params_parts.append(f"RF: {rf:.1%}")
310-
if periods_per_year != 252:
311-
params_parts.append(f"Periods: {periods_per_year}")
312-
if not compounded:
313-
params_parts.append("Simple Returns")
345+
params_parts.append(f"Periods/Year: {periods_per_year}")
346+
params_parts.append(f"RF: {rf:.1%}")
347+
314348
params_str = " &bull; ".join(params_parts)
315349
if params_str:
316350
params_str += " | "
@@ -725,8 +759,16 @@ def html(
725759

726760
# Handle output - either download in browser or save to file
727761
if output is None:
728-
# _open_html(tpl)
729-
_download_html(tpl, download_filename)
762+
if _get_utils()._in_notebook():
763+
_download_html(tpl, download_filename)
764+
else:
765+
# Save to temp file and open in browser
766+
with tempfile.NamedTemporaryFile(
767+
mode="w", suffix=".html", delete=False, encoding="utf-8"
768+
) as f:
769+
f.write(tpl)
770+
temp_path = f.name
771+
webbrowser.open("file://" + temp_path)
730772
return
731773

732774
# Write HTML content to specified output file
@@ -888,6 +930,13 @@ def full(
888930
iDisplay(iHTML("<h4>Strategy Visualization</h4>"))
889931
else:
890932
# Display in console/terminal environment
933+
_print_parameters_table(
934+
benchmark_title=benchmark_title,
935+
periods_per_year=periods_per_year,
936+
rf=rf,
937+
compounded=compounded,
938+
match_dates=match_dates,
939+
)
891940
print("[Performance Metrics]\n")
892941
metrics(
893942
returns=returns,
@@ -1043,6 +1092,13 @@ def basic(
10431092
iDisplay(iHTML("<h4>Strategy Visualization</h4>"))
10441093
else:
10451094
# Display in console/terminal environment
1095+
_print_parameters_table(
1096+
benchmark_title=benchmark_title,
1097+
periods_per_year=periods_per_year,
1098+
rf=rf,
1099+
compounded=compounded,
1100+
match_dates=match_dates,
1101+
)
10461102
print("[Performance Metrics]\n")
10471103
metrics(
10481104
returns=returns,
@@ -1383,9 +1439,24 @@ def metrics(
13831439
metrics["Skew"] = _get_stats().skew(df, prepare_returns=False)
13841440
metrics["Kurtosis"] = _get_stats().kurtosis(df, prepare_returns=False)
13851441

1442+
# Additional ratios
1443+
metrics["Ulcer Performance Index"] = _get_stats().ulcer_performance_index(df, rf)
1444+
metrics["Risk-Adjusted Return %"] = _get_stats().rar(df, rf) * pct
1445+
metrics["Risk-Return Ratio"] = _get_stats().risk_return_ratio(df, prepare_returns=False)
1446+
13861447
# Add separator
13871448
metrics["~~~~~~~~~~"] = blank
13881449

1450+
# Average return metrics
1451+
metrics["Avg. Return %"] = _get_stats().avg_return(df, prepare_returns=False) * pct
1452+
metrics["Avg. Win %"] = _get_stats().avg_win(df, prepare_returns=False) * pct
1453+
metrics["Avg. Loss %"] = _get_stats().avg_loss(df, prepare_returns=False) * pct
1454+
metrics["Win/Loss Ratio"] = _get_stats().win_loss_ratio(df, prepare_returns=False)
1455+
metrics["Profit Ratio"] = _get_stats().profit_ratio(df, prepare_returns=False)
1456+
1457+
# Add separator
1458+
metrics["~~~~~~~~~~~"] = blank
1459+
13891460
# Expected returns at different frequencies
13901461
metrics["Expected Daily %%"] = (
13911462
_get_stats().expected_return(df, compounded=compounded, prepare_returns=False)

quantstats/stats.py

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2115,11 +2115,11 @@ def profit_ratio(returns, prepare_returns=True):
21152115
providing insight into the consistency of profitable periods.
21162116
21172117
Args:
2118-
returns (pd.Series): Return series to analyze
2118+
returns (pd.Series or pd.DataFrame): Return series to analyze
21192119
prepare_returns (bool): Whether to prepare returns first (default: True)
21202120
21212121
Returns:
2122-
float: Profit ratio
2122+
float or pd.Series: Profit ratio
21232123
21242124
Example:
21252125
>>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02])
@@ -2129,30 +2129,33 @@ def profit_ratio(returns, prepare_returns=True):
21292129
if prepare_returns:
21302130
returns = _utils._prepare_returns(returns)
21312131

2132-
# Separate wins and losses
2133-
wins = returns[returns >= 0]
2134-
loss = returns[returns < 0]
2132+
def _profit_ratio(ret):
2133+
# Separate wins and losses
2134+
wins = ret[ret >= 0]
2135+
loss = ret[ret < 0]
21352136

2136-
# Handle edge cases
2137-
if wins.count() == 0:
2138-
warn("No winning returns found for profit ratio calculation")
2139-
return 0.0
2140-
if loss.count() == 0:
2141-
warn("No losing returns found for profit ratio calculation, returning infinity")
2142-
return float('inf')
2137+
# Handle edge cases
2138+
win_count = len(wins)
2139+
loss_count = len(loss)
21432140

2144-
# Calculate win and loss ratios
2145-
win_ratio = abs(wins.mean() / wins.count()) if wins.count() > 0 else 0
2146-
loss_ratio = abs(loss.mean() / loss.count()) if loss.count() > 0 else 0
2141+
if win_count == 0:
2142+
return 0.0
2143+
if loss_count == 0:
2144+
return _np.nan
2145+
2146+
# Calculate win and loss ratios
2147+
win_ratio = abs(wins.mean() / win_count) if win_count > 0 else 0
2148+
loss_ratio = abs(loss.mean() / loss_count) if loss_count > 0 else 0
21472149

2148-
try:
21492150
if loss_ratio == 0:
2150-
warn("Loss ratio is zero, returning infinity for profit ratio")
2151-
return float('inf')
2151+
return _np.nan
21522152
return win_ratio / loss_ratio
2153-
except (ValueError, TypeError) as e:
2154-
warn(f"Error calculating profit ratio: {e}, returning 0.0")
2155-
return 0.0
2153+
2154+
# Handle DataFrame by applying to each column
2155+
if isinstance(returns, _pd.DataFrame):
2156+
return returns.apply(_profit_ratio)
2157+
2158+
return _profit_ratio(returns)
21562159

21572160

21582161
def profit_factor(returns, prepare_returns=True):

tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# QuantStats test suite

0 commit comments

Comments
 (0)