diff --git a/.claude/2026-modernization-plan.md b/.claude/2026-modernization-plan.md new file mode 100644 index 00000000..e677e5b7 --- /dev/null +++ b/.claude/2026-modernization-plan.md @@ -0,0 +1,228 @@ +# QuantStats 2026 Modernization Plan + +## Overview + +Modernize QuantStats for Python 3.10+ with modern tooling, better typing, and bug fixes. + +--- + +## 1. Python Version & Packaging + +### Drop Python 3.8/3.9 Support +- **Minimum**: Python 3.10 (or 3.11 for even cleaner code) +- **Benefits**: + - Native `X | Y` union types (no `Union[X, Y]`) + - `match` statements for cleaner branching + - Better error messages + - Structural pattern matching + - Parenthesized context managers + +### Migrate setup.py → pyproject.toml +```toml +[project] +name = "quantstats" +requires-python = ">=3.10" +dependencies = [ + "pandas>=2.0.0", + "numpy>=1.24.0", + ... +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +``` + +**Tasks:** +- [x] Create `pyproject.toml` with all metadata +- [x] Remove `setup.py` +- [x] Update classifiers for Python 3.10-3.13 only +- [x] Update `README.md` requirements section + +--- + +## 2. Dependency Updates + +| Current | Proposed | Why | +|---------|----------|-----| +| `pandas>=1.5.0` | `pandas>=2.0.0` | Better performance, CoW | +| `numpy>=1.21.0` | `numpy>=1.24.0` | NumPy 2.0 compat | +| `seaborn>=0.11.0` | `seaborn>=0.13.0` | Better defaults | +| `matplotlib>=3.3.0` | `matplotlib>=3.7.0` | Style improvements | +| `scipy>=1.7.0` | `scipy>=1.11.0` | Performance | + +**Optional modern deps:** +- `polars` (optional) for faster data processing +- `plotly>=5.0` for interactive plots + +--- + +## 3. Type Hints & Code Quality + +### Add Comprehensive Type Hints +Currently minimal typing. Add throughout: + +```python +# Before +def sharpe(returns, rf=0.0, periods=252, annualize=True, smart=False): + +# After +def sharpe( + returns: pd.Series | pd.DataFrame, + rf: float = 0.0, + periods: int = 252, + annualize: bool = True, + smart: bool = False, +) -> float | pd.Series: +``` + +**Tasks:** +- [x] Add type hints to key public functions in `stats.py` (sharpe, volatility, etc.) +- [ ] Add type hints to remaining functions in `stats.py` +- [ ] Add type hints to all public functions in `utils.py` +- [x] Add type hints to plotting functions +- [x] Add `py.typed` marker for PEP 561 +- [x] Configure `pyright` in CI (with continue-on-error) + +### Remove Legacy Patterns +- [ ] Remove `_pd`, `_np` import aliases (use `pd`, `np`) - optional, low priority +- [x] Remove `# -*- coding: UTF-8 -*-` (Python 3 default) +- [x] Fix deprecated IPython import + +--- + +## 4. Open Issues to Address + +### High Priority Bugs + +| # | Issue | Priority | Status | +|---|-------|----------|--------| +| 493 | Trade-analysis metrics computed from returns | HIGH | Documented (valid for returns) | +| 491 | Error generating HTML report with benchmark Series | HIGH | FIXED | +| 486 | reports.metrics vs reports.full inconsistency | HIGH | FIXED | +| 485 | Benchmark Omega always same as Strategy | HIGH | FIXED | +| 484 | make_index is incorrect | HIGH | FIXED | + +### Medium Priority + +| # | Issue | Priority | Status | +|---|-------|----------|--------| +| 481 | NaN in EOY Returns vs Benchmark | MEDIUM | FIXED | +| 480 | Inconsistent metrics benchmark vs return-only | MEDIUM | FIXED | +| 479 | EOY Returns vs Benchmark section issues | MEDIUM | FIXED | +| 475 | Double "%" in HTML report | MEDIUM | FIXED | +| 477 | Noisy variance warning messages | MEDIUM | FIXED | + +### Low Priority / Features + +| # | Issue | Priority | Status | +|---|-------|----------|--------| +| 492 | Chrome dark mode styling | LOW | FIXED | +| 489 | Underwater plot average drawdown | LOW | FIXED | +| 472 | Add parameters table to HTML report | FEATURE | FIXED | +| 473 | Mass closure communication | META | Open | + +--- + +## 5. Code Structure Improvements + +### Simplify Module Structure +``` +quantstats/ +├── __init__.py +├── stats.py # Core statistics +├── plots.py # Plotting wrappers +├── reports.py # Report generation +├── utils.py # Utilities +├── _montecarlo.py # Monte Carlo (new) +├── _plotting/ +│ ├── __init__.py +│ ├── core.py # Plot implementations +│ └── wrappers.py # Public API +└── py.typed # PEP 561 marker +``` + +### Remove Compat Layers (if dropping old Python) +- [ ] Review `_compat.py` - may be removable +- [ ] Review `_numpy_compat.py` - may be removable + +--- + +## 6. Testing & CI + +### Current: 179 tests passing + +### Improvements: +- [x] Add GitHub Actions workflow +- [x] Add type checking to CI (`pyright --warnings`) +- [x] Add coverage reporting (Codecov integration) +- [x] Add benchmarking tests for performance regression + +### Test Categories: +```yaml +# .github/workflows/test.yml +jobs: + test: + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] +``` + +--- + +## 7. Documentation + +- [ ] Keep README.md (done - converted from RST) +- [ ] Monte Carlo docs (done) +- [ ] Generate API docs with `mkdocs` or `pdoc` +- [ ] Add examples notebook + +--- + +## 8. Implementation Order + +### Phase 1: Foundation (Week 1) +1. Create `pyproject.toml` +2. Update Python version requirement +3. Update dependency versions +4. Remove `setup.py` + +### Phase 2: Bug Fixes (Week 2-3) +1. Fix #493 (trade metrics) +2. Fix #491 (HTML report benchmark) +3. Fix #486 (metrics inconsistency) +4. Fix #485 (Omega benchmark) +5. Fix #484 (make_index) + +### Phase 3: Modernization (Week 4) +1. Add type hints to stats.py +2. Add type hints to utils.py +3. Remove legacy patterns +4. Add `py.typed` + +### Phase 4: Polish (Week 5) +1. Fix remaining issues +2. Add GitHub Actions CI +3. Update all docs +4. Release v1.0.0 + +--- + +## 9. Breaking Changes + +Document these for CHANGELOG: + +1. **Python 3.10+ required** (drop 3.8, 3.9) +2. **pandas 2.0+ required** (drop 1.x) +3. Any function signature changes from bug fixes + +--- + +## 10. New Features (Post-Modernization) + +Ideas for future: +- [ ] Polars backend support +- [ ] Async data fetching +- [ ] Interactive Plotly reports +- [ ] PDF export +- [ ] Multi-strategy comparison reports diff --git a/.claude/PROJECT.md b/.claude/PROJECT.md new file mode 100644 index 00000000..0f71032a --- /dev/null +++ b/.claude/PROJECT.md @@ -0,0 +1,102 @@ +--- +updated: 2026-01-13T14:49:14Z +--- + +# QuantStats + +## Purpose +Portfolio analytics library for quants - calculates 50+ performance metrics (Sharpe, Sortino, max drawdown, VaR, etc.) and generates HTML tearsheet reports comparing strategies to benchmarks. + +## Domain Knowledge +- Returns can be simple (arithmetic) or log returns; most metrics use simple +- "Compounded" means geometric growth (1+r1)*(1+r2)... vs arithmetic sum +- Drawdown = peak-to-trough decline; max drawdown is worst historical decline +- Risk-free rate (rf) typically US Treasury; affects Sharpe/Sortino calculations +- Trading days: 252/year (US equities), 365 for crypto +- Monte Carlo: shuffling returns preserves distribution but varies paths +- **Period-based metrics**: win_rate, consecutive_wins, payoff_ratio analyze return periods (not discrete trades) + +## Conventions +- `_prepare_returns()` normalizes all input data before calculations +- `_compat.py` handles pandas 1.5+/2.0+ specifics (freq aliases, timezone normalization) +- `_numpy_compat.py` handles numpy 1.24+ specifics (product deprecation) +- Stats functions return scalar for Series input, Series for DataFrame input +- `extend_pandas()` adds methods directly to DataFrame/Series objects +- Monte Carlo functions return `MonteCarloResult` dataclass with properties + +## Current Focus +v0.0.78 released - 2026 modernization complete. +Remaining open: #493 (trade-based metrics - documented as period-based, not removing) + +## Recently Completed (v0.0.78) +- GitHub release v0.0.78 published +- 12 issues closed (#472, #475, #477, #479, #480, #481, #484, #485, #486, #489, #491, #492) +- 5 PRs closed (superseded by #494) +- PR #495 merged: README updated with period-based metrics clarification +- PR #483 merged: Dependabot - actions/checkout v4 → v6 +- Feature #472: Parameters table in HTML/text reports +- Type hints: Python 3.10+ union syntax throughout +- pyproject.toml migration (setup.py removed) +- Monte Carlo integration (stats, plots, pandas extension) +- pandas >=1.5.0 (supports both 1.x and 2.x) +- 104 tests passing + +## Project Structure + +``` +quantstats/ +├── __init__.py # Main exports (stats, plots, reports, utils) +├── stats.py # Statistical metrics (Sharpe, Sortino, drawdown, etc.) +├── plots.py # Visualization functions +├── reports.py # Report generation (HTML, metrics tables) +├── utils.py # Utility functions (data prep, download) +├── _compat.py # Pandas version compatibility layer +├── _numpy_compat.py # NumPy version compatibility layer +├── _montecarlo.py # Monte Carlo simulation module +├── version.py # Version string +└── report.html # HTML template for reports +``` + +## Development + +### Testing +```bash +pytest tests/ -v # Run all tests +pytest tests/test_stats.py -v # Run specific test file +pytest tests/ --cov=quantstats # Run with coverage +``` + +### Code Quality +```bash +ruff check quantstats/ # Lint +pyright quantstats/ # Type check +``` + +## Common Tasks + +### Adding a New Metric +1. Add function to `stats.py` with full type hints +2. Add tests to `tests/test_stats.py` +3. If shown in reports, update `reports.py` metrics dict + +### Updating Dependencies +1. Edit `pyproject.toml` dependencies section +2. Update README Requirements section to match +3. If pandas/numpy behavior changed, update `_compat.py` or `_numpy_compat.py` + +### Version Bump +1. Update `quantstats/version.py` +2. Update `CHANGELOG.md` +3. Create GitHub release with tag + +## Gotchas + +1. **yfinance MultiIndex**: When downloading data, use `.squeeze()` to flatten single-ticker results +2. **Frequency aliases**: Use `_compat.get_frequency_alias()` for pandas 2.2.0+ compatibility +3. **DataFrame vs Series**: Many stats functions handle both - check `isinstance()` and handle appropriately +4. **NaN handling**: Use `dropna()` before calculations, especially for CVaR/VaR + +## Notes +- Author: Ran Aroussi (same as yfinance) +- Target: Python 3.10+, pandas >=1.5.0, numpy >=1.24.0 +- PR #470 still open (Dependabot setup-python) - requires workflow scope to merge diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..3a06ead4 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 120 +exclude = .git,__pycache__,build,dist diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..ce2e2e9d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] + +# custom: ['https://paypal.me/ranaroussi'] +patreon: ranaroussi diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a04c486d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 00000000..9fe40e93 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,31 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/.github/workflows/test-compatibility.yml b/.github/workflows/test-compatibility.yml new file mode 100644 index 00000000..5f490846 --- /dev/null +++ b/.github/workflows/test-compatibility.yml @@ -0,0 +1,171 @@ +name: Test Pandas/Numpy Compatibility + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main, dev ] + +jobs: + test-compatibility: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + pandas-version: ['1.5.0', '2.0.0', '2.1.0', '2.2.0'] + numpy-version: ['1.21.0', '1.24.0', '1.26.0'] + exclude: + # Exclude incompatible combinations + - python-version: '3.12' + pandas-version: '1.5.0' + - python-version: '3.12' + numpy-version: '1.21.0' + - python-version: '3.8' + pandas-version: '2.2.0' + - python-version: '3.8' + numpy-version: '1.26.0' + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install specific pandas and numpy versions + run: | + python -m pip install --upgrade pip + pip install pandas==${{ matrix.pandas-version }} + pip install numpy==${{ matrix.numpy-version }} + pip install packaging + + - name: Install other dependencies + run: | + pip install seaborn>=0.11.0 + pip install matplotlib>=3.3.0 + pip install scipy>=1.7.0 + pip install tabulate>=0.8.9 + pip install yfinance>=0.2.18 + pip install python-dateutil>=2.8.0 + pip install pytest + + - name: Install quantstats in development mode + run: | + pip install -e . + + - name: Print versions + run: | + python -c "import pandas as pd; import numpy as np; print(f'pandas: {pd.__version__}, numpy: {np.__version__}')" + + - name: Run compatibility tests + run: | + python test_fixes.py + + - name: Run pytest compatibility tests + run: | + pytest tests/test_compatibility.py -v + + - name: Test frequency aliases + run: | + python -c " + import sys + sys.path.insert(0, '.') + from quantstats._compat import get_frequency_alias + print('M ->', get_frequency_alias('M')) + print('Q ->', get_frequency_alias('Q')) + print('Y ->', get_frequency_alias('Y')) + " + + - name: Test safe resample operations + run: | + python -c " + import sys + sys.path.insert(0, '.') + import pandas as pd + import numpy as np + from quantstats._compat import safe_resample + + # Create test data + dates = pd.date_range('2020-01-01', periods=100, freq='D') + data = pd.Series(np.random.randn(100), index=dates) + + # Test resample operations + monthly = safe_resample(data, 'M', 'sum') + quarterly = safe_resample(data, 'Q', 'mean') + yearly = safe_resample(data, 'Y', 'sum') + + print('Monthly periods:', len(monthly)) + print('Quarterly periods:', len(quarterly)) + print('Yearly periods:', len(yearly)) + print('All resample operations successful!') + " + + test-latest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python 3.12 with latest packages + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install latest versions + run: | + python -m pip install --upgrade pip + pip install pandas numpy + pip install packaging + + - name: Install other dependencies + run: | + pip install seaborn matplotlib scipy tabulate yfinance python-dateutil pytest + + - name: Install quantstats in development mode + run: | + pip install -e . + + - name: Print versions + run: | + python -c "import pandas as pd; import numpy as np; print(f'pandas: {pd.__version__}, numpy: {np.__version__}')" + + - name: Run compatibility tests with latest versions + run: | + python test_fixes.py + + - name: Run pytest compatibility tests + run: | + pytest tests/test_compatibility.py -v + + test-python-313: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pandas numpy packaging + pip install seaborn matplotlib scipy tabulate yfinance python-dateutil pytest + + - name: Install quantstats in development mode + run: | + pip install -e . + + - name: Print versions + run: | + python -c "import pandas as pd; import numpy as np; print(f'pandas: {pd.__version__}, numpy: {np.__version__}')" + + - name: Run compatibility tests with Python 3.13 + run: | + python test_fixes.py + + - name: Run pytest compatibility tests + run: | + pytest tests/test_compatibility.py -v \ No newline at end of file diff --git a/.gitignore b/.gitignore index c3604b2a..9b2a9377 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ dist quantstats.egg-info QuantStats.egg-info Icon -/tests .vscode Icon +CLAUDE.md +.DS_Store +.claude/settings.local.json diff --git a/.travis.yml b/.travis.yml index 0ab65565..ccda5558 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,9 +5,10 @@ fast_finish: true matrix: include: - - python: 3.5 - python: 3.6 - python: 3.7 + - python: 3.8 + - python: 3.9 dist: xenial sudo: true @@ -24,9 +25,10 @@ after_success: branches: only: - - master + - main notifications: + slack: tradologics:HcnS6XusfcuS02waQPCG18oc webhooks: urls: on_success: change # options: [always|never|change] default: always diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..11b36417 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,547 @@ +Changelog +=========== + +0.0.81 +------ + +**Bugfixes for 0.0.78 release** + +- Fixed circular import errors that broke `import quantstats` (#499, #501) +- Fixed `NameError: name 'dd_get_stats' is not defined` in `reports.full()` (#502) +- Fixed `reports.html()` to work without specifying output file (opens in browser) +- Fixed `profit_ratio()` to handle DataFrame inputs properly +- Removed dark mode CSS from HTML report template +- Suppressed pandas FutureWarning for callable resampler.apply() + +- Improved HTML report header: + - Title shows "(Compounded)" only when compounded=True + - Date range shows "(matched dates)" only when match_dates=True with benchmark + - Parameters now always show Benchmark, Periods/Year, and RF rate + +- Added new metrics to full HTML report: + - Ulcer Performance Index, Risk-Adjusted Return, Risk-Return Ratio + - Avg. Return, Avg. Win, Avg. Loss, Win/Loss Ratio, Profit Ratio + +- Added terminal output parameters table for `reports.full()` and `reports.basic()` + +- Added comprehensive test suite (125 tests): + - Tests for stats, reports, utils, plots, compat, extend_pandas, and Monte Carlo + +0.0.78 +------ + +**2026 Modernization Update** + +- Added comprehensive type hints to stats.py functions: + - All major public functions now have full type annotations + - Uses Python 3.10+ union syntax (`X | Y` instead of `Union[X, Y]`) + - Added `Returns` type alias for `pd.Series | pd.DataFrame` + - Functions typed include: `ghpr()`, `outliers()`, `remove_outliers()`, `best()`, `worst()`, + `consecutive_wins()`, `consecutive_losses()`, `exposure()`, `win_rate()`, `avg_return()`, + `avg_win()`, `avg_loss()`, `rolling_volatility()`, `implied_volatility()`, `autocorr_penalty()`, + `smart_sharpe()`, `rolling_sharpe()`, `smart_sortino()`, `rolling_sortino()`, `adjusted_sortino()`, + `probabilistic_ratio()`, `probabilistic_sharpe_ratio()`, and more + +- Simplified compatibility layers for Python 3.10+/pandas 2.0+/numpy 1.24+: + - _compat.py: Removed obsolete pandas < 1.0.0 and < 1.4.0 version checks + - _numpy_compat.py: Removed obsolete numpy < 1.17.0, < 1.21.0, and < 1.22.0 version checks + - Cleaner, more maintainable codebase for modern dependency versions + +0.0.77 +------ + +- Fixed issue #467 - CVaR calculation returning NaN for DataFrame inputs: + - The conditional_value_at_risk() function now properly handles DataFrame inputs + - When filtering DataFrames, NaN values are now correctly removed before calculating the mean + - CVaR calculations are now consistent between Series and DataFrame inputs + - This fix ensures accurate risk metrics in HTML reports when using benchmarks + +- Confirmed issue #468 is already resolved: + - The "mode.use_inf_as_null" pandas option error reported in v0.0.64 no longer occurs + - This issue was resolved in a previous version through updates to pandas compatibility + +0.0.76 +------ + +- Fixed issue #457 - Inconsistent benchmark EOY returns in reports: + - Benchmark yearly returns now remain consistent regardless of strategy's trading calendar + - HTML reports preserve original benchmark data for accurate EOY calculations + - Previously, benchmark returns would change when aligned to different strategies' trading days + - Added comprehensive tests to verify benchmark consistency across different comparisons + +- Improved timezone handling for cross-market comparisons: + - All resampling operations now normalize timezones to prevent comparison errors + - Mixed timezone-aware and timezone-naive data can now be compared without errors + - Data is converted to UTC then made timezone-naive for consistent comparisons + - Fixes "Cannot compare dtypes datetime64[ns] and datetime64[ns, UTC]" errors + +- Fixed FutureWarning for pandas pct_change(): + - Updated all pct_change() calls to use fill_method=None parameter + - Prevents "fill_method='pad' is deprecated" warnings in pandas 2.x + - Ensures compatibility with future pandas versions + +0.0.75 +------ + +- Fixed FutureWarning for deprecated pandas frequency aliases: + - Updated make_index default rebalance parameter from "1M" to "1ME" + - Ensures compatibility with pandas 2.2.0+ without warnings + - The _compat module already handles conversion for older pandas versions + +0.0.74 +------ + +- Completed fix for issue #463 - DataFrame handling in qs.reports functions: + - kelly_criterion: Fixed improper use of 'or' operator with Series values + - Now properly detects Series vs scalar inputs + - Handles zero and NaN values correctly for DataFrames + - recovery_factor: Added proper DataFrame input handling + - Detects when max_dd is a Series and handles accordingly + - Prevents "truth value of Series is ambiguous" errors + - All functions now tested with qs.reports.html() and qs.reports.metrics() with benchmarks + - Verified working with exact code examples from issue reporters + +0.0.73 +------ + +- Fixed payoff_ratio to handle DataFrame inputs properly (fixes issue #463) + - When using qs.reports.html with a benchmark, payoff_ratio receives a DataFrame + - Previously caused "ValueError: truth value of Series is ambiguous" + - Now properly handles both Series and scalar avg_loss values + - Returns Series for DataFrame inputs, scalar for Series inputs + +0.0.72 +------ + +- Fixed ValueError "truth value of Series is ambiguous" for DataFrame inputs in multiple stats functions: + - sortino: Properly handles Series downside deviation from DataFrame inputs + - outlier_win_ratio: Handles Series positive_mean calculations correctly + - outlier_loss_ratio: Handles Series negative_mean calculations correctly + - risk_return_ratio: Handles Series standard deviation properly + - ulcer_performance_index: Handles Series ulcer index values + - serenity_index: Handles Series std and denominator calculations + - gain_to_pain_ratio: Handles Series downside calculations + - All functions now properly return Series for DataFrame inputs and scalars for Series inputs + +0.0.71 +------ + +- Fixed RuntimeWarnings in tail_ratio function by properly handling edge cases: + - Handle divide by zero when lower quantile is 0 + - Handle invalid values when quantiles return NaN + - Handle DataFrame inputs that return Series from quantile operations + - Return NaN gracefully instead of triggering warnings + +- Added comprehensive divide by zero protection across multiple stats functions: + - gain_to_pain_ratio: Returns NaN when no negative returns (downside = 0) + - recovery_factor: Returns NaN when no drawdown (max_dd = 0) + - sortino: Returns NaN when downside deviation is 0 + - calmar: Returns NaN when max drawdown is 0 + - ulcer_performance_index: Returns NaN when ulcer index is 0 + - serenity_index: Returns NaN when std or denominator is 0 + - payoff_ratio: Returns NaN when average loss is 0 + - outlier_win_ratio: Returns NaN when no positive returns + - outlier_loss_ratio: Returns NaN when no negative returns + - risk_return_ratio: Returns NaN when standard deviation is 0 + - kelly_criterion: Returns NaN when win/loss ratio is 0 or NaN + - greeks: Returns NaN for beta when benchmark variance is 0 + - rolling_greeks: Handles zero benchmark std gracefully + - All functions now return NaN instead of triggering RuntimeWarnings + +0.0.70 +------ + +- Fixed chart naming inconsistency: renamed "Daily Active Returns" to "Daily Active Returns (Cumulative Sum)" and "Daily Returns" to "Daily Returns (Cumulative Sum)" to accurately reflect that charts show cumulative values (fixes issue #454) +- Fixed CAGR calculation bug where years were incorrectly calculated using calendar days instead of trading periods, causing drastically reduced CAGR values (fixes issue #458) +- Fixed inconsistent EOY returns for benchmarks by preserving original benchmark data for aggregation while aligning to strategy index for other calculations (fixes issue #457) + +0.0.69 +------ + +- Added `periods` parameter to `calmar()` function to support custom annualization periods (fixes issue #455) +- Updated reports.py to pass periods parameter to Calmar ratio calculation for consistency with other metrics + +0.0.68 +------ + +- Fixed ValueError when comparing Series with scalar in _get_baseline_value() function (fixes issue #448) +- Properly handle both DataFrame and Series inputs in drawdown calculations + +0.0.67 +------ + +- Added support for NumPy 2.0.0+ (fixes issue #445) + +0.0.66 +------ + +- Fixed bug with calculating drawdowns when first return is < 0 + +0.0.65 +------ +- Misc bug fixes + +0.0.64 +------ +**MAJOR RELEASE - Comprehensive Compatibility and Performance Improvements** + +**🔧 Major Fixes:** +- Fixed pandas resampling compatibility issues (UnsupportedFunctionCall errors) +- Added yfinance proxy configuration compatibility layer for all versions +- Implemented comprehensive pandas compatibility layer with frequency alias mapping (M→ME, Q→QE, A→YE) +- Fixed all pandas FutureWarnings related to chained assignment operations +- Added numpy compatibility layer for deprecated functions (np.product → np.prod) +- Replaced broad exception handling with specific exception types throughout codebase + +**📈 Performance Improvements:** +- Implemented LRU caching for _prepare_returns function (10-100x faster for repeated operations) +- Optimized autocorrelation calculations with vectorized numpy operations +- Improved rolling Sortino calculation performance and memory usage +- Optimized multi_shift memory usage with incremental concatenation +- Eliminated redundant dropna operations with intelligent caching +- Replaced inefficient iterrows usage with vectorized operations + +**🛡️ Reliability Improvements:** +- Added comprehensive input validation to public functions +- Implemented safe_resample compatibility function for all pandas versions +- Created robust error handling with custom exception classes +- Added safe_yfinance_download function handling proxy configuration changes +- Fixed matplotlib/seaborn compatibility issues and deprecation warnings + +**🎨 Visualization Fixes:** +- Updated seaborn compatibility (sns.set() → sns.set_theme()) +- Fixed legend handling in plotting functions +- Improved chart rendering with better error handling +- Fixed monthly heatmap display issues + +**📊 Technical Improvements:** +- Created comprehensive compatibility layer in _compat.py module +- Implemented frequency alias mapping for pandas version compatibility +- Added version detection and handling for pandas/numpy changes +- Enhanced data preparation pipeline with caching and validation + +**🚀 Overall Impact:** +- 10-100x performance improvement for large datasets +- 5-10x memory usage reduction +- Eliminated all pandas/numpy compatibility warnings +- Future-proofed against dependency updates +- Maintained full backward compatibility + +This release addresses 23+ community-reported issues and PRs, making QuantStats significantly faster, more reliable, and compatible with modern pandas/numpy versions. + +0.0.63 +------ +- Misc pd/np compatibility stuff + +0.0.62 +------ +- Changed `serenity_index` and `recovery_factor` to use simple sum instead of compounded sum +- Reports passing the `compounded` param to all supporting methods +- Fixed a bug related to monthly_heatmap display + +0.0.61 +------ +- Fixed positional arguments passed to cagr() + +0.0.60 +------ +- Multi-strategy reports! You can now pass a dataframe with a column for each strategy to get a unified, single report for all +- Support request proxy with yfinance +- Added custom periods to CAGR +- Correct drawdown days calculation when last day is a drawdown +- Write report in correct file path +- IPython 7+ compatibility +- Pandas 2.0 compatibility +- Fix for benchmark name when supplied by the user +- Handles tz-native and tz-aware comparisson issue +- Adding benchmark name to html report +- Update README ticker to META :) +- Many pull requests merged + + +0.0.59 +------ +- Fixed EOY compounded return calculation + +0.0.58 +------ +- Run fillna(0) on plot's beta (issue #193) + +0.0.57 +------ +- Fixed `sigma` calculation in `stats.probabilistic_ratio()` + +0.0.56 +------ +- Added option to explicitly provide the benchmark title via `benchmark_title=...` + +0.0.55 +------ +- Fix for benchmark name in html report when supplied by the user + +0.0.54 +------ +- Fixed dependency name in requirements.txt + + +0.0.53 +------ +- Added information ratio to reports + +0.0.52 +------ +- Added Treynor ratio + +0.0.51 +------ +- Added max consecutive wins/losses to full report +- Added “correlation to benchmark” to report +- Cleanup inf/nan from reports +- Added benchmark name to stats column and html report +- Added probabilistic sharpe/sortino ratios +- Fix relative dates calculations + +0.0.50 +------ +- Fixed a bug when reporting the max drawdown + +0.0.49 +------ +- Fixed an issue with saving the HTML report as a file + +0.0.48 +------ +- Fixed RF display bug + +0.0.47 +------ +- Fixed average DD display bug + +0.0.46 +------ +- Misc bug fixes and speedups + +0.0.45 +------ +- Fixed ``stats.rolling_sharpe()`` parameter mismatch + +0.0.44 +------ +- Match dates logic on ``utils.make_index()`` + +0.0.43 +------ +- Fixed ``stats.rolling_sortino()`` calculations +- Added ``match_dates`` flag to reports to make strategy and benchmark comparible by syncing their dates and frequency +- Added ``prepare_returns`` flag to ``utils._prepare_benchmark()`` +- Misc code cleanup and speedups + +0.0.42 +------ +- Usability improvements + +0.0.41 +------ +- Typos fixed + +0.0.40 +------ +- Added rebalance option to ``utils.make_index()`` +- Added option to add ``log_scale=True/False` to ``plots.snapshot()`` + +0.0.39 +------ +- Fixed ``plots.rolling_volatility()`` benchmark display (bug introduced in 0.0.37) + +0.0.38 +------ +- Added ``stats.smart_sharpe()`` and ``stats.smart_sortino()`` + +0.0.37 +------ +- added ``stats.rolling_sharpe()``, ``stats.rolling_sortino()``, ``stats.and rolling_volatility()`` +- Added ``stats.distribution()`` +- Added Omega ratio +- BREAKING CHANGE: Eenamed ``trading_year_days`` param to ``periods_per_year`` +- Misc code cleanup and speedups + +0.0.36 +------ +- Added ``as_pct`` params to ``reports.metrics()`` for when you need display data as DataFrame + +0.0.35 +------ +- Passing correct rolling windows in ``rolling_beta()`` +- Added Serenity Index +- Passing ``trading_year_days`` to method ``metrics`` +- Fixed "day is out of range for month" error + +0.0.34 +------ +- Fixed bug in ``stats.consecutive_wins()`` and ``stats.consecutive_losses()`` +- Fixed seaborn's depreated ``distplot`` warning +- Improved annualization by passing ``trading_year_days`` + +0.0.33 +------ +- Added option to pass the number of days per year in reports, so you can now use ``trading_year_days=365`` if you're trading crypto, or any other number for intl. markets. + +0.0.32 +------ +- Fixed bug in ``plot_histogram()`` (issues 94+95) + +0.0.31 +------ +- Enable period setting for adjusted sortino +- Added ``utils.make_index()`` for easy "etf" creation + +0.0.30 +------ +- Fixed PIP installer + +0.0.29 +------ +- Minor code refactoring + +0.0.28 +------ +- ``gain_to_pain`` renamed to ``gain_to_pain_ratio`` +- Minor code refactoring + +0.0.27 +------ +- Added Sortino/√2 and Gain/Pain ratio to report +- Merged PRs to fix some bugs + +0.0.26 +------ +- Misc bug fixes and code improvements + +0.0.25 +------ +- Fixed ``conditional_value_at_risk()`` +- Fixed ``%matplotlib inline`` issue notebooks + +0.0.24 +------ +- Added mtd/qtd/ytd methods for panda (usage: ``df.mtd()``) +- Fixed Pandas deprecation warning +- Fixed Matplotlib deprecation warning +- Try setting ``%matplotlib inline`` automatic in notebooks + +0.0.23 +------ +- Fixed profit Factor formula + +0.0.22 +------ +- Misc bug fixes + +0.0.21 +------ +- Fixed chart EOY chart's ``xticks`` when charting data with 10+ years +- Fixed issue where daily return >= 100% +- Fixed Snapshot plot +- Removed duplicaated code +- Added conda installer +- Misc code refactoring and optimizations + +0.0.20 +------ +- Misc bugfixes + +0.0.19 +------ +- Cleaning up data before calculations (replaces inf/-inf/-0 with 0) +- Removed usage of ``pandas.compound()`` for future ``pandas`` version compatibility +- Auto conversion of price-to-returns and returns-to-data as needed + +0.0.18 +------ +- Fixed issue when last date in data is in the past (issue #4) +- Fixed issue when data has less than 5 drawdown periods (issue #4) + +0.0.17 +------ +- Fixed CAGR calculation for more accuracy +- Handles drawdowns better in live trading mode when currently in drawdown + +0.0.16 +------ +- Handles no drawdowns better + +0.0.15 +------ +- Better report formatting +- Code cleanup + +0.0.14 +------ +- Fixed calculation for rolling sharpe and rolling sortino charts +- Nicer CSS when printing html reports + +0.0.13 +------ +- Fixed non-compounded plots in reports when using ``compounded=False`` + +0.0.12 +------ +- Option to add ``compounded=True/False`` to reports (default is ``True``) + +0.0.11 +------ +- Minor bug fixes + +0.0.10 +------ +- Updated to install and use ``yfinance`` instead of ``fix_yahoo_finance`` + +0.0.09 +------ +- Added support for 3 modes (cumulative, compounded, fixed amount) in ``plots.earnings()`` and ``utils.make_portfolio()`` +- Added two DataFrame utilities: ``df.curr_month()`` and ``df.date(date)`` +- Misc bug fixes and code refactoring + + +0.0.08 +------ +- Better calculations for cagr, var, cvar, avg win/loss and payoff_ratio +- Removed unused param from ``to_plotly()`` +- Added risk free param to ``log_returns()`` + renamed it to ``to_log_returns()`` +- Misc bug fixes and code improvements + +0.0.07 +------ +- Plots returns figure if ``show`` is set to False + +0.0.06 +------ +- Minor bug fix + +0.0.05 +------ +- Added ``plots.to_plotly()`` method +- Added Ulcer Index to metrics report +- Better returns/price detection +- Bug fixes and code refactoring + +0.0.04 +------ +- Added ``pct_rank()`` method to stats +- Added ``multi_shift()`` method to utils + +0.0.03 +------ +- Better VaR/cVaR calculation +- Fixed calculation of ``to_drawdown_series()`` +- Changed VaR/cVaR default confidence to 95% +- Improved Sortino formula +- Fixed conversion of returns to prices (``to_prices()``) + +0.0.02 +------ +- Initial release + +0.0.01 +------ +- Pre-release placeholder diff --git a/CHANGELOG.rst b/CHANGELOG.rst deleted file mode 100644 index 0f234610..00000000 --- a/CHANGELOG.rst +++ /dev/null @@ -1,126 +0,0 @@ -Change Log -=========== - -0.0.24 ------- -- Added mtd/qtd/ytd methods for panda (usage: `df.mtd()`) -- Fixed Pandas deprecation warning -- Fixed Matplotlib deprecation warning -- Try setting `%matplotlib inline` automatic in notebooks - -0.0.23 ------- -- Fixed profit Factor formula - -0.0.22 ------- -- Misc bug fixes - -0.0.21 ------- -- Fixed chart EOY chart's `xticks` when charting data with 10+ years -- Fixed issue where daily return >= 100% -- Fixed Snapshot plot -- Removed duplicaated code -- Added conda installer -- Misc code refactoring and optimizations - -0.0.20 ------- -- Misc bugfixes - -0.0.19 ------- -- Cleaning up data before calculations (replaces inf/-inf/-0 with 0) -- Removed usage of `pandas.compound()` for future `pandas` version compatibility -- Auto conversion of price-to-returns and returns-to-data as needed - -0.0.18 ------- -- Fixed issue when last date in data is in the past (issue #4) -- Fixed issue when data has less than 5 drawdown periods (issue #4) - -0.0.17 ------- -- Fixed CAGR calculation for more accuracy -- Handles drawdowns better in live trading mode when currently in drawdown - -0.0.16 ------- -- Handles no drawdowns better - -0.0.15 ------- -- Better report formatting -- Code cleanup - -0.0.14 ------- -- Fixed calculation for rolling sharpe and rolling sortino charts -- Nicer CSS when printing html reports - -0.0.13 ------- -- Fixed non-compounded plots in reports when using ``compounded=False`` - -0.0.12 ------- -- Option to add ``compounded=True/False`` to reports (default is ``True``) - -0.0.11 ------- -- Minor bug fixes - -0.0.10 ------- -- Updated to install and use ``yfinance`` instead of ``fix_yahoo_finance`` - -0.0.09 ------- -- Added support for 3 modes (cumulative, compounded, fixed amount) in ``plots.earnings()`` and ``utils.make_portfolio()`` -- Added two DataFrame utilities: ``df.curr_month()`` and ``df.date(date)`` -- Misc bug fixes and code refactoring - - -0.0.08 ------- -- Better calculations for cagr, var, cvar, avg win/loss and payoff_ratio -- Removed unused param from ``to_plotly()`` -- Added risk free param to ``log_returns()`` + renamed it to ``to_log_returns()`` -- Misc bug fixes and code improvements - -0.0.07 ------- -- Plots returns figure if ``show`` is set to False - -0.0.06 ------- -- Minor bug fix - -0.0.05 ------- -- Added ``plots.to_plotly()`` method -- Added Ulcer Index to metrics report -- Better returns/price detection -- Bug fixes and code refactoring - -0.0.04 ------- -- Added ``pct_rank()`` method to stats -- Added ``multi_shift()`` method to utils - -0.0.03 ------- -- Better VaR/cVaR calculation -- Fixed calculation of ``to_drawdown_series()`` -- Changed VaR/cVaR default confidence to 95% -- Improved Sortino formula -- Fixed conversion of returns to prices (``to_prices()``) - -0.0.02 ------- -- Initial release - -0.0.01 ------- -- Pre-release placeholder diff --git a/MANIFEST.in b/MANIFEST.in index 45af0ee2..81ff4949 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ # Include the license file include LICENSE.txt +include requirements.txt include quantstats/report.html # Include the data files diff --git a/README.md b/README.md new file mode 100644 index 00000000..6afa775c --- /dev/null +++ b/README.md @@ -0,0 +1,282 @@ +[![Python version](https://img.shields.io/badge/python-3.10+-blue.svg?style=flat)](https://pypi.python.org/pypi/quantstats) +[![PyPi version](https://img.shields.io/pypi/v/quantstats.svg?maxAge=60)](https://pypi.python.org/pypi/quantstats) +[![PyPi status](https://img.shields.io/pypi/status/quantstats.svg?maxAge=60)](https://pypi.python.org/pypi/quantstats) +[![PyPi downloads](https://img.shields.io/pypi/dm/quantstats.svg?maxAge=2592000&label=installs&color=%2327B1FF)](https://pypi.python.org/pypi/quantstats) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ranaroussi/quantstats) +[![Star this repo](https://img.shields.io/github/stars/ranaroussi/quantstats.svg?style=social&label=Star&maxAge=60)](https://github.com/ranaroussi/quantstats) +[![Follow me on twitter](https://img.shields.io/twitter/follow/aroussi.svg?style=social&label=Follow&maxAge=60)](https://twitter.com/aroussi) + +# QuantStats: Portfolio analytics for quants + +**QuantStats** Python library that performs portfolio profiling, allowing quants and portfolio managers to understand their performance better by providing them with in-depth analytics and risk metrics. + +[Changelog »](./CHANGELOG.md) + +### QuantStats is comprised of 3 main modules: + +1. `quantstats.stats` - for calculating various performance metrics, like Sharpe ratio, Win rate, Volatility, etc. +2. `quantstats.plots` - for visualizing performance, drawdowns, rolling statistics, monthly returns, etc. +3. `quantstats.reports` - for generating metrics reports, batch plotting, and creating tear sheets that can be saved as an HTML file. + +--- + +### **NEW! Monte Carlo Simulations** + +Monte Carlo Simulation + +Run probabilistic risk analysis with built-in Monte Carlo simulations: + +```python +mc = qs.stats.montecarlo(returns, sims=1000, bust=-0.20, goal=0.50) +print(f"Bust probability: {mc.bust_probability:.1%}") +print(f"Goal probability: {mc.goal_probability:.1%}") +mc.plot() +``` + +[Full Monte Carlo documentation »](./docs/montecarlo.md) + +--- + +## Quick Start + +```python +%matplotlib inline +import quantstats as qs + +# extend pandas functionality with metrics, etc. +qs.extend_pandas() + +# fetch the daily returns for a stock +stock = qs.utils.download_returns('META') + +# show sharpe ratio +qs.stats.sharpe(stock) + +# or using extend_pandas() :) +stock.sharpe() +``` + +Output: + +``` +0.7604779884378278 +``` + +### Visualize stock performance + +```python +qs.plots.snapshot(stock, title='Facebook Performance', show=True) + +# can also be called via: +# stock.plot_snapshot(title='Facebook Performance', show=True) +``` + +Output: + +![Snapshot plot](https://github.com/ranaroussi/quantstats/blob/main/docs/snapshot.webp?raw=true) + +### Creating a report + +You can create 7 different report tearsheets: + +1. `qs.reports.metrics(mode='basic|full", ...)` - shows basic/full metrics +2. `qs.reports.plots(mode='basic|full", ...)` - shows basic/full plots +3. `qs.reports.basic(...)` - shows basic metrics and plots +4. `qs.reports.full(...)` - shows full metrics and plots +5. `qs.reports.html(...)` - generates a complete report as html + +Let's create an html tearsheet: + +```python +# benchmark can be a pandas Series or ticker +qs.reports.html(stock, "SPY") +``` + +Output will generate something like this: + +![HTML tearsheet](https://github.com/ranaroussi/quantstats/blob/main/docs/report.webp?raw=true) + +[View original html file](https://rawcdn.githack.com/ranaroussi/quantstats/main/docs/tearsheet.html) + +### Available methods + +To view a complete list of available methods, run: + +```python +[f for f in dir(qs.stats) if f[0] != '_'] +``` + +```python +['avg_loss', + 'avg_return', + 'avg_win', + 'best', + 'cagr', + 'calmar', + 'common_sense_ratio', + 'comp', + 'compare', + 'compsum', + 'conditional_value_at_risk', + 'consecutive_losses', + 'consecutive_wins', + 'cpc_index', + 'cvar', + 'drawdown_details', + 'expected_return', + 'expected_shortfall', + 'exposure', + 'gain_to_pain_ratio', + 'geometric_mean', + 'ghpr', + 'greeks', + 'implied_volatility', + 'information_ratio', + 'kelly_criterion', + 'kurtosis', + 'max_drawdown', + 'monthly_returns', + 'montecarlo', + 'montecarlo_cagr', + 'montecarlo_drawdown', + 'montecarlo_sharpe', + 'outlier_loss_ratio', + 'outlier_win_ratio', + 'outliers', + 'payoff_ratio', + 'profit_factor', + 'profit_ratio', + 'r2', + 'r_squared', + 'rar', + 'recovery_factor', + 'remove_outliers', + 'risk_of_ruin', + 'risk_return_ratio', + 'rolling_greeks', + 'ror', + 'sharpe', + 'skew', + 'sortino', + 'adjusted_sortino', + 'tail_ratio', + 'to_drawdown_series', + 'ulcer_index', + 'ulcer_performance_index', + 'upi', + 'value_at_risk', + 'var', + 'volatility', + 'win_loss_ratio', + 'win_rate', + 'worst'] +``` + +```python +[f for f in dir(qs.plots) if f[0] != '_'] +``` + +```python +['daily_returns', + 'distribution', + 'drawdown', + 'drawdowns_periods', + 'earnings', + 'histogram', + 'log_returns', + 'monthly_heatmap', + 'montecarlo', + 'montecarlo_distribution', + 'returns', + 'rolling_beta', + 'rolling_sharpe', + 'rolling_sortino', + 'rolling_volatility', + 'snapshot', + 'yearly_returns'] +``` + +**\*\*\* Full documentation coming soon \*\*\*** + +### Important: Period-Based vs Trade-Based Metrics + +QuantStats analyzes **return series** (daily, weekly, monthly returns), not discrete trade data. This means: + +- **Win Rate** = percentage of periods with positive returns +- **Consecutive Wins/Losses** = consecutive positive/negative return periods +- **Payoff Ratio** = average winning period return / average losing period return +- **Profit Factor** = sum of positive returns / sum of negative returns + +These metrics are **valid and useful** for: +- Systematic/algorithmic strategies with regular rebalancing +- Analyzing return-series behavior over time +- Comparing strategies on a period-by-period basis + +For **discretionary traders** with multi-day trades, these period-based metrics may differ from trade-level statistics. A single 5-day trade might span 3 positive days and 2 negative days - QuantStats would count these as 3 "wins" and 2 "losses" at the daily level. + +This is consistent with how all return-based analytics work (Sharpe ratio, Sortino ratio, drawdown analysis, etc.) - they operate on return periods, not discrete trade entries/exits. + +--- + +In the meantime, you can get insights as to optional parameters for each method, by using Python's `help` method: + +```python +help(qs.stats.conditional_value_at_risk) +``` + +``` +Help on function conditional_value_at_risk in module quantstats.stats: + +conditional_value_at_risk(returns, sigma=1, confidence=0.99) + calculates the conditional daily value-at-risk (aka expected shortfall) + quantifies the amount of tail risk an investment +``` + +## Installation + +Install using `pip`: + +```bash +$ pip install quantstats --upgrade --no-cache-dir +``` + +Install using `conda`: + +```bash +$ conda install -c ranaroussi quantstats +``` + +## Requirements + +* [Python](https://www.python.org) >= 3.10 +* [pandas](https://github.com/pydata/pandas) >= 1.5.0 +* [numpy](http://www.numpy.org) >= 1.24.0 +* [scipy](https://www.scipy.org) >= 1.11.0 +* [matplotlib](https://matplotlib.org) >= 3.7.0 +* [seaborn](https://seaborn.pydata.org) >= 0.13.0 +* [tabulate](https://bitbucket.org/astanin/python-tabulate) >= 0.9.0 +* [yfinance](https://github.com/ranaroussi/yfinance) >= 0.2.40 +* [plotly](https://plot.ly/) >= 5.0.0 (optional, for using `plots.to_plotly()`) + +## Questions? + +This is a new library... If you find a bug, please +[open an issue](https://github.com/ranaroussi/quantstats/issues). + +If you'd like to contribute, a great place to look is the +[issues marked with help-wanted](https://github.com/ranaroussi/quantstats/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22). + +## Known Issues + +For some reason, I couldn't find a way to tell seaborn not to return the +monthly returns heatmap when instructed to save - so even if you save the plot (by passing `savefig={...}`) it will still show the plot. + +## Legal Stuff + +**QuantStats** is distributed under the **Apache Software License**. See the [LICENSE.txt](./LICENSE.txt) file in the release for details. + +## P.S. + +Please drop me a note with any feedback you have. + +**Ran Aroussi** diff --git a/README.rst b/README.rst deleted file mode 100644 index 21927504..00000000 --- a/README.rst +++ /dev/null @@ -1,289 +0,0 @@ -.. image:: https://img.shields.io/badge/python-3.5+-blue.svg?style=flat - :target: https://pypi.python.org/pypi/quantstats - :alt: Python version - -.. image:: https://img.shields.io/pypi/v/quantstats.svg?maxAge=60 - :target: https://pypi.python.org/pypi/quantstats - :alt: PyPi version - -.. image:: https://img.shields.io/pypi/status/quantstats.svg?maxAge=60 - :target: https://pypi.python.org/pypi/quantstats - :alt: PyPi status - -.. image:: https://img.shields.io/travis/ranaroussi/quantstats/master.svg?maxAge=1 - :target: https://travis-ci.org/ranaroussi/quantstats - :alt: Travis-CI build status - -.. image:: https://img.shields.io/pypi/dm/quantstats.svg?maxAge=2592000&label=installs&color=%2327B1FF - :target: https://pypi.python.org/pypi/quantstats - :alt: PyPi downloads - -.. image:: https://www.codefactor.io/repository/github/ranaroussi/quantstats/badge - :target: https://www.codefactor.io/repository/github/ranaroussi/quantstats - :alt: CodeFactor - -.. image:: https://img.shields.io/github/stars/ranaroussi/quantstats.svg?style=social&label=Star&maxAge=60 - :target: https://github.com/ranaroussi/quantstats - :alt: Star this repo - -.. image:: https://img.shields.io/twitter/follow/aroussi.svg?style=social&label=Follow&maxAge=60 - :target: https://twitter.com/aroussi - :alt: Follow me on twitter - -\ - -QuantStats: Portfolio analytics for quants -========================================== - -**QuantStats** Python library that performs portfolio profiling, allowing quants and portfolio managers to understand their performance better by providing them with in-depth analytics and risk metrics. - -`Changelog » <./CHANGELOG.rst>`__ - -QuantStats is comprised of 3 main modules: -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -1. ``quantstats.stats`` - for calculating various performance metrics, like Sharpe ratio, Win rate, Volatility, etc. -2. ``quantstats.plots`` - for visualizing performance, drawdowns, rolling statistics, monthly returns, etc. -3. ``quantstats.reports`` - for generating metrics reports, batch plotting, and creating tear sheets that can be saved as an HTML file. - -Here's an example of a simple tear sheet analyzing a strategy: - -Quick Start -=========== - -.. code:: python - - %matplotlib inline - import quantstats as qs - - # extend pandas functionality with metrics, etc. - qs.extend_pandas() - - # fetch the daily returns for a stock - stock = qs.utils.download_returns('FB') - - # show sharpe ratio - qs.stats.sharpe(stock) - - # or using extend_pandas() :) - stock.sharpe() - -Output: - -.. code:: text - - 0.8135304438803402 - - -Visualize stock performance -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - qs.plots.snapshot(stock, title='Facebook Performance') - - # can also be called via: - # stock.plot_snapshot(title='Facebook Performance') - -Output: - -.. image:: https://raw.githubusercontent.com/ranaroussi/quantstats/dev/docs/snapshot.jpg - :alt: Snapshot plot - - -Creating a report -~~~~~~~~~~~~~~~~~ - -You can create 7 different report tearsheets: - -1. ``qs.reports.metrics(mode='basic|full", ...)`` - shows basic/full metrics -2. ``qs.reports.plots(mode='basic|full", ...)`` - shows basic/full plots -3. ``qs.reports.basic(...)`` - shows basic metrics and plots -4. ``qs.reports.full(...)`` - shows full metrics and plots -5. ``qs.reports.html(...)`` - generates a complete report as html - -Let' create an html tearsheet - -.. code:: python - - (benchmark can be a pandas Series or ticker) - qs.reports.html(stock, "SPY") - -Output will generate something like this: - -.. image:: https://raw.githubusercontent.com/ranaroussi/quantstats/dev/docs/report.jpg - :alt: HTML tearsheet - -(`view original html file `_) - - -To view a complete list of available methods, run -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - [f for f in dir(qs.stats) if f[0] != '_'] - - -.. code:: text - - ['avg_loss', - 'avg_return', - 'avg_win', - 'best', - 'cagr', - 'calmar', - 'common_sense_ratio', - 'comp', - 'compare', - 'compsum', - 'conditional_value_at_risk', - 'consecutive_losses', - 'consecutive_wins', - 'cpc_index', - 'cvar', - 'drawdown_details', - 'expected_return', - 'expected_shortfall', - 'exposure', - 'gain_to_pain_ratio', - 'geometric_mean', - 'ghpr', - 'greeks', - 'implied_volatility', - 'information_ratio', - 'kelly_criterion', - 'kurtosis', - 'max_drawdown', - 'monthly_returns', - 'outlier_loss_ratio', - 'outlier_win_ratio', - 'outliers', - 'payoff_ratio', - 'profit_factor', - 'profit_ratio', - 'r2', - 'r_squared', - 'rar', - 'recovery_factor', - 'remove_outliers', - 'risk_of_ruin', - 'risk_return_ratio', - 'rolling_greeks', - 'ror', - 'sharpe', - 'skew', - 'sortino', - 'tail_ratio', - 'to_drawdown_series', - 'ulcer_index', - 'ulcer_performance_index', - 'upi', - 'utils', - 'value_at_risk', - 'var', - 'volatility', - 'win_loss_ratio', - 'win_rate', - 'worst'] - -.. code:: python - - [f for f in dir(qs.plots) if f[0] != '_'] - -.. code:: text - - ['daily_returns', - 'distribution', - 'drawdown', - 'drawdowns_periods', - 'earnings', - 'histogram', - 'log_returns', - 'monthly_heatmap', - 'returns', - 'rolling_beta', - 'rolling_sharpe', - 'rolling_sortino', - 'rolling_volatility', - 'snapshot', - 'yearly_returns'] - - -**\*\*\* Full documenttion coming soon \*\*\*** - -In the meantime, you can get insights as to optional parameters for each method, by using Python's ``help`` method: - -.. code:: python - - help(qs.stats.conditional_value_at_risk) - -.. code:: text - - Help on function conditional_value_at_risk in module quantstats.stats: - - conditional_value_at_risk(returns, sigma=1, confidence=0.99) - calculats the conditional daily value-at-risk (aka expected shortfall) - quantifies the amount of tail risk an investment - - -Installation ------------- - -Install using ``pip``: - -.. code:: bash - - $ pip install quantstats --upgrade --no-cache-dir - - -Install using ``conda``: - -.. code:: bash - - $ conda install -c ranaroussi quantstats - - -Requirements ------------- - -* `Python `_ >= 3.5+ -* `pandas `_ (tested to work with >=0.24.0) -* `numpy `_ >= 1.15.0 -* `scipy `_ >= 1.2.0 -* `matplotlib `_ >= 3.0.0 -* `seaborn `_ >= 0.9.0 -* `tabulate `_ >= 0.8.0 -* `yfinance `_ >= 0.1.38 -* `plotly `_ >= 3.4.1 (optional, for using ``plots.to_plotly()``) - -Questions? ----------- - -This is a new library... If you find a bug, please -`open an issue `_ -in this repository. - -If you'd like to contribute, a great place to look is the -`issues marked with help-wanted `_. - - -Known Issues ------------- - -For some reason, I couldn't find a way to tell seaborn not to return the -monthly returns heatmap when instructed to save - so even if you save the plot (by passing ``savefig={...}``) it will still show the plot. - - -Legal Stuff ------------- - -**QuantStats** is distributed under the **Apache Software License**. See the `LICENSE.txt <./LICENSE.txt>`_ file in the release for details. - - -P.S. ------------- - -Please drop me a note with any feedback you have. - -**Ran Aroussi** diff --git a/docs/montecarlo.md b/docs/montecarlo.md new file mode 100644 index 00000000..eb8d32cc --- /dev/null +++ b/docs/montecarlo.md @@ -0,0 +1,211 @@ +# Monte Carlo Simulations + +Monte Carlo Simulation Demo + +QuantStats includes built-in [Monte Carlo simulation](https://en.wikipedia.org/wiki/Monte_Carlo_method) +capabilities for probabilistic risk analysis. Run thousands of simulations by shuffling +historical returns to understand the range of possible outcomes for your strategy. + +## Quick Start + +```python +import quantstats as qs + +# fetch daily returns +returns = qs.utils.download_returns('SPY') + +# run Monte Carlo simulation with 1000 paths +mc = qs.stats.montecarlo(returns, sims=1000, seed=42) + +# view terminal value statistics +print(mc.stats) +``` + +Output: + +```python +{ + 'min': -0.15, + 'max': 0.85, + 'mean': 0.32, + 'median': 0.31, + 'std': 0.18, + 'percentile_5': 0.05, + 'percentile_95': 0.62 +} +``` + +## Bust & Goal Probabilities + +Calculate the probability of hitting a drawdown threshold (bust) or +reaching a return goal: + +```python +# Set bust threshold at -20% drawdown, goal at +50% return +mc = qs.stats.montecarlo(returns, sims=1000, bust=-0.20, goal=0.50, seed=42) + +print(f"Probability of bust: {mc.bust_probability:.1%}") +print(f"Probability of reaching goal: {mc.goal_probability:.1%}") +``` + +Output: + +``` +Probability of bust: 23.4% +Probability of reaching goal: 67.8% +``` + +## Max Drawdown Distribution + +Analyze the distribution of maximum drawdowns across all simulations: + +```python +print(mc.maxdd) +``` + +Output: + +```python +{ + 'min': -0.45, # worst drawdown across all sims + 'max': -0.05, # best (smallest) drawdown + 'mean': -0.18, + 'median': -0.16, + 'std': 0.08, + 'percentile_5': -0.35, + 'percentile_95': -0.08 +} +``` + +## Confidence Bands + +Get confidence intervals for the simulation paths: + +```python +# 95% confidence band +lower, upper = mc.confidence_band(0.95) + +# specific percentile path +p10 = mc.percentile(10) # 10th percentile path +p90 = mc.percentile(90) # 90th percentile path +``` + +## Visualization + +**Plot all simulation paths:** + +```python +qs.plots.montecarlo(returns, sims=500, seed=42) + +# or from an existing result +mc.plot() +``` + +**Plot terminal value distribution:** + +```python +qs.plots.montecarlo_distribution(returns, sims=500, seed=42) +``` + +## Pandas Extension + +After calling `qs.extend_pandas()`, you can use Monte Carlo directly on Series: + +```python +qs.extend_pandas() + +# run simulation directly on a returns Series +mc = returns.montecarlo(sims=1000, bust=-0.15, goal=0.30) + +# plot directly +returns.plot_montecarlo(sims=500) +``` + +## Sharpe, Drawdown & CAGR Distributions + +Get distributions of key metrics across simulations: + +```python +# Sharpe ratio distribution +sharpe_dist = qs.stats.montecarlo_sharpe(returns, sims=1000) +print(f"Sharpe range: {sharpe_dist['percentile_5']:.2f} to {sharpe_dist['percentile_95']:.2f}") + +# Max drawdown distribution +dd_dist = qs.stats.montecarlo_drawdown(returns, sims=1000) +print(f"Drawdown range: {dd_dist['percentile_5']:.1%} to {dd_dist['percentile_95']:.1%}") + +# CAGR distribution +cagr_dist = qs.stats.montecarlo_cagr(returns, sims=1000) +print(f"CAGR range: {cagr_dist['percentile_5']:.1%} to {cagr_dist['percentile_95']:.1%}") +``` + +## Access Raw Data + +The raw simulation data is available as a DataFrame: + +```python +print(mc.data.head()) +``` + +``` + sim_0 sim_1 sim_2 sim_3 sim_4 ... +0 0.000000 0.017745 -0.002586 -0.005346 -0.042107 ... +1 0.002647 0.017795 -0.002398 0.004795 -0.034664 ... +2 0.003351 0.020711 0.002926 0.004868 -0.037902 ... +3 0.007572 0.029275 0.004323 0.012818 -0.044294 ... +4 0.010900 0.028764 0.009446 0.026309 -0.049399 ... +``` + +## How It Works + +Monte Carlo simulation in QuantStats uses **return shuffling**: + +1. Take your historical returns +2. Randomly shuffle the order of returns (preserving the distribution) +3. Calculate cumulative returns for each shuffled path +4. Repeat for N simulations + +This approach: + +- Preserves the exact return distribution of your data +- Breaks any time-series dependencies (autocorrelation) +- Shows the range of outcomes if returns occurred in different orders +- Helps quantify luck vs. skill in your strategy's performance + +> **Note:** Because shuffling preserves the product of all (1+r) values, the terminal +> value is the same across all simulations. What differs is the *path* taken to +> get there, which affects drawdowns, Sharpe ratios, and other path-dependent metrics. + +## API Reference + +### `qs.stats.montecarlo(returns, sims=1000, bust=None, goal=None, seed=None)` + +Run Monte Carlo simulation on returns. + +**Parameters:** +- `returns`: pd.Series of daily returns +- `sims`: Number of simulations (default: 1000) +- `bust`: Drawdown threshold for bust probability (e.g., -0.20) +- `goal`: Return threshold for goal probability (e.g., 0.50) +- `seed`: Random seed for reproducibility + +**Returns:** `MonteCarloResult` object + +### MonteCarloResult properties + +| Property | Description | +|----------|-------------| +| `data` | DataFrame with all simulation paths | +| `original` | Series with original cumulative returns | +| `stats` | Dict with terminal value statistics | +| `maxdd` | Dict with max drawdown statistics | +| `bust_probability` | Float (if bust threshold set) | +| `goal_probability` | Float (if goal threshold set) | + +### MonteCarloResult methods + +| Method | Description | +|--------|-------------| +| `percentile(p)` | Get p-th percentile path | +| `confidence_band(level)` | Get (lower, upper) confidence bounds | +| `plot(**kwargs)` | Plot simulation paths | diff --git a/docs/report.jpg b/docs/report.jpg deleted file mode 100644 index 44841f35..00000000 Binary files a/docs/report.jpg and /dev/null differ diff --git a/docs/report.webp b/docs/report.webp new file mode 100644 index 00000000..978d90b7 Binary files /dev/null and b/docs/report.webp differ diff --git a/docs/snapshot.jpg b/docs/snapshot.jpg deleted file mode 100644 index 0dcccb71..00000000 Binary files a/docs/snapshot.jpg and /dev/null differ diff --git a/docs/snapshot.webp b/docs/snapshot.webp new file mode 100644 index 00000000..d033e685 Binary files /dev/null and b/docs/snapshot.webp differ diff --git a/docs/tearsheet.html b/docs/tearsheet.html index 702f80cb..579fcec5 100644 --- a/docs/tearsheet.html +++ b/docs/tearsheet.html @@ -1 +1 @@ - Tearsheet (generated by QuantStats for Python)

Facebook Buy & Hold "Strategy"
18 May, 2012 - 7 May, 2019

Generated by QuantStats for Python (v. 0.0.01)


\ No newline at end of file + Tearsheet (generated by QuantStats)

Strategy Tearsheet
15 Jul, 2015 - 11 Jul, 2025

Benchmark is SPY | Generated by QuantStats (v. 0.0.64)


2025-07-14T02:38:41.732729 image/svg+xml Matplotlib v3.10.0, https://matplotlib.org/
2025-07-14T02:38:41.809199 image/svg+xml Matplotlib v3.10.0, https://matplotlib.org/
2025-07-14T02:38:41.888186 image/svg+xml Matplotlib v3.10.0, https://matplotlib.org/
2025-07-14T02:38:41.972673 image/svg+xml Matplotlib v3.10.0, https://matplotlib.org/
2025-07-14T02:38:42.102446 image/svg+xml Matplotlib v3.10.0, https://matplotlib.org/
2025-07-14T02:38:42.203838 image/svg+xml Matplotlib v3.10.0, https://matplotlib.org/
2025-07-14T02:38:42.360534 image/svg+xml Matplotlib v3.10.0, https://matplotlib.org/
2025-07-14T02:38:42.448610 image/svg+xml Matplotlib v3.10.0, https://matplotlib.org/
2025-07-14T02:38:42.519030 image/svg+xml Matplotlib v3.10.0, https://matplotlib.org/
2025-07-14T02:38:42.588096 image/svg+xml Matplotlib v3.10.0, https://matplotlib.org/
2025-07-14T02:38:42.687049 image/svg+xml Matplotlib v3.10.0, https://matplotlib.org/
2025-07-14T02:38:42.750953 image/svg+xml Matplotlib v3.10.0, https://matplotlib.org/
2025-07-14T02:38:43.228373 image/svg+xml Matplotlib v3.10.0, https://matplotlib.org/
2025-07-14T02:38:43.400607 image/svg+xml Matplotlib v3.10.0, https://matplotlib.org/
\ No newline at end of file diff --git a/meta.yaml b/meta.yaml deleted file mode 100644 index ddb038a6..00000000 --- a/meta.yaml +++ /dev/null @@ -1,61 +0,0 @@ -{% set name = "QuantStats" %} -{% set version = "0.0.22" %} - -package: - name: "{{ name|lower }}" - version: "{{ version }}" - -source: - url: "https://pypi.io/packages/source/{{ name[0] }}/{{ name }}/{{ name }}-{{ version }}.tar.gz" - sha256: "c445478da26bedf5517c61272b4b49d834f72c600740951f36d02424aba321c8" - -build: - noarch: python - number: 0 - script: "{{ PYTHON }} -m pip install . --no-deps --ignore-installed -vv " - -requirements: - host: - - matplotlib >=3.0.0 - - numpy >=1.15.0 - - pandas >=0.24.0 - - pip - - python - - scipy >=1.2.0 - - seaborn >=0.9.0 - - tabulate >=0.8.0 - - yfinance >=0.1.44 - run: - - matplotlib >=3.0.0 - - numpy >=1.15.0 - - pandas >=0.24.0 - - python - - scipy >=1.2.0 - - seaborn >=0.9.0 - - tabulate >=0.8.0 - - yfinance >=0.1.44 - -test: - imports: - - quantstats - - quantstats._plotting - -about: - home: "https://github.com/ranaroussi/quantstats" - license: "Apache Software" - license_family: "APACHE" - license_file: "" - summary: "QuantStats: Portfolio analytics for quants" - description: | - QuantStats Python library that performs portfolio profiling, - allowing quants and portfolio managers to understand their - performance better by providing them with in-depth analytics - and risk metrics. - doc_url: "https://github.com/ranaroussi/quantstats" - dev_url: "https://pypi.python.org/pypi/quantstats" - doc_source_url: https://github.com/ranaroussi/quantstats/blob/master/README.rst - - -extra: - recipe-maintainers: - - ranaroussi diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..d3d98dbb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,124 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "quantstats" +dynamic = ["version"] +description = "Portfolio analytics for quants" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "Ran Aroussi", email = "ran@aroussi.com" }, +] +keywords = [ + "quant", + "algotrading", + "algorithmic-trading", + "quantitative-trading", + "quantitative-analysis", + "algo-trading", + "visualization", + "plotting", + "portfolio", + "finance", +] +classifiers = [ + "License :: OSI Approved :: Apache Software License", + "Development Status :: 5 - Production/Stable", + "Operating System :: OS Independent", + "Intended Audience :: Developers", + "Intended Audience :: Financial and Insurance Industry", + "Intended Audience :: Science/Research", + "Topic :: Office/Business :: Financial", + "Topic :: Office/Business :: Financial :: Investment", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Information Analysis", + "Topic :: Scientific/Engineering :: Mathematics", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "pandas>=1.5.0", + "numpy>=1.24.0", + "scipy>=1.11.0", + "matplotlib>=3.7.0", + "seaborn>=0.13.0", + "tabulate>=0.9.0", + "yfinance>=0.2.40", + "python-dateutil>=2.8.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "pyright>=1.1.0", + "ruff>=0.1.0", +] +plotly = [ + "plotly>=5.0.0", +] + +[project.urls] +Homepage = "https://github.com/ranaroussi/quantstats" +Documentation = "https://github.com/ranaroussi/quantstats" +Repository = "https://github.com/ranaroussi/quantstats" +Changelog = "https://github.com/ranaroussi/quantstats/blob/main/CHANGELOG.md" + +[tool.hatch.version] +path = "quantstats/version.py" +pattern = 'version = "(?P[^"]+)"' + +[tool.hatch.build.targets.sdist] +include = [ + "/quantstats", + "/README.md", + "/CHANGELOG.md", + "/LICENSE.txt", +] + +[tool.hatch.build.targets.wheel] +packages = ["quantstats"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --tb=short" + +[tool.pyright] +include = ["quantstats"] +exclude = ["tests"] +pythonVersion = "3.10" +typeCheckingMode = "basic" + +[tool.ruff] +target-version = "py310" +line-length = 88 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify +] +ignore = [ + "E501", # line too long (handled by formatter) + "B008", # function call in default argument + "SIM108", # ternary operator +] + +[tool.ruff.lint.isort] +known-first-party = ["quantstats"] diff --git a/quantstats/__init__.py b/quantstats/__init__.py index 9d7af63e..f9e219a0 100644 --- a/quantstats/__init__.py +++ b/quantstats/__init__.py @@ -1,10 +1,9 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- # # QuantStats: Portfolio analytics for quants # https://github.com/ranaroussi/quantstats # -# Copyright 2019 Ran Aroussi +# Copyright 2019-2025 Ran Aroussi # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,12 +17,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.0.24" +from . import version + +__version__ = version.version __author__ = "Ran Aroussi" from . import stats, utils, plots, reports -__all__ = ['stats', 'plots', 'reports', 'utils', 'extend_pandas'] +__all__ = ["stats", "plots", "reports", "utils", "extend_pandas"] # try automatic matplotlib inline utils._in_notebook(matplotlib_inline=True) @@ -31,104 +32,129 @@ def extend_pandas(): """ - extends pandas by exposing methods to be used like: + Extends pandas by exposing methods to be used like: df.sharpe(), df.best('day'), ... """ - from pandas.core.base import PandasObject as _po - - _po.compsum = stats.compsum - _po.comp = stats.comp - _po.expected_return = stats.expected_return - _po.geometric_mean = stats.geometric_mean - _po.ghpr = stats.ghpr - _po.outliers = stats.outliers - _po.remove_outliers = stats.remove_outliers - _po.best = stats.best - _po.worst = stats.worst - _po.consecutive_wins = stats.consecutive_wins - _po.consecutive_losses = stats.consecutive_losses - _po.exposure = stats.exposure - _po.win_rate = stats.win_rate - _po.avg_return = stats.avg_return - _po.avg_win = stats.avg_win - _po.avg_loss = stats.avg_loss - _po.volatility = stats.volatility - _po.implied_volatility = stats.implied_volatility - _po.sharpe = stats.sharpe - _po.sortino = stats.sortino - _po.cagr = stats.cagr - _po.rar = stats.rar - _po.skew = stats.skew - _po.kurtosis = stats.kurtosis - _po.calmar = stats.calmar - _po.ulcer_index = stats.ulcer_index - _po.ulcer_performance_index = stats.ulcer_performance_index - _po.upi = stats.upi - _po.risk_of_ruin = stats.risk_of_ruin - _po.ror = stats.ror - _po.value_at_risk = stats.value_at_risk - _po.var = stats.var - _po.conditional_value_at_risk = stats.conditional_value_at_risk - _po.cvar = stats.cvar - _po.expected_shortfall = stats.expected_shortfall - _po.tail_ratio = stats.tail_ratio - _po.payoff_ratio = stats.payoff_ratio - _po.win_loss_ratio = stats.win_loss_ratio - _po.profit_ratio = stats.profit_ratio - _po.profit_factor = stats.profit_factor - _po.gain_to_pain_ratio = stats.gain_to_pain_ratio - _po.cpc_index = stats.cpc_index - _po.common_sense_ratio = stats.common_sense_ratio - _po.outlier_win_ratio = stats.outlier_win_ratio - _po.outlier_loss_ratio = stats.outlier_loss_ratio - _po.recovery_factor = stats.recovery_factor - _po.risk_return_ratio = stats.risk_return_ratio - _po.max_drawdown = stats.max_drawdown - _po.to_drawdown_series = stats.to_drawdown_series - _po.kelly_criterion = stats.kelly_criterion - _po.monthly_returns = stats.monthly_returns - _po.pct_rank = stats.pct_rank + from pandas.core.base import PandasObject as _po # type: ignore[import] + + _po.compsum = stats.compsum # type: ignore[attr-defined] + _po.comp = stats.comp # type: ignore[attr-defined] + _po.expected_return = stats.expected_return # type: ignore[attr-defined] + _po.geometric_mean = stats.geometric_mean # type: ignore[attr-defined] + _po.ghpr = stats.ghpr # type: ignore[attr-defined] + _po.outliers = stats.outliers # type: ignore[attr-defined] + _po.remove_outliers = stats.remove_outliers # type: ignore[attr-defined] + _po.best = stats.best # type: ignore[attr-defined] + _po.worst = stats.worst # type: ignore[attr-defined] + _po.consecutive_wins = stats.consecutive_wins # type: ignore[attr-defined] + _po.consecutive_losses = stats.consecutive_losses # type: ignore[attr-defined] + _po.exposure = stats.exposure # type: ignore[attr-defined] + _po.win_rate = stats.win_rate # type: ignore[attr-defined] + _po.avg_return = stats.avg_return # type: ignore[attr-defined] + _po.avg_win = stats.avg_win # type: ignore[attr-defined] + _po.avg_loss = stats.avg_loss # type: ignore[attr-defined] + _po.volatility = stats.volatility # type: ignore[attr-defined] + _po.rolling_volatility = stats.rolling_volatility # type: ignore[attr-defined] + _po.implied_volatility = stats.implied_volatility # type: ignore[attr-defined] + _po.sharpe = stats.sharpe # type: ignore[attr-defined] + _po.smart_sharpe = stats.smart_sharpe # type: ignore[attr-defined] + _po.rolling_sharpe = stats.rolling_sharpe # type: ignore[attr-defined] + _po.sortino = stats.sortino # type: ignore[attr-defined] + _po.smart_sortino = stats.smart_sortino # type: ignore[attr-defined] + _po.adjusted_sortino = stats.adjusted_sortino # type: ignore[attr-defined] + _po.rolling_sortino = stats.rolling_sortino # type: ignore[attr-defined] + _po.omega = stats.omega # type: ignore[attr-defined] + _po.cagr = stats.cagr # type: ignore[attr-defined] + _po.rar = stats.rar # type: ignore[attr-defined] + _po.skew = stats.skew # type: ignore[attr-defined] + _po.kurtosis = stats.kurtosis # type: ignore[attr-defined] + _po.calmar = stats.calmar # type: ignore[attr-defined] + _po.ulcer_index = stats.ulcer_index # type: ignore[attr-defined] + _po.ulcer_performance_index = stats.ulcer_performance_index # type: ignore[attr-defined] + _po.upi = stats.upi # type: ignore[attr-defined] + _po.serenity_index = stats.serenity_index # type: ignore[attr-defined] + _po.risk_of_ruin = stats.risk_of_ruin # type: ignore[attr-defined] + _po.ror = stats.ror # type: ignore[attr-defined] + _po.value_at_risk = stats.value_at_risk # type: ignore[attr-defined] + _po.var = stats.var # type: ignore[attr-defined] + _po.conditional_value_at_risk = stats.conditional_value_at_risk # type: ignore[attr-defined] + _po.cvar = stats.cvar # type: ignore[attr-defined] + _po.expected_shortfall = stats.expected_shortfall # type: ignore[attr-defined] + _po.tail_ratio = stats.tail_ratio # type: ignore[attr-defined] + _po.payoff_ratio = stats.payoff_ratio # type: ignore[attr-defined] + _po.win_loss_ratio = stats.win_loss_ratio # type: ignore[attr-defined] + _po.profit_ratio = stats.profit_ratio # type: ignore[attr-defined] + _po.profit_factor = stats.profit_factor # type: ignore[attr-defined] + _po.gain_to_pain_ratio = stats.gain_to_pain_ratio # type: ignore[attr-defined] + _po.cpc_index = stats.cpc_index # type: ignore[attr-defined] + _po.common_sense_ratio = stats.common_sense_ratio # type: ignore[attr-defined] + _po.outlier_win_ratio = stats.outlier_win_ratio # type: ignore[attr-defined] + _po.outlier_loss_ratio = stats.outlier_loss_ratio # type: ignore[attr-defined] + _po.recovery_factor = stats.recovery_factor # type: ignore[attr-defined] + _po.risk_return_ratio = stats.risk_return_ratio # type: ignore[attr-defined] + _po.max_drawdown = stats.max_drawdown # type: ignore[attr-defined] + _po.to_drawdown_series = stats.to_drawdown_series # type: ignore[attr-defined] + _po.kelly_criterion = stats.kelly_criterion # type: ignore[attr-defined] + _po.monthly_returns = stats.monthly_returns # type: ignore[attr-defined] + _po.pct_rank = stats.pct_rank # type: ignore[attr-defined] + + _po.treynor_ratio = stats.treynor_ratio # type: ignore[attr-defined] + _po.probabilistic_sharpe_ratio = stats.probabilistic_sharpe_ratio # type: ignore[attr-defined] + _po.probabilistic_sortino_ratio = stats.probabilistic_sortino_ratio # type: ignore[attr-defined] + _po.probabilistic_adjusted_sortino_ratio = ( # type: ignore[attr-defined] + stats.probabilistic_adjusted_sortino_ratio + ) + + # Monte Carlo simulation + _po.montecarlo = stats.montecarlo # type: ignore[attr-defined] + _po.montecarlo_sharpe = stats.montecarlo_sharpe # type: ignore[attr-defined] + _po.montecarlo_drawdown = stats.montecarlo_drawdown # type: ignore[attr-defined] + _po.montecarlo_cagr = stats.montecarlo_cagr # type: ignore[attr-defined] # methods from utils - _po.to_returns = utils.to_returns - _po.to_prices = utils.to_prices - _po.to_log_returns = utils.to_log_returns - _po.log_returns = utils.log_returns - _po.exponential_stdev = utils.exponential_stdev - _po.rebase = utils.rebase - _po.aggregate_returns = utils.aggregate_returns - _po.to_excess_returns = utils.to_excess_returns - _po.multi_shift = utils.multi_shift - _po.curr_month = utils._pandas_current_month - _po.date = utils._pandas_date - _po.mtd = utils._mtd - _po.qtd = utils._qtd - _po.ytd = utils._ytd + _po.to_returns = utils.to_returns # type: ignore[attr-defined] + _po.to_prices = utils.to_prices # type: ignore[attr-defined] + _po.to_log_returns = utils.to_log_returns # type: ignore[attr-defined] + _po.log_returns = utils.log_returns # type: ignore[attr-defined] + _po.exponential_stdev = utils.exponential_stdev # type: ignore[attr-defined] + _po.rebase = utils.rebase # type: ignore[attr-defined] + _po.aggregate_returns = utils.aggregate_returns # type: ignore[attr-defined] + _po.to_excess_returns = utils.to_excess_returns # type: ignore[attr-defined] + _po.multi_shift = utils.multi_shift # type: ignore[attr-defined] + _po.curr_month = utils._pandas_current_month # type: ignore[attr-defined] + _po.date = utils._pandas_date # type: ignore[attr-defined] + _po.mtd = utils._mtd # type: ignore[attr-defined] + _po.qtd = utils._qtd # type: ignore[attr-defined] + _po.ytd = utils._ytd # type: ignore[attr-defined] # methods that requires benchmark stats - _po.r_squared = stats.r_squared - _po.r2 = stats.r2 - _po.information_ratio = stats.information_ratio - _po.greeks = stats.greeks - _po.rolling_greeks = stats.rolling_greeks - _po.compare = stats.compare + _po.r_squared = stats.r_squared # type: ignore[attr-defined] + _po.r2 = stats.r2 # type: ignore[attr-defined] + _po.information_ratio = stats.information_ratio # type: ignore[attr-defined] + _po.greeks = stats.greeks # type: ignore[attr-defined] + _po.rolling_greeks = stats.rolling_greeks # type: ignore[attr-defined] + _po.compare = stats.compare # type: ignore[attr-defined] # plotting methods - _po.plot_snapshot = plots.snapshot - _po.plot_earnings = plots.earnings - _po.plot_daily_returns = plots.daily_returns - _po.plot_distribution = plots.distribution - _po.plot_drawdown = plots.drawdown - _po.plot_drawdowns_periods = plots.drawdowns_periods - _po.plot_histogram = plots.histogram - _po.plot_log_returns = plots.log_returns - _po.plot_returns = plots.returns - _po.plot_rolling_beta = plots.rolling_beta - _po.plot_rolling_sharpe = plots.rolling_sharpe - _po.plot_rolling_sortino = plots.rolling_sortino - _po.plot_rolling_volatility = plots.rolling_volatility - _po.plot_yearly_returns = plots.yearly_returns - _po.plot_monthly_heatmap = plots.monthly_heatmap - - _po.metrics = reports.metrics + _po.plot_snapshot = plots.snapshot # type: ignore[attr-defined] + _po.plot_earnings = plots.earnings # type: ignore[attr-defined] + _po.plot_daily_returns = plots.daily_returns # type: ignore[attr-defined] + _po.plot_distribution = plots.distribution # type: ignore[attr-defined] + _po.plot_drawdown = plots.drawdown # type: ignore[attr-defined] + _po.plot_drawdowns_periods = plots.drawdowns_periods # type: ignore[attr-defined] + _po.plot_histogram = plots.histogram # type: ignore[attr-defined] + _po.plot_log_returns = plots.log_returns # type: ignore[attr-defined] + _po.plot_returns = plots.returns # type: ignore[attr-defined] + _po.plot_rolling_beta = plots.rolling_beta # type: ignore[attr-defined] + _po.plot_rolling_sharpe = plots.rolling_sharpe # type: ignore[attr-defined] + _po.plot_rolling_sortino = plots.rolling_sortino # type: ignore[attr-defined] + _po.plot_rolling_volatility = plots.rolling_volatility # type: ignore[attr-defined] + _po.plot_yearly_returns = plots.yearly_returns # type: ignore[attr-defined] + _po.plot_monthly_heatmap = plots.monthly_heatmap # type: ignore[attr-defined] + _po.plot_montecarlo = plots.montecarlo # type: ignore[attr-defined] + _po.plot_montecarlo_distribution = plots.montecarlo_distribution # type: ignore[attr-defined] + + _po.metrics = reports.metrics # type: ignore[attr-defined] + + # extend_pandas() diff --git a/quantstats/_compat.py b/quantstats/_compat.py new file mode 100644 index 00000000..1b2270ec --- /dev/null +++ b/quantstats/_compat.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python +""" +Compatibility layer for pandas/numpy versions +Handles version differences and deprecated functionality + +This module provides a unified interface for working with different versions of pandas +and numpy, ensuring that quantstats functions work consistently across various +dependency versions. It handles deprecated functionality and version-specific changes. +""" + +import pandas as pd +import numpy as np +import warnings +from packaging import version +import yfinance as yf +from typing import Union, Optional, List, Callable + +# Version detection - Parse version strings to enable version comparisons +PANDAS_VERSION = version.parse(pd.__version__) +NUMPY_VERSION = version.parse(np.__version__) + +# Frequency alias mapping for pandas compatibility +# Starting from pandas 2.2.0, frequency aliases changed to be more explicit +# M -> ME (Month End), Q -> QE (Quarter End), A/Y -> YE (Year End) +FREQUENCY_ALIASES = { + "M": "ME" if PANDAS_VERSION >= version.parse("2.2.0") else "M", + "Q": "QE" if PANDAS_VERSION >= version.parse("2.2.0") else "Q", + "A": "YE" if PANDAS_VERSION >= version.parse("2.2.0") else "A", + "Y": "YE" if PANDAS_VERSION >= version.parse("2.2.0") else "Y", +} + + +def get_frequency_alias(freq: str) -> str: + """ + Get the correct frequency alias for current pandas version. + + This function maps old frequency strings to their new equivalents in + pandas 2.2.0+, ensuring backward compatibility across pandas versions. + + Parameters + ---------- + freq : str + The frequency string (e.g., 'M', 'Q', 'A', 'Y') + + Returns + ------- + str + The appropriate frequency alias for the current pandas version + + Examples + -------- + >>> get_frequency_alias('M') # Returns 'ME' in pandas 2.2.0+, 'M' in older versions + >>> get_frequency_alias('D') # Returns 'D' (unchanged) + """ + # Look up the frequency in our mapping, return original if not found + return FREQUENCY_ALIASES.get(freq, freq) + + +def normalize_timezone(data: Union[pd.Series, pd.DataFrame]) -> Union[pd.Series, pd.DataFrame]: + """ + Normalize timezone information for consistent comparisons. + + If data has timezone info, converts to UTC then removes timezone info. + This ensures all data can be compared regardless of original timezone. + + Parameters + ---------- + data : pd.Series or pd.DataFrame + Time series data with DatetimeIndex + + Returns + ------- + pd.Series or pd.DataFrame + Data with timezone-naive DatetimeIndex + """ + if not isinstance(data.index, pd.DatetimeIndex): + return data + + # If timezone aware, convert to UTC then make naive + if data.index.tz is not None: + result = data.copy() + result.index = result.index.tz_convert('UTC').tz_localize(None) + return result + + # Already timezone naive, return as is + return data + + +def safe_resample(data: Union[pd.Series, pd.DataFrame], + freq: str, + func_name: Optional[Union[str, Callable]] = None, + **kwargs): + """ + Safe resample operation that works with all pandas versions. + + This function handles the resampling of time series data using the correct + frequency aliases and aggregation methods that are compatible across + different pandas versions. It also normalizes timezones to ensure + consistent comparisons. + + Parameters + ---------- + data : pd.Series or pd.DataFrame + The time series data to resample + freq : str + The frequency to resample to (e.g., 'M', 'Q', 'A', 'D') + func_name : str or callable, optional + The aggregation function to apply. Can be a string name like 'sum', + 'mean', 'std', etc., or a callable function + **kwargs + Additional arguments passed to the aggregation function + + Returns + ------- + pd.Series or pd.DataFrame + The resampled data with the specified frequency and aggregation, + with timezone normalized to UTC if present, or naive if not + + Examples + -------- + >>> safe_resample(data, 'M', 'sum') # Monthly sum aggregation + >>> safe_resample(data, 'Q', 'mean') # Quarterly mean aggregation + """ + # Convert frequency to the appropriate alias for current pandas version + freq_alias = get_frequency_alias(freq) + + # Create the resampler object using the correct frequency + resampler = data.resample(freq_alias) + + # If no aggregation function specified, return the resampler object + if func_name is None: + return resampler + + # Handle string function names with explicit method calls + # This approach avoids deprecation warnings and ensures compatibility + result = None + if isinstance(func_name, str): + # Map common aggregation functions to their pandas methods + if func_name == "sum": + result = resampler.sum(**kwargs) + elif func_name == "mean": + result = resampler.mean(**kwargs) + elif func_name == "std": + result = resampler.std(**kwargs) + elif func_name == "count": + result = resampler.count(**kwargs) + elif func_name == "min": + result = resampler.min(**kwargs) + elif func_name == "max": + result = resampler.max(**kwargs) + elif func_name == "first": + result = resampler.first(**kwargs) + elif func_name == "last": + result = resampler.last(**kwargs) + else: + # Try to find the method on the resampler object + if hasattr(resampler, func_name): + result = getattr(resampler, func_name)(**kwargs) + else: + # Fallback to apply for custom string functions + result = resampler.apply(func_name, **kwargs) + else: + # For callable functions, use apply method + # Suppress FutureWarning about callable usage - our use is intentional + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning, + message=".*callable.*") + result = resampler.apply(func_name, **kwargs) + + # Normalize timezone to ensure consistent comparisons + return normalize_timezone(result) + + +def safe_concat(objs: List[Union[pd.Series, pd.DataFrame]], + axis: int = 0, + ignore_index: bool = False, + sort: bool = False, + **kwargs) -> Union[pd.Series, pd.DataFrame]: + """ + Safe concatenation that handles pandas version differences. + + This function provides a wrapper around pd.concat with consistent parameters. + + Parameters + ---------- + objs : list of pd.Series or pd.DataFrame + Objects to concatenate along the specified axis + axis : int, default 0 + Axis to concatenate along. 0 for rows, 1 for columns + ignore_index : bool, default False + Whether to ignore the index and create a new default integer index + sort : bool, default False + Whether to sort the result + **kwargs + Additional arguments passed to pd.concat + + Returns + ------- + pd.Series or pd.DataFrame + The concatenated result + + Examples + -------- + >>> safe_concat([df1, df2]) # Concatenate along rows + >>> safe_concat([df1, df2], axis=1) # Concatenate along columns + """ + # Perform the concatenation with sort parameter (available in pandas 2.0+) + return pd.concat(objs, axis=axis, ignore_index=ignore_index, sort=sort, **kwargs) # type: ignore[arg-type] + + +def safe_append(df: pd.DataFrame, + other: Union[pd.DataFrame, pd.Series], + ignore_index: bool = False, + sort: bool = False) -> pd.DataFrame: + """ + Safe append operation using pd.concat. + + DataFrame.append() was removed in pandas 2.0.0. This function provides + a unified interface using pd.concat. + + Parameters + ---------- + df : pd.DataFrame + The DataFrame to append to (base DataFrame) + other : pd.DataFrame or pd.Series + The data to append to the base DataFrame + ignore_index : bool, default False + Whether to ignore the index and create a new default integer index + sort : bool, default False + Whether to sort the result by columns + + Returns + ------- + pd.DataFrame + The result of the append operation + + Examples + -------- + >>> safe_append(df, new_row) # Append a new row + >>> safe_append(df, other_df, ignore_index=True) # Append and reset index + """ + # Use concat (append was removed in pandas 2.0) + result = safe_concat([df, other], ignore_index=ignore_index, sort=sort) + # Ensure we return a DataFrame + if isinstance(result, pd.DataFrame): + return result + elif isinstance(result, pd.Series): + return pd.DataFrame([result]) + else: + return pd.DataFrame(result) + + +def safe_frequency_conversion(data: Union[pd.Series, pd.DataFrame], + freq: str) -> Union[pd.Series, pd.DataFrame]: + """ + Safe frequency conversion for time series data. + + This function converts time series data to a specified frequency using + the most appropriate method available in the current pandas version. + + Parameters + ---------- + data : pd.Series or pd.DataFrame + Time series data with a datetime index + freq : str + Target frequency (e.g., 'D', 'M', 'Q', 'A') + + Returns + ------- + pd.Series or pd.DataFrame + Data with converted frequency + + Examples + -------- + >>> safe_frequency_conversion(data, 'M') # Convert to monthly frequency + >>> safe_frequency_conversion(data, 'D') # Convert to daily frequency + """ + # Get the appropriate frequency alias for current pandas version + freq_alias = get_frequency_alias(freq) + + # Handle different methods for frequency conversion + if hasattr(data, "asfreq"): + # Use asfreq if available (most direct method) + return data.asfreq(freq_alias) + else: + # Fallback to resampling with 'last' aggregation + # This preserves the last value in each period + return safe_resample(data, freq_alias, "last") + + +def handle_pandas_warnings(): + """ + Context manager to handle pandas warnings appropriately. + + This function returns a context manager that can be used to suppress + or handle pandas warnings in a controlled manner. Useful for managing + deprecation warnings when working with multiple pandas versions. + + Returns + ------- + warnings.catch_warnings + A context manager for handling warnings + + Examples + -------- + >>> with handle_pandas_warnings(): + ... # Code that might generate pandas warnings + ... pass + """ + # Return the warnings context manager for flexible warning handling + return warnings.catch_warnings() + + +# Pandas accessor compatibility functions +def get_datetime_accessor(series: pd.Series): + """ + Get datetime accessor for pandas Series. + + This function provides a consistent interface for accessing datetime + properties of a pandas Series across different versions. + + Parameters + ---------- + series : pd.Series + The series with datetime data to get the accessor for + + Returns + ------- + pd.Series.dt + The datetime accessor for the series + + Examples + -------- + >>> dt_accessor = get_datetime_accessor(date_series) + >>> dt_accessor.year # Access year component + >>> dt_accessor.month # Access month component + """ + # Return the datetime accessor - consistent across pandas versions + return series.dt + + +def get_string_accessor(series: pd.Series): + """ + Get string accessor for pandas Series. + + This function provides a consistent interface for accessing string + methods of a pandas Series across different versions. + + Parameters + ---------- + series : pd.Series + The series with string data to get the accessor for + + Returns + ------- + pd.Series.str + The string accessor for the series + + Examples + -------- + >>> str_accessor = get_string_accessor(string_series) + >>> str_accessor.lower() # Convert to lowercase + >>> str_accessor.contains('pattern') # Check for pattern + """ + # Return the string accessor - consistent across pandas versions + return series.str + + +def safe_yfinance_download(tickers: Union[str, List[str]], + proxy: Optional[str] = None, + **kwargs) -> pd.DataFrame: + """ + Safe yfinance download that handles proxy configuration properly. + + This function provides a wrapper around yfinance.download that handles + proxy configuration differences between yfinance versions. It ensures + compatibility with both old and new yfinance proxy configuration methods. + + Parameters + ---------- + tickers : str or list + Ticker symbols to download data for. Can be a single ticker string + or a list of ticker symbols + proxy : str, optional + Proxy configuration string (e.g., 'http://proxy.server:port') + Handled automatically based on yfinance version + **kwargs + Additional arguments passed to yfinance.download such as: + - start: Start date for data download + - end: End date for data download + - period: Period to download (e.g., '1y', '6mo') + - interval: Data interval (e.g., '1d', '1h') + + Returns + ------- + pd.DataFrame + Downloaded financial data with columns like Open, High, Low, Close, Volume + + Examples + -------- + >>> data = safe_yfinance_download('AAPL', start='2020-01-01', end='2021-01-01') + >>> data = safe_yfinance_download(['AAPL', 'MSFT'], period='1y') + """ + # Handle proxy configuration based on yfinance version + if proxy is not None: + # Check if the new configuration method exists in yfinance + if hasattr(yf, "set_config"): + # New method: use set_config for global proxy configuration + # This approach is preferred in newer yfinance versions + yf.set_config(proxy=proxy) + # Remove proxy from kwargs to avoid duplicate parameter error + kwargs.pop("proxy", None) + else: + # Old method: pass proxy directly to download function + # This is for backward compatibility with older yfinance versions + kwargs["proxy"] = proxy + + # Suppress yfinance warnings about deprecation and future changes + # This keeps the output clean while maintaining functionality + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning, module="yfinance") + # Download the data using yfinance with all provided parameters + result = yf.download(tickers, **kwargs) + + # Handle case where yfinance returns None (network issues, invalid ticker, etc.) + if result is None: + # Return empty DataFrame with standard yfinance columns + return pd.DataFrame(columns=['Open', 'High', 'Low', 'Close', 'Volume', 'Adj Close']) + + return result diff --git a/quantstats/_montecarlo.py b/quantstats/_montecarlo.py new file mode 100644 index 00000000..669eb2b1 --- /dev/null +++ b/quantstats/_montecarlo.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python +# +# QuantStats: Portfolio analytics for quants +# https://github.com/ranaroussi/quantstats +# +# Copyright 2019-2025 Ran Aroussi +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Monte Carlo Simulation Module + +This module provides Monte Carlo simulation functionality for portfolio +analysis. It allows users to generate multiple simulated return paths +by shuffling historical returns, enabling probability-based risk assessment. +""" + +from dataclasses import dataclass, field +from typing import Optional, Tuple, Dict, Any + +import numpy as np +import pandas as pd + + +@dataclass +class MonteCarloResult: + """ + Container for Monte Carlo simulation results. + + This class holds the results of a Monte Carlo simulation and provides + convenient methods for analyzing and visualizing the simulated paths. + + Attributes + ---------- + data : pd.DataFrame + Raw simulation paths (sims x periods) as cumulative returns + original : pd.Series + Original cumulative returns path + bust_threshold : float, optional + Drawdown threshold used for bust probability calculation + goal_threshold : float, optional + Return threshold used for goal probability calculation + + Examples + -------- + >>> import quantstats as qs + >>> returns = qs.utils.download_returns("SPY") + >>> mc = qs.stats.montecarlo(returns, sims=1000) + >>> print(mc.stats) + >>> mc.plot() + """ + + data: pd.DataFrame + original: pd.Series + bust_threshold: Optional[float] = None + goal_threshold: Optional[float] = None + _maxdd_cache: Optional[pd.Series] = field(default=None, repr=False) + + @property + def stats(self) -> Dict[str, float]: + """ + Terminal value statistics across all simulations. + + Returns + ------- + dict + Dictionary containing min, max, mean, median, std of terminal values + """ + terminal = self.data.iloc[-1] + return { + "min": terminal.min(), + "max": terminal.max(), + "mean": terminal.mean(), + "median": terminal.median(), + "std": terminal.std(), + "percentile_5": terminal.quantile(0.05), + "percentile_25": terminal.quantile(0.25), + "percentile_75": terminal.quantile(0.75), + "percentile_95": terminal.quantile(0.95), + } + + @property + def maxdd(self) -> Dict[str, float]: + """ + Maximum drawdown statistics across all simulations. + + Returns + ------- + dict + Dictionary containing min, max, mean, median of max drawdowns + """ + if self._maxdd_cache is None: + # Calculate max drawdown for each simulation path + maxdd_values = [] + for col in self.data.columns: + path = self.data[col] + # Calculate drawdown from cumulative returns + cumulative = path + 1 # Convert to growth factor + running_max = cumulative.cummax() + drawdown = (cumulative - running_max) / running_max + maxdd_values.append(drawdown.min()) + object.__setattr__(self, "_maxdd_cache", pd.Series(maxdd_values)) + + dd = self._maxdd_cache + return { + "min": dd.min(), # Worst (most negative) drawdown + "max": dd.max(), # Best (least negative) drawdown + "mean": dd.mean(), + "median": dd.median(), + "std": dd.std(), + "percentile_5": dd.quantile(0.05), + "percentile_95": dd.quantile(0.95), + } + + @property + def bust_probability(self) -> Optional[float]: + """ + Probability of exceeding the bust (drawdown) threshold. + + Returns + ------- + float or None + Probability of bust if threshold was set, None otherwise + """ + if self.bust_threshold is None: + return None + + if self._maxdd_cache is None: + # Trigger maxdd calculation + _ = self.maxdd + + # Count simulations where max drawdown exceeds bust threshold + bust_count = (self._maxdd_cache <= self.bust_threshold).sum() + return bust_count / len(self._maxdd_cache) + + @property + def goal_probability(self) -> Optional[float]: + """ + Probability of reaching the goal (return) threshold. + + Returns + ------- + float or None + Probability of reaching goal if threshold was set, None otherwise + """ + if self.goal_threshold is None: + return None + + terminal = self.data.iloc[-1] + goal_count = (terminal >= self.goal_threshold).sum() + return goal_count / len(terminal) + + def percentile(self, p: float) -> pd.Series: + """ + Get the p-th percentile path across all simulations. + + Parameters + ---------- + p : float + Percentile value between 0 and 100 + + Returns + ------- + pd.Series + The p-th percentile path + """ + return self.data.quantile(p / 100, axis=1) + + def confidence_band( + self, level: float = 0.95 + ) -> Tuple[pd.Series, pd.Series]: + """ + Get lower and upper bounds for a confidence interval. + + Parameters + ---------- + level : float, default 0.95 + Confidence level (e.g., 0.95 for 95% confidence interval) + + Returns + ------- + tuple + (lower_bound, upper_bound) as pd.Series + """ + alpha = (1 - level) / 2 + lower = self.data.quantile(alpha, axis=1) + upper = self.data.quantile(1 - alpha, axis=1) + return lower, upper + + def plot(self, **kwargs) -> Any: + """ + Plot all simulation paths with the original path highlighted. + + Parameters + ---------- + **kwargs + Additional arguments passed to the plotting function + + Returns + ------- + matplotlib.figure.Figure + The generated figure + """ + # Import here to avoid circular imports + from . import plots + + return plots.montecarlo(self, **kwargs) + + +def run_montecarlo( + returns: pd.Series, + sims: int = 1000, + bust: Optional[float] = None, + goal: Optional[float] = None, + seed: Optional[int] = None, +) -> MonteCarloResult: + """ + Run Monte Carlo simulation by shuffling returns. + + This function creates multiple simulated return paths by randomly + shuffling the historical returns. This preserves the return distribution + while breaking any time-series dependencies. + + Parameters + ---------- + returns : pd.Series + Daily returns (not prices) + sims : int, default 1000 + Number of simulations to run + bust : float, optional + Drawdown threshold for "bust" probability (e.g., -0.1 for -10%) + goal : float, optional + Return threshold for "goal" probability (e.g., 1.0 for +100%) + seed : int, optional + Random seed for reproducibility + + Returns + ------- + MonteCarloResult + Object containing simulation results with stats, maxdd, and plot methods + + Examples + -------- + >>> import quantstats as qs + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> mc = run_montecarlo(returns, sims=100, bust=-0.1, goal=0.5) + >>> print(mc.stats) + >>> print(f"Bust probability: {mc.bust_probability:.1%}") + """ + # Initialize random number generator + rng = np.random.default_rng(seed) + + # Get returns as numpy array for efficiency + returns_array = returns.dropna().values + n_periods = len(returns_array) + + # Pre-allocate simulation array + sim_returns = np.empty((n_periods, sims)) + + # First column is original (unshuffled) returns + sim_returns[:, 0] = returns_array + + # Generate shuffled paths + for i in range(1, sims): + sim_returns[:, i] = rng.permutation(returns_array) + + # Calculate cumulative returns for all paths + # Using (1 + r).cumprod() - 1 formula + cumulative = np.cumprod(1 + sim_returns, axis=0) - 1 + + # Create DataFrame with simulation results + sim_df = pd.DataFrame( + cumulative, + index=range(n_periods), + columns=[f"sim_{i}" for i in range(sims)], + ) + + # Original cumulative returns + original = pd.Series(cumulative[:, 0], index=range(n_periods), name="original") + + return MonteCarloResult( + data=sim_df, + original=original, + bust_threshold=bust, + goal_threshold=goal, + ) diff --git a/quantstats/_numpy_compat.py b/quantstats/_numpy_compat.py new file mode 100644 index 00000000..344aacd5 --- /dev/null +++ b/quantstats/_numpy_compat.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python +""" +Numpy compatibility layer +Handles numpy version differences and deprecated functionality + +This module provides a unified interface for working with different versions of numpy, +ensuring that quantstats functions work consistently across various numpy versions. +It handles deprecated functionality, version-specific changes, and provides fallback +mechanisms for functions that may not exist in older versions. +""" + +import numpy as np +import warnings +from packaging import version +from typing import Union, Optional, Any + +# Version detection - Parse numpy version string to enable version comparisons +NUMPY_VERSION = version.parse(np.__version__) + +# Handle deprecated numpy functions +# In numpy 1.25.0+, np.product was deprecated in favor of np.prod +if NUMPY_VERSION >= version.parse("1.25.0"): + # Use np.prod instead of deprecated np.product for newer versions + product = np.prod +else: + # Use np.product if available, otherwise fallback to np.prod for older versions + product = getattr(np, "product", np.prod) + + +def safe_numpy_operation(data, operation: str): + """ + Safe numpy operations with deprecation handling. + + This function provides a unified interface for numpy operations that may + have been deprecated or changed behavior across different numpy versions. + It handles the transition from deprecated functions to their replacements. + + Parameters + ---------- + data : array_like + Input data to perform the operation on. Can be any array-like structure + that numpy can process (list, tuple, numpy array, etc.) + operation : str + The numpy operation to perform. Supported operations include 'product', + 'prod', and any other numpy function name that exists in the current version + + Returns + ------- + ndarray + Result of the numpy operation applied to the input data + + Examples + -------- + >>> safe_numpy_operation([1, 2, 3, 4], 'product') # Returns 24 + >>> safe_numpy_operation([1, 2, 3, 4], 'sum') # Returns 10 + """ + # Handle the deprecated 'product' operation specifically + if operation == "product": + # Use our version-aware product function + return product(data) + elif operation == "prod": + # Use np.prod directly for 'prod' operation + return np.prod(data) + else: + # For all other operations, dynamically get the function from numpy + # This allows for flexible operation support across numpy versions + return getattr(np, operation)(data) + + +def safe_array_function(func_name: str, *args, **kwargs) -> Any: + """ + Safe wrapper for numpy array functions. + + This function provides a safe way to call numpy functions that may not exist + in all numpy versions or have been deprecated. It handles version-specific + function availability and provides meaningful error messages. + + Parameters + ---------- + func_name : str + Name of the numpy function to call (e.g., 'mean', 'std', 'product') + *args + Positional arguments to pass to the numpy function + **kwargs + Keyword arguments to pass to the numpy function + + Returns + ------- + Any + Result of the numpy function call. Return type depends on the specific + function being called and the input data. + + Raises + ------ + AttributeError + If the requested function doesn't exist in the current numpy version + + Examples + -------- + >>> safe_array_function('mean', [1, 2, 3, 4]) # Returns 2.5 + >>> safe_array_function('product', [1, 2, 3, 4]) # Returns 24 + """ + # Handle deprecated functions with special cases + if func_name == "product": + # Use our version-aware product function + return product(*args, **kwargs) + + # Handle functions that might not exist in older numpy versions + if hasattr(np, func_name): + # Function exists in current numpy version, call it normally + return getattr(np, func_name)(*args, **kwargs) + else: + # Function doesn't exist in current numpy version, raise informative error + raise AttributeError( + f"numpy has no attribute '{func_name}' in version {NUMPY_VERSION}" + ) + + +def handle_numpy_warnings(): + """ + Context manager to handle numpy warnings appropriately. + + This function returns a context manager that can be used to suppress + or handle numpy warnings in a controlled manner. Useful for managing + deprecation warnings and other numpy-specific warnings when working + with multiple numpy versions. + + Returns + ------- + warnings.catch_warnings + A context manager for handling numpy warnings + + Examples + -------- + >>> with handle_numpy_warnings(): + ... # Code that might generate numpy warnings + ... result = np.some_deprecated_function() + """ + # Return the warnings context manager for flexible warning handling + return warnings.catch_warnings() + + +def safe_percentile(data, percentile: Union[float, list], **kwargs): + """ + Safe percentile calculation. + + This function provides a wrapper around np.percentile with consistent parameters. + + Parameters + ---------- + data : array_like + Input data to calculate percentiles from. Can be any array-like + structure that numpy can process + percentile : float or array_like + Percentile(s) to compute. Values should be between 0 and 100. + Can be a single value or an array of values + **kwargs + Additional arguments passed to np.percentile + + Returns + ------- + float or ndarray + The computed percentile(s). Returns float for single percentile, + ndarray for multiple percentiles + + Examples + -------- + >>> safe_percentile([1, 2, 3, 4, 5], 50) # Returns 3.0 (median) + >>> safe_percentile([1, 2, 3, 4, 5], [25, 75]) # Returns [2.0, 4.0] + """ + # Numpy 1.24+ supports 'method' parameter for percentile calculation + return np.percentile(data, percentile, **kwargs) + + +def safe_nanpercentile(data, percentile: Union[float, list], **kwargs): + """ + Safe nanpercentile calculation that ignores NaN values. + + This function provides a wrapper around np.nanpercentile with consistent parameters. + + Parameters + ---------- + data : array_like + Input data to calculate percentiles from. NaN values are ignored. + Can be any array-like structure that numpy can process + percentile : float or array_like + Percentile(s) to compute. Values should be between 0 and 100. + Can be a single value or an array of values + **kwargs + Additional arguments passed to np.nanpercentile + + Returns + ------- + float or ndarray + The computed percentile(s) ignoring NaN values. Returns float for + single percentile, ndarray for multiple percentiles + + Examples + -------- + >>> safe_nanpercentile([1, 2, np.nan, 4, 5], 50) # Returns 3.0 + >>> safe_nanpercentile([1, np.nan, 3, 4, 5], [25, 75]) # Returns [2.0, 4.5] + """ + # Numpy 1.24+ supports 'method' parameter for nanpercentile calculation + return np.nanpercentile(data, percentile, **kwargs) + + +def safe_quantile(data, quantile: Union[float, list], **kwargs): + """ + Safe quantile calculation. + + This function provides a wrapper around np.quantile with consistent parameters. + Quantiles are similar to percentiles but use values between 0 and 1. + + Parameters + ---------- + data : array_like + Input data to calculate quantiles from. Can be any array-like + structure that numpy can process + quantile : float or array_like + Quantile(s) to compute. Values should be between 0 and 1. + Can be a single value or an array of values + **kwargs + Additional arguments passed to np.quantile + + Returns + ------- + float or ndarray + The computed quantile(s). Returns float for single quantile, + ndarray for multiple quantiles + + Examples + -------- + >>> safe_quantile([1, 2, 3, 4, 5], 0.5) # Returns 3.0 (median) + >>> safe_quantile([1, 2, 3, 4, 5], [0.25, 0.75]) # Returns [2.0, 4.0] + """ + # Numpy 1.24+ supports 'method' parameter for quantile calculation + return np.quantile(data, quantile, **kwargs) + + +def safe_random_seed(seed: Optional[int]): + """ + Safe random seed setting for numpy. + + This function provides a unified interface for setting random seeds. + + Parameters + ---------- + seed : int or None + Random seed value to set. If None, the random state is not modified. + Setting the same seed ensures reproducible random number sequences + + Examples + -------- + >>> safe_random_seed(42) # Sets seed for reproducible results + >>> safe_random_seed(None) # No seed set, random behavior continues + """ + if seed is not None: + # Use the modern random number generator (numpy 1.17.0+) + np.random.default_rng(seed) + + +def safe_datetime64_unit(dt, unit: str): + """ + Safe datetime64 unit conversion. + + This function provides a safe way to convert numpy datetime64 objects + to different time units. + + Parameters + ---------- + dt : np.datetime64 + Input datetime object to convert + unit : str + Target time unit (e.g., 'D' for days, 'H' for hours, 'M' for minutes, + 'S' for seconds, 'ms' for milliseconds) + + Returns + ------- + np.datetime64 + Converted datetime object with the specified unit + + Examples + -------- + >>> dt = np.datetime64('2023-01-01T12:00:00') + >>> safe_datetime64_unit(dt, 'D') # Convert to day precision + >>> safe_datetime64_unit(dt, 'H') # Convert to hour precision + """ + return dt.astype(f"datetime64[{unit}]") diff --git a/quantstats/_plotting/__init__.py b/quantstats/_plotting/__init__.py index 7de20e21..e69de29b 100644 --- a/quantstats/_plotting/__init__.py +++ b/quantstats/_plotting/__init__.py @@ -1,19 +0,0 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- -# -# QuantStats: Portfolio analytics for quants -# https://github.com/ranaroussi/quantstats -# -# Copyright 2019 Ran Aroussi -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. diff --git a/quantstats/_plotting/core.py b/quantstats/_plotting/core.py index 76e0347c..8390d283 100644 --- a/quantstats/_plotting/core.py +++ b/quantstats/_plotting/core.py @@ -1,10 +1,9 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- # # Quantreturns: Portfolio analytics for quants # https://github.com/ranaroussi/quantreturns # -# Copyright 2019 Ran Aroussi +# Copyright 2019-2025 Ran Aroussi # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,144 +17,319 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + +from typing import TYPE_CHECKING + import matplotlib.pyplot as _plt + +# Set default font to Arial, fall back gracefully if not available try: _plt.rcParams["font.family"] = "Arial" -except Exception: +except (KeyError, ValueError, OSError): pass import matplotlib.dates as _mdates from matplotlib.ticker import ( FormatStrFormatter as _FormatStrFormatter, - FuncFormatter as _FuncFormatter + FuncFormatter as _FuncFormatter, ) import pandas as _pd import numpy as _np import seaborn as _sns -from .. import stats as _stats - -_sns.set(font_scale=1.1, rc={ - 'figure.figsize': (10, 6), - 'axes.facecolor': 'white', - 'figure.facecolor': 'white', - 'grid.color': '#dddddd', - 'grid.linewidth': 0.5, - "lines.linewidth": 1.5, - 'text.color': '#333333', - 'xtick.color': '#666666', - 'ytick.color': '#666666' -}) +# Lazy import to avoid circular dependency during package initialization +_stats = None + + +def _get_stats(): + global _stats + if _stats is None: + from .. import stats + _stats = stats + return _stats + + +from .._compat import safe_resample + +if TYPE_CHECKING: + from matplotlib.figure import Figure as _Figure + +# Type alias for return data +Returns = _pd.Series | _pd.DataFrame + +# Configure seaborn theme with custom styling +_sns.set_theme( + font_scale=1.1, + rc={ + "figure.figsize": (10, 6), + "axes.facecolor": "white", + "figure.facecolor": "white", + "grid.color": "#dddddd", + "grid.linewidth": 0.5, + "lines.linewidth": 1.5, + "text.color": "#333333", + "xtick.color": "#666666", + "ytick.color": "#666666", + }, +) -_FLATUI_COLORS = ["#fedd78", "#348dc1", "#af4b64", - "#4fa487", "#9b59b6", "#808080"] -_GRAYSCALE_COLORS = ['silver', '#222222', 'gray'] * 3 +# Color palettes for different chart styles +_FLATUI_COLORS = [ + "#FEDD78", + "#348DC1", + "#BA516B", + "#4FA487", + "#9B59B6", + "#613F66", + "#84B082", + "#DC136C", + "#559CAD", + "#4A5899", +] +_GRAYSCALE_COLORS = [ + "#000000", + "#222222", + "#555555", + "#888888", + "#AAAAAA", + "#CCCCCC", + "#EEEEEE", + "#333333", + "#666666", + "#999999", +] def _get_colors(grayscale): + """ + Get color palette, line style, and alpha values based on grayscale setting + + Parameters + ---------- + grayscale : bool + Whether to use grayscale colors + + Returns + ------- + tuple + (colors, line_style, alpha) - Color palette, line style, and alpha value + """ colors = _FLATUI_COLORS - ls = '-' - alpha = .8 + ls = "-" + alpha = 0.8 if grayscale: colors = _GRAYSCALE_COLORS - ls = '-' + ls = "-" alpha = 0.5 return colors, ls, alpha -def plot_returns_bars(returns, benchmark=None, - returns_label="Strategy", - hline=None, hlw=None, hlcolor="red", hllabel="", - resample="A", title="Returns", match_volatility=False, - log_scale=False, figsize=(10, 6), - grayscale=False, fontname='Arial', ylabel=True, - subtitle=True, savefig=None, show=True): - +def plot_returns_bars( + returns, + benchmark=None, + returns_label="Strategy", + hline=None, + hlw=None, + hlcolor="red", + hllabel="", + resample="YE", + title="Returns", + match_volatility=False, + log_scale=False, + figsize=(10, 6), + grayscale=False, + fontname="Arial", + ylabel=True, + subtitle=True, + savefig=None, + show=True, +): + """ + Plot returns as a bar chart with optional benchmark comparison + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Returns data to plot + benchmark : pd.Series, optional + Benchmark returns for comparison + returns_label : str, default "Strategy" + Label for returns data + hline : float, optional + Horizontal line value + hlw : float, optional + Horizontal line width + hlcolor : str, default "red" + Horizontal line color + hllabel : str, default "" + Horizontal line label + resample : str, default "YE" + Resampling frequency + title : str, default "Returns" + Chart title + match_volatility : bool, default False + Whether to match volatility with benchmark + log_scale : bool, default False + Whether to use log scale for y-axis + figsize : tuple, default (10, 6) + Figure size + grayscale : bool, default False + Whether to use grayscale colors + fontname : str, default "Arial" + Font name for labels + ylabel : bool, default True + Whether to show y-axis label + subtitle : bool, default True + Whether to show subtitle with date range + savefig : str or dict, optional + Save figure parameters + show : bool, default True + Whether to display the plot + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None + """ + # Validate volatility matching requirements if match_volatility and benchmark is None: - raise ValueError('match_volatility requires passing of ' - 'benchmark.') + raise ValueError("match_volatility requires passing of " "benchmark.") if match_volatility and benchmark is not None: bmark_vol = benchmark.loc[returns.index].std() returns = (returns / returns.std()) * bmark_vol # --------------- + # Prepare data and colors colors, _, _ = _get_colors(grayscale) - df = _pd.DataFrame(index=returns.index, data={returns_label: returns}) + if isinstance(returns, _pd.Series): + df = _pd.DataFrame(index=returns.index, data={returns.name: returns}) + elif isinstance(returns, _pd.DataFrame): + df = _pd.DataFrame( + index=returns.index, data={col: returns[col] for col in returns.columns} + ) + + # Add benchmark data if provided if isinstance(benchmark, _pd.Series): - df['Benchmark'] = benchmark[benchmark.index.isin(returns.index)] - df = df[['Benchmark', returns_label]] - + df[benchmark.name] = benchmark[benchmark.index.isin(returns.index)] + if isinstance(returns, _pd.Series): + df = df[[benchmark.name, returns.name]] + elif isinstance(returns, _pd.DataFrame): + col_names = [benchmark.name, returns.columns] + df = df[list(_pd.core.common.flatten(col_names))] + + # Clean data and apply resampling df = df.dropna() if resample is not None: - df = df.resample(resample).apply( - _stats.comp).resample(resample).last() + df = safe_resample(df, resample, _get_stats().comp) + df = safe_resample(df, resample, "last") # --------------- + # Create figure and axis fig, ax = _plt.subplots(figsize=figsize) - ax.spines['top'].set_visible(False) - ax.spines['right'].set_visible(False) - ax.spines['bottom'].set_visible(False) - ax.spines['left'].set_visible(False) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["bottom"].set_visible(False) + ax.spines["left"].set_visible(False) # use a more precise date string for the x axis locations in the toolbar - fig.suptitle(title+"\n", y=.99, fontweight="bold", fontname=fontname, - fontsize=14, color="black") + fig.suptitle( + title, y=0.94, fontweight="bold", fontname=fontname, fontsize=14, color="black" + ) + # Add subtitle with date range if enabled if subtitle: - ax.set_title("\n%s - %s " % ( - df.index.date[:1][0].strftime('%Y'), - df.index.date[-1:][0].strftime('%Y') - ), fontsize=12, color='gray') - + ax.set_title( + "%s - %s \n" + % ( + df.index.date[:1][0].strftime("%Y"), + df.index.date[-1:][0].strftime("%Y"), + ), + fontsize=12, + color="gray", + ) + + # Adjust colors if no benchmark if benchmark is None: colors = colors[1:] - df.plot(kind='bar', ax=ax, color=colors) + df.plot(kind="bar", ax=ax, color=colors) + + # Set background colors + fig.set_facecolor("white") + ax.set_facecolor("white") - fig.set_facecolor('white') - ax.set_facecolor('white') + # Format x-axis labels + try: + ax.set_xticklabels(df.index.year) + years = sorted(list(set(df.index.year))) + except AttributeError: + ax.set_xticklabels(df.index) + years = sorted(list(set(df.index))) - ax.set_xticklabels(df.index.year) # ax.fmt_xdata = _mdates.DateFormatter('%Y-%m-%d') - years = sorted(list(set(df.index.year))) + # years = sorted(list(set(df.index.year))) + + # Reduce label density for long time series if len(years) > 10: - mod = int(len(years)/10) - _plt.xticks(_np.arange(len(years)), [ - str(year) if not i % mod else '' for i, year in enumerate(years)]) + mod = int(len(years) / 10) + _plt.xticks( + _np.arange(len(years)), + [str(year) if not i % mod else "" for i, year in enumerate(years)], + ) # rotate and align the tick labels so they look better fig.autofmt_xdate() - if hline: - if grayscale: - hlcolor = 'gray' - ax.axhline(hline, ls="--", lw=hlw, color=hlcolor, - label=hllabel, zorder=2) + # Add horizontal line if specified + if hline is not None: + if not isinstance(hline, _pd.Series): + if grayscale: + hlcolor = "gray" + ax.axhline(hline, ls="--", lw=hlw, color=hlcolor, label=hllabel, zorder=2) + # Add zero line for reference ax.axhline(0, ls="--", lw=1, color="#000000", zorder=2) - if isinstance(benchmark, _pd.Series) or hline: - ax.legend(fontsize=12) + # Only show legend if there are labeled elements + handles, labels = ax.get_legend_handles_labels() + if handles: + ax.legend(fontsize=11) + # Set y-axis scale _plt.yscale("symlog" if log_scale else "linear") - ax.set_xlabel('') + # Configure axis labels + ax.set_xlabel("") if ylabel: - ax.set_ylabel("Returns", fontname=fontname, - fontweight='bold', fontsize=12, color="black") - ax.yaxis.set_label_coords(-.1, .5) + ax.set_ylabel( + "Returns", fontname=fontname, fontweight="bold", fontsize=12, color="black" + ) + ax.yaxis.set_label_coords(-0.1, 0.5) + # Format y-axis as percentage ax.yaxis.set_major_formatter(_FuncFormatter(format_pct_axis)) + # Remove legend for single series without benchmark + if benchmark is None and len(_pd.DataFrame(returns).columns) == 1: + try: + legend = ax.get_legend() + if legend: + legend.remove() + except (ValueError, AttributeError, TypeError, RuntimeError): + pass + + # Adjust layout try: _plt.subplots_adjust(hspace=0, bottom=0, top=1) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass try: fig.tight_layout() - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass + # Handle saving and displaying if savefig: if isinstance(savefig, dict): _plt.savefig(**savefig) @@ -173,74 +347,178 @@ def plot_returns_bars(returns, benchmark=None, return None -def plot_timeseries(returns, benchmark=None, - title="Returns", compound=False, cumulative=True, - fill=False, returns_label="Strategy", - hline=None, hlw=None, hlcolor="red", hllabel="", - percent=True, match_volatility=False, log_scale=False, - resample=None, lw=1.5, figsize=(10, 6), ylabel="", - grayscale=False, fontname="Arial", - subtitle=True, savefig=None, show=True): +def plot_timeseries( + returns, + benchmark=None, + title="Returns", + compound=False, + fill=False, + returns_label="Strategy", + hline=None, + hlw=None, + hlcolor="red", + hllabel="", + percent=True, + match_volatility=False, + log_scale=False, + resample=None, + lw=1.5, + figsize=(10, 6), + ylabel="", + grayscale=False, + fontname="Arial", + subtitle=True, + savefig=None, + show=True, + raw_data=False, +): + """ + Plot returns as a time series line chart + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Returns data to plot + benchmark : pd.Series, optional + Benchmark returns for comparison + title : str, default "Returns" + Chart title + compound : bool, default False + Whether to compound returns + fill : bool, default False + Whether to fill area under the line + returns_label : str, default "Strategy" + Label for returns data + hline : float, optional + Horizontal line value + hlw : float, optional + Horizontal line width + hlcolor : str, default "red" + Horizontal line color + hllabel : str, default "" + Horizontal line label + percent : bool, default True + Whether to format y-axis as percentage + match_volatility : bool, default False + Whether to match volatility with benchmark + log_scale : bool, default False + Whether to use log scale for y-axis + resample : str, optional + Resampling frequency + lw : float, default 1.5 + Line width + figsize : tuple, default (10, 6) + Figure size + ylabel : str, default "" + Y-axis label + grayscale : bool, default False + Whether to use grayscale colors + fontname : str, default "Arial" + Font name for labels + subtitle : bool, default True + Whether to show subtitle with date range + savefig : str or dict, optional + Save figure parameters + show : bool, default True + Whether to display the plot + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None + """ colors, ls, alpha = _get_colors(grayscale) - returns.fillna(0, inplace=True) + # Fill NaN values with zeros + returns = returns.fillna(0) if isinstance(benchmark, _pd.Series): - benchmark.fillna(0, inplace=True) + benchmark = benchmark.fillna(0) + # Validate volatility matching requirements if match_volatility and benchmark is None: - raise ValueError('match_volatility requires passing of ' - 'benchmark.') + raise ValueError("match_volatility requires passing of " "benchmark.") if match_volatility and benchmark is not None: bmark_vol = benchmark.std() returns = (returns / returns.std()) * bmark_vol # --------------- - if compound is True: - if cumulative: - returns = _stats.compsum(returns) + # Transform data based on compound setting (skip for raw data like drawdowns) + if not raw_data: + if compound: + returns = _get_stats().compsum(returns) if isinstance(benchmark, _pd.Series): - benchmark = _stats.compsum(benchmark) + benchmark = _get_stats().compsum(benchmark) else: returns = returns.cumsum() if isinstance(benchmark, _pd.Series): benchmark = benchmark.cumsum() + # Apply resampling if specified if resample: - returns = returns.resample(resample) - returns = returns.last() if compound is True else returns.sum() + from .._compat import safe_resample + + returns = safe_resample( + returns, resample, "last" if compound is True else "sum" + ) if isinstance(benchmark, _pd.Series): - benchmark = benchmark.resample(resample) - benchmark = benchmark.last( - ) if compound is True else benchmark.sum() + benchmark = safe_resample( + benchmark, resample, "last" if compound is True else "sum" + ) # --------------- + # Create figure and axis fig, ax = _plt.subplots(figsize=figsize) - ax.spines['top'].set_visible(False) - ax.spines['right'].set_visible(False) - ax.spines['bottom'].set_visible(False) - ax.spines['left'].set_visible(False) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["bottom"].set_visible(False) + ax.spines["left"].set_visible(False) - fig.suptitle(title+"\n", y=.99, fontweight="bold", fontname=fontname, - fontsize=14, color="black") + # Set main title + fig.suptitle( + title, y=0.94, fontweight="bold", fontname=fontname, fontsize=14, color="black" + ) + # Add subtitle with date range if enabled if subtitle: - ax.set_title("\n%s - %s " % ( - returns.index.date[:1][0].strftime('%e %b \'%y'), - returns.index.date[-1:][0].strftime('%e %b \'%y') - ), fontsize=12, color='gray') - - fig.set_facecolor('white') - ax.set_facecolor('white') - + ax.set_title( + "%s - %s \n" + % ( + returns.index.date[:1][0].strftime("%e %b '%y"), + returns.index.date[-1:][0].strftime("%e %b '%y"), + ), + fontsize=12, + color="gray", + ) + + # Set background colors + fig.set_facecolor("white") + ax.set_facecolor("white") + + # Plot benchmark first if provided if isinstance(benchmark, _pd.Series): - ax.plot(benchmark, lw=lw, ls=ls, label="Benchmark", color=colors[0]) + ax.plot(benchmark, lw=lw, ls=ls, label=benchmark.name, color=colors[0]) + + # Adjust alpha for grayscale + alpha = 0.25 if grayscale else 1 - alpha = .25 if grayscale else 1 - ax.plot(returns, lw=lw, label=returns_label, color=colors[1], alpha=alpha) + # Plot returns data + if isinstance(returns, _pd.Series): + ax.plot(returns, lw=lw, label=returns.name, color=colors[1], alpha=alpha) + elif isinstance(returns, _pd.DataFrame): + # Plot each column in the DataFrame + for i, col in enumerate(returns.columns): + ax.plot(returns[col], lw=lw, label=col, alpha=alpha, color=colors[i + 1]) + # Add fill under the line if requested if fill: - ax.fill_between(returns.index, 0, returns, color=colors[1], alpha=.25) + if isinstance(returns, _pd.Series): + ax.fill_between(returns.index, 0, returns, color=colors[1], alpha=0.25) + elif isinstance(returns, _pd.DataFrame): + for i, col in enumerate(returns.columns): + ax.fill_between( + returns[col].index, 0, returns[col], color=colors[i + 1], alpha=0.25 + ) # rotate and align the tick labels so they look better fig.autofmt_xdate() @@ -248,43 +526,60 @@ def plot_timeseries(returns, benchmark=None, # use a more precise date string for the x axis locations in the toolbar # ax.fmt_xdata = _mdates.DateFormatter('%Y-%m-%d') - if hline: - if grayscale: - hlcolor = 'black' - ax.axhline(hline, ls="--", lw=hlw, color=hlcolor, - label=hllabel, zorder=2) + # Add horizontal line if specified + if hline is not None: + if not isinstance(hline, _pd.Series): + if grayscale: + hlcolor = "black" + ax.axhline(hline, ls="--", lw=hlw, color=hlcolor, label=hllabel, zorder=2) - ax.axhline(0, ls="-", lw=1, - color='gray', zorder=1) - ax.axhline(0, ls="--", lw=1, - color='white' if grayscale else 'black', zorder=2) + # Add reference lines at zero + ax.axhline(0, ls="-", lw=1, color="gray", zorder=1) + ax.axhline(0, ls="--", lw=1, color="white" if grayscale else "black", zorder=2) - if isinstance(benchmark, _pd.Series) or hline: - ax.legend(fontsize=12) + # Only show legend if there are labeled elements + handles, labels = ax.get_legend_handles_labels() + if handles: + ax.legend(fontsize=11) + # Set y-axis scale _plt.yscale("symlog" if log_scale else "linear") + # Format y-axis as percentage if requested if percent: ax.yaxis.set_major_formatter(_FuncFormatter(format_pct_axis)) # ax.yaxis.set_major_formatter(_plt.FuncFormatter( # lambda x, loc: "{:,}%".format(int(x*100)))) - ax.set_xlabel('') + # Configure axis labels + ax.set_xlabel("") if ylabel: - ax.set_ylabel(ylabel, fontname=fontname, - fontweight='bold', fontsize=12, color="black") - ax.yaxis.set_label_coords(-.1, .5) + ax.set_ylabel( + ylabel, fontname=fontname, fontweight="bold", fontsize=12, color="black" + ) + ax.yaxis.set_label_coords(-0.1, 0.5) + + # Remove legend for single series without benchmark + if benchmark is None and len(_pd.DataFrame(returns).columns) == 1: + try: + legend = ax.get_legend() + if legend: + legend.remove() + except (ValueError, AttributeError, TypeError, RuntimeError): + pass + # Adjust layout try: _plt.subplots_adjust(hspace=0, bottom=0, top=1) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass try: fig.tight_layout() - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass + # Handle saving and displaying if savefig: if isinstance(savefig, dict): _plt.savefig(**savefig) @@ -302,74 +597,244 @@ def plot_timeseries(returns, benchmark=None, return None -def plot_histogram(returns, resample="M", bins=20, - fontname='Arial', grayscale=False, - title="Returns", kde=True, figsize=(10, 6), - ylabel=True, subtitle=True, compounded=True, - savefig=None, show=True): +def plot_histogram( + returns, + benchmark, + resample="ME", + bins=20, + fontname="Arial", + grayscale=False, + title="Returns", + kde=True, + figsize=(10, 6), + ylabel=True, + subtitle=True, + compounded=True, + savefig=None, + show=True, +): + """ + Plot histogram of returns with optional KDE and benchmark comparison + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Returns data to plot + benchmark : pd.Series + Benchmark returns for comparison + resample : str, default "ME" + Resampling frequency + bins : int, default 20 + Number of histogram bins + fontname : str, default "Arial" + Font name for labels + grayscale : bool, default False + Whether to use grayscale colors + title : str, default "Returns" + Chart title + kde : bool, default True + Whether to show kernel density estimate + figsize : tuple, default (10, 6) + Figure size + ylabel : bool, default True + Whether to show y-axis label + subtitle : bool, default True + Whether to show subtitle with date range + compounded : bool, default True + Whether to use compounded returns + savefig : str or dict, optional + Save figure parameters + show : bool, default True + Whether to display the plot + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None + """ + + # colors = ['#348dc1', '#003366', 'red'] + # if grayscale: + # colors = ['silver', 'gray', 'black'] - colors = ['#348dc1', '#003366', 'red'] - if grayscale: - colors = ['silver', 'gray', 'black'] - - apply_fnc = _stats.comp if compounded else _np.sum - returns = returns.fillna(0).resample(resample).apply( - apply_fnc).resample(resample).last() - - fig, ax = _plt.subplots(figsize=figsize) - ax.spines['top'].set_visible(False) - ax.spines['right'].set_visible(False) - ax.spines['bottom'].set_visible(False) - ax.spines['left'].set_visible(False) - - fig.suptitle(title+"\n", y=.99, fontweight="bold", fontname=fontname, - fontsize=14, color="black") + colors, _, _ = _get_colors(grayscale) - if subtitle: - ax.set_title("\n%s - %s " % ( - returns.index.date[:1][0].strftime('%Y'), - returns.index.date[-1:][0].strftime('%Y') - ), fontsize=12, color='gray') + # Choose aggregation function based on compounded setting + apply_fnc = _get_stats().comp if compounded else _np.sum - fig.set_facecolor('white') - ax.set_facecolor('white') + # Process benchmark data + if benchmark is not None: + benchmark = benchmark.fillna(0) + benchmark = safe_resample(benchmark, resample, apply_fnc) + benchmark = safe_resample(benchmark, resample, "last") - ax.axvline(returns.mean(), ls="--", lw=1.5, - color=colors[2], zorder=2, label="Average") + # Process returns data + returns = returns.fillna(0) + returns = safe_resample(returns, resample, apply_fnc) + returns = safe_resample(returns, resample, "last") - _sns.distplot(returns, bins=bins, - axlabel="", color=colors[0], hist_kws=dict(alpha=1), - kde=kde, - # , label="Kernel Estimate" - kde_kws=dict(color='black', alpha=.7), - ax=ax) + # Adjust figure size slightly + figsize = (0.995 * figsize[0], figsize[1]) + fig, ax = _plt.subplots(figsize=figsize) - ax.xaxis.set_major_formatter(_plt.FuncFormatter( - lambda x, loc: "{:,}%".format(int(x*100)))) + # Configure axis appearance + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["bottom"].set_visible(False) + ax.spines["left"].set_visible(False) - ax.axhline(0.01, lw=1, color="#000000", zorder=2) - ax.axvline(0, lw=1, color="#000000", zorder=2) + # Set main title + fig.suptitle( + title, y=0.94, fontweight="bold", fontname=fontname, fontsize=14, color="black" + ) - ax.set_xlabel('') + # Add subtitle with date range if enabled + if subtitle: + ax.set_title( + "%s - %s \n" + % ( + returns.index.date[:1][0].strftime("%Y"), + returns.index.date[-1:][0].strftime("%Y"), + ), + fontsize=12, + color="gray", + ) + + # Set background colors + fig.set_facecolor("white") + ax.set_facecolor("white") + + # Convert single-column DataFrame to Series for easier handling + if isinstance(returns, _pd.DataFrame) and len(returns.columns) == 1: + returns = returns[returns.columns[0]] + + # Set up color palette and alpha based on data structure + pallete = colors[1:2] if benchmark is None else colors[:2] + alpha = 0.7 + if isinstance(returns, _pd.DataFrame): + pallete = ( + colors[1:(len(returns.columns) + 1)] + if benchmark is None + else colors[:(len(returns.columns) + 1)] + ) + if len(returns.columns) > 1: + alpha = 0.5 + + # Plot histogram with benchmark comparison + if benchmark is not None: + if isinstance(returns, _pd.Series): + combined_returns = ( + benchmark.to_frame() + .join(returns.to_frame()) + .stack() + .reset_index() + .rename(columns={"level_1": "", 0: "Returns"}) + ) + elif isinstance(returns, _pd.DataFrame): + combined_returns = ( + benchmark.to_frame() + .join(returns) + .stack() + .reset_index() + .rename(columns={"level_1": "", 0: "Returns"}) + ) + _sns.histplot( + data=combined_returns, + x="Returns", + bins=bins, + alpha=alpha, + kde=kde, + stat="density", + hue="", + palette=pallete, + ax=ax, + ) + + else: + # Plot histogram without benchmark + if isinstance(returns, _pd.Series): + combined_returns = returns.copy() + if kde: + _sns.kdeplot(data=combined_returns, color="black", ax=ax, warn_singular=False) + + _sns.histplot( + data=combined_returns, + bins=bins, + alpha=alpha, + kde=False, + stat="density", + color=colors[1], + ax=ax, + ) + + elif isinstance(returns, _pd.DataFrame): + combined_returns = ( + returns.stack() + .reset_index() + .rename(columns={"level_1": "", 0: "Returns"}) + ) + # _sns.kdeplot(data=combined_returns, color='black', ax=ax) + _sns.histplot( + data=combined_returns, + x="Returns", + bins=bins, + alpha=alpha, + kde=kde, + stat="density", + hue="", + palette=pallete, + ax=ax, + ) + + # Add average line for single series + if isinstance(combined_returns, _pd.Series) or len(combined_returns.columns) == 1: + ax.axvline( + combined_returns.mean(), + ls="--", + lw=1.5, + zorder=2, + label="Average", + color="red", + ) + + # Format x-axis as percentage + ax.xaxis.set_major_formatter( + _plt.FuncFormatter(lambda x, loc: "{:,}%".format(int(x * 100))) + ) + + # Removed static lines for clarity + # ax.axhline(0.01, lw=1, color="#000000", zorder=2) + # ax.axvline(0, lw=1, color="#000000", zorder=2) + + # Configure axis labels + ax.set_xlabel("") if ylabel: - ax.set_ylabel("Occurrences", fontname=fontname, - fontweight='bold', fontsize=12, color="black") - ax.yaxis.set_label_coords(-.1, .5) - - ax.legend(fontsize=12) + ax.set_ylabel( + "Occurrences", + fontname=fontname, + fontweight="bold", + fontsize=12, + color="black", + ) + else: + ax.set_ylabel("") + + ax.yaxis.set_label_coords(-0.1, 0.5) # fig.autofmt_xdate() + # Adjust layout try: _plt.subplots_adjust(hspace=0, bottom=0, top=1) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass try: fig.tight_layout() - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass + # Handle saving and displaying if savefig: if isinstance(savefig, dict): _plt.savefig(**savefig) @@ -387,72 +852,187 @@ def plot_histogram(returns, resample="M", bins=20, return None -def plot_rolling_stats(returns, benchmark=None, title="", - returns_label="Strategy", - hline=None, hlw=None, hlcolor="red", hllabel="", - lw=1.5, figsize=(10, 6), ylabel="", - grayscale=False, fontname="Arial", subtitle=True, - savefig=None, show=True): +def plot_rolling_stats( + returns, + benchmark=None, + title="", + returns_label="Strategy", + hline=None, + hlw=None, + hlcolor="red", + hllabel="", + lw=1.5, + figsize=(10, 6), + ylabel="", + grayscale=False, + fontname="Arial", + subtitle=True, + savefig=None, + show=True, +): + """ + Plot rolling statistics time series + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Returns data to plot + benchmark : pd.Series, optional + Benchmark returns for comparison + title : str, default "" + Chart title + returns_label : str, default "Strategy" + Label for returns data + hline : float, optional + Horizontal line value + hlw : float, optional + Horizontal line width + hlcolor : str, default "red" + Horizontal line color + hllabel : str, default "" + Horizontal line label + lw : float, default 1.5 + Line width + figsize : tuple, default (10, 6) + Figure size + ylabel : str, default "" + Y-axis label + grayscale : bool, default False + Whether to use grayscale colors + fontname : str, default "Arial" + Font name for labels + subtitle : bool, default True + Whether to show subtitle with date range + savefig : str or dict, optional + Save figure parameters + show : bool, default True + Whether to display the plot + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None + """ colors, _, _ = _get_colors(grayscale) + # Create figure and axis fig, ax = _plt.subplots(figsize=figsize) - ax.spines['top'].set_visible(False) - ax.spines['right'].set_visible(False) - ax.spines['bottom'].set_visible(False) - ax.spines['left'].set_visible(False) - - df = _pd.DataFrame(index=returns.index, data={returns_label: returns}) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["bottom"].set_visible(False) + ax.spines["left"].set_visible(False) + + # Handle DataFrame case for returns_label + if isinstance(returns, _pd.DataFrame): + returns_label = list(returns.columns) + + # Prepare data structure + if isinstance(returns, _pd.Series): + df = _pd.DataFrame(index=returns.index, data={returns_label: returns}) + elif isinstance(returns, _pd.DataFrame): + df = _pd.DataFrame( + index=returns.index, data={col: returns[col] for col in returns.columns} + ) + + # Add benchmark data if provided if isinstance(benchmark, _pd.Series): - df['Benchmark'] = benchmark[benchmark.index.isin(returns.index)] - df = df[['Benchmark', returns_label]].dropna() - ax.plot(df['Benchmark'], lw=lw, label="Benchmark", - color=colors[0], alpha=.8) - - ax.plot(df[returns_label].dropna(), lw=lw, - label=returns_label, color=colors[1]) + df["Benchmark"] = benchmark[benchmark.index.isin(returns.index)] + if isinstance(returns, _pd.Series): + df = df[["Benchmark", returns_label]].dropna() + ax.plot( + df[returns_label].dropna(), lw=lw, label=returns.name, color=colors[1] + ) + elif isinstance(returns, _pd.DataFrame): + col_names = ["Benchmark", returns_label] + df = df[list(_pd.core.common.flatten(col_names))].dropna() + for i, col in enumerate(returns_label): + ax.plot(df[col], lw=lw, label=col, color=colors[i + 1]) + # Plot benchmark line + ax.plot( + df["Benchmark"], lw=lw, label=benchmark.name, color=colors[0], alpha=0.8 + ) + else: + # Plot without benchmark + if isinstance(returns, _pd.Series): + df = df[[returns_label]].dropna() + ax.plot( + df[returns_label].dropna(), lw=lw, label=returns.name, color=colors[1] + ) + elif isinstance(returns, _pd.DataFrame): + df = df[returns_label].dropna() + for i, col in enumerate(returns_label): + ax.plot(df[col], lw=lw, label=col, color=colors[i + 1]) # rotate and align the tick labels so they look better fig.autofmt_xdate() # use a more precise date string for the x axis locations in the toolbar # ax.fmt_xdata = _mdates.DateFormatter('%Y-%m-%d')\ - fig.suptitle(title+"\n", y=.99, fontweight="bold", fontname=fontname, - fontsize=14, color="black") - - if subtitle: - ax.set_title("\n%s - %s " % ( - df.index.date[:1][0].strftime('%e %b \'%y'), - df.index.date[-1:][0].strftime('%e %b \'%y') - ), fontsize=12, color='gray') - if hline: - if grayscale: - hlcolor = 'black' - ax.axhline(hline, ls="--", lw=hlw, color=hlcolor, - label=hllabel, zorder=2) + # Set main title + fig.suptitle( + title, y=0.94, fontweight="bold", fontname=fontname, fontsize=14, color="black" + ) + # Add subtitle with date range if enabled + if subtitle: + ax.set_title( + "%s - %s \n" + % ( + df.index.date[:1][0].strftime("%e %b '%y"), + df.index.date[-1:][0].strftime("%e %b '%y"), + ), + fontsize=12, + color="gray", + ) + + # Add horizontal line if specified + if hline is not None: + if not isinstance(hline, _pd.Series): + if grayscale: + hlcolor = "black" + ax.axhline(hline, ls="--", lw=hlw, color=hlcolor, label=hllabel, zorder=2) + + # Add zero reference line ax.axhline(0, ls="--", lw=1, color="#000000", zorder=2) + # Configure y-axis label if ylabel: - ax.set_ylabel(ylabel, fontname=fontname, - fontweight='bold', fontsize=12, color="black") - ax.yaxis.set_label_coords(-.1, .5) + ax.set_ylabel( + ylabel, fontname=fontname, fontweight="bold", fontsize=12, color="black" + ) + ax.yaxis.set_label_coords(-0.1, 0.5) + + # Format y-axis with fixed decimals + ax.yaxis.set_major_formatter(_FormatStrFormatter("%.2f")) - ax.yaxis.set_major_formatter(_FormatStrFormatter('%.2f')) + # Only show legend if there are labeled elements + handles, labels = ax.get_legend_handles_labels() + if handles: + ax.legend(fontsize=11) - ax.legend(fontsize=12) + # Remove legend for single series without benchmark + if benchmark is None and len(_pd.DataFrame(returns).columns) == 1: + try: + legend = ax.get_legend() + if legend: + legend.remove() + except (ValueError, AttributeError, TypeError, RuntimeError): + pass + # Adjust layout try: _plt.subplots_adjust(hspace=0, bottom=0, top=1) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass try: fig.tight_layout() - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass + # Handle saving and displaying if savefig: if isinstance(savefig, dict): _plt.savefig(**savefig) @@ -469,68 +1049,201 @@ def plot_rolling_stats(returns, benchmark=None, title="", return None -def plot_rolling_beta(returns, benchmark, - window1=126, window1_label="", - window2=None, window2_label="", - title="", hlcolor="red", figsize=(10, 6), - grayscale=False, fontname="Arial", lw=1.5, - ylabel=True, subtitle=True, savefig=None, show=True): +def plot_rolling_beta( + returns, + benchmark, + window1=126, + window1_label="", + window2=None, + window2_label="", + title="", + hlcolor="red", + figsize=(10, 6), + grayscale=False, + fontname="Arial", + lw=1.5, + ylabel=True, + subtitle=True, + savefig=None, + show=True, +): + """ + Plot rolling beta calculation over time + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Returns data to calculate beta for + benchmark : pd.Series + Benchmark returns for beta calculation + window1 : int, default 126 + Primary rolling window size + window1_label : str, default "" + Label for primary window + window2 : int, optional + Secondary rolling window size + window2_label : str, default "" + Label for secondary window + title : str, default "" + Chart title + hlcolor : str, default "red" + Horizontal line color + figsize : tuple, default (10, 6) + Figure size + grayscale : bool, default False + Whether to use grayscale colors + fontname : str, default "Arial" + Font name for labels + lw : float, default 1.5 + Line width + ylabel : bool, default True + Whether to show y-axis label + subtitle : bool, default True + Whether to show subtitle with date range + savefig : str or dict, optional + Save figure parameters + show : bool, default True + Whether to display the plot + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None + """ colors, _, _ = _get_colors(grayscale) + # Create figure and axis fig, ax = _plt.subplots(figsize=figsize) - ax.spines['top'].set_visible(False) - ax.spines['right'].set_visible(False) - ax.spines['bottom'].set_visible(False) - ax.spines['left'].set_visible(False) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["bottom"].set_visible(False) + ax.spines["left"].set_visible(False) - fig.suptitle(title+"\n", y=.99, fontweight="bold", fontname=fontname, - fontsize=14, color="black") + # Set main title + fig.suptitle( + title, y=0.94, fontweight="bold", fontname=fontname, fontsize=14, color="black" + ) + # Add subtitle with date range if enabled if subtitle: - ax.set_title("\n%s - %s " % ( - returns.index.date[:1][0].strftime('%e %b \'%y'), - returns.index.date[-1:][0].strftime('%e %b \'%y') - ), fontsize=12, color='gray') - - beta = _stats.rolling_greeks(returns, benchmark, window1)['beta'] - ax.plot(beta, lw=lw, label=window1_label, color=colors[1]) - + ax.set_title( + "%s - %s \n" + % ( + returns.index.date[:1][0].strftime("%e %b '%y"), + returns.index.date[-1:][0].strftime("%e %b '%y"), + ), + fontsize=12, + color="gray", + ) + + # Calculate and plot primary beta window + i = 1 + if isinstance(returns, _pd.Series): + beta = _get_stats().rolling_greeks(returns, benchmark, window1)["beta"].fillna(0) + ax.plot(beta, lw=lw, label=window1_label, color=colors[1]) + elif isinstance(returns, _pd.DataFrame): + beta = { + col: _get_stats().rolling_greeks(returns[col], benchmark, window1)["beta"].fillna( + 0 + ) + for col in returns.columns + } + for name, b in beta.items(): + ax.plot(b, lw=lw, label=name + " " + f"({window1_label})", color=colors[i]) + i += 1 + + # Calculate and plot secondary beta window if provided + i = 1 if window2: - ax.plot(_stats.rolling_greeks(returns, benchmark, window2)['beta'], - lw=lw, label=window2_label, color="gray", alpha=0.8) - mmin = min([-100, int(beta.min()*100)]) - mmax = max([100, int(beta.max()*100)]) - step = 50 if (mmax-mmin) >= 200 else 100 + lw = lw - 0.5 # Thinner line for secondary window + if isinstance(returns, _pd.Series): + ax.plot( + _get_stats().rolling_greeks(returns, benchmark, window2)["beta"], + lw=lw, + label=window2_label, + color="gray", + alpha=0.8, + ) + elif isinstance(returns, _pd.DataFrame): + betas_w2 = { + col: _get_stats().rolling_greeks(returns[col], benchmark, window2)["beta"] + for col in returns.columns + } + for name, beta_w2 in betas_w2.items(): + ax.plot( + beta_w2, + lw=lw, + ls="--", + label=name + " " + f"({window2_label})", + alpha=0.5, + color=colors[i], + ) + i += 1 + + # Calculate beta range for y-axis ticks + beta_min = ( + beta.min() + if isinstance(returns, _pd.Series) + else min([b.min() for b in beta.values()]) + ) + beta_max = ( + beta.max() + if isinstance(returns, _pd.Series) + else max([b.max() for b in beta.values()]) + ) + mmin = min([-100, int(beta_min * 100)]) + mmax = max([100, int(beta_max * 100)]) + step = 50 if (mmax - mmin) >= 200 else 100 ax.set_yticks([x / 100 for x in list(range(mmin, mmax, step))]) - hlcolor = 'black' if grayscale else hlcolor - ax.axhline(beta.mean(), ls="--", lw=1.5, - color=hlcolor, zorder=2) + # Add mean line for single series + if isinstance(returns, _pd.Series): + hlcolor = "black" if grayscale else hlcolor + ax.axhline(beta.mean(), ls="--", lw=1.5, color=hlcolor, zorder=2) + # Add zero reference line ax.axhline(0, ls="--", lw=1, color="#000000", zorder=2) + # Format dates on x-axis fig.autofmt_xdate() # use a more precise date string for the x axis locations in the toolbar - ax.fmt_xdata = _mdates.DateFormatter('%Y-%m-%d') + ax.fmt_xdata = _mdates.DateFormatter("%Y-%m-%d") + # Configure y-axis label if ylabel: - ax.set_ylabel("Beta", fontname=fontname, - fontweight='bold', fontsize=12, color="black") - ax.yaxis.set_label_coords(-.1, .5) + ax.set_ylabel( + "Beta", fontname=fontname, fontweight="bold", fontsize=12, color="black" + ) + ax.yaxis.set_label_coords(-0.1, 0.5) + + # Only show legend if there are labeled elements + handles, labels = ax.get_legend_handles_labels() + if handles: + ax.legend(fontsize=11) + + # Remove legend for single series without benchmark + if benchmark is None and len(_pd.DataFrame(returns).columns) == 1: + try: + legend = ax.get_legend() + if legend: + legend.remove() + except (ValueError, AttributeError, TypeError, RuntimeError): + pass - ax.legend(fontsize=12) + # Adjust layout try: _plt.subplots_adjust(hspace=0, bottom=0, top=1) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass try: fig.tight_layout() - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass + # Handle saving and displaying if savefig: if isinstance(savefig, dict): _plt.savefig(**savefig) @@ -548,75 +1261,160 @@ def plot_rolling_beta(returns, benchmark, return None -def plot_longest_drawdowns(returns, periods=5, lw=1.5, - fontname='Arial', grayscale=False, - log_scale=False, figsize=(10, 6), ylabel=True, - subtitle=True, compounded=True, - savefig=None, show=True): - - colors = ['#348dc1', '#003366', 'red'] +def plot_longest_drawdowns( + returns, + periods=5, + lw=1.5, + fontname="Arial", + grayscale=False, + title=None, + log_scale=False, + figsize=(10, 6), + ylabel=True, + subtitle=True, + compounded=True, + savefig=None, + show=True, +): + """ + Plot cumulative returns with longest drawdown periods highlighted + + Parameters + ---------- + returns : pd.Series + Returns data to analyze + periods : int, default 5 + Number of longest drawdown periods to highlight + lw : float, default 1.5 + Line width + fontname : str, default "Arial" + Font name for labels + grayscale : bool, default False + Whether to use grayscale colors + title : str, optional + Chart title + log_scale : bool, default False + Whether to use log scale for y-axis + figsize : tuple, default (10, 6) + Figure size + ylabel : bool, default True + Whether to show y-axis label + subtitle : bool, default True + Whether to show subtitle with date range + compounded : bool, default True + Whether to use compounded returns + savefig : str or dict, optional + Save figure parameters + show : bool, default True + Whether to display the plot + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None + """ + + colors = ["#348dc1", "#003366", "red"] if grayscale: - colors = ['#000000'] * 3 + colors = ["#000000"] * 3 - dd = _stats.to_drawdown_series(returns.fillna(0)) - dddf = _stats.drawdown_details(dd) - longest_dd = dddf.sort_values( - by='days', ascending=False, kind='mergesort')[:periods] + # Calculate drawdown statistics + dd = _get_stats().to_drawdown_series(returns.fillna(0)) + dddf = _get_stats().drawdown_details(dd) + longest_dd = dddf.sort_values(by="days", ascending=False, kind="mergesort")[ + :periods + ] + # Create figure and axis fig, ax = _plt.subplots(figsize=figsize) - ax.spines['top'].set_visible(False) - ax.spines['right'].set_visible(False) - ax.spines['bottom'].set_visible(False) - ax.spines['left'].set_visible(False) - - fig.suptitle("Top %.0f Drawdown Periods\n" % - periods, y=.99, fontweight="bold", fontname=fontname, - fontsize=14, color="black") + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["bottom"].set_visible(False) + ax.spines["left"].set_visible(False) + + # Set main title with period count + fig.suptitle( + f"{title} - Worst %.0f Drawdown Periods" % periods, + y=0.94, + fontweight="bold", + fontname=fontname, + fontsize=14, + color="black", + ) + + # Add subtitle with date range if enabled if subtitle: - ax.set_title("\n%s - %s " % ( - returns.index.date[:1][0].strftime('%e %b \'%y'), - returns.index.date[-1:][0].strftime('%e %b \'%y') - ), fontsize=12, color='gray') - - fig.set_facecolor('white') - ax.set_facecolor('white') - series = _stats.compsum(returns) if compounded else returns.cumsum() + ax.set_title( + "%s - %s \n" + % ( + returns.index.date[:1][0].strftime("%e %b '%y"), + returns.index.date[-1:][0].strftime("%e %b '%y"), + ), + fontsize=12, + color="gray", + ) + + # Set background colors + fig.set_facecolor("white") + ax.set_facecolor("white") + + # Calculate cumulative returns + series = _get_stats().compsum(returns) if compounded else returns.cumsum() ax.plot(series, lw=lw, label="Backtest", color=colors[0]) - highlight = 'black' if grayscale else 'red' - for _, row in longest_dd.iterrows(): - ax.axvspan(*_mdates.datestr2num([str(row['start']), str(row['end'])]), - color=highlight, alpha=.1) + # Highlight drawdown periods + highlight = "black" if grayscale else "red" + # Vectorized approach instead of iterrows + for start, end in zip(longest_dd["start"], longest_dd["end"]): + ax.axvspan( + *_mdates.datestr2num([str(start), str(end)]), + color=highlight, + alpha=0.1, + ) # rotate and align the tick labels so they look better fig.autofmt_xdate() # use a more precise date string for the x axis locations in the toolbar - ax.fmt_xdata = _mdates.DateFormatter('%Y-%m-%d') + ax.fmt_xdata = _mdates.DateFormatter("%Y-%m-%d") + # Add zero reference line ax.axhline(0, ls="--", lw=1, color="#000000", zorder=2) + + # Set y-axis scale _plt.yscale("symlog" if log_scale else "linear") - if ylabel: - ax.set_ylabel("Cumulative Returns", fontname=fontname, - fontweight='bold', fontsize=12, color="black") - ax.yaxis.set_label_coords(-.1, .5) + # Configure y-axis label + if ylabel: + ax.set_ylabel( + "Cumulative Returns", + fontname=fontname, + fontweight="bold", + fontsize=12, + color="black", + ) + ax.yaxis.set_label_coords(-0.1, 0.5) + + # Format y-axis as percentage ax.yaxis.set_major_formatter(_FuncFormatter(format_pct_axis)) # ax.yaxis.set_major_formatter(_plt.FuncFormatter( # lambda x, loc: "{:,}%".format(int(x*100)))) + # Format dates on x-axis fig.autofmt_xdate() + # Adjust layout try: _plt.subplots_adjust(hspace=0, bottom=0, top=1) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass try: fig.tight_layout() - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass + # Handle saving and displaying if savefig: if isinstance(savefig, dict): _plt.savefig(**savefig) @@ -634,77 +1432,148 @@ def plot_longest_drawdowns(returns, periods=5, lw=1.5, return None -def plot_distribution(returns, figsize=(10, 6), - fontname='Arial', grayscale=False, ylabel=True, - subtitle=True, compounded=True, - savefig=None, show=True): +def plot_distribution( + returns, + figsize=(10, 6), + fontname="Arial", + grayscale=False, + ylabel=True, + subtitle=True, + compounded=True, + title=None, + savefig=None, + show=True, +): + """ + Plot box plot showing return distribution across different time periods + + Parameters + ---------- + returns : pd.Series + Returns data to analyze + figsize : tuple, default (10, 6) + Figure size + fontname : str, default "Arial" + Font name for labels + grayscale : bool, default False + Whether to use grayscale colors + ylabel : bool, default True + Whether to show y-axis label + subtitle : bool, default True + Whether to show subtitle with date range + compounded : bool, default True + Whether to use compounded returns + title : str, optional + Chart title + savefig : str or dict, optional + Save figure parameters + show : bool, default True + Whether to display the plot + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None + """ colors = _FLATUI_COLORS if grayscale: - colors = ['#f9f9f9', '#dddddd', '#bbbbbb', '#999999', '#808080'] + colors = ["#f9f9f9", "#dddddd", "#bbbbbb", "#999999", "#808080"] # colors, ls, alpha = _get_colors(grayscale) + # Create portfolio data structure port = _pd.DataFrame(returns.fillna(0)) - port.columns = ['Daily'] - - apply_fnc = _stats.comp if compounded else _np.sum - - port['Weekly'] = port['Daily'].resample( - 'W-MON').apply(apply_fnc).resample('W-MON').last() - port['Weekly'].ffill(inplace=True) - - port['Monthly'] = port['Daily'].resample( - 'M').apply(apply_fnc).resample('M').last() - port['Monthly'].ffill(inplace=True) - - port['Quarterly'] = port['Daily'].resample( - 'Q').apply(apply_fnc).resample('Q').last() - port['Quarterly'].ffill(inplace=True) - - port['Yearly'] = port['Daily'].resample( - 'A').apply(apply_fnc).resample('A').last() - port['Yearly'].ffill(inplace=True) - + port.columns = ["Daily"] + + # Calculate returns for different time periods + if compounded: + port["Weekly"] = safe_resample(port["Daily"], "W-MON", _get_stats().comp) + port["Monthly"] = safe_resample(port["Daily"], "ME", _get_stats().comp) + port["Quarterly"] = safe_resample(port["Daily"], "QE", _get_stats().comp) + port["Yearly"] = safe_resample(port["Daily"], "YE", _get_stats().comp) + else: + port["Weekly"] = safe_resample(port["Daily"], "W-MON", "sum") + port["Monthly"] = safe_resample(port["Daily"], "ME", "sum") + port["Quarterly"] = safe_resample(port["Daily"], "QE", "sum") + port["Yearly"] = safe_resample(port["Daily"], "YE", "sum") + + # Forward fill missing values + port["Weekly"] = port["Weekly"].ffill() + port["Monthly"] = port["Monthly"].ffill() + port["Quarterly"] = port["Quarterly"].ffill() + port["Yearly"] = port["Yearly"].ffill() + + # Create figure and axis fig, ax = _plt.subplots(figsize=figsize) - ax.spines['top'].set_visible(False) - ax.spines['right'].set_visible(False) - ax.spines['bottom'].set_visible(False) - ax.spines['left'].set_visible(False) - - fig.suptitle("Return Quantiles\n", y=.99, - fontweight="bold", fontname=fontname, - fontsize=14, color="black") - + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["bottom"].set_visible(False) + ax.spines["left"].set_visible(False) + + # Set main title + if title: + title = f"{title} - Return Quantiles" + else: + title = "Return Quantiles" + fig.suptitle( + title, y=0.94, fontweight="bold", fontname=fontname, fontsize=14, color="black" + ) + + # Add subtitle with date range if enabled if subtitle: - ax.set_title("\n%s - %s " % ( - returns.index.date[:1][0].strftime('%e %b \'%y'), - returns.index.date[-1:][0].strftime('%e %b \'%y') - ), fontsize=12, color='gray') - - fig.set_facecolor('white') - ax.set_facecolor('white') - - _sns.boxplot(data=port, ax=ax, palette=tuple(colors[:5])) - - ax.yaxis.set_major_formatter(_plt.FuncFormatter( - lambda x, loc: "{:,}%".format(int(x*100)))) - + ax.set_title( + "%s - %s \n" + % ( + returns.index.date[:1][0].strftime("%e %b '%y"), + returns.index.date[-1:][0].strftime("%e %b '%y"), + ), + fontsize=12, + color="gray", + ) + + # Set background colors + fig.set_facecolor("white") + ax.set_facecolor("white") + + # Create box plot with custom color palette + _sns.boxplot( + data=port, + ax=ax, + palette={ + "Daily": colors[0], + "Weekly": colors[1], + "Monthly": colors[2], + "Quarterly": colors[3], + "Yearly": colors[4], + }, + ) + + # Format y-axis as percentage + ax.yaxis.set_major_formatter( + _plt.FuncFormatter(lambda x, loc: "{:,}%".format(int(x * 100))) + ) + + # Configure y-axis label if ylabel: - ax.set_ylabel('Rerurns', fontname=fontname, - fontweight='bold', fontsize=12, color="black") - ax.yaxis.set_label_coords(-.1, .5) + ax.set_ylabel( + "Returns", fontname=fontname, fontweight="bold", fontsize=12, color="black" + ) + ax.yaxis.set_label_coords(-0.1, 0.5) + # Format dates on x-axis fig.autofmt_xdate() + # Adjust layout try: _plt.subplots_adjust(hspace=0) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass try: fig.tight_layout(w_pad=0, h_pad=0) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass + # Handle saving and displaying if savefig: if isinstance(savefig, dict): _plt.savefig(**savefig) @@ -722,74 +1591,132 @@ def plot_distribution(returns, figsize=(10, 6), return None -def plot_table(tbl, columns=None, title="", title_loc="left", - header=True, - colWidths=None, - rowLoc='right', - colLoc='right', - colLabels=None, - edges='horizontal', - orient='horizontal', - figsize=(5.5, 6), - savefig=None, - show=False): - +def plot_table( + tbl, + columns=None, + title="", + title_loc="left", + header=True, + colWidths=None, + rowLoc="right", + colLoc="right", + colLabels=None, + edges="horizontal", + orient="horizontal", + figsize=(5.5, 6), + savefig=None, + show=False, +): + """ + Plot a data table as a matplotlib figure + + Parameters + ---------- + tbl : pd.DataFrame + Data table to plot + columns : list, optional + Column names to use + title : str, default "" + Table title + title_loc : str, default "left" + Title location + header : bool, default True + Whether to show header row + colWidths : list, optional + Column widths + rowLoc : str, default "right" + Row alignment + colLoc : str, default "right" + Column alignment + colLabels : list, optional + Column labels + edges : str, default "horizontal" + Table edge style + orient : str, default "horizontal" + Table orientation + figsize : tuple, default (5.5, 6) + Figure size + savefig : str or dict, optional + Save figure parameters + show : bool, default False + Whether to display the plot + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None + """ + + # Set column names if provided if columns is not None: try: tbl.columns = columns - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass + # Create figure and axis fig = _plt.figure(figsize=figsize) ax = _plt.subplot(111, frame_on=False) + # Set title if provided if title != "": - ax.set_title(title, fontweight="bold", - fontsize=14, color="black", loc=title_loc) - - the_table = ax.table(cellText=tbl.values, - colWidths=colWidths, - rowLoc=rowLoc, - colLoc=colLoc, - edges=edges, - colLabels=(tbl.columns if header else colLabels), - loc='center', - zorder=2 - ) - + ax.set_title( + title, fontweight="bold", fontsize=14, color="black", loc=title_loc + ) + + # Create table + the_table = ax.table( + cellText=tbl.values, + colWidths=colWidths, + rowLoc=rowLoc, + colLoc=colLoc, + edges=edges, + colLabels=(tbl.columns if header else colLabels), + loc="center", + zorder=2, + ) + + # Configure table appearance the_table.auto_set_font_size(False) the_table.set_fontsize(12) the_table.scale(1, 1) + # Style individual cells for (row, col), cell in the_table.get_celld().items(): cell.set_height(0.08) - cell.set_text_props(color='black') - cell.set_edgecolor('#dddddd') + cell.set_text_props(color="black") + cell.set_edgecolor("#dddddd") + # Header row styling if row == 0 and header: - cell.set_edgecolor('black') - cell.set_facecolor('black') + cell.set_edgecolor("black") + cell.set_facecolor("black") cell.set_linewidth(2) - cell.set_text_props(weight='bold', color='black') + cell.set_text_props(weight="bold", color="black") + # First column styling for vertical orientation elif col == 0 and "vertical" in orient: - cell.set_edgecolor('#dddddd') + cell.set_edgecolor("#dddddd") cell.set_linewidth(1) - cell.set_text_props(weight='bold', color='black') + cell.set_text_props(weight="bold", color="black") + # Data row styling elif row > 1: cell.set_linewidth(1) + # Remove axis elements ax.grid(False) ax.set_xticks([]) ax.set_yticks([]) + # Adjust layout try: _plt.subplots_adjust(hspace=0) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass try: fig.tight_layout(w_pad=0, h_pad=0) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass + # Handle saving and displaying if savefig: if isinstance(savefig, dict): _plt.savefig(**savefig) @@ -808,35 +1735,403 @@ def plot_table(tbl, columns=None, title="", title_loc="left", def format_cur_axis(x, _): + """ + Format currency values for axis labels with appropriate units + + Parameters + ---------- + x : float + Value to format + _ : unused + Matplotlib formatter parameter (not used) + + Returns + ------- + str + Formatted currency string with appropriate unit suffix + """ + # Format large values with appropriate suffixes if x >= 1e12: - res = '$%1.1fT' % (x * 1e-12) - return res.replace('.0T', 'T') + res = "$%1.1fT" % (x * 1e-12) + return res.replace(".0T", "T") if x >= 1e9: - res = '$%1.1fB' % (x * 1e-9) - return res.replace('.0B', 'B') + res = "$%1.1fB" % (x * 1e-9) + return res.replace(".0B", "B") if x >= 1e6: - res = '$%1.1fM' % (x * 1e-6) - return res.replace('.0M', 'M') + res = "$%1.1fM" % (x * 1e-6) + return res.replace(".0M", "ME") if x >= 1e3: - res = '$%1.0fK' % (x * 1e-3) - return res.replace('.0K', 'K') - res = '$%1.0f' % x - return res.replace('.0', '') + res = "$%1.0fK" % (x * 1e-3) + return res.replace(".0K", "K") + # Format small values without suffix + res = "$%1.0f" % x + return res.replace(".0", "") def format_pct_axis(x, _): + """ + Format percentage values for axis labels with appropriate units + + Parameters + ---------- + x : float + Value to format (as decimal, e.g., 0.01 for 1%) + _ : unused + Matplotlib formatter parameter (not used) + + Returns + ------- + str + Formatted percentage string with appropriate unit suffix + """ + # Convert to percentage x *= 100 # lambda x, loc: "{:,}%".format(int(x * 100)) + + # Format large percentage values with appropriate suffixes if x >= 1e12: - res = '%1.1fT%%' % (x * 1e-12) - return res.replace('.0T%', 'T%') + res = "%1.1fT%%" % (x * 1e-12) + return res.replace(".0T%", "T%") if x >= 1e9: - res = '%1.1fB%%' % (x * 1e-9) - return res.replace('.0B%', 'B%') + res = "%1.1fB%%" % (x * 1e-9) + return res.replace(".0B%", "B%") if x >= 1e6: - res = '%1.1fM%%' % (x * 1e-6) - return res.replace('.0M%', 'M%') + res = "%1.1fM%%" % (x * 1e-6) + return res.replace(".0M%", "M%") if x >= 1e3: - res = '%1.1fK%%' % (x * 1e-3) - return res.replace('.0K%', 'K%') - res = '%1.0f%%' % x - return res.replace('.0%', '%') + res = "%1.1fK%%" % (x * 1e-3) + return res.replace(".0K%", "K%") + # Format small percentage values without suffix + res = "%1.0f%%" % x + return res.replace(".0%", "%") + + +# ======== MONTE CARLO PLOTS ======== + + +def plot_montecarlo( + mc_result, + title="Monte Carlo Simulation", + figsize=(10, 6), + grayscale=False, + fontname="Arial", + ylabel=True, + subtitle=True, + savefig=None, + show=True, + confidence_level=0.95, +): + """ + Plot Monte Carlo simulation results showing all paths with original highlighted. + + Parameters + ---------- + mc_result : MonteCarloResult + Monte Carlo simulation result object + title : str, default "Monte Carlo Simulation" + Chart title + figsize : tuple, default (10, 6) + Figure size + grayscale : bool, default False + Whether to use grayscale colors + fontname : str, default "Arial" + Font name for labels + ylabel : bool, default True + Whether to show y-axis label + subtitle : bool, default True + Whether to show subtitle with statistics + savefig : str or dict, optional + Save figure parameters + show : bool, default True + Whether to display the plot + confidence_level : float, default 0.95 + Confidence level for shaded band (e.g., 0.95 for 95%) + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None + """ + colors, _, _ = _get_colors(grayscale) + + fig, ax = _plt.subplots(figsize=figsize) + + # Get simulation data + data = mc_result.data + + # Plot all simulation paths with low alpha + sim_color = colors[1] if not grayscale else "#888888" + for col in data.columns[1:]: # Skip first (original) path + ax.plot( + data.index, + data[col] * 100, # Convert to percentage + color=sim_color, + alpha=0.03, + linewidth=0.5, + ) + + # Plot confidence bands + lower, upper = mc_result.confidence_band(confidence_level) + ax.fill_between( + data.index, + lower * 100, + upper * 100, + alpha=0.2, + color=colors[1] if not grayscale else "#666666", + label=f"{int(confidence_level * 100)}% Confidence Band", + ) + + # Plot median path + median_path = mc_result.percentile(50) + ax.plot( + data.index, + median_path * 100, + color=colors[0] if not grayscale else "#444444", + linewidth=1.5, + linestyle="--", + label="Median", + ) + + # Plot original path (highlighted) + ax.plot( + data.index, + mc_result.original * 100, + color="red" if not grayscale else "#000000", + linewidth=2, + label="Original", + ) + + # Add bust/goal threshold lines if set + if mc_result.bust_threshold is not None: + ax.axhline( + mc_result.bust_threshold * 100, + color="darkred", + linestyle="--", + linewidth=1, + label=f"Bust ({mc_result.bust_threshold:.0%})", + ) + + if mc_result.goal_threshold is not None: + ax.axhline( + mc_result.goal_threshold * 100, + color="darkgreen", + linestyle="--", + linewidth=1, + label=f"Goal ({mc_result.goal_threshold:.0%})", + ) + + # Configure axes + ax.set_xlabel("Trading Days", fontname=fontname) + if ylabel: + ax.set_ylabel( + "Cumulative Return (%)", + fontname=fontname, + fontweight="bold", + fontsize=12, + ) + + # Add title + ax.set_title( + title, + fontname=fontname, + fontweight="bold", + fontsize=14, + color="black", + ) + + # Add subtitle with stats + if subtitle: + stats = mc_result.stats + subtitle_text = ( + f"Sims: {len(data.columns)} | " + f"Mean: {stats['mean']:.1%} | " + f"Median: {stats['median']:.1%} | " + f"5th-95th: {stats['percentile_5']:.1%} to {stats['percentile_95']:.1%}" + ) + if mc_result.bust_probability is not None: + subtitle_text += f" | Bust: {mc_result.bust_probability:.1%}" + if mc_result.goal_probability is not None: + subtitle_text += f" | Goal: {mc_result.goal_probability:.1%}" + + ax.set_title( + f"{title}\n{subtitle_text}", + fontname=fontname, + fontweight="bold", + fontsize=12, + ) + + # Add legend + ax.legend(loc="upper left", fontsize=9) + + # Add horizontal line at 0 + ax.axhline(0, color="black", linewidth=0.5, linestyle="-") + + # Configure grid + ax.grid(True, alpha=0.3) + + # Tight layout + fig.tight_layout() + + # Save figure if requested + if savefig: + if isinstance(savefig, dict): + _plt.savefig(**savefig) + else: + _plt.savefig(savefig) + + if show: + _plt.show() + + _plt.close() + + if not show: + return fig + + return None + + +def plot_montecarlo_distribution( + mc_result, + title="Terminal Value Distribution", + figsize=(10, 6), + grayscale=False, + fontname="Arial", + ylabel=True, + subtitle=True, + savefig=None, + show=True, + bins=50, +): + """ + Plot histogram of terminal values from Monte Carlo simulation. + + Parameters + ---------- + mc_result : MonteCarloResult + Monte Carlo simulation result object + title : str, default "Terminal Value Distribution" + Chart title + figsize : tuple, default (10, 6) + Figure size + grayscale : bool, default False + Whether to use grayscale colors + fontname : str, default "Arial" + Font name for labels + ylabel : bool, default True + Whether to show y-axis label + subtitle : bool, default True + Whether to show subtitle with statistics + savefig : str or dict, optional + Save figure parameters + show : bool, default True + Whether to display the plot + bins : int, default 50 + Number of histogram bins + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None + """ + colors, _, _ = _get_colors(grayscale) + + fig, ax = _plt.subplots(figsize=figsize) + + # Get terminal values + terminal = mc_result.data.iloc[-1] * 100 # Convert to percentage + + # Plot histogram + hist_color = colors[1] if not grayscale else "#666666" + ax.hist(terminal, bins=bins, alpha=0.7, color=hist_color, edgecolor="white") + + # Add vertical lines for key percentiles + stats = mc_result.stats + ax.axvline( + stats["mean"] * 100, + color="red", + linewidth=2, + linestyle="-", + label=f"Mean: {stats['mean']:.1%}", + ) + ax.axvline( + stats["median"] * 100, + color="blue", + linewidth=2, + linestyle="--", + label=f"Median: {stats['median']:.1%}", + ) + ax.axvline( + stats["percentile_5"] * 100, + color="darkred", + linewidth=1.5, + linestyle=":", + label=f"5th pct: {stats['percentile_5']:.1%}", + ) + ax.axvline( + stats["percentile_95"] * 100, + color="darkgreen", + linewidth=1.5, + linestyle=":", + label=f"95th pct: {stats['percentile_95']:.1%}", + ) + + # Mark original terminal value + original_terminal = mc_result.original.iloc[-1] * 100 + ax.axvline( + original_terminal, + color="black", + linewidth=2, + linestyle="-", + label=f"Original: {original_terminal:.1f}%", + ) + + # Configure axes + ax.set_xlabel("Terminal Return (%)", fontname=fontname) + if ylabel: + ax.set_ylabel( + "Frequency", + fontname=fontname, + fontweight="bold", + fontsize=12, + ) + + # Add title + title_text = title + if subtitle: + title_text = ( + f"{title}\n" + f"Min: {stats['min']:.1%} | Max: {stats['max']:.1%} | " + f"Std: {stats['std']:.1%}" + ) + + ax.set_title( + title_text, + fontname=fontname, + fontweight="bold", + fontsize=12, + ) + + # Add legend + ax.legend(loc="upper right", fontsize=9) + + # Configure grid + ax.grid(True, alpha=0.3, axis="y") + + # Tight layout + fig.tight_layout() + + # Save figure if requested + if savefig: + if isinstance(savefig, dict): + _plt.savefig(**savefig) + else: + _plt.savefig(savefig) + + if show: + _plt.show() + + _plt.close() + + if not show: + return fig + + return None diff --git a/quantstats/_plotting/wrappers.py b/quantstats/_plotting/wrappers.py index 870f994b..19c822ed 100644 --- a/quantstats/_plotting/wrappers.py +++ b/quantstats/_plotting/wrappers.py @@ -1,10 +1,9 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- # # Quantreturns: Portfolio analytics for quants # https://github.com/ranaroussi/quantreturns # -# Copyright 2019 Ran Aroussi +# Copyright 2019-2025 Ran Aroussi # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,143 +17,376 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import warnings +from typing import TYPE_CHECKING, Any + import matplotlib.pyplot as _plt from matplotlib.ticker import ( StrMethodFormatter as _StrMethodFormatter, - FuncFormatter as _FuncFormatter + FuncFormatter as _FuncFormatter, ) import numpy as _np -from pandas import DataFrame as _df +import pandas as _pd +from .._compat import safe_resample import seaborn as _sns -from .. import ( - stats as _stats, utils as _utils, -) +# Lazy imports to avoid circular dependency during package initialization +# These modules are imported when first accessed via _get_stats() and _get_utils() +_stats = None +_utils = None + + +def _get_stats(): + global _stats + if _stats is None: + from .. import stats + _stats = stats + return _stats + + +def _get_utils(): + global _utils + if _utils is None: + from .. import utils + _utils = utils + return _utils from . import core as _core +if TYPE_CHECKING: + from matplotlib.figure import Figure as _Figure + +# Type alias for return data (Series or DataFrame) +Returns = _pd.Series | _pd.DataFrame -_FLATUI_COLORS = ["#fedd78", "#348dc1", "#af4b64", - "#4fa487", "#9b59b6", "#808080"] -_GRAYSCALE_COLORS = (len(_FLATUI_COLORS) * ['black']) + ['white'] +_FLATUI_COLORS = ["#fedd78", "#348dc1", "#af4b64", "#4fa487", "#9b59b6", "#808080"] +_GRAYSCALE_COLORS = (len(_FLATUI_COLORS) * ["black"]) + ["white"] +# Check if plotly is available for optional conversion functionality _HAS_PLOTLY = False try: import plotly + _HAS_PLOTLY = True except ImportError: pass -def to_plotly(fig): +def to_plotly(fig: _Figure) -> _Figure: + """ + Convert a matplotlib figure to a Plotly interactive plot. + + Parameters + ---------- + fig : matplotlib.figure.Figure + The matplotlib figure to convert to Plotly format. + + Returns + ------- + plotly.graph_objects.Figure or matplotlib.figure.Figure + Interactive Plotly figure if plotly is available, otherwise original figure. + + Notes + ----- + This function requires the plotly library to be installed. If plotly is not + available, the original matplotlib figure is returned unchanged. + """ + # Return original figure if plotly not available if not _HAS_PLOTLY: return fig + + # Suppress warnings during conversion to avoid noise with warnings.catch_warnings(): warnings.simplefilter("ignore") + # Convert matplotlib figure to plotly format fig = plotly.tools.mpl_to_plotly(fig) - return plotly.plotly.iplot(fig, filename='quantstats-plot', - overwrite=True) - - -def snapshot(returns, grayscale=False, figsize=(10, 8), - title='Portfolio Summary', fontname='Arial', lw=1.5, - mode="comp", subtitle=True, savefig=None, show=True): - + # Upload and display the interactive plot + return plotly.plotly.iplot(fig, filename="quantstats-plot", overwrite=True) # type: ignore + + +def snapshot( + returns: Returns, + grayscale: bool = False, + figsize: tuple[float, float] = (10, 8), + title: str = "Portfolio Summary", + fontname: str = "Arial", + lw: float = 1.5, + mode: str = "comp", + subtitle: bool = True, + savefig: str | dict | None = None, + show: bool = True, + log_scale: bool = False, + **kwargs, +) -> _Figure | None: + """ + Generate a comprehensive portfolio performance snapshot with multiple subplots. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. If DataFrame with multiple columns, uses mean or specific column. + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 8)). + title : str, optional + Main title for the plot (default: "Portfolio Summary"). + fontname : str, optional + Font family for text elements (default: "Arial"). + lw : float, optional + Line width for plots (default: 1.5). + mode : str, optional + Calculation mode: "comp" for compound returns or "sum" for simple sum. + subtitle : bool, optional + Whether to show subtitle with date range and Sharpe ratio (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + log_scale : bool, optional + Whether to use logarithmic scale for y-axes (default: False). + **kwargs : dict + Additional keyword arguments, including strategy_col for column selection. + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Creates a three-panel plot showing: + 1. Cumulative returns over time + 2. Drawdown periods + 3. Daily returns distribution + """ + # Extract strategy column name from kwargs + strategy_colname = kwargs.get("strategy_col", "Strategy") + + # Handle multi-column DataFrame input + multi_column = False + if isinstance(returns, _pd.Series): + returns.name = strategy_colname + elif isinstance(returns, _pd.DataFrame): + if len(returns.columns) > 1: + # Check if specific strategy column exists + if strategy_colname in returns.columns: + returns = returns[strategy_colname] + else: + # Use mean of all columns if no specific column found + multi_column = True + returns = returns.mean(axis=1) + title = title + " (daily equal-weighted*)" + returns.columns = strategy_colname + + # Select color scheme based on grayscale preference colors = _GRAYSCALE_COLORS if grayscale else _FLATUI_COLORS + # Convert to portfolio format and calculate percentage changes + returns = _get_utils().make_portfolio(returns.dropna(), 1, mode).pct_change(fill_method=None).fillna(0) - returns = _utils.make_portfolio(returns, 1, mode).pct_change().fillna(0) - + # Use current figure size if not specified if figsize is None: size = list(_plt.gcf().get_size_inches()) - figsize = (size[0], size[0]*.75) - - fig, axes = _plt.subplots(3, 1, sharex=True, figsize=figsize, - gridspec_kw={'height_ratios': [3, 1, 1]}) - + figsize = (size[0], size[0] * 0.75) + + # Create figure with three subplots: cumulative returns, drawdown, daily returns + fig, axes = _plt.subplots( + 3, 1, sharex=True, figsize=figsize, gridspec_kw={"height_ratios": [3, 1, 1]} + ) + + # Add footnote for multi-column DataFrame explanation + if multi_column: + _plt.figtext( + 0, + -0.05, + " * When a multi-column DataFrame is passed, the mean of all columns will be used as returns.\n" + " To change this behavior, use a pandas Series or pass the column name in the " + "`strategy_col` parameter.", + ha="left", + fontsize=11, + color="black", + alpha=0.6, + linespacing=1.5, + ) + + # Remove spines from all axes for cleaner appearance for ax in axes: - ax.spines['top'].set_visible(False) - ax.spines['right'].set_visible(False) - ax.spines['bottom'].set_visible(False) - ax.spines['left'].set_visible(False) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["bottom"].set_visible(False) + ax.spines["left"].set_visible(False) - fig.suptitle(title, fontsize=14, y=.995, - fontname=fontname, fontweight='bold', color='black') + # Set main title + fig.suptitle( + title, fontsize=14, y=0.97, fontname=fontname, fontweight="bold", color="black" + ) - fig.set_facecolor('white') + fig.set_facecolor("white") + # Add subtitle with date range and Sharpe ratio if subtitle: - axes[0].set_title("\n%s - %s ; Sharpe: %.2f " % ( - returns.index.date[:1][0].strftime('%e %b \'%y'), - returns.index.date[-1:][0].strftime('%e %b \'%y'), - _stats.sharpe(returns) - ), fontsize=12, color='gray') - - axes[0].set_ylabel('Cumulative Return', fontname=fontname, - fontweight='bold', fontsize=12) - axes[0].plot(_stats.compsum(returns) * 100, color=colors[1], - lw=1 if grayscale else lw, zorder=1) - axes[0].axhline(0, color='silver', lw=1, zorder=0) - - dd = _stats.to_drawdown_series(returns) * 100 - ddmin = _utils._round_to_closest(abs(dd.min()), 5) + if isinstance(returns, _pd.Series): + axes[0].set_title( + "%s - %s ; Sharpe: %.2f \n" + % ( + returns.index.date[:1][0].strftime("%e %b '%y"), # type: ignore + returns.index.date[-1:][0].strftime("%e %b '%y"), # type: ignore + _get_stats().sharpe(returns), + ), + fontsize=12, + color="gray", + ) + elif isinstance(returns, _pd.DataFrame): + axes[0].set_title( + "\n%s - %s ; " + % ( + returns.index.date[:1][0].strftime("%e %b '%y"), # type: ignore + returns.index.date[-1:][0].strftime("%e %b '%y"), # type: ignore + ), + fontsize=12, + color="gray", + ) + + # Configure first subplot: Cumulative Returns + axes[0].set_ylabel( + "Cumulative Return", fontname=fontname, fontweight="bold", fontsize=12 + ) + + # Plot cumulative returns for Series or DataFrame + if isinstance(returns, _pd.Series): + # Calculate cumulative returns based on mode + if mode.lower() in ["cumsum", "sum"]: + cum_ret = returns.cumsum() * 100 + else: + cum_ret = _get_stats().compsum(returns) * 100 + # Plot cumulative returns line + axes[0].plot( + cum_ret, + color=colors[1], + lw=1 if grayscale else lw, + zorder=1, + ) + elif isinstance(returns, _pd.DataFrame): + # Plot each column separately for DataFrame + for col in returns.columns: + if mode.lower() in ["cumsum", "sum"]: + cum_ret = returns[col].cumsum() * 100 + else: + cum_ret = _get_stats().compsum(returns[col]) * 100 + axes[0].plot( + cum_ret, + label=col, + lw=1 if grayscale else lw, + zorder=1, + ) + # Add horizontal line at zero + axes[0].axhline(0, color="silver", lw=1, zorder=0) + + # Set y-axis scale based on log_scale parameter + axes[0].set_yscale("symlog" if log_scale else "linear") + # axes[0].legend(fontsize=12) + + # Configure second subplot: Drawdown + dd = _get_stats().to_drawdown_series(returns) * 100 + # Calculate appropriate tick spacing for drawdown + ddmin = _get_utils()._round_to_closest(abs(dd.min()), 5) ddmin_ticks = 5 if ddmin > 50: ddmin_ticks = ddmin / 4 elif ddmin > 20: ddmin_ticks = ddmin / 3 - ddmin_ticks = int(_utils._round_to_closest(ddmin_ticks, 5)) + ddmin_ticks = int(_get_utils()._round_to_closest(ddmin_ticks, 5)) - # ddmin_ticks = int(_utils._round_to_closest(ddmin, 5)) - axes[1].set_ylabel('Drawdown', fontname=fontname, - fontweight='bold', fontsize=12) + # ddmin_ticks = int(_get_utils()._round_to_closest(ddmin, 5)) + axes[1].set_ylabel("Drawdown", fontname=fontname, fontweight="bold", fontsize=12) axes[1].set_yticks(_np.arange(-ddmin, 0, step=ddmin_ticks)) - axes[1].plot(dd, color=colors[2], lw=1 if grayscale else 1, zorder=1) - axes[1].axhline(0, color='silver', lw=1, zorder=0) - if not grayscale: - axes[1].fill_between(dd.index, 0, dd, color=colors[2], alpha=.1) - axes[2].set_ylabel('Daily Return', fontname=fontname, - fontweight='bold', fontsize=12) - axes[2].plot(returns * 100, color=colors[0], lw=0.5, zorder=1) - axes[2].axhline(0, color='silver', lw=1, zorder=0) - axes[2].axhline(0, color=colors[-1], linestyle='--', lw=1, zorder=2) + # Plot drawdown series + if isinstance(dd, _pd.Series): + axes[1].plot(dd, color=colors[2], lw=1 if grayscale else lw, zorder=1) + elif isinstance(dd, _pd.DataFrame): + for col in dd.columns: + axes[1].plot(dd[col], label=col, lw=1 if grayscale else lw, zorder=1) + axes[1].axhline(0, color="silver", lw=1, zorder=0) - retmax = _utils._round_to_closest(returns.max() * 100, 5) - retmin = _utils._round_to_closest(returns.min() * 100, 5) - retdiff = (retmax - retmin) + # Add filled area under drawdown curve if not grayscale + if not grayscale: + if isinstance(dd, _pd.Series): + axes[1].fill_between(dd.index, 0, dd, color=colors[2], alpha=0.25) + elif isinstance(dd, _pd.DataFrame): + for i, col in enumerate(dd.columns): + axes[1].fill_between( + dd[col].index, 0, dd[col], color=colors[i + 1], alpha=0.25 + ) + + axes[1].set_yscale("symlog" if log_scale else "linear") + # axes[1].legend(fontsize=12) + + # Configure third subplot: Daily Returns + axes[2].set_ylabel( + "Daily Return", fontname=fontname, fontweight="bold", fontsize=12 + ) + + # Plot daily returns + if isinstance(returns, _pd.Series): + axes[2].plot( + returns * 100, color=colors[0], label=returns.name, lw=0.5, zorder=1 + ) + elif isinstance(returns, _pd.DataFrame): + for i, col in enumerate(returns.columns): + axes[2].plot( + returns[col] * 100, color=colors[i], label=col, lw=0.5, zorder=1 + ) + # Add horizontal lines at zero + axes[2].axhline(0, color="silver", lw=1, zorder=0) + axes[2].axhline(0, color=colors[-1], linestyle="--", lw=1, zorder=2) + + axes[2].set_yscale("symlog" if log_scale else "linear") + # axes[2].legend(fontsize=12) + + # Calculate appropriate tick spacing for daily returns + retmax = _get_utils()._round_to_closest(returns.max() * 100, 5) + retmin = _get_utils()._round_to_closest(returns.min() * 100, 5) + retdiff = retmax - retmin steps = 5 if retdiff > 50: steps = retdiff / 5 elif retdiff > 30: steps = retdiff / 4 - steps = int(_utils._round_to_closest(steps, 5)) + steps = _get_utils()._round_to_closest(steps, 5) axes[2].set_yticks(_np.arange(retmin, retmax, step=steps)) + # Apply common formatting to all axes for ax in axes: - ax.set_facecolor('white') - ax.yaxis.set_label_coords(-.1, .5) - ax.yaxis.set_major_formatter(_StrMethodFormatter('{x:,.0f}%')) + ax.set_facecolor("white") + ax.yaxis.set_label_coords(-0.1, 0.5) + ax.yaxis.set_major_formatter(_StrMethodFormatter("{x:,.0f}%")) + # Adjust layout _plt.subplots_adjust(hspace=0, bottom=0, top=1) fig.autofmt_xdate() + # Apply layout adjustments with error handling try: _plt.subplots_adjust(hspace=0) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass try: fig.tight_layout(w_pad=0, h_pad=0) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass + # Save figure if requested if savefig: if isinstance(savefig, dict): _plt.savefig(**savefig) else: _plt.savefig(savefig) + # Show plot if requested if show: _plt.show(block=False) @@ -166,75 +398,165 @@ def snapshot(returns, grayscale=False, figsize=(10, 8), return None -def earnings(returns, start_balance=1e5, mode="comp", - grayscale=False, figsize=(10, 6), - title='Portfolio Earnings', - fontname='Arial', lw=1.5, - subtitle=True, savefig=None, show=True): - +def earnings( + returns: Returns, + start_balance: float = 1e5, + mode: str = "comp", + grayscale: bool = False, + figsize: tuple[float, float] = (10, 6), + title: str = "Portfolio Earnings", + fontname: str = "Arial", + lw: float = 1.5, + subtitle: bool = True, + savefig: str | dict | None = None, + show: bool = True, +) -> _Figure | None: + """ + Plot portfolio earnings over time showing absolute dollar value growth. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + start_balance : float, optional + Starting portfolio balance in dollars (default: 100000). + mode : str, optional + Calculation mode: "comp" for compound returns or "sum" for simple sum. + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 6)). + title : str, optional + Main title for the plot (default: "Portfolio Earnings"). + fontname : str, optional + Font family for text elements (default: "Arial"). + lw : float, optional + Line width for the earnings line (default: 1.5). + subtitle : bool, optional + Whether to show subtitle with date range and P&L (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Shows portfolio value over time starting from the specified balance. + Highlights the maximum portfolio value achieved during the period. + """ + # Select color scheme and transparency based on grayscale preference colors = _GRAYSCALE_COLORS if grayscale else _FLATUI_COLORS - alpha = .5 if grayscale else .8 + alpha = 0.5 if grayscale else 0.8 - returns = _utils.make_portfolio(returns, start_balance, mode) + # Convert returns to portfolio dollar values + returns = _get_utils().make_portfolio(returns, start_balance, mode) + # Use current figure size if not specified if figsize is None: size = list(_plt.gcf().get_size_inches()) - figsize = (size[0], size[0]*.55) + figsize = (size[0], size[0] * 0.55) + # Create single subplot figure fig, ax = _plt.subplots(figsize=figsize) - ax.spines['top'].set_visible(False) - ax.spines['right'].set_visible(False) - ax.spines['bottom'].set_visible(False) - ax.spines['left'].set_visible(False) - - fig.suptitle(title, fontsize=14, y=.995, - fontname=fontname, fontweight='bold', color='black') + # Remove spines for cleaner appearance + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["bottom"].set_visible(False) + ax.spines["left"].set_visible(False) + + # Set main title + fig.suptitle( + f" {title}", + fontsize=12, + y=0.95, + fontname=fontname, + fontweight="bold", + color="black", + ) + + # Add subtitle with date range and P&L information if subtitle: - ax.set_title("\n%s - %s ; P&L: %s (%s) " % ( - returns.index.date[1:2][0].strftime('%e %b \'%y'), - returns.index.date[-1:][0].strftime('%e %b \'%y'), - _utils._score_str("${:,}".format( - round(returns.values[-1]-returns.values[0], 2))), - _utils._score_str("{:,}%".format( - round((returns.values[-1]/returns.values[0]-1)*100, 2))) - ), fontsize=12, color='gray') - + ax.set_title( + "\n%s - %s ; P&L: %s (%s) " + % ( + returns.index.date[1:2][0].strftime("%e %b '%y"), # type: ignore + returns.index.date[-1:][0].strftime("%e %b '%y"), # type: ignore + _get_utils()._score_str( + "${:,}".format(round(returns.values[-1] - returns.values[0], 2)) + ), + _get_utils()._score_str( + "{:,}%".format( + round((returns.values[-1] / returns.values[0] - 1) * 100, 2) + ) + ), + ), + fontsize=10, + color="gray", + ) + + # Find and highlight maximum portfolio value mx = returns.max() returns_max = returns[returns == mx] ix = returns_max[~_np.isnan(returns_max)].index[0] returns_max = _np.where(returns.index == ix, mx, _np.nan) - ax.plot(returns.index, returns_max, marker='o', lw=0, - alpha=alpha, markersize=12, color=colors[0]) - ax.plot(returns.index, returns, color=colors[1], - lw=1 if grayscale else lw) - - ax.set_ylabel('Value of ${:,.0f}'.format(start_balance), - fontname=fontname, fontweight='bold', fontsize=12) - + # Plot maximum value point as a marker + ax.plot( + returns.index, + returns_max, + marker="o", + lw=0, + alpha=alpha, + markersize=12, + color=colors[0], + ) + + # Plot main earnings line + ax.plot(returns.index, returns, color=colors[1], lw=1 if grayscale else lw) + + # Set y-axis label showing starting balance + ax.set_ylabel( + "Value of ${:,.0f}".format(start_balance), + fontname=fontname, + fontweight="bold", + fontsize=11, + ) + + # Format y-axis as currency ax.yaxis.set_major_formatter(_FuncFormatter(_core.format_cur_axis)) - ax.yaxis.set_label_coords(-.1, .5) + ax.yaxis.set_label_coords(-0.1, 0.5) + _plt.xticks(fontsize=11) + _plt.yticks(fontsize=11) - fig.set_facecolor('white') - ax.set_facecolor('white') + # Set background colors + fig.set_facecolor("white") + ax.set_facecolor("white") fig.autofmt_xdate() + # Apply layout adjustments with error handling try: _plt.subplots_adjust(hspace=0) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass try: fig.tight_layout(w_pad=0, h_pad=0) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass + # Save figure if requested if savefig: if isinstance(savefig, dict): _plt.savefig(**savefig) else: _plt.savefig(savefig) + # Show plot if requested if show: _plt.show(block=False) @@ -246,384 +568,1295 @@ def earnings(returns, start_balance=1e5, mode="comp", return None -def returns(returns, benchmark=None, - grayscale=False, figsize=(10, 6), - fontname='Arial', lw=1.5, - match_volatility=False, compound=True, cumulative=True, - resample=None, ylabel="Cumulative Returns", - subtitle=True, savefig=None, show=True): - - title = 'Cumulative Returns' if compound else 'Returns' +def returns( + returns: Returns, + benchmark: Returns | str | None = None, + grayscale: bool = False, + figsize: tuple[float, float] = (10, 6), + fontname: str = "Arial", + lw: float = 1.5, + match_volatility: bool = False, + compound: bool = True, + resample: str | None = None, + ylabel: str = "Cumulative Returns", + subtitle: bool = True, + savefig: str | dict | None = None, + show: bool = True, + prepare_returns: bool = True, +) -> _Figure | None: + """ + Plot cumulative returns over time, optionally compared to a benchmark. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + benchmark : pandas.Series, pandas.DataFrame, or str, optional + Benchmark returns data or ticker symbol (default: None). + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 6)). + fontname : str, optional + Font family for text elements (default: "Arial"). + lw : float, optional + Line width for plots (default: 1.5). + match_volatility : bool, optional + If True, matches volatility between returns and benchmark (default: False). + compound : bool, optional + If True, uses compound returns; if False, uses simple returns (default: True). + resample : str, optional + Resampling frequency (e.g., 'M' for monthly, 'Q' for quarterly). + ylabel : str, optional + Y-axis label (default: "Cumulative Returns"). + subtitle : bool, optional + Whether to show subtitle with date range and statistics (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + prepare_returns : bool, optional + Whether to prepare returns data (default: True). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Creates a time series plot of cumulative returns. If benchmark is provided, + both series are plotted for comparison. + """ + # Build title based on parameters + title = "Cumulative Returns" if compound else "Returns" if benchmark is not None: if isinstance(benchmark, str): - title += ' vs %s' % benchmark.upper() + title += " vs %s" % benchmark.upper() else: - title += ' vs Benchmark' + title += " vs Benchmark" if match_volatility: - title += ' (Volatility Matched)' - - returns = _utils._prepare_returns(returns) - benchmark = _utils._prepare_benchmark(benchmark, returns.index) - - _core.plot_timeseries(returns, benchmark, title, - ylabel=ylabel, - match_volatility=match_volatility, - log_scale=False, - resample=resample, - compound=compound, - cumulative=cumulative, - lw=lw, - figsize=figsize, - fontname=fontname, - grayscale=grayscale, - subtitle=subtitle, savefig=savefig, show=show) - - -def log_returns(returns, benchmark=None, - grayscale=False, figsize=(10, 5), - fontname='Arial', lw=1.5, - match_volatility=False, compound=True, cumulative=True, - resample=None, ylabel="Cumulative Returns", - subtitle=True, savefig=None, show=True): - - title = 'Cumulative Returns' if compound else 'Returns' + title += " (Volatility Matched)" + + # Prepare benchmark data to match returns index + benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index) + + # Prepare returns data if requested + if prepare_returns: + returns = _get_utils()._prepare_returns(returns) + + # Use core plotting function for time series + fig = _core.plot_timeseries( + returns, + benchmark, + title, + ylabel=ylabel, + match_volatility=match_volatility, + log_scale=False, + resample=resample, + compound=compound, + lw=lw, + figsize=figsize, + fontname=fontname, + grayscale=grayscale, + subtitle=subtitle, + savefig=savefig, + show=show, + ) + if not show: + return fig + + +def log_returns( + returns: Returns, + benchmark: Returns | str | None = None, + grayscale: bool = False, + figsize: tuple[float, float] = (10, 5), + fontname: str = "Arial", + lw: float = 1.5, + match_volatility: bool = False, + compound: bool = True, + resample: str | None = None, + ylabel: str = "Cumulative Returns", + subtitle: bool = True, + savefig: str | dict | None = None, + show: bool = True, + prepare_returns: bool = True, +) -> _Figure | None: + """ + Plot cumulative returns on a logarithmic scale for better trend visualization. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + benchmark : pandas.Series, pandas.DataFrame, or str, optional + Benchmark returns data or ticker symbol (default: None). + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 5)). + fontname : str, optional + Font family for text elements (default: "Arial"). + lw : float, optional + Line width for plots (default: 1.5). + match_volatility : bool, optional + If True, matches volatility between returns and benchmark (default: False). + compound : bool, optional + If True, uses compound returns; if False, uses simple returns (default: True). + resample : str, optional + Resampling frequency (e.g., 'M' for monthly, 'Q' for quarterly). + ylabel : str, optional + Y-axis label (default: "Cumulative Returns"). + subtitle : bool, optional + Whether to show subtitle with date range and statistics (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + prepare_returns : bool, optional + Whether to prepare returns data (default: True). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Similar to returns() but uses logarithmic scale which is better for visualizing + exponential growth and making percentage changes more comparable across time. + """ + # Build title with log scale indication + title = "Cumulative Returns" if compound else "Returns" if benchmark is not None: if isinstance(benchmark, str): - title += ' vs %s (Log Scaled' % benchmark.upper() + title += " vs %s (Log Scaled" % benchmark.upper() else: - title += ' vs Benchmark (Log Scaled' + title += " vs Benchmark (Log Scaled" if match_volatility: - title += ', Volatility Matched' + title += ", Volatility Matched" else: - title += ' (Log Scaled' - title += ')' - - returns = _utils._prepare_returns(returns) - benchmark = _utils._prepare_benchmark(benchmark, returns.index) - - _core.plot_timeseries(returns, benchmark, title, - ylabel=ylabel, - match_volatility=match_volatility, - log_scale=True, - resample=resample, - compound=compound, - cumulative=cumulative, - lw=lw, - figsize=figsize, - fontname=fontname, - grayscale=grayscale, - subtitle=subtitle, savefig=savefig, show=show) - - -def daily_returns(returns, - grayscale=False, figsize=(10, 4), - fontname='Arial', lw=0.5, - log_scale=False, ylabel="Returns", - subtitle=True, savefig=None, show=True): - - returns = _utils._prepare_returns(returns) - _core.plot_timeseries(returns, None, 'Daily Returns', - ylabel=ylabel, - match_volatility=False, - log_scale=log_scale, - resample='D', - compound=False, - lw=lw, - figsize=figsize, - fontname=fontname, - grayscale=grayscale, - subtitle=subtitle, savefig=savefig, show=show) - - -def yearly_returns(returns, benchmark=None, - fontname='Arial', grayscale=False, - hlw=1.5, hlcolor="red", hllabel="", - match_volatility=False, - log_scale=False, figsize=(10, 5), ylabel=True, - subtitle=True, compounded=True, - savefig=None, show=True): - - title = 'EOY Returns' + title += " (Log Scaled" + title += ")" + + # Prepare returns data if requested + if prepare_returns: + returns = _get_utils()._prepare_returns(returns) + + # Prepare benchmark data to match returns index + benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index) # type: ignore + + # Use core plotting function with log scale enabled + fig = _core.plot_timeseries( + returns, + benchmark, + title, + ylabel=ylabel, + match_volatility=match_volatility, + log_scale=True, + resample=resample, + compound=compound, + lw=lw, + figsize=figsize, + fontname=fontname, + grayscale=grayscale, + subtitle=subtitle, + savefig=savefig, + show=show, + ) + if not show: + return fig + + +def daily_returns( + returns: Returns, + benchmark: Returns | str | None, + grayscale: bool = False, + figsize: tuple[float, float] = (10, 4), + fontname: str = "Arial", + lw: float = 0.5, + log_scale: bool = False, + ylabel: str = "Returns", + subtitle: bool = True, + savefig: str | dict | None = None, + show: bool = True, + prepare_returns: bool = True, + active: bool = False, +) -> _Figure | None: + """ + Plot daily returns over time, optionally as active returns vs benchmark. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + benchmark : pandas.Series, pandas.DataFrame, or str + Benchmark returns data or ticker symbol. + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 4)). + fontname : str, optional + Font family for text elements (default: "Arial"). + lw : float, optional + Line width for plots (default: 0.5). + log_scale : bool, optional + Whether to use logarithmic scale for y-axis (default: False). + ylabel : str, optional + Y-axis label (default: "Returns"). + subtitle : bool, optional + Whether to show subtitle with date range and statistics (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + prepare_returns : bool, optional + Whether to prepare returns data (default: True). + active : bool, optional + If True, plots active returns (returns - benchmark) (default: False). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Shows daily return variations over time. If active=True, displays the difference + between portfolio returns and benchmark returns. + """ + # Prepare returns data if requested + if prepare_returns: + returns = _get_utils()._prepare_returns(returns) + # Calculate active returns if requested + if active and benchmark is not None: + benchmark = _get_utils()._prepare_returns(benchmark) + returns = returns - benchmark + + # Set plot title based on active returns setting + plot_title = "Daily Active Returns" if active else "Daily Returns" + plot_title += " (Cumulative Sum)" + + # Use core plotting function for daily time series + fig = _core.plot_timeseries( + returns, + None, # No benchmark for daily returns plot + plot_title, + ylabel=ylabel, + match_volatility=False, + log_scale=log_scale, + resample="D", # Daily resampling + compound=False, # No compounding for daily returns + lw=lw, + figsize=figsize, + fontname=fontname, + grayscale=grayscale, + subtitle=subtitle, + savefig=savefig, + show=show, + ) + if not show: + return fig + + +def yearly_returns( + returns: Returns, + benchmark: Returns | str | None = None, + fontname: str = "Arial", + grayscale: bool = False, + hlw: float = 1.5, + hlcolor: str = "red", + hllabel: str = "", + match_volatility: bool = False, + log_scale: bool = False, + figsize: tuple[float, float] = (10, 5), + ylabel: bool = True, + subtitle: bool = True, + compounded: bool = True, + savefig: str | dict | None = None, + show: bool = True, + prepare_returns: bool = True, +) -> _Figure | None: + """ + Plot end-of-year returns as a bar chart, optionally compared to benchmark. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + benchmark : pandas.Series, pandas.DataFrame, or str, optional + Benchmark returns data or ticker symbol (default: None). + fontname : str, optional + Font family for text elements (default: "Arial"). + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + hlw : float, optional + Horizontal line width for mean line (default: 1.5). + hlcolor : str, optional + Color for horizontal mean line (default: "red"). + hllabel : str, optional + Label for horizontal mean line (default: ""). + match_volatility : bool, optional + If True, matches volatility between returns and benchmark (default: False). + log_scale : bool, optional + Whether to use logarithmic scale for y-axis (default: False). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 5)). + ylabel : bool, optional + Whether to show y-axis label (default: True). + subtitle : bool, optional + Whether to show subtitle with date range and statistics (default: True). + compounded : bool, optional + If True, uses compound returns; if False, uses simple sum (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + prepare_returns : bool, optional + Whether to prepare returns data (default: True). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Aggregates returns by year and displays as bars. Shows mean return as horizontal line. + """ + # Set plot title + title = "EOY Returns" if benchmark is not None: - title += ' vs Benchmark' - benchmark = _utils._prepare_benchmark( - benchmark, returns.index).resample('A').apply( - _stats.compsum).resample('A').last() + title += " vs Benchmark" + # Prepare and resample benchmark data + benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index) + benchmark = safe_resample(benchmark, "YE", _get_stats().comp) + benchmark = safe_resample(benchmark, "YE", "last") - returns = _utils._prepare_returns(returns).resample('A') + # Prepare returns data if requested + if prepare_returns: + returns = _get_utils()._prepare_returns(returns) + + # Resample returns to year-end based on compounding preference if compounded: - returns = returns.apply(_stats.compsum) + returns = safe_resample(returns, "YE", _get_stats().comp) else: - returns = returns.apply(_df.cumsum) - returns = returns.resample('A').last() - - _core.plot_returns_bars(returns, benchmark, - fontname=fontname, - hline=returns.mean(), - hlw=hlw, - hllabel=hllabel, - hlcolor=hlcolor, - match_volatility=match_volatility, - log_scale=log_scale, - resample=None, - title=title, - figsize=figsize, - grayscale=grayscale, - ylabel=ylabel, - subtitle=subtitle, savefig=savefig, show=show) - - -def distribution(returns, fontname='Arial', grayscale=False, ylabel=True, - figsize=(10, 6), subtitle=True, compounded=True, - savefig=None, show=True): - returns = _utils._prepare_returns(returns) - _core.plot_distribution(returns, - fontname=fontname, - grayscale=grayscale, - figsize=figsize, - ylabel=ylabel, - subtitle=subtitle, - compounded=compounded, - savefig=savefig, show=show) - - -def histogram(returns, resample='M', fontname='Arial', - grayscale=False, figsize=(10, 5), ylabel=True, - subtitle=True, compounded=True, savefig=None, show=True): - - returns = _utils._prepare_returns(returns) - if resample == 'W': + returns = safe_resample(returns, "YE", "sum") + returns = safe_resample(returns, "YE", "last") + + # Use core plotting function for bar chart + fig = _core.plot_returns_bars( + returns, + benchmark, + fontname=fontname, + hline=returns.mean(), # Show mean as horizontal line + hlw=hlw, + hllabel=hllabel, + hlcolor=hlcolor, + match_volatility=match_volatility, + log_scale=log_scale, + resample="YE", + title=title, + figsize=figsize, + grayscale=grayscale, + ylabel=ylabel, + subtitle=subtitle, + savefig=savefig, + show=show, + ) + if not show: + return fig + + +def distribution( + returns: Returns, + fontname: str = "Arial", + grayscale: bool = False, + ylabel: bool = True, + figsize: tuple[float, float] = (10, 6), + subtitle: bool = True, + compounded: bool = True, + savefig: str | dict | None = None, + show: bool = True, + title: str | None = None, + prepare_returns: bool = True, +) -> _Figure | None: + """ + Plot the distribution of returns using histogram and density curves. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + fontname : str, optional + Font family for text elements (default: "Arial"). + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + ylabel : bool, optional + Whether to show y-axis label (default: True). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 6)). + subtitle : bool, optional + Whether to show subtitle with distribution statistics (default: True). + compounded : bool, optional + If True, uses compound returns; if False, uses simple returns (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + title : str, optional + Custom title for the plot (default: None). + prepare_returns : bool, optional + Whether to prepare returns data (default: True). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Shows the distribution of returns with histogram bars and overlaid density curve. + Helpful for understanding return characteristics and identifying outliers. + """ + # Prepare returns data if requested + if prepare_returns: + returns = _get_utils()._prepare_returns(returns) + + # Use core plotting function for distribution + fig = _core.plot_distribution( + returns, + fontname=fontname, + grayscale=grayscale, + figsize=figsize, + ylabel=ylabel, + subtitle=subtitle, + title=title, + compounded=compounded, + savefig=savefig, + show=show, + ) + if not show: + return fig + + +def histogram( + returns: Returns, + benchmark: Returns | str | None = None, + resample: str = "ME", + fontname: str = "Arial", + grayscale: bool = False, + figsize: tuple[float, float] = (10, 5), + ylabel: bool = True, + subtitle: bool = True, + compounded: bool = True, + savefig: str | dict | None = None, + show: bool = True, + prepare_returns: bool = True, +) -> _Figure | None: + """ + Plot histogram of returns resampled to specified frequency. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + benchmark : pandas.Series, pandas.DataFrame, or str, optional + Benchmark returns data or ticker symbol (default: None). + resample : str, optional + Resampling frequency: 'W' for weekly, 'ME' for monthly, 'QE' for quarterly, + 'YE' for yearly (default: 'ME'). + fontname : str, optional + Font family for text elements (default: "Arial"). + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 5)). + ylabel : bool, optional + Whether to show y-axis label (default: True). + subtitle : bool, optional + Whether to show subtitle with distribution statistics (default: True). + compounded : bool, optional + If True, uses compound returns; if False, uses simple returns (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + prepare_returns : bool, optional + Whether to prepare returns data (default: True). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Resamples returns to specified frequency and plots distribution histogram. + Useful for analyzing return patterns at different time horizons. + """ + # Prepare returns data if requested + if prepare_returns: + returns = _get_utils()._prepare_returns(returns) + if benchmark is not None: + benchmark = _get_utils()._prepare_returns(benchmark) + + # Determine title based on resampling frequency + if resample == "W": title = "Weekly " - elif resample == 'M': + elif resample == "ME": title = "Monthly " - elif resample == 'Q': + elif resample == "QE": title = "Quarterly " - elif resample == 'A': + elif resample == "YE": title = "Annual " else: title = "" - return _core.plot_histogram(returns, - resample=resample, - grayscale=grayscale, - fontname=fontname, - title="Distribution of %sReturns" % title, - figsize=figsize, - ylabel=ylabel, - subtitle=subtitle, - compounded=compounded, - savefig=savefig, show=show) - - -def drawdown(returns, grayscale=False, figsize=(10, 5), - fontname='Arial', lw=1, log_scale=False, - match_volatility=False, compound=False, ylabel="Drawdown", - resample=None, subtitle=True, savefig=None, show=True): - - dd = _stats.to_drawdown_series(returns) - - _core.plot_timeseries(dd, title='Underwater Plot', - hline=dd.mean(), hlw=2, hllabel="Average", - returns_label="Drawdown", - compound=compound, match_volatility=match_volatility, - log_scale=log_scale, resample=resample, - fill=True, lw=lw, figsize=figsize, - ylabel=ylabel, - fontname=fontname, grayscale=grayscale, - subtitle=subtitle, - savefig=savefig, show=show) - - -def drawdowns_periods(returns, periods=5, lw=1.5, log_scale=False, - fontname='Arial', grayscale=False, figsize=(10, 5), - ylabel=True, subtitle=True, compounded=True, - savefig=None, show=True): - returns = _utils._prepare_returns(returns) - _core.plot_longest_drawdowns(returns, - periods=periods, - lw=lw, - log_scale=log_scale, - fontname=fontname, - grayscale=grayscale, - figsize=figsize, - ylabel=ylabel, - subtitle=subtitle, - compounded=compounded, - savefig=savefig, show=show) - - -def rolling_beta(returns, benchmark, - window1=126, window1_label="6-Months", - window2=252, window2_label="12-Months", - lw=1.5, fontname='Arial', grayscale=False, - figsize=(10, 3), ylabel=True, - subtitle=True, savefig=None, show=True): - - returns = _utils._prepare_returns(returns) - benchmark = _utils._prepare_benchmark(benchmark, returns.index) - - _core.plot_rolling_beta(returns, benchmark, - window1=window1, window1_label=window1_label, - window2=window2, window2_label=window2_label, - title="Rolling Beta to Benchmark", - fontname=fontname, - grayscale=grayscale, - lw=lw, - figsize=figsize, - ylabel=ylabel, - subtitle=subtitle, savefig=savefig, show=show) - - -def rolling_volatility(returns, benchmark=None, - period=126, period_label="6-Months", - lw=1.5, fontname='Arial', grayscale=False, - figsize=(10, 3), ylabel="Volatility", - subtitle=True, savefig=None, show=True): - - returns = _utils._prepare_returns(returns) - returns = returns.rolling(period).std() * _np.sqrt(252) + # Use core plotting function for histogram + return _core.plot_histogram( + returns, + benchmark, + resample=resample, + grayscale=grayscale, + fontname=fontname, + title="Distribution of %sReturns" % title, + figsize=figsize, + ylabel=ylabel, + subtitle=subtitle, + compounded=compounded, + savefig=savefig, + show=show, + ) + + +def drawdown( + returns: Returns, + grayscale: bool = False, + figsize: tuple[float, float] = (10, 5), + fontname: str = "Arial", + lw: float = 1, + log_scale: bool = False, + match_volatility: bool = False, + compound: bool = False, + ylabel: str = "Drawdown", + resample: str | None = None, + subtitle: bool = True, + savefig: str | dict | None = None, + show: bool = True, +) -> _Figure | None: + """ + Plot drawdown series over time showing periods of loss from peak values. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 5)). + fontname : str, optional + Font family for text elements (default: "Arial"). + lw : float, optional + Line width for drawdown plot (default: 1). + log_scale : bool, optional + Whether to use logarithmic scale for y-axis (default: False). + match_volatility : bool, optional + Not used in drawdown plot (default: False). + compound : bool, optional + Not used in drawdown plot (default: False). + ylabel : str, optional + Y-axis label (default: "Drawdown"). + resample : str, optional + Resampling frequency for data aggregation. + subtitle : bool, optional + Whether to show subtitle with drawdown statistics (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Shows underwater plot of drawdowns with filled area. Includes average drawdown + line as reference. Useful for understanding portfolio risk and recovery periods. + """ + # Convert returns to drawdown series + dd = _get_stats().to_drawdown_series(returns) + + # Use core plotting function for drawdown time series + fig = _core.plot_timeseries( + dd, + title="Underwater Plot", + hline=dd.mean(), # Show average drawdown as horizontal line + hlw=2, + hllabel="Average", + returns_label="Drawdown", + compound=compound, + match_volatility=match_volatility, + log_scale=log_scale, + resample=resample, + fill=True, # Fill area under drawdown curve + lw=lw, + figsize=figsize, + ylabel=ylabel, + fontname=fontname, + grayscale=grayscale, + subtitle=subtitle, + savefig=savefig, + show=show, + raw_data=True, # Skip cumulative transformation for drawdown data + ) + if not show: + return fig + + +def drawdowns_periods( + returns: Returns, + periods: int = 5, + lw: float = 1.5, + log_scale: bool = False, + fontname: str = "Arial", + grayscale: bool = False, + title: str | None = None, + figsize: tuple[float, float] = (10, 5), + ylabel: bool = True, + subtitle: bool = True, + compounded: bool = True, + savefig: str | dict | None = None, + show: bool = True, + prepare_returns: bool = True, +) -> _Figure | None: + """ + Plot the longest drawdown periods as separate lines for detailed analysis. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + periods : int, optional + Number of longest drawdown periods to display (default: 5). + lw : float, optional + Line width for drawdown lines (default: 1.5). + log_scale : bool, optional + Whether to use logarithmic scale for y-axis (default: False). + fontname : str, optional + Font family for text elements (default: "Arial"). + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + title : str, optional + Custom title for the plot (default: None). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 5)). + ylabel : bool, optional + Whether to show y-axis label (default: True). + subtitle : bool, optional + Whether to show subtitle with drawdown statistics (default: True). + compounded : bool, optional + If True, uses compound returns; if False, uses simple returns (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + prepare_returns : bool, optional + Whether to prepare returns data (default: True). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Identifies and plots the longest drawdown periods separately. Each period is + shown as a different colored line for easy comparison of severity and duration. + """ + # Prepare returns data if requested + if prepare_returns: + returns = _get_utils()._prepare_returns(returns) + + # Use core plotting function for longest drawdown periods + fig = _core.plot_longest_drawdowns( + returns, + periods=periods, + lw=lw, + log_scale=log_scale, + fontname=fontname, + grayscale=grayscale, + title=title, + figsize=figsize, + ylabel=ylabel, + subtitle=subtitle, + compounded=compounded, + savefig=savefig, + show=show, + ) + if not show: + return fig + +def rolling_beta( + returns: Returns, + benchmark: Returns | str, + window1: int = 126, + window1_label: str = "6-Months", + window2: int = 252, + window2_label: str = "12-Months", + lw: float = 1.5, + fontname: str = "Arial", + grayscale: bool = False, + figsize: tuple[float, float] = (10, 3), + ylabel: bool = True, + subtitle: bool = True, + savefig: str | dict | None = None, + show: bool = True, + prepare_returns: bool = True, +) -> _Figure | None: + """ + Plot rolling beta coefficients over time using multiple window sizes. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + benchmark : pandas.Series, pandas.DataFrame, or str + Benchmark returns data or ticker symbol. + window1 : int, optional + First rolling window size in days (default: 126). + window1_label : str, optional + Label for first window (default: "6-Months"). + window2 : int, optional + Second rolling window size in days (default: 252). + window2_label : str, optional + Label for second window (default: "12-Months"). + lw : float, optional + Line width for beta lines (default: 1.5). + fontname : str, optional + Font family for text elements (default: "Arial"). + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 3)). + ylabel : bool, optional + Whether to show y-axis label (default: True). + subtitle : bool, optional + Whether to show subtitle with beta statistics (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + prepare_returns : bool, optional + Whether to prepare returns data (default: True). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Shows how portfolio beta (systematic risk) changes over time relative to benchmark. + Uses two different window sizes to show short-term and long-term beta trends. + """ + # Prepare returns data if requested + if prepare_returns: + returns = _get_utils()._prepare_returns(returns) + + # Prepare benchmark data to match returns index + benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index) # type: ignore + + # Use core plotting function for rolling beta + fig = _core.plot_rolling_beta( + returns, + benchmark, + window1=window1, + window1_label=window1_label, + window2=window2, + window2_label=window2_label, + title="Rolling Beta to Benchmark", + fontname=fontname, + grayscale=grayscale, + lw=lw, + figsize=figsize, + ylabel=ylabel, + subtitle=subtitle, + savefig=savefig, + show=show, + ) + if not show: + return fig + + +def rolling_volatility( + returns: Returns, + benchmark: Returns | str | None = None, + period: int = 126, + period_label: str = "6-Months", + periods_per_year: int = 252, + lw: float = 1.5, + fontname: str = "Arial", + grayscale: bool = False, + figsize: tuple[float, float] = (10, 3), + ylabel: str = "Volatility", + subtitle: bool = True, + savefig: str | dict | None = None, + show: bool = True, +) -> _Figure | None: + """ + Plot rolling volatility over time, optionally compared to benchmark. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + benchmark : pandas.Series, pandas.DataFrame, or str, optional + Benchmark returns data or ticker symbol (default: None). + period : int, optional + Rolling window size in days (default: 126). + period_label : str, optional + Label for the rolling period (default: "6-Months"). + periods_per_year : int, optional + Number of periods per year for annualization (default: 252). + lw : float, optional + Line width for volatility lines (default: 1.5). + fontname : str, optional + Font family for text elements (default: "Arial"). + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 3)). + ylabel : str, optional + Y-axis label (default: "Volatility"). + subtitle : bool, optional + Whether to show subtitle with volatility statistics (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Shows rolling volatility (standard deviation) over time. Includes mean volatility + as horizontal reference line. Useful for understanding risk patterns over time. + """ + # Calculate rolling volatility for returns + returns = _get_stats().rolling_volatility(returns, period, periods_per_year) + + # Calculate rolling volatility for benchmark if provided if benchmark is not None: - benchmark = _utils._prepare_benchmark(benchmark, returns.index) - benchmark = benchmark.rolling(period).std() * _np.sqrt(252) - - _core.plot_rolling_stats(returns, benchmark, - hline=returns.mean(), - hlw=1.5, - ylabel=ylabel, - title='Rolling Volatility (%s)' % period_label, - fontname=fontname, - grayscale=grayscale, - lw=lw, - figsize=figsize, - subtitle=subtitle, savefig=savefig, show=show) - - -def rolling_sharpe(returns, benchmark=None, rf=0., - period=126, period_label="6-Months", - lw=1.25, fontname='Arial', grayscale=False, - figsize=(10, 3), ylabel="Sharpe", - subtitle=True, savefig=None, show=True): - - returns = _utils._prepare_returns(returns, rf) - returns = returns.rolling(period).mean() / returns.rolling(period).std() - returns = returns * _np.sqrt(1 if period is None else period) + benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index) + benchmark = _get_stats().rolling_volatility( + benchmark, period, periods_per_year, prepare_returns=False + ) + + # Use core plotting function for rolling statistics + fig = _core.plot_rolling_stats( + returns, + benchmark, + hline=returns.mean(), # Show mean volatility as horizontal line + hlw=1.5, + ylabel=ylabel, + title="Rolling Volatility (%s)" % period_label, + fontname=fontname, + grayscale=grayscale, + lw=lw, + figsize=figsize, + subtitle=subtitle, + savefig=savefig, + show=show, + ) + if not show: + return fig + +def rolling_sharpe( + returns: Returns, + benchmark: Returns | str | None = None, + rf: float = 0.0, + period: int = 126, + period_label: str = "6-Months", + periods_per_year: int = 252, + lw: float = 1.25, + fontname: str = "Arial", + grayscale: bool = False, + figsize: tuple[float, float] = (10, 3), + ylabel: str = "Sharpe", + subtitle: bool = True, + savefig: str | dict | None = None, + show: bool = True, +) -> _Figure | None: + """ + Plot rolling Sharpe ratio over time, optionally compared to benchmark. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + benchmark : pandas.Series, pandas.DataFrame, or str, optional + Benchmark returns data or ticker symbol (default: None). + rf : float, optional + Risk-free rate for Sharpe calculation (default: 0.0). + period : int, optional + Rolling window size in days (default: 126). + period_label : str, optional + Label for the rolling period (default: "6-Months"). + periods_per_year : int, optional + Number of periods per year for annualization (default: 252). + lw : float, optional + Line width for Sharpe lines (default: 1.25). + fontname : str, optional + Font family for text elements (default: "Arial"). + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 3)). + ylabel : str, optional + Y-axis label (default: "Sharpe"). + subtitle : bool, optional + Whether to show subtitle with Sharpe statistics (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Shows rolling Sharpe ratio (risk-adjusted returns) over time. Higher values + indicate better risk-adjusted performance. Includes mean Sharpe as reference. + """ + # Calculate rolling Sharpe ratio for returns + returns = _get_stats().rolling_sharpe( + returns, + rf, + period, + True, # prepare_returns + periods_per_year, + ) + + # Calculate rolling Sharpe ratio for benchmark if provided if benchmark is not None: - benchmark = _utils._prepare_benchmark(benchmark, returns.index, rf) - benchmark = benchmark.rolling( - period).mean() / benchmark.rolling(period).std() - benchmark = benchmark * _np.sqrt(1 if period is None else period) - - _core.plot_rolling_stats(returns, benchmark, - hline=returns.mean(), - hlw=1.5, - ylabel=ylabel, - title='Rolling Sharpe (%s)' % period_label, - fontname=fontname, - grayscale=grayscale, - lw=lw, - figsize=figsize, - subtitle=subtitle, savefig=savefig, show=show) - - -def rolling_sortino(returns, benchmark=None, rf=0., - period=126, period_label="6-Months", - lw=1.25, fontname='Arial', grayscale=False, - figsize=(10, 3), ylabel="Sortino", - subtitle=True, savefig=None, show=True): - - returns = _utils._prepare_returns(returns, rf) - returns = returns.rolling(period).mean() / \ - returns[returns < 0].rolling(period).std() - returns = returns * _np.sqrt(1 if period is None else period) + benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index, rf) + benchmark = _get_stats().rolling_sharpe( + benchmark, rf, period, True, periods_per_year, prepare_returns=False + ) + + # Use core plotting function for rolling statistics + fig = _core.plot_rolling_stats( + returns, + benchmark, + hline=returns.mean(), # Show mean Sharpe as horizontal line + hlw=1.5, + ylabel=ylabel, + title="Rolling Sharpe (%s)" % period_label, + fontname=fontname, + grayscale=grayscale, + lw=lw, + figsize=figsize, + subtitle=subtitle, + savefig=savefig, + show=show, + ) + if not show: + return fig + +def rolling_sortino( + returns: Returns, + benchmark: Returns | str | None = None, + rf: float = 0.0, + period: int = 126, + period_label: str = "6-Months", + periods_per_year: int = 252, + lw: float = 1.25, + fontname: str = "Arial", + grayscale: bool = False, + figsize: tuple[float, float] = (10, 3), + ylabel: str = "Sortino", + subtitle: bool = True, + savefig: str | dict | None = None, + show: bool = True, +) -> _Figure | None: + """ + Plot rolling Sortino ratio over time, optionally compared to benchmark. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + benchmark : pandas.Series, pandas.DataFrame, or str, optional + Benchmark returns data or ticker symbol (default: None). + rf : float, optional + Risk-free rate for Sortino calculation (default: 0.0). + period : int, optional + Rolling window size in days (default: 126). + period_label : str, optional + Label for the rolling period (default: "6-Months"). + periods_per_year : int, optional + Number of periods per year for annualization (default: 252). + lw : float, optional + Line width for Sortino lines (default: 1.25). + fontname : str, optional + Font family for text elements (default: "Arial"). + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 3)). + ylabel : str, optional + Y-axis label (default: "Sortino"). + subtitle : bool, optional + Whether to show subtitle with Sortino statistics (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Shows rolling Sortino ratio (downside deviation adjusted returns) over time. + Similar to Sharpe but only considers downside volatility. Higher values indicate + better downside-adjusted performance. + """ + # Calculate rolling Sortino ratio for returns + returns = _get_stats().rolling_sortino(returns, rf, period, True, periods_per_year) + + # Calculate rolling Sortino ratio for benchmark if provided if benchmark is not None: - benchmark = _utils._prepare_benchmark(benchmark, returns.index, rf) - benchmark = benchmark.rolling(period).mean() / benchmark[ - benchmark < 0].rolling(period).std() - benchmark = benchmark * _np.sqrt(1 if period is None else period) - - _core.plot_rolling_stats(returns, benchmark, - hline=returns.mean(), - hlw=1.5, - ylabel=ylabel, - title='Rolling Sortino (%s)' % period_label, - fontname=fontname, - grayscale=grayscale, - lw=lw, - figsize=figsize, - subtitle=subtitle, savefig=savefig, show=show) - - -def monthly_heatmap(returns, annot_size=10, figsize=(10, 5), - cbar=True, square=False, - compounded=True, eoy=False, - grayscale=False, fontname='Arial', - ylabel=True, savefig=None, show=True): + benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index, rf) + benchmark = _get_stats().rolling_sortino( + benchmark, rf, period, True, periods_per_year, prepare_returns=False + ) + + # Use core plotting function for rolling statistics + fig = _core.plot_rolling_stats( + returns, + benchmark, + hline=returns.mean(), # Show mean Sortino as horizontal line + hlw=1.5, + ylabel=ylabel, + title="Rolling Sortino (%s)" % period_label, + fontname=fontname, + grayscale=grayscale, + lw=lw, + figsize=figsize, + subtitle=subtitle, + savefig=savefig, + show=show, + ) + if not show: + return fig + +def monthly_heatmap( + returns: Returns, + benchmark: Returns | str | None = None, + annot_size: int = 10, + figsize: tuple[float, float] = (8, 5), + cbar: bool = True, + square: bool = False, + returns_label: str = "Strategy", + compounded: bool = True, + eoy: bool = False, + grayscale: bool = False, + fontname: str = "Arial", + ylabel: bool = True, + savefig: str | dict | None = None, + show: bool = True, + active: bool = False, +) -> _Figure | None: + """ + Create a heatmap of monthly returns showing performance across years and months. + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + benchmark : pandas.Series, pandas.DataFrame, or str, optional + Benchmark returns data or ticker symbol (default: None). + annot_size : int, optional + Font size for annotations in heatmap cells (default: 10). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (8, 5)). + cbar : bool, optional + Whether to show color bar (default: True). + square : bool, optional + Whether to make heatmap cells square (default: False). + returns_label : str, optional + Label for the returns series (default: "Strategy"). + compounded : bool, optional + If True, uses compound returns; if False, uses simple returns (default: True). + eoy : bool, optional + Whether to include end-of-year column (default: False). + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + fontname : str, optional + Font family for text elements (default: "Arial"). + ylabel : bool, optional + Whether to show y-axis label (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + active : bool, optional + If True, shows active returns (returns - benchmark) (default: False). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + Creates a color-coded heatmap where each cell represents a month's performance. + Green indicates positive returns, red indicates negative returns. Useful for + identifying seasonal patterns and performance consistency. + """ # colors, ls, alpha = _core._get_colors(grayscale) - cmap = 'gray' if grayscale else 'RdYlGn' + # Select color map based on grayscale preference + cmap = "gray" if grayscale else "RdYlGn" - returns = _stats.monthly_returns(returns, eoy=eoy, - compounded=compounded) * 100 + # Convert to monthly returns and convert to percentage + returns = _get_stats().monthly_returns(returns, eoy=eoy, compounded=compounded) * 100 - fig_height = len(returns) / 3 + # Calculate figure height based on number of years + fig_height = len(returns) / 2.5 + # Use current figure size if not specified if figsize is None: size = list(_plt.gcf().get_size_inches()) figsize = (size[0], size[1]) + # Adjust figure size based on data and color bar figsize = (figsize[0], max([fig_height, figsize[1]])) if cbar: - figsize = (figsize[0]*1.04, max([fig_height, figsize[1]])) + figsize = (figsize[0] * 1.051, max([fig_height, figsize[1]])) + # Create figure and axis fig, ax = _plt.subplots(figsize=figsize) - ax.spines['top'].set_visible(False) - ax.spines['right'].set_visible(False) - ax.spines['bottom'].set_visible(False) - ax.spines['left'].set_visible(False) - fig.set_facecolor('white') - ax.set_facecolor('white') + # Remove spines for cleaner appearance + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["bottom"].set_visible(False) + ax.spines["left"].set_visible(False) - ax.set_title(' Monthly Returns (%)\n', fontsize=14, y=.995, - fontname=fontname, fontweight='bold', color='black') + # Set background colors + fig.set_facecolor("white") + ax.set_facecolor("white") # _sns.set(font_scale=.9) - ax = _sns.heatmap(returns, ax=ax, annot=True, center=0, - annot_kws={"size": annot_size}, - fmt="0.2f", linewidths=0.5, - square=square, cbar=cbar, cmap=cmap, - cbar_kws={'format': '%.0f%%'}) - # _sns.set(font_scale=1) - - # align plot to match other + # Create heatmap for active returns vs benchmark + if active and benchmark is not None: + ax.set_title( + f"{returns_label} - Monthly Active Returns (%)\n", + fontsize=14, + y=0.995, + fontname=fontname, + fontweight="bold", + color="black", + ) + # Calculate benchmark monthly returns + benchmark = ( + _get_stats().monthly_returns(benchmark, eoy=eoy, compounded=compounded) * 100 + ) + # Calculate active returns (strategy - benchmark) + active_returns = returns - benchmark + + # Create heatmap with active returns + ax = _sns.heatmap( + active_returns, + ax=ax, + annot=True, + center=0, # Center colormap at zero + annot_kws={"size": annot_size}, + fmt="0.2f", + linewidths=0.5, + square=square, + cbar=cbar, + cmap=cmap, + cbar_kws={"format": "%.0f%%"}, + ) + else: + # Create standard monthly returns heatmap + ax.set_title( + f"{returns_label} - Monthly Returns (%)\n", + fontsize=12, + y=0.995, + fontname=fontname, + fontweight="bold", + color="black", + ) + + # Create heatmap with monthly returns + ax = _sns.heatmap( + returns, + ax=ax, + annot=True, + center=0, # Center colormap at zero + annot_kws={"size": annot_size}, + fmt="0.2f", + linewidths=0.5, + square=square, + cbar=cbar, + cmap=cmap, + cbar_kws={"format": "%.0f%%"}, + ) + + # Format color bar if present + if cbar: + cbar = ax.collections[0].colorbar + cbar.ax.tick_params(labelsize=annot_size) + + # Set y-axis label if ylabel: - ax.set_ylabel('Years', fontname=fontname, - fontweight='bold', fontsize=12) - ax.yaxis.set_label_coords(-.1, .5) + ax.set_ylabel("Years", fontname=fontname, fontweight="bold", fontsize=12) + ax.yaxis.set_label_coords(-0.1, 0.5) + # Format tick labels ax.tick_params(colors="#808080") - _plt.xticks(rotation=0, fontsize=annot_size*1.2) - _plt.yticks(rotation=0, fontsize=annot_size*1.2) + _plt.xticks(rotation=0, fontsize=annot_size * 1.2) + _plt.yticks(rotation=0, fontsize=annot_size * 1.2) + # Apply layout adjustments with error handling try: _plt.subplots_adjust(hspace=0, bottom=0, top=1) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass try: fig.tight_layout(w_pad=0, h_pad=0) - except Exception: + except (ValueError, AttributeError, TypeError, RuntimeError): pass + # Save figure if requested if savefig: if isinstance(savefig, dict): _plt.savefig(**savefig) else: _plt.savefig(savefig) + # Show plot if requested if show: _plt.show(block=False) @@ -635,13 +1868,247 @@ def monthly_heatmap(returns, annot_size=10, figsize=(10, 5), return None -def monthly_returns(returns, annot_size=10, figsize=(10, 5), - cbar=True, square=False, - compounded=True, eoy=False, - grayscale=False, fontname='Arial', - ylabel=True, savefig=None, show=True): - return monthly_heatmap(returns, annot_size, figsize, - cbar, square, - compounded, eoy, - grayscale, fontname, - ylabel, savefig, show) +def monthly_returns( + returns: Returns, + annot_size: int = 9, + figsize: tuple[float, float] = (10, 5), + cbar: bool = True, + square: bool = False, + compounded: bool = True, + eoy: bool = False, + grayscale: bool = False, + fontname: str = "Arial", + ylabel: bool = True, + savefig: str | dict | None = None, + show: bool = True, +) -> _Figure | None: + """ + Create a heatmap of monthly returns (wrapper function for monthly_heatmap). + + Parameters + ---------- + returns : pandas.Series or pandas.DataFrame + Daily returns data. + annot_size : int, optional + Font size for annotations in heatmap cells (default: 9). + figsize : tuple, optional + Figure size as (width, height) in inches (default: (10, 5)). + cbar : bool, optional + Whether to show color bar (default: True). + square : bool, optional + Whether to make heatmap cells square (default: False). + compounded : bool, optional + If True, uses compound returns; if False, uses simple returns (default: True). + eoy : bool, optional + Whether to include end-of-year column (default: False). + grayscale : bool, optional + If True, uses grayscale colors instead of default color scheme (default: False). + fontname : str, optional + Font family for text elements (default: "Arial"). + ylabel : bool, optional + Whether to show y-axis label (default: True). + savefig : str or dict, optional + Path to save figure or dict with matplotlib savefig parameters. + show : bool, optional + Whether to display the plot (default: True). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Notes + ----- + This is a convenience wrapper around monthly_heatmap() with commonly used + default parameters for displaying monthly returns. + """ + # Call monthly_heatmap with provided parameters + return monthly_heatmap( + returns=returns, + annot_size=annot_size, + figsize=figsize, + cbar=cbar, + square=square, + compounded=compounded, + eoy=eoy, + grayscale=grayscale, + fontname=fontname, + ylabel=ylabel, + savefig=savefig, + show=show, + ) + + +# ======== MONTE CARLO PLOTS ======== + + +def montecarlo( + mc_result_or_returns: Returns | Any, + sims: int = 1000, + bust: float | None = None, + goal: float | None = None, + seed: int | None = None, + title: str = "Monte Carlo Simulation", + figsize: tuple[float, float] = (10, 6), + grayscale: bool = False, + fontname: str = "Arial", + ylabel: bool = True, + subtitle: bool = True, + savefig: str | dict | None = None, + show: bool = True, + confidence_level: float = 0.95, +) -> _Figure | None: + """ + Plot Monte Carlo simulation results. + + This function can accept either a MonteCarloResult object (from qs.stats.montecarlo) + or a returns Series to run a new simulation. + + Parameters + ---------- + mc_result_or_returns : MonteCarloResult or pd.Series + Either a MonteCarloResult object or a returns Series to simulate. + sims : int, optional + Number of simulations (only used if returns Series provided, default: 1000). + bust : float, optional + Drawdown threshold for "bust" probability (e.g., -0.1 for -10%). + goal : float, optional + Return threshold for "goal" probability (e.g., 1.0 for +100%). + seed : int, optional + Random seed for reproducibility. + title : str, optional + Chart title (default: "Monte Carlo Simulation"). + figsize : tuple, optional + Figure size (default: (10, 6)). + grayscale : bool, optional + Whether to use grayscale colors (default: False). + fontname : str, optional + Font name for labels (default: "Arial"). + ylabel : bool, optional + Whether to show y-axis label (default: True). + subtitle : bool, optional + Whether to show subtitle with statistics (default: True). + savefig : str or dict, optional + Save figure parameters. + show : bool, optional + Whether to display the plot (default: True). + confidence_level : float, optional + Confidence level for shaded band (default: 0.95). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + + Examples + -------- + >>> import quantstats as qs + >>> returns = qs.utils.download_returns("SPY") + >>> # Run simulation and plot + >>> qs.plots.montecarlo(returns, sims=1000, bust=-0.2, goal=0.5) + >>> # Or use pre-computed result + >>> mc = qs.stats.montecarlo(returns, sims=1000) + >>> mc.plot() + """ + from .._montecarlo import MonteCarloResult + + # Check if we received a MonteCarloResult or returns data + if isinstance(mc_result_or_returns, MonteCarloResult): + mc_result = mc_result_or_returns + else: + # Run Monte Carlo simulation + mc_result = _get_stats().montecarlo( + mc_result_or_returns, + sims=sims, + bust=bust, + goal=goal, + seed=seed, + ) + + return _core.plot_montecarlo( + mc_result, + title=title, + figsize=figsize, + grayscale=grayscale, + fontname=fontname, + ylabel=ylabel, + subtitle=subtitle, + savefig=savefig, + show=show, + confidence_level=confidence_level, + ) + + +def montecarlo_distribution( + mc_result_or_returns: Returns | Any, + sims: int = 1000, + seed: int | None = None, + title: str = "Terminal Value Distribution", + figsize: tuple[float, float] = (10, 6), + grayscale: bool = False, + fontname: str = "Arial", + ylabel: bool = True, + subtitle: bool = True, + savefig: str | dict | None = None, + show: bool = True, + bins: int = 50, +) -> _Figure | None: + """ + Plot histogram of terminal values from Monte Carlo simulation. + + This function can accept either a MonteCarloResult object (from qs.stats.montecarlo) + or a returns Series to run a new simulation. + + Parameters + ---------- + mc_result_or_returns : MonteCarloResult or pd.Series + Either a MonteCarloResult object or a returns Series to simulate. + sims : int, optional + Number of simulations (only used if returns Series provided, default: 1000). + seed : int, optional + Random seed for reproducibility. + title : str, optional + Chart title (default: "Terminal Value Distribution"). + figsize : tuple, optional + Figure size (default: (10, 6)). + grayscale : bool, optional + Whether to use grayscale colors (default: False). + fontname : str, optional + Font name for labels (default: "Arial"). + ylabel : bool, optional + Whether to show y-axis label (default: True). + subtitle : bool, optional + Whether to show subtitle with statistics (default: True). + savefig : str or dict, optional + Save figure parameters. + show : bool, optional + Whether to display the plot (default: True). + bins : int, optional + Number of histogram bins (default: 50). + + Returns + ------- + matplotlib.figure.Figure or None + Figure object if show=False, otherwise None. + """ + from .._montecarlo import MonteCarloResult + + # Check if we received a MonteCarloResult or returns data + if isinstance(mc_result_or_returns, MonteCarloResult): + mc_result = mc_result_or_returns + else: + # Run Monte Carlo simulation + mc_result = _get_stats().montecarlo(mc_result_or_returns, sims=sims, seed=seed) + + return _core.plot_montecarlo_distribution( + mc_result, + title=title, + figsize=figsize, + grayscale=grayscale, + fontname=fontname, + ylabel=ylabel, + subtitle=subtitle, + savefig=savefig, + show=show, + bins=bins, + ) diff --git a/quantstats/plots.py b/quantstats/plots.py index 5b3ed4e3..3c21ac5d 100644 --- a/quantstats/plots.py +++ b/quantstats/plots.py @@ -1,10 +1,9 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- # # QuantStats: Portfolio analytics for quants # https://github.com/ranaroussi/quantstats # -# Copyright 2019 Ran Aroussi +# Copyright 2019-2025 Ran Aroussi # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,6 +19,7 @@ try: from pandas.plotting import register_matplotlib_converters as _rmc + _rmc() except ImportError: pass diff --git a/quantstats/py.typed b/quantstats/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/quantstats/report.html b/quantstats/report.html index 8c4870e0..d5675304 100644 --- a/quantstats/report.html +++ b/quantstats/report.html @@ -10,7 +10,8 @@ @@ -19,8 +20,8 @@
-

{{title}}
{{date_range}}

-

Generated by QuantStats (v. {{v}})

+

{{title}}
{{date_range}}{{matched_dates}}

+

{{params}} Generated by QuantStats (v. {{v}})


diff --git a/quantstats/reports.py b/quantstats/reports.py index 49345303..315411db 100644 --- a/quantstats/reports.py +++ b/quantstats/reports.py @@ -1,10 +1,9 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- # # QuantStats: Portfolio analytics for quants # https://github.com/ranaroussi/quantstats # -# Copyright 2019 Ran Aroussi +# Copyright 2019-2025 Ran Aroussi # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,615 +19,2499 @@ import pandas as _pd import numpy as _np -from datetime import ( - datetime as _dt, timedelta as _td -) +from math import sqrt as _sqrt, ceil as _ceil +from datetime import datetime as _dt +from base64 import b64encode as _b64encode import re as _regex from tabulate import tabulate as _tabulate -from . import ( - __version__, stats as _stats, - utils as _utils, plots as _plots -) +from . import __version__ + +# Lazy imports to avoid circular dependency during package initialization +_stats = None +_utils = None +_plots = None + + +def _get_stats(): + global _stats + if _stats is None: + from . import stats + _stats = stats + return _stats + + +def _get_utils(): + global _utils + if _utils is None: + from . import utils + _utils = utils + return _utils + + +def _get_plots(): + global _plots + if _plots is None: + from . import plots + _plots = plots + return _plots +from dateutil.relativedelta import relativedelta +from io import StringIO +from pathlib import Path +import tempfile +import webbrowser try: - from IPython.core.display import ( - display as iDisplay, HTML as iHTML - ) + from IPython.display import display as iDisplay, HTML as iHTML except ImportError: - pass + pass # IPython not available, display functions won't be used + + +def _get_trading_periods(periods_per_year=252): + """ + Calculate trading periods for different time windows. + + This helper function computes the number of trading periods for full year + and half year periods, which are commonly used in financial calculations + for annualization and rolling window analysis. + + Parameters + ---------- + periods_per_year : int, default 252 + Number of trading periods in a year (e.g., 252 for daily data, + 12 for monthly data) + + Returns + ------- + tuple + A tuple containing (periods_per_year, half_year_periods) + + Examples + -------- + >>> _get_trading_periods(252) # Daily data + (252, 126) + >>> _get_trading_periods(12) # Monthly data + (12, 6) + """ + # Calculate half year periods using ceiling to ensure we get at least half + half_year = _ceil(periods_per_year / 2) + return periods_per_year, half_year + + +def _print_parameters_table( + benchmark_title=None, + periods_per_year=252, + rf=0.0, + compounded=True, + match_dates=True, +): + """ + Print a formatted parameters table for terminal/console output. + + Parameters + ---------- + benchmark_title : str or None + Benchmark name/ticker + periods_per_year : int + Number of trading periods per year + rf : float + Risk-free rate + compounded : bool + Whether returns are compounded + match_dates : bool + Whether dates are matched with benchmark + """ + width = 40 + print("=" * width) + print(" Parameters") + print("-" * width) + if benchmark_title: + print(f"{'Benchmark':<25}{benchmark_title.upper():>15}") + print(f"{'Periods/Year':<25}{periods_per_year:>15}") + print(f"{'Risk-Free Rate':<25}{rf:>14.1%}") + print(f"{'Compounded':<25}{'Yes' if compounded else 'No':>15}") + if benchmark_title: + print(f"{'Match Dates':<25}{'Yes' if match_dates else 'No':>15}") + print("=" * width) + print() + + +def _match_dates(returns, benchmark): + """ + Align returns and benchmark data to start from the same date. + + This function ensures that both the returns and benchmark series start + from the same date by finding the latest start date where both series + have non-zero values. This is crucial for accurate performance comparisons. + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Returns data that may be a Series or DataFrame with multiple columns + benchmark : pd.Series + Benchmark returns data + + Returns + ------- + tuple + A tuple containing (aligned_returns, aligned_benchmark) both starting + from the same date + + Examples + -------- + >>> returns_aligned, bench_aligned = _match_dates(returns, benchmark) + """ + # Handle different types of returns data (Series vs DataFrame) + if isinstance(returns, _pd.DataFrame): + # For DataFrame, use the first column to find the start date + loc = max(returns[returns.columns[0]].ne(0).idxmax(), benchmark.ne(0).idxmax()) + else: + # For Series, find the maximum of start dates for both series + loc = max(returns.ne(0).idxmax(), benchmark.ne(0).idxmax()) + + # Slice both series to start from the latest common start date + returns = returns.loc[loc:] + benchmark = benchmark.loc[loc:] + + return returns, benchmark + + +def html( + returns, + benchmark=None, + rf=0.0, + grayscale=False, + title="Strategy Tearsheet", + output=None, + compounded=True, + periods_per_year=252, + download_filename="quantstats-tearsheet.html", + figfmt="svg", + template_path=None, + match_dates=True, + **kwargs, +): + """ + Generate an HTML tearsheet report for portfolio performance analysis. + + This function creates a comprehensive HTML report containing performance + metrics, visualizations, and analysis of investment returns. The report + includes comparisons with benchmarks, drawdown analysis, and various + performance charts. + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Daily returns data for the strategy/portfolio + benchmark : pd.Series, str, or None, default None + Benchmark returns for comparison. Can be a Series of returns, + a ticker symbol string, or None for no benchmark + rf : float, default 0.0 + Risk-free rate for calculations (as decimal, e.g., 0.02 for 2%) + grayscale : bool, default False + Whether to generate charts in grayscale instead of color + title : str, default "Strategy Tearsheet" + Title to display at the top of the HTML report + output : str or None, default None + File path to save the HTML report. If None, downloads in browser + compounded : bool, default True + Whether to compound returns for calculations + periods_per_year : int, default 252 + Number of trading periods per year for annualization + download_filename : str, default "quantstats-tearsheet.html" + Filename for browser download if output is None + figfmt : str, default "svg" + Format for embedded charts ('svg', 'png', 'jpg') + template_path : str or None, default None + Path to custom HTML template file. Uses default if None + match_dates : bool, default True + Whether to align returns and benchmark start dates + **kwargs + Additional keyword arguments for customization: + - strategy_title: Custom name for the strategy + - benchmark_title: Custom name for the benchmark + - active_returns: Whether to show active returns vs benchmark + + Returns + ------- + None + Generates HTML file either as download or saved to specified path + + Examples + -------- + >>> html(returns, benchmark='^GSPC', title='My Strategy') + >>> html(returns, output='report.html', grayscale=True) + + Raises + ------ + FileNotFoundError + If custom template_path doesn't exist + """ + # Clean returns data by removing NaN values if date matching is enabled + if match_dates: + returns = returns.dropna() + + # Get trading periods for calculations + win_year, win_half_year = _get_trading_periods(periods_per_year) + + # Secure file path handling for HTML template + if template_path is None: + # Use default template path - report.html in same directory + template_path = Path(__file__).parent / 'report.html' + else: + template_path = Path(template_path) + # Resolve to absolute path and validate template file existence + template_path = template_path.resolve() -def html(returns, benchmark=None, rf=0., - grayscale=False, title='Strategy Tearsheet', - output=None, compounded=True): + if not template_path.exists(): + raise FileNotFoundError(f"Template file not found: {template_path}") + if not template_path.is_file(): + raise ValueError(f"Template path is not a file: {template_path}") - if output is None and not _utils._in_notebook(): - raise ValueError("`file` must be specified") + # Read template securely with UTF-8 encoding + tpl = template_path.read_text(encoding='utf-8') - tpl = "" - with open(__file__[:-4] + '.html') as f: - tpl = f.read() - f.close() + # prepare timeseries + if match_dates: + returns = returns.dropna() + # Clean and prepare returns data for analysis + returns = _get_utils()._prepare_returns(returns) - date_range = returns.index.strftime('%e %b, %Y') - tpl = tpl.replace('{{date_range}}', date_range[0] + ' - ' + date_range[-1]) - tpl = tpl.replace('{{title}}', title) - tpl = tpl.replace('{{v}}', __version__) + # Handle strategy title - can be single string or list for multiple columns + strategy_title = kwargs.get("strategy_title", "Strategy") + if isinstance(returns, _pd.DataFrame): + if len(returns.columns) > 1 and isinstance(strategy_title, str): + strategy_title = list(returns.columns) - mtrx = metrics(returns=returns, benchmark=benchmark, - rf=rf, display=False, mode='full', - sep=True, internal="True", - compounded=compounded)[2:] - mtrx.index.name = 'Metric' - tpl = tpl.replace('{{metrics}}', _html_table(mtrx)) - tpl = tpl.replace('', - '
') - tpl = tpl.replace('', - '
') + # Process benchmark data if provided + if benchmark is not None: + benchmark_title = kwargs.get("benchmark_title", "Benchmark") + # Auto-determine benchmark title if not provided + if kwargs.get("benchmark_title") is None: + if isinstance(benchmark, str): + benchmark_title = benchmark + elif isinstance(benchmark, _pd.Series): + benchmark_title = benchmark.name if benchmark.name else "Benchmark" + elif isinstance(benchmark, _pd.DataFrame): + col_name = benchmark[benchmark.columns[0]].name + benchmark_title = col_name if col_name else "Benchmark" + + # Ensure benchmark_title is a string for .upper() call + if benchmark_title is None: + benchmark_title = "Benchmark" + # Store original benchmark before any alignment for accurate EOY calculations + # This preserves the full benchmark data including non-trading days + if isinstance(benchmark, str): + # Download the full benchmark data + benchmark_original = _get_utils().download_returns(benchmark) + if rf != 0: + benchmark_original = _get_utils().to_excess_returns( + benchmark_original, rf, nperiods=periods_per_year + ) + elif isinstance(benchmark, _pd.Series): + benchmark_original = benchmark.copy() + else: + benchmark_original = benchmark + # Prepare benchmark data to match returns index and risk-free rate + benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index, rf) + # Align dates between returns and benchmark if requested + if match_dates is True: + returns, benchmark = _match_dates(returns, benchmark) + else: + benchmark_title = None + benchmark_original = None + + # Format date range for display in template + date_range = returns.index.strftime("%e %b, %Y") + tpl = tpl.replace("{{date_range}}", date_range[0] + " - " + date_range[-1]) + + # Build title with compounding indicator (only show if compounded) + full_title = f"{title} (Compounded)" if compounded else title + tpl = tpl.replace("{{title}}", full_title) + tpl = tpl.replace("{{v}}", __version__) + + # Build parameters string for subtitle + params_parts = [] + + # Add user-provided parameters first if present + user_params = kwargs.get("parameters", {}) + if user_params: + for key, value in user_params.items(): + params_parts.append(f"{key}: {value}") + + # Add auto-detected parameters (always show key params) + if benchmark_title: + params_parts.append(f"Benchmark: {benchmark_title.upper()}") + params_parts.append(f"Periods/Year: {periods_per_year}") + params_parts.append(f"RF: {rf:.1%}") + + params_str = " • ".join(params_parts) + if params_str: + params_str += " | " + tpl = tpl.replace("{{params}}", params_str) + + # Add matched dates indicator + matched_dates_str = " (matched dates)" if match_dates and benchmark is not None else "" + tpl = tpl.replace("{{matched_dates}}", matched_dates_str) + + # Set names for data series to be used in charts and tables + if benchmark is not None: + benchmark.name = benchmark_title + if isinstance(returns, _pd.Series): + returns.name = strategy_title + elif isinstance(returns, _pd.DataFrame): + returns.columns = strategy_title + + # Generate comprehensive performance metrics table + mtrx = metrics( + returns=returns, + benchmark=benchmark, + rf=rf, + display=False, + mode="full", + sep=True, + internal="True", + compounded=compounded, + periods_per_year=periods_per_year, + prepare_returns=False, + benchmark_title=benchmark_title, + strategy_title=strategy_title, + )[2:] + + # Format metrics table for HTML display + mtrx.index.name = "Metric" + tpl = tpl.replace("{{metrics}}", _html_table(mtrx)) + + # Handle table formatting for multiple columns + if isinstance(returns, _pd.DataFrame): + num_cols = len(returns.columns) + # Replace empty table rows with horizontal rule separators + for i in reversed(range(num_cols + 1, num_cols + 3)): + str_td = "" * i + tpl = tpl.replace( + f"{str_td}", '
'.format(i) + ) + + # Clean up table formatting with horizontal rules + tpl = tpl.replace( + "", '
' + ) + tpl = tpl.replace( + "", '
' + ) + # Generate end-of-year (EOY) returns comparison table if benchmark is not None: - yoy = _stats.compare(returns, benchmark, "A", compounded=compounded) - yoy.columns = ['Benchmark', 'Strategy', 'Multiplier', 'Won'] - yoy.index.name = 'Year' - tpl = tpl.replace('{{eoy_title}}', '

EOY Returns vs Benchmark

') - tpl = tpl.replace('{{eoy_table}}', _html_table(yoy)) + # Use original benchmark for EOY comparison to preserve accurate yearly returns + # This prevents loss of benchmark returns on non-trading days + benchmark_for_eoy = benchmark_original if benchmark_original is not None else benchmark + yoy = _get_stats().compare( + returns, benchmark_for_eoy, "YE", compounded=compounded, prepare_returns=False + ) + # Set appropriate column names based on data type + if isinstance(returns, _pd.Series): + yoy.columns = [benchmark_title, strategy_title, "Multiplier", "Won"] + elif isinstance(returns, _pd.DataFrame): + yoy.columns = list( + _pd.core.common.flatten([benchmark_title, strategy_title]) + ) + yoy.index.name = "Year" + tpl = tpl.replace("{{eoy_title}}", "

EOY Returns vs Benchmark

") + tpl = tpl.replace("{{eoy_table}}", _html_table(yoy)) else: + # Generate EOY returns table without benchmark comparison # pct multiplier - yoy = _pd.DataFrame( - _utils.group_returns(returns, returns.index.year) * 100) - yoy.columns = ['Return'] - yoy['Cumulative'] = _utils.group_returns( - returns, returns.index.year, True) - yoy['Return'] = yoy['Return'].round(2).astype(str) + '%' - yoy['Cumulative'] = (yoy['Cumulative'] * - 100).round(2).astype(str) + '%' - yoy.index.name = 'Year' - tpl = tpl.replace('{{eoy_title}}', '

EOY Returns

') - tpl = tpl.replace('{{eoy_table}}', _html_table(yoy)) - - dd = _stats.to_drawdown_series(returns) - dd_info = _stats.drawdown_details(dd).sort_values( - by='max drawdown', ascending=True)[:10] - - dd_info = dd_info[['start', 'end', 'max drawdown', 'days']] - dd_info.columns = ['Started', 'Recovered', 'Drawdown', 'Days'] - tpl = tpl.replace('{{dd_info}}', _html_table(dd_info, False)) - + yoy = _pd.DataFrame(_get_utils().group_returns(returns, returns.index.year) * 100) + if isinstance(returns, _pd.Series): + yoy.columns = ["Return"] + yoy["Cumulative"] = _get_utils().group_returns(returns, returns.index.year, True) * 100 + # Don't add "%" here - the CSS in report.html handles it via :after pseudo-element + # Adding "%" in Python causes double "%" display (bug #475) + elif isinstance(returns, _pd.DataFrame): + # Don't show cumulative for multiple strategy portfolios + # just show compounded like when we have a benchmark + yoy.columns = list(_pd.core.common.flatten(strategy_title)) + + yoy.index.name = "Year" + tpl = tpl.replace("{{eoy_title}}", "

EOY Returns

") + tpl = tpl.replace("{{eoy_table}}", _html_table(yoy)) + + # Generate drawdown analysis table + if isinstance(returns, _pd.Series): + # Calculate drawdown series and get worst drawdown periods + dd = _get_stats().to_drawdown_series(returns) + dd_info = _get_stats().drawdown_details(dd).sort_values( + by="max drawdown", ascending=True + )[:10] + dd_info = dd_info[["start", "end", "max drawdown", "days"]] + dd_info.columns = ["Started", "Recovered", "Drawdown", "Days"] + tpl = tpl.replace("{{dd_info}}", _html_table(dd_info, False)) + elif isinstance(returns, _pd.DataFrame): + # Handle multiple strategy columns + dd_info_list = [] + for col in returns.columns: + dd = _get_stats().to_drawdown_series(returns[col]) + dd_info = _get_stats().drawdown_details(dd).sort_values( + by="max drawdown", ascending=True + )[:10] + dd_info = dd_info[["start", "end", "max drawdown", "days"]] + dd_info.columns = ["Started", "Recovered", "Drawdown", "Days"] + dd_info_list.append(_html_table(dd_info, False)) + + # Combine all drawdown tables with headers + dd_html_table = "" + for html_str, col in zip(dd_info_list, returns.columns): + dd_html_table = ( + dd_html_table + f"

{col}


" + StringIO(html_str).read() + ) + tpl = tpl.replace("{{dd_info}}", dd_html_table) + + # Get active returns setting for plots + active = kwargs.get("active_returns", False) + + # Generate all the performance plots and embed them in the HTML # plots - figfile = _utils._file_stream() - _plots.returns(returns, benchmark, grayscale=grayscale, - figsize=(8, 5), subtitle=False, - savefig={'fname': figfile, 'format': 'svg'}, - show=False, ylabel=False, cumulative=compounded) - tpl = tpl.replace('{{returns}}', figfile.getvalue().decode()) - - figfile = _utils._file_stream() - _plots.log_returns(returns, benchmark, grayscale=grayscale, - figsize=(8, 4), subtitle=False, - savefig={'fname': figfile, 'format': 'svg'}, - show=False, ylabel=False, cumulative=compounded) - tpl = tpl.replace('{{log_returns}}', figfile.getvalue().decode()) + figfile = _get_utils()._file_stream() + _get_plots().returns( + returns, + benchmark, + grayscale=grayscale, + figsize=(8, 5), + subtitle=False, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + compound=compounded, + prepare_returns=False, + ) + tpl = tpl.replace("{{returns}}", _embed_figure(figfile, figfmt)) + + # Log returns plot for better visualization of performance + figfile = _get_utils()._file_stream() + _get_plots().log_returns( + returns, + benchmark, + grayscale=grayscale, + figsize=(8, 4), + subtitle=False, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + compound=compounded, + prepare_returns=False, + ) + tpl = tpl.replace("{{log_returns}}", _embed_figure(figfile, figfmt)) + # Volatility-matched returns plot (only if benchmark exists) if benchmark is not None: - figfile = _utils._file_stream() - _plots.returns(returns, benchmark, match_volatility=True, - grayscale=grayscale, figsize=(8, 4), subtitle=False, - savefig={'fname': figfile, 'format': 'svg'}, - show=False, ylabel=False, cumulative=compounded) - tpl = tpl.replace('{{vol_returns}}', figfile.getvalue().decode()) - - figfile = _utils._file_stream() - _plots.yearly_returns(returns, benchmark, grayscale=grayscale, - figsize=(8, 4), subtitle=False, - savefig={'fname': figfile, 'format': 'svg'}, - show=False, ylabel=False, compounded=compounded) - tpl = tpl.replace('{{eoy_returns}}', figfile.getvalue().decode()) - - figfile = _utils._file_stream() - _plots.histogram(returns, grayscale=grayscale, - figsize=(8, 4), subtitle=False, - savefig={'fname': figfile, 'format': 'svg'}, - show=False, ylabel=False, compounded=compounded) - tpl = tpl.replace('{{monthly_dist}}', figfile.getvalue().decode()) - - figfile = _utils._file_stream() - _plots.daily_returns(returns, grayscale=grayscale, - figsize=(8, 3), subtitle=False, - savefig={'fname': figfile, 'format': 'svg'}, - show=False, ylabel=False) - tpl = tpl.replace('{{daily_returns}}', figfile.getvalue().decode()) + figfile = _get_utils()._file_stream() + _get_plots().returns( + returns, + benchmark, + match_volatility=True, + grayscale=grayscale, + figsize=(8, 4), + subtitle=False, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + compound=compounded, + prepare_returns=False, + ) + tpl = tpl.replace("{{vol_returns}}", _embed_figure(figfile, figfmt)) + + # Yearly returns comparison chart + figfile = _get_utils()._file_stream() + _get_plots().yearly_returns( + returns, + benchmark, + grayscale=grayscale, + figsize=(8, 4), + subtitle=False, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + compounded=compounded, + prepare_returns=False, + ) + tpl = tpl.replace("{{eoy_returns}}", _embed_figure(figfile, figfmt)) + + # Returns distribution histogram + figfile = _get_utils()._file_stream() + _get_plots().histogram( + returns, + benchmark, + grayscale=grayscale, + figsize=(7, 4), + subtitle=False, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + compounded=compounded, + prepare_returns=False, + ) + tpl = tpl.replace("{{monthly_dist}}", _embed_figure(figfile, figfmt)) + + # Daily returns scatter plot + figfile = _get_utils()._file_stream() + _get_plots().daily_returns( + returns, + benchmark, + grayscale=grayscale, + figsize=(8, 3), + subtitle=False, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + prepare_returns=False, + active=active, + ) + tpl = tpl.replace("{{daily_returns}}", _embed_figure(figfile, figfmt)) + # Rolling beta analysis (only if benchmark exists) if benchmark is not None: - figfile = _utils._file_stream() - _plots.rolling_beta(returns, benchmark, grayscale=grayscale, - figsize=(8, 3), subtitle=False, - savefig={'fname': figfile, 'format': 'svg'}, - show=False, ylabel=False) - tpl = tpl.replace('{{rolling_beta}}', figfile.getvalue().decode()) - - figfile = _utils._file_stream() - _plots.rolling_volatility(returns, benchmark, grayscale=grayscale, - figsize=(8, 3), subtitle=False, - savefig={'fname': figfile, 'format': 'svg'}, - show=False, ylabel=False) - tpl = tpl.replace('{{rolling_vol}}', figfile.getvalue().decode()) - - figfile = _utils._file_stream() - _plots.rolling_sharpe(returns, grayscale=grayscale, - figsize=(8, 3), subtitle=False, - savefig={'fname': figfile, 'format': 'svg'}, - show=False, ylabel=False) - tpl = tpl.replace('{{rolling_sharpe}}', figfile.getvalue().decode()) - - figfile = _utils._file_stream() - _plots.rolling_sortino(returns, grayscale=grayscale, - figsize=(8, 3), subtitle=False, - savefig={'fname': figfile, 'format': 'svg'}, - show=False, ylabel=False) - tpl = tpl.replace('{{rolling_sortino}}', figfile.getvalue().decode()) - - figfile = _utils._file_stream() - _plots.drawdowns_periods(returns, grayscale=grayscale, - figsize=(8, 4), subtitle=False, - savefig={'fname': figfile, 'format': 'svg'}, - show=False, ylabel=False, compounded=compounded) - tpl = tpl.replace('{{dd_periods}}', figfile.getvalue().decode()) - - figfile = _utils._file_stream() - _plots.drawdown(returns, grayscale=grayscale, - figsize=(8, 3), subtitle=False, - savefig={'fname': figfile, 'format': 'svg'}, - show=False, ylabel=False) - tpl = tpl.replace('{{dd_plot}}', figfile.getvalue().decode()) - - figfile = _utils._file_stream() - _plots.monthly_heatmap(returns, grayscale=grayscale, - figsize=(8, 4), cbar=False, - savefig={'fname': figfile, 'format': 'svg'}, - show=False, ylabel=False, compounded=compounded) - tpl = tpl.replace('{{monthly_heatmap}}', figfile.getvalue().decode()) - - figfile = _utils._file_stream() - _plots.distribution(returns, grayscale=grayscale, - figsize=(8, 4), subtitle=False, - savefig={'fname': figfile, 'format': 'svg'}, - show=False, ylabel=False, compounded=compounded) - tpl = tpl.replace('{{returns_dist}}', figfile.getvalue().decode()) - - tpl = _regex.sub(r'\{\{(.*?)\}\}', '', tpl) - tpl = tpl.replace('white-space:pre;', '') - + figfile = _get_utils()._file_stream() + _get_plots().rolling_beta( + returns, + benchmark, + grayscale=grayscale, + figsize=(8, 3), + subtitle=False, + window1=win_half_year, + window2=win_year, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + prepare_returns=False, + ) + tpl = tpl.replace("{{rolling_beta}}", _embed_figure(figfile, figfmt)) + + # Rolling volatility analysis + figfile = _get_utils()._file_stream() + _get_plots().rolling_volatility( + returns, + benchmark, + grayscale=grayscale, + figsize=(8, 3), + subtitle=False, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + period=win_half_year, + periods_per_year=win_year, + ) + tpl = tpl.replace("{{rolling_vol}}", _embed_figure(figfile, figfmt)) + + # Rolling Sharpe ratio analysis + figfile = _get_utils()._file_stream() + _get_plots().rolling_sharpe( + returns, + grayscale=grayscale, + figsize=(8, 3), + subtitle=False, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + period=win_half_year, + periods_per_year=win_year, + ) + tpl = tpl.replace("{{rolling_sharpe}}", _embed_figure(figfile, figfmt)) + + # Rolling Sortino ratio analysis + figfile = _get_utils()._file_stream() + _get_plots().rolling_sortino( + returns, + grayscale=grayscale, + figsize=(8, 3), + subtitle=False, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + period=win_half_year, + periods_per_year=win_year, + ) + tpl = tpl.replace("{{rolling_sortino}}", _embed_figure(figfile, figfmt)) + + # Drawdown periods analysis + figfile = _get_utils()._file_stream() + if isinstance(returns, _pd.Series): + _get_plots().drawdowns_periods( + returns, + grayscale=grayscale, + figsize=(8, 4), + subtitle=False, + title=returns.name, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + compounded=compounded, + prepare_returns=False, + ) + tpl = tpl.replace("{{dd_periods}}", _embed_figure(figfile, figfmt)) + elif isinstance(returns, _pd.DataFrame): + # Handle multiple strategy columns + embed = [] + for col in returns.columns: + _get_plots().drawdowns_periods( + returns[col], + grayscale=grayscale, + figsize=(8, 4), + subtitle=False, + title=col, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + compounded=compounded, + prepare_returns=False, + ) + embed.append(figfile) + tpl = tpl.replace("{{dd_periods}}", _embed_figure(embed, figfmt)) + + # Underwater (drawdown) plot + figfile = _get_utils()._file_stream() + _get_plots().drawdown( + returns, + grayscale=grayscale, + figsize=(8, 3), + subtitle=False, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + ) + tpl = tpl.replace("{{dd_plot}}", _embed_figure(figfile, figfmt)) + + # Monthly returns heatmap + figfile = _get_utils()._file_stream() + if isinstance(returns, _pd.Series): + _get_plots().monthly_heatmap( + returns, + benchmark, + grayscale=grayscale, + figsize=(8, 4), + cbar=False, + returns_label=returns.name, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + compounded=compounded, + active=active, + ) + tpl = tpl.replace("{{monthly_heatmap}}", _embed_figure(figfile, figfmt)) + elif isinstance(returns, _pd.DataFrame): + # Handle multiple strategy columns + embed = [] + for col in returns.columns: + _get_plots().monthly_heatmap( + returns[col], + benchmark, + grayscale=grayscale, + figsize=(8, 4), + cbar=False, + returns_label=col, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + compounded=compounded, + active=active, + ) + embed.append(figfile) + tpl = tpl.replace("{{monthly_heatmap}}", _embed_figure(embed, figfmt)) + + # Returns distribution analysis + figfile = _get_utils()._file_stream() + + if isinstance(returns, _pd.Series): + _get_plots().distribution( + returns, + grayscale=grayscale, + figsize=(8, 4), + subtitle=False, + title=returns.name, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + compounded=compounded, + prepare_returns=False, + ) + tpl = tpl.replace("{{returns_dist}}", _embed_figure(figfile, figfmt)) + elif isinstance(returns, _pd.DataFrame): + # Handle multiple strategy columns + embed = [] + for col in returns.columns: + _get_plots().distribution( + returns[col], + grayscale=grayscale, + figsize=(8, 4), + subtitle=False, + title=col, + savefig={"fname": figfile, "format": figfmt}, + show=False, + ylabel="", + compounded=compounded, + prepare_returns=False, + ) + embed.append(figfile) + tpl = tpl.replace("{{returns_dist}}", _embed_figure(embed, figfmt)) + + # Clean up any remaining template placeholders + tpl = _regex.sub(r"\{\{(.*?)\}\}", "", tpl) + tpl = tpl.replace("white-space:pre;", "") + + # Handle output - either download in browser or save to file if output is None: - # _open_html(tpl) - _download_html(tpl, 'quantstats-tearsheet.html') + if _get_utils()._in_notebook(): + _download_html(tpl, download_filename) + else: + # Save to temp file and open in browser + with tempfile.NamedTemporaryFile( + mode="w", suffix=".html", delete=False, encoding="utf-8" + ) as f: + f.write(tpl) + temp_path = f.name + webbrowser.open("file://" + temp_path) return - with open(output, 'w', encoding='utf-8') as f: + # Write HTML content to specified output file + with open(output, "w", encoding="utf-8") as f: f.write(tpl) -def full(returns, benchmark=None, rf=0., grayscale=False, - figsize=(8, 5), display=True, compounded=True): - - dd = _stats.to_drawdown_series(returns) - dd_info = _stats.drawdown_details(dd).sort_values( - by='max drawdown', ascending=True)[:5] +def full( + returns, + benchmark=None, + rf=0.0, + grayscale=False, + figsize=(8, 5), + display=True, + compounded=True, + periods_per_year=252, + match_dates=True, + **kwargs, +): + """ + Generate a comprehensive performance analysis report. + + This function creates a full performance analysis including metrics, + worst drawdowns analysis, and complete visualization suite. It's designed + for detailed portfolio analysis and can handle both single strategies + and multiple strategy comparisons. + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Daily returns data for the strategy/portfolio + benchmark : pd.Series, str, or None, default None + Benchmark returns for comparison + rf : float, default 0.0 + Risk-free rate for calculations (as decimal) + grayscale : bool, default False + Whether to generate charts in grayscale + figsize : tuple, default (8, 5) + Figure size for plots as (width, height) + display : bool, default True + Whether to display results in notebook/console + compounded : bool, default True + Whether to compound returns for calculations + periods_per_year : int, default 252 + Number of trading periods per year + match_dates : bool, default True + Whether to align returns and benchmark start dates + **kwargs + Additional keyword arguments: + - strategy_title: Custom name for the strategy + - benchmark_title: Custom name for the benchmark + - active_returns: Whether to show active returns vs benchmark + + Returns + ------- + None + Displays comprehensive analysis including metrics, drawdowns, and plots + + Examples + -------- + >>> full(returns, benchmark='^GSPC', rf=0.02) + >>> full(returns, figsize=(10, 6), grayscale=True) + """ + # prepare timeseries + if match_dates: + returns = returns.dropna() + # Clean and prepare returns data + returns = _get_utils()._prepare_returns(returns) + + # Process benchmark if provided + if benchmark is not None: + benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index, rf) + if match_dates is True: + returns, benchmark = _match_dates(returns, benchmark) - if not dd_info.empty: - dd_info.index = range(1, min(6, len(dd_info)+1)) - dd_info.columns = map(lambda x: str(x).title(), dd_info.columns) + # Extract title parameters from kwargs + benchmark_title = None + if benchmark is not None: + benchmark_title = kwargs.get("benchmark_title", "Benchmark") + strategy_title = kwargs.get("strategy_title", "Strategy") + active = kwargs.get("active_returns", False) - if _utils._in_notebook(): - iDisplay(iHTML('

Performance Metrics

')) - iDisplay(metrics(returns=returns, benchmark=benchmark, - rf=rf, display=display, mode='full', - compounded=compounded)) - iDisplay(iHTML('

5 Worst Drawdowns

')) - if dd_info.empty: - iDisplay(iHTML("

(no drawdowns)

")) - else: - iDisplay(dd_info) + # Handle multiple strategy columns + if isinstance(returns, _pd.DataFrame): + if len(returns.columns) > 1 and isinstance(strategy_title, str): + strategy_title = list(returns.columns) - iDisplay(iHTML('

Strategy Visualization

')) + # Set names for display purposes + if benchmark is not None: + benchmark.name = benchmark_title + if isinstance(returns, _pd.Series): + returns.name = strategy_title + elif isinstance(returns, _pd.DataFrame): + returns.columns = strategy_title + + # Calculate drawdown analysis for worst periods display + dd = _get_stats().to_drawdown_series(returns) + + # Process drawdown details based on data type + if isinstance(dd, _pd.Series): + col = _get_stats().drawdown_details(dd).columns[4] + dd_info = _get_stats().drawdown_details(dd).sort_values(by=col, ascending=True)[:5] + if not dd_info.empty: + dd_info.index = range(1, min(6, len(dd_info) + 1)) + dd_info.columns = map(lambda x: str(x).title(), dd_info.columns) + elif isinstance(dd, _pd.DataFrame): + # Handle multiple strategy columns + col = _get_stats().drawdown_details(dd).columns.get_level_values(1)[4] + dd_info_dict = {} + for ptf in dd.columns: + dd_info = _get_stats().drawdown_details(dd[ptf]).sort_values( + by=col, ascending=True + )[:5] + if not dd_info.empty: + dd_info.index = range(1, min(6, len(dd_info) + 1)) + dd_info.columns = map(lambda x: str(x).title(), dd_info.columns) + dd_info_dict[ptf] = dd_info + + # Display results based on environment (notebook vs console) + if _get_utils()._in_notebook(): + # Display in Jupyter notebook with HTML formatting + iDisplay(iHTML("

Performance Metrics

")) + iDisplay( + metrics( + returns=returns, + benchmark=benchmark, + rf=rf, + display=display, + mode="full", + compounded=compounded, + periods_per_year=periods_per_year, + prepare_returns=False, + benchmark_title=benchmark_title, + strategy_title=strategy_title, + ) + ) + + # Display worst drawdowns analysis + if isinstance(dd, _pd.Series): + iDisplay(iHTML('

Worst 5 Drawdowns

')) + if dd_info.empty: + iDisplay(iHTML("

(no drawdowns)

")) + else: + iDisplay(dd_info) + elif isinstance(dd, _pd.DataFrame): + # Display drawdowns for each strategy + for ptf, dd_info in dd_info_dict.items(): + iDisplay( + iHTML( + '

%s - Worst 5 Drawdowns

' + % ptf + ) + ) + if dd_info.empty: + iDisplay(iHTML("

(no drawdowns)

")) + else: + iDisplay(dd_info) + + iDisplay(iHTML("

Strategy Visualization

")) else: - print('[Performance Metrics]\n') - metrics(returns=returns, benchmark=benchmark, - rf=rf, display=display, mode='full', - compounded=compounded) - print('\n\n') - print('[5 Worst Drawdowns]\n') - if dd_info.empty: - print("(no drawdowns)") - else: - print(_tabulate(dd_info, headers="keys", - tablefmt='simple', floatfmt=".2f")) - print('\n\n') - print('[Strategy Visualization]\nvia Matplotlib') - - plots(returns=returns, benchmark=benchmark, - grayscale=grayscale, figsize=figsize, mode='full') + # Display in console/terminal environment + _print_parameters_table( + benchmark_title=benchmark_title, + periods_per_year=periods_per_year, + rf=rf, + compounded=compounded, + match_dates=match_dates, + ) + print("[Performance Metrics]\n") + metrics( + returns=returns, + benchmark=benchmark, + rf=rf, + display=display, + mode="full", + compounded=compounded, + periods_per_year=periods_per_year, + prepare_returns=False, + benchmark_title=benchmark_title, + strategy_title=strategy_title, + ) + print("\n\n") + print("[Worst 5 Drawdowns]\n") + + # Display drawdowns in tabular format + if isinstance(dd, _pd.Series): + if dd_info.empty: + print("(no drawdowns)") + else: + print( + _tabulate( + dd_info, headers="keys", tablefmt="simple", floatfmt=".2f" + ) + ) + elif isinstance(dd, _pd.DataFrame): + for ptf, dd_info in dd_info_dict.items(): + if dd_info.empty: + print("(no drawdowns)") + else: + print(f"{ptf}\n") + print( + _tabulate( + dd_info, headers="keys", tablefmt="simple", floatfmt=".2f" + ) + ) + + print("\n\n") + print("[Strategy Visualization]\nvia Matplotlib") + + # Generate comprehensive plots + plots( + returns=returns, + benchmark=benchmark, + grayscale=grayscale, + figsize=figsize, + mode="full", + compounded=compounded, + periods_per_year=periods_per_year, + prepare_returns=False, + benchmark_title=benchmark_title, + strategy_title=strategy_title, + active=active, + ) -def basic(returns, benchmark=None, rf=0., grayscale=False, - figsize=(8, 5), display=True, compounded=True): +def basic( + returns, + benchmark=None, + rf=0.0, + grayscale=False, + figsize=(8, 5), + display=True, + compounded=True, + periods_per_year=252, + match_dates=True, + **kwargs, +): + """ + Generate a basic performance analysis report. + + This function creates a simplified performance analysis with essential + metrics and basic visualizations. It's designed for quick portfolio + analysis when detailed analysis is not needed. + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Daily returns data for the strategy/portfolio + benchmark : pd.Series, str, or None, default None + Benchmark returns for comparison + rf : float, default 0.0 + Risk-free rate for calculations (as decimal) + grayscale : bool, default False + Whether to generate charts in grayscale + figsize : tuple, default (8, 5) + Figure size for plots as (width, height) + display : bool, default True + Whether to display results in notebook/console + compounded : bool, default True + Whether to compound returns for calculations + periods_per_year : int, default 252 + Number of trading periods per year + match_dates : bool, default True + Whether to align returns and benchmark start dates + **kwargs + Additional keyword arguments: + - strategy_title: Custom name for the strategy + - benchmark_title: Custom name for the benchmark + - active_returns: Whether to show active returns vs benchmark + + Returns + ------- + None + Displays basic analysis including essential metrics and plots + + Examples + -------- + >>> basic(returns, benchmark='^GSPC') + >>> basic(returns, figsize=(10, 6), display=False) + """ + # prepare timeseries + if match_dates: + returns = returns.dropna() + # Clean and prepare returns data + returns = _get_utils()._prepare_returns(returns) + + # Process benchmark if provided + if benchmark is not None: + benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index, rf) + if match_dates is True: + returns, benchmark = _match_dates(returns, benchmark) - if _utils._in_notebook(): - iDisplay(iHTML('

Performance Metrics

')) - metrics(returns=returns, benchmark=benchmark, - rf=rf, display=display, mode='basic', - compounded=compounded) - iDisplay(iHTML('

Strategy Visualization

')) + # Extract title parameters from kwargs + benchmark_title = None + if benchmark is not None: + benchmark_title = kwargs.get("benchmark_title", "Benchmark") + strategy_title = kwargs.get("strategy_title", "Strategy") + active = kwargs.get("active_returns", False) + + # Handle multiple strategy columns + if isinstance(returns, _pd.DataFrame): + if len(returns.columns) > 1 and isinstance(strategy_title, str): + strategy_title = list(returns.columns) + + # Display results based on environment (notebook vs console) + if _get_utils()._in_notebook(): + # Display in Jupyter notebook with HTML formatting + iDisplay(iHTML("

Performance Metrics

")) + metrics( + returns=returns, + benchmark=benchmark, + rf=rf, + display=display, + mode="basic", + compounded=compounded, + periods_per_year=periods_per_year, + prepare_returns=False, + benchmark_title=benchmark_title, + strategy_title=strategy_title, + ) + iDisplay(iHTML("

Strategy Visualization

")) else: - print('[Performance Metrics]\n') - metrics(returns=returns, benchmark=benchmark, - rf=rf, display=display, mode='basic', - compounded=compounded) - - print('\n\n') - print('[Strategy Visualization]\nvia Matplotlib') - - plots(returns=returns, benchmark=benchmark, - grayscale=grayscale, figsize=figsize, mode='basic') - - -def metrics(returns, benchmark=None, rf=0., display=True, - mode='basic', sep=False, compounded=True, **kwargs): + # Display in console/terminal environment + _print_parameters_table( + benchmark_title=benchmark_title, + periods_per_year=periods_per_year, + rf=rf, + compounded=compounded, + match_dates=match_dates, + ) + print("[Performance Metrics]\n") + metrics( + returns=returns, + benchmark=benchmark, + rf=rf, + display=display, + mode="basic", + compounded=compounded, + periods_per_year=periods_per_year, + prepare_returns=False, + benchmark_title=benchmark_title, + strategy_title=strategy_title, + ) + + print("\n\n") + print("[Strategy Visualization]\nvia Matplotlib") + + # Generate basic plots + plots( + returns=returns, + benchmark=benchmark, + grayscale=grayscale, + figsize=figsize, + mode="basic", + compounded=compounded, + periods_per_year=periods_per_year, + prepare_returns=False, + benchmark_title=benchmark_title, + strategy_title=strategy_title, + active=active, + ) - if isinstance(returns, _pd.DataFrame) and len(returns.columns) > 1: - raise ValueError("`returns` must be a pandas Series, " - "but a multi-column DataFrame was passed") +def metrics( + returns, + benchmark=None, + rf=0.0, + display=True, + mode="basic", + sep=False, + compounded=True, + periods_per_year=252, + prepare_returns=True, + match_dates=True, + **kwargs, +): + """ + Calculate comprehensive performance metrics for portfolio analysis. + + This function computes a wide range of performance metrics including + returns, risk measures, ratios, and statistical measures. It can handle + both single strategies and multiple strategy comparisons with optional + benchmark analysis. + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Daily returns data for the strategy/portfolio + benchmark : pd.Series, str, or None, default None + Benchmark returns for comparison + rf : float, default 0.0 + Risk-free rate for calculations (as decimal) + display : bool, default True + Whether to display results in formatted table + mode : str, default "basic" + Analysis mode - "basic" for essential metrics, "full" for comprehensive + sep : bool, default False + Whether to include separator rows in output + compounded : bool, default True + Whether to compound returns for calculations + periods_per_year : int, default 252 + Number of trading periods per year + prepare_returns : bool, default True + Whether to prepare/clean returns data + match_dates : bool, default True + Whether to align returns and benchmark start dates + **kwargs + Additional keyword arguments: + - strategy_title: Custom name for the strategy + - benchmark_title: Custom name for the benchmark + - as_pct: Whether to return percentages + - internal: Internal calculation flag + + Returns + ------- + pd.DataFrame or None + DataFrame with performance metrics if display=False, else None + + Examples + -------- + >>> metrics_df = metrics(returns, benchmark='^GSPC', display=False) + >>> metrics(returns, mode="full", rf=0.02) + """ + # Clean returns data if date matching is enabled + if match_dates: + returns = returns.dropna() + # Remove timezone information from index for consistent processing + returns.index = returns.index.tz_localize(None) + + # Get trading periods for annualization calculations + win_year, _ = _get_trading_periods(periods_per_year) + + # Extract column names from kwargs or use defaults + benchmark_colname = kwargs.get("benchmark_title", "Benchmark") + strategy_colname = kwargs.get("strategy_title", "Strategy") + + # Handle benchmark column naming if benchmark is not None: - if isinstance(returns, _pd.DataFrame) and len(returns.columns) > 1: - raise ValueError("`benchmark` must be a pandas Series, " - "but a multi-column DataFrame was passed") - - blank = [''] - df = _pd.DataFrame({"returns": _utils._prepare_returns(returns, rf)}) + if isinstance(benchmark, str): + benchmark_colname = f"Benchmark ({benchmark.upper()})" + elif isinstance(benchmark, _pd.DataFrame) and len(benchmark.columns) > 1: + raise ValueError( + "`benchmark` must be a pandas Series, " + "but a multi-column DataFrame was passed" + ) + + # Handle strategy column naming for multiple strategies + if isinstance(returns, _pd.DataFrame): + if len(returns.columns) > 1: + blank = [""] * len(returns.columns) + if isinstance(strategy_colname, str): + strategy_colname = list(returns.columns) + else: + blank = [""] + + # if isinstance(returns, _pd.DataFrame): + # if len(returns.columns) > 1: + # raise ValueError("`returns` needs to be a Pandas Series or one column DataFrame. " + # "multi colums DataFrame was passed") + # returns = returns[returns.columns[0]] + + # Prepare returns data if requested + if prepare_returns: + df = _get_utils()._prepare_returns(returns) + + # Create main DataFrame for calculations + if isinstance(returns, _pd.Series): + df = _pd.DataFrame({"returns": returns}) + elif isinstance(returns, _pd.DataFrame): + df = _pd.DataFrame( + { + "returns_" + str(i + 1): returns[strategy_col] + for i, strategy_col in enumerate(returns.columns) + } + ) + + # Process benchmark data if provided if benchmark is not None: - blank = ['', ''] - df["benchmark"] = _utils._prepare_benchmark( - benchmark, returns.index, rf) + benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index, rf) + if match_dates is True: + returns, benchmark = _match_dates(returns, benchmark) + # Truncate df to the aligned date range to exclude leading zeros + df = df.loc[returns.index] + df["benchmark"] = benchmark + # Update blank list for proper formatting + if isinstance(returns, _pd.Series): + blank = ["", ""] + df["returns"] = returns + elif isinstance(returns, _pd.DataFrame): + blank = [""] * len(returns.columns) + [""] + for i, strategy_col in enumerate(returns.columns): + df["returns_" + str(i + 1)] = returns[strategy_col] + + # Calculate start and end dates for each series + if isinstance(returns, _pd.Series): + s_start = {"returns": df["returns"].index.strftime("%Y-%m-%d")[0]} + s_end = {"returns": df["returns"].index.strftime("%Y-%m-%d")[-1]} + s_rf = {"returns": rf} + elif isinstance(returns, _pd.DataFrame): + df_strategy_columns = [col for col in df.columns if col != "benchmark"] + s_start = { + strategy_col: df[strategy_col].dropna().index.strftime("%Y-%m-%d")[0] + for strategy_col in df_strategy_columns + } + s_end = { + strategy_col: df[strategy_col].dropna().index.strftime("%Y-%m-%d")[-1] + for strategy_col in df_strategy_columns + } + s_rf = {strategy_col: rf for strategy_col in df_strategy_columns} - df = df.dropna() + # Add benchmark dates if present + if "benchmark" in df: + s_start["benchmark"] = df["benchmark"].index.strftime("%Y-%m-%d")[0] + s_end["benchmark"] = df["benchmark"].index.strftime("%Y-%m-%d")[-1] + s_rf["benchmark"] = rf + # Fill missing values with zeros for calculations + df = df.fillna(0) + + # Determine percentage multiplier for display # pct multiplier pct = 100 if display or "internal" in kwargs else 1 + if kwargs.get("as_pct", False): + pct = 100 - # return df - dd = _calc_dd(df, display=(display or "internal" in kwargs)) - + # Initialize metrics DataFrame with basic information metrics = _pd.DataFrame() + metrics["Start Period"] = _pd.Series(s_start) + metrics["End Period"] = _pd.Series(s_end) + metrics["Risk-Free Rate %"] = _pd.Series(s_rf) * 100 + metrics["Time in Market %"] = _get_stats().exposure(df, prepare_returns=False) * pct - s_start = {'returns': df['returns'].index.strftime('%Y-%m-%d')[0]} - s_end = {'returns': df['returns'].index.strftime('%Y-%m-%d')[-1]} - s_rf = {'returns': rf} - - if "benchmark" in df: - s_start['benchmark'] = df['benchmark'].index.strftime('%Y-%m-%d')[0] - s_end['benchmark'] = df['benchmark'].index.strftime('%Y-%m-%d')[-1] - s_rf['benchmark'] = rf - - metrics['Start Period'] = _pd.Series(s_start) - metrics['End Period'] = _pd.Series(s_end) - metrics['Risk-Free Rate %'] = _pd.Series(s_rf) - metrics['Time in Market %'] = _stats.exposure(df) * pct - - metrics['~'] = blank + # Add separator row + metrics["~"] = blank + # Calculate return metrics based on compounding preference if compounded: - metrics['Cumulative Return %'] = ( - _stats.comp(df) * pct).map('{:,.2f}'.format) + metrics["Cumulative Return %"] = (_get_stats().comp(df) * pct).map("{:,.2f}".format) else: - metrics['Total Return %'] = (df.sum() * pct).map('{:,.2f}'.format) + metrics["Total Return %"] = (df.sum() * pct).map("{:,.2f}".format) - metrics['CAGR%%'] = _stats.cagr(df, rf, compounded) * pct - metrics['Sharpe'] = _stats.sharpe(df, rf) - metrics['Sortino'] = _stats.sortino(df, rf) - metrics['Max Drawdown %'] = blank - metrics['Longest DD Days'] = blank + # Calculate annualized return (CAGR) + metrics["CAGR﹪%"] = _get_stats().cagr(df, rf, compounded, win_year) * pct - if mode.lower() == 'full': - ret_vol = _stats.volatility(df['returns']) * pct - if "benchmark" in df: - bench_vol = _stats.volatility(df['benchmark']) * pct - metrics['Volatility (ann.) %'] = [ret_vol, bench_vol] - metrics['R^2'] = _stats.r_squared(df['returns'], df['benchmark']) - else: - metrics['Volatility (ann.) %'] = [ret_vol] + # Add separator row + metrics["~~~~~~~~~~~~~~"] = blank - metrics['Calmar'] = _stats.calmar(df) - metrics['Skew'] = _stats.skew(df) - metrics['Kurtosis'] = _stats.kurtosis(df) + # Calculate risk-adjusted return ratios + metrics["Sharpe"] = _get_stats().sharpe(df, rf, win_year, True) + metrics["Prob. Sharpe Ratio %"] = ( + _get_stats().probabilistic_sharpe_ratio(df, rf, win_year, False) * pct + ) - metrics['~~~~~~~~~~'] = blank + # Add advanced Sharpe metrics for full mode + if mode.lower() == "full": + metrics["Smart Sharpe"] = _get_stats().smart_sharpe(df, rf, win_year, True) + # metrics['Prob. Smart Sharpe Ratio %'] = _get_stats().probabilistic_sharpe_ratio(df, rf, win_year, False, True) * pct + + # Calculate Sortino ratio (downside deviation-based) + metrics["Sortino"] = _get_stats().sortino(df, rf, win_year, True) + if mode.lower() == "full": + # metrics['Prob. Sortino Ratio %'] = _get_stats().probabilistic_sortino_ratio(df, rf, win_year, False) * pct + metrics["Smart Sortino"] = _get_stats().smart_sortino(df, rf, win_year, True) + # metrics['Prob. Smart Sortino Ratio %'] = _get_stats().probabilistic_sortino_ratio( + # df, rf, win_year, False, True) * pct + + # Calculate adjusted Sortino ratio + metrics["Sortino/√2"] = metrics["Sortino"] / _sqrt(2) + if mode.lower() == "full": + # metrics['Prob. Sortino/√2 Ratio %'] = _get_stats().probabilistic_adjusted_sortino_ratio( + # df, rf, win_year, False) * pct + metrics["Smart Sortino/√2"] = metrics["Smart Sortino"] / _sqrt(2) + # metrics['Prob. Smart Sortino/√2 Ratio %'] = _get_stats().probabilistic_adjusted_sortino_ratio( + # df, rf, win_year, False, True) * pct + + # Calculate Omega ratio (probability-weighted ratio) + if isinstance(returns, _pd.Series): + if "benchmark" in df: + metrics["Omega"] = [ + _get_stats().omega(df["returns"], rf, 0.0, win_year), + _get_stats().omega(df["benchmark"], rf, 0.0, win_year), + ] + else: + metrics["Omega"] = _get_stats().omega(df["returns"], rf, 0.0, win_year) + elif isinstance(returns, _pd.DataFrame): + omega_values = [ + _get_stats().omega(df[strategy_col], rf, 0.0, win_year) + for strategy_col in df_strategy_columns + ] + if "benchmark" in df: + omega_values.append(_get_stats().omega(df["benchmark"], rf, 0.0, win_year)) + metrics["Omega"] = omega_values + + # Add separator and prepare for drawdown metrics + metrics["~~~~~~~~"] = blank + metrics["Max Drawdown %"] = blank + metrics["Max DD Date"] = blank + metrics["Max DD Period Start"] = blank + metrics["Max DD Period End"] = blank + metrics["Longest DD Days"] = blank + + # Add detailed volatility and risk metrics for full mode + if mode.lower() == "full": + # Calculate annualized volatility + if isinstance(returns, _pd.Series): + ret_vol = ( + _get_stats().volatility(df["returns"], win_year, True, prepare_returns=False) + * pct + ) + elif isinstance(returns, _pd.DataFrame): + ret_vol = [ + _get_stats().volatility( + df[strategy_col], win_year, True, prepare_returns=False + ) + * pct + for strategy_col in df_strategy_columns + ] + + # Add benchmark volatility if present + if "benchmark" in df: + bench_vol = ( + _get_stats().volatility( + df["benchmark"], win_year, True, prepare_returns=False + ) + * pct + ) + + vol_ = [ret_vol, bench_vol] + if isinstance(ret_vol, list): + metrics["Volatility (ann.) %"] = list(_pd.core.common.flatten(vol_)) + else: + metrics["Volatility (ann.) %"] = vol_ + + # Calculate benchmark-relative metrics + if isinstance(returns, _pd.Series): + metrics["R^2"] = _get_stats().r_squared( + df["returns"], df["benchmark"], prepare_returns=False + ) + metrics["Information Ratio"] = _get_stats().information_ratio( + df["returns"], df["benchmark"], prepare_returns=False + ) + elif isinstance(returns, _pd.DataFrame): + metrics["R^2"] = ( + [ + _get_stats().r_squared( + df[strategy_col], df["benchmark"], prepare_returns=False + ).round(2) + for strategy_col in df_strategy_columns + ] + ) + ["-"] + metrics["Information Ratio"] = ( + [ + _get_stats().information_ratio( + df[strategy_col], df["benchmark"], prepare_returns=False + ).round(2) + for strategy_col in df_strategy_columns + ] + ) + ["-"] + else: + # No benchmark case + if isinstance(returns, _pd.Series): + metrics["Volatility (ann.) %"] = [ret_vol] + elif isinstance(returns, _pd.DataFrame): + metrics["Volatility (ann.) %"] = ret_vol + + # Additional risk and return metrics + metrics["Calmar"] = _get_stats().calmar( + df, prepare_returns=False, periods=win_year, compounded=compounded + ) + metrics["Skew"] = _get_stats().skew(df, prepare_returns=False) + metrics["Kurtosis"] = _get_stats().kurtosis(df, prepare_returns=False) + + # Additional ratios + metrics["Ulcer Performance Index"] = _get_stats().ulcer_performance_index(df, rf) + metrics["Risk-Adjusted Return %"] = _get_stats().rar(df, rf) * pct + metrics["Risk-Return Ratio"] = _get_stats().risk_return_ratio(df, prepare_returns=False) + + # Add separator + metrics["~~~~~~~~~~"] = blank + + # Average return metrics + metrics["Avg. Return %"] = _get_stats().avg_return(df, prepare_returns=False) * pct + metrics["Avg. Win %"] = _get_stats().avg_win(df, prepare_returns=False) * pct + metrics["Avg. Loss %"] = _get_stats().avg_loss(df, prepare_returns=False) * pct + metrics["Win/Loss Ratio"] = _get_stats().win_loss_ratio(df, prepare_returns=False) + metrics["Profit Ratio"] = _get_stats().profit_ratio(df, prepare_returns=False) + + # Add separator + metrics["~~~~~~~~~~~"] = blank + + # Expected returns at different frequencies + metrics["Expected Daily %%"] = ( + _get_stats().expected_return(df, compounded=compounded, prepare_returns=False) + * pct + ) + metrics["Expected Monthly %%"] = ( + _get_stats().expected_return( + df, compounded=compounded, aggregate="ME", prepare_returns=False + ) + * pct + ) + metrics["Expected Yearly %%"] = ( + _get_stats().expected_return( + df, compounded=compounded, aggregate="YE", prepare_returns=False + ) + * pct + ) + + # Risk management metrics + metrics["Kelly Criterion %"] = ( + _get_stats().kelly_criterion(df, prepare_returns=False) * pct + ) + metrics["Risk of Ruin %"] = _get_stats().risk_of_ruin(df, prepare_returns=False) + + # Value at Risk metrics + metrics["Daily Value-at-Risk %"] = -abs( + _get_stats().var(df, prepare_returns=False) * pct + ) + metrics["Expected Shortfall (cVaR) %"] = -abs( + _get_stats().cvar(df, prepare_returns=False) * pct + ) + + # Add separator + metrics["~~~~~~"] = blank + + # Consecutive wins/losses analysis (full mode only) + if mode.lower() == "full": + metrics["Max Consecutive Wins *int"] = _get_stats().consecutive_wins(df) + metrics["Max Consecutive Losses *int"] = _get_stats().consecutive_losses(df) + + # Pain-based metrics (Gain/Pain ratio) + metrics["Gain/Pain Ratio"] = _get_stats().gain_to_pain_ratio(df, rf) + metrics["Gain/Pain (1M)"] = _get_stats().gain_to_pain_ratio(df, rf, "ME") + # if mode.lower() == 'full': + # metrics['GPR (3M)'] = _get_stats().gain_to_pain_ratio(df, rf, "QE") + # metrics['GPR (6M)'] = _get_stats().gain_to_pain_ratio(df, rf, "2Q") + # metrics['GPR (1Y)'] = _get_stats().gain_to_pain_ratio(df, rf, "YE") + + # Add separator + metrics["~~~~~~~"] = blank + + # Trading-based performance metrics + metrics["Payoff Ratio"] = _get_stats().payoff_ratio(df, prepare_returns=False) + metrics["Profit Factor"] = _get_stats().profit_factor(df, prepare_returns=False) + metrics["Common Sense Ratio"] = _get_stats().common_sense_ratio(df, prepare_returns=False) + metrics["CPC Index"] = _get_stats().cpc_index(df, prepare_returns=False) + metrics["Tail Ratio"] = _get_stats().tail_ratio(df, prepare_returns=False) + metrics["Outlier Win Ratio"] = _get_stats().outlier_win_ratio(df, prepare_returns=False) + metrics["Outlier Loss Ratio"] = _get_stats().outlier_loss_ratio(df, prepare_returns=False) + + # # returns + metrics["~~"] = blank + + # Time-based return analysis + today = df.index[-1] # _dt.today() + m3 = today - relativedelta(months=3) + m6 = today - relativedelta(months=6) + y1 = today - relativedelta(years=1) - metrics['Expected Daily %%'] = _stats.expected_return(df) * pct - metrics['Expected Monthly %%'] = _stats.expected_return( - df, aggregate='M') * pct - metrics['Expected Yearly %%'] = _stats.expected_return( - df, aggregate='A') * pct - metrics['Kelly Criterion %'] = _stats.kelly_criterion(df) * pct - metrics['Risk of Ruin %'] = _stats.risk_of_ruin(df) + # Calculate period returns based on compounding preference + if compounded: + metrics["MTD %"] = ( + _get_stats().comp(df[df.index >= _dt(today.year, today.month, 1)]) * pct + ) + metrics["3M %"] = _get_stats().comp(df[df.index >= m3]) * pct + metrics["6M %"] = _get_stats().comp(df[df.index >= m6]) * pct + metrics["YTD %"] = _get_stats().comp(df[df.index >= _dt(today.year, 1, 1)]) * pct + metrics["1Y %"] = _get_stats().comp(df[df.index >= y1]) * pct + else: + metrics["MTD %"] = ( + _np.sum(df[df.index >= _dt(today.year, today.month, 1)], axis=0) * pct + ) + metrics["3M %"] = _np.sum(df[df.index >= m3], axis=0) * pct + metrics["6M %"] = _np.sum(df[df.index >= m6], axis=0) * pct + metrics["YTD %"] = _np.sum(df[df.index >= _dt(today.year, 1, 1)], axis=0) * pct + metrics["1Y %"] = _np.sum(df[df.index >= y1], axis=0) * pct + + # Multi-year annualized returns + d = today - relativedelta(months=35) + metrics["3Y (ann.) %"] = ( + _get_stats().cagr(df[df.index >= d], 0.0, compounded, win_year) * pct + ) - metrics['Daily Value-at-Risk %'] = -abs(_stats.var(df) * pct) - metrics['Expected Shortfall (cVaR) %'] = -abs(_stats.cvar(df) * pct) + d = today - relativedelta(months=59) + metrics["5Y (ann.) %"] = ( + _get_stats().cagr(df[df.index >= d], 0.0, compounded, win_year) * pct + ) - metrics['~~~~~~'] = blank + d = today - relativedelta(years=10) + metrics["10Y (ann.) %"] = ( + _get_stats().cagr(df[df.index >= d], 0.0, compounded, win_year) * pct + ) - metrics['Payoff Ratio'] = _stats.payoff_ratio(df) - metrics['Profit Factor'] = _stats.profit_factor(df) - metrics['Common Sense Ratio'] = _stats.common_sense_ratio(df) - metrics['CPC Index'] = _stats.cpc_index(df) - metrics['Tail Ratio'] = _stats.tail_ratio(df) - metrics['Outlier Win Ratio'] = _stats.outlier_win_ratio(df) - metrics['Outlier Loss Ratio'] = _stats.outlier_loss_ratio(df) + metrics["All-time (ann.) %"] = _get_stats().cagr(df, 0.0, compounded, win_year) * pct - # returns - metrics['~~'] = blank - comp_func = _stats.comp if compounded else _np.sum + # Best/worst period analysis (full mode only) + # best/worst + if mode.lower() == "full": + metrics["~~~"] = blank + metrics["Best Day %"] = ( + _get_stats().best(df, compounded=compounded, prepare_returns=False) * pct + ) + metrics["Worst Day %"] = _get_stats().worst(df, prepare_returns=False) * pct + metrics["Best Month %"] = ( + _get_stats().best( + df, compounded=compounded, aggregate="ME", prepare_returns=False + ) + * pct + ) + metrics["Worst Month %"] = ( + _get_stats().worst(df, aggregate="ME", prepare_returns=False) * pct + ) + metrics["Best Year %"] = ( + _get_stats().best( + df, compounded=compounded, aggregate="YE", prepare_returns=False + ) + * pct + ) + metrics["Worst Year %"] = ( + _get_stats().worst( + df, compounded=compounded, aggregate="YE", prepare_returns=False + ) + * pct + ) + + # Calculate and integrate drawdown metrics + # return drawdown (dd) df + dd = _calc_dd( + df, + display=(display or "internal" in kwargs), + as_pct=kwargs.get("as_pct", False), + ) - today = df.index[-1] # _dt.today() - metrics['MTD %'] = comp_func( - df[df.index >= _dt(today.year, today.month, 1)]) * pct - - d = today - _td(3*365/12) - metrics['3M %'] = comp_func( - df[df.index >= _dt(d.year, d.month, d.day)]) * pct - - d = today - _td(6*365/12) - metrics['6M %'] = comp_func( - df[df.index >= _dt(d.year, d.month, d.day)]) * pct - - metrics['YTD %'] = comp_func(df[df.index >= _dt(today.year, 1, 1)]) * pct - - d = today - _td(12*365/12) - metrics['1Y %'] = comp_func( - df[df.index >= _dt(d.year, d.month, d.day)]) * pct - metrics['3Y (ann.) %'] = _stats.cagr( - df[df.index >= _dt(today.year-3, today.month, today.day) - ], 0., compounded) * pct - metrics['5Y (ann.) %'] = _stats.cagr( - df[df.index >= _dt(today.year-5, today.month, today.day) - ], 0., compounded) * pct - metrics['10Y (ann.) %'] = _stats.cagr( - df[df.index >= _dt(today.year-10, today.month, today.day) - ], 0., compounded) * pct - metrics['All-time (ann.) %'] = _stats.cagr(df, 0., compounded) * pct + # Add drawdown metrics to main metrics DataFrame + # drawdown (dd) detail + metrics["~~~~"] = blank + # Properly integrate drawdown data into metrics + for metric_name in dd.index: + metrics[metric_name] = dd.loc[metric_name].values - # best/worst - if mode.lower() == 'full': - metrics['~~~'] = blank - metrics['Best Day %'] = _stats.best(df) * pct - metrics['Worst Day %'] = _stats.worst(df) * pct - metrics['Best Month %'] = _stats.best(df, aggregate='M') * pct - metrics['Worst Month %'] = _stats.worst(df, aggregate='M') * pct - metrics['Best Year %'] = _stats.best(df, aggregate='A') * pct - metrics['Worst Year %'] = _stats.worst(df, aggregate='A') * pct - - # dd - metrics['~~~~'] = blank - for ix, row in dd.iterrows(): - metrics[ix] = row - metrics['Recovery Factor'] = _stats.recovery_factor(df) - metrics['Ulcer Index'] = _stats.ulcer_index(df, rf) + # Additional drawdown-based metrics + metrics["Recovery Factor"] = _get_stats().recovery_factor(df) + metrics["Ulcer Index"] = _get_stats().ulcer_index(df) + metrics["Serenity Index"] = _get_stats().serenity_index(df, rf) + # Win rate analysis (full mode only) # win rate - if mode.lower() == 'full': - metrics['~~~~~'] = blank - metrics['Avg. Up Month %'] = _stats.avg_win(df, aggregate='M') * pct - metrics['Avg. Down Month %'] = _stats.avg_loss(df, aggregate='M') * pct - metrics['Win Days %%'] = _stats.win_rate(df) * pct - metrics['Win Month %%'] = _stats.win_rate(df, aggregate='M') * pct - metrics['Win Quarter %%'] = _stats.win_rate(df, aggregate='Q') * pct - metrics['Win Year %%'] = _stats.win_rate(df, aggregate='A') * pct - + if mode.lower() == "full": + metrics["~~~~~"] = blank + metrics["Avg. Up Month %"] = ( + _get_stats().avg_win( + df, compounded=compounded, aggregate="ME", prepare_returns=False + ) + * pct + ) + metrics["Avg. Down Month %"] = ( + _get_stats().avg_loss( + df, compounded=compounded, aggregate="ME", prepare_returns=False + ) + * pct + ) + metrics["Win Days %%"] = _get_stats().win_rate(df, prepare_returns=False) * pct + metrics["Win Month %%"] = ( + _get_stats().win_rate( + df, compounded=compounded, aggregate="ME", prepare_returns=False + ) + * pct + ) + metrics["Win Quarter %%"] = ( + _get_stats().win_rate( + df, compounded=compounded, aggregate="QE", prepare_returns=False + ) + * pct + ) + metrics["Win Year %%"] = ( + _get_stats().win_rate( + df, compounded=compounded, aggregate="YE", prepare_returns=False + ) + * pct + ) + + # Greek letters and correlation analysis (if benchmark exists) if "benchmark" in df: - metrics['~~~~~~~'] = blank - greeks = _stats.greeks(df['returns'], df['benchmark']) - metrics['Beta'] = [str(round(greeks['beta'], 2)), '-'] - metrics['Alpha'] = [str(round(greeks['alpha'], 2)), '-'] - + metrics["~~~~~~~~~~~~"] = blank + if isinstance(returns, _pd.Series): + # Calculate Greek letters (Beta, Alpha) for single strategy + greeks = _get_stats().greeks( + df["returns"], df["benchmark"], win_year, prepare_returns=False + ) + metrics["Beta"] = [str(round(greeks["beta"], 2)), "-"] + metrics["Alpha"] = [str(round(greeks["alpha"], 2)), "-"] + metrics["Correlation"] = [ + str(round(df["benchmark"].corr(df["returns"]) * pct, 2)) + "%", + "-", + ] + metrics["Treynor Ratio"] = [ + str( + round( + _get_stats().treynor_ratio( + df["returns"], df["benchmark"], win_year, rf + ) + * pct, + 2, + ) + ) + + "%", + "-", + ] + elif isinstance(returns, _pd.DataFrame): + # Calculate Greek letters for multiple strategies + greeks = [ + _get_stats().greeks( + df[strategy_col], + df["benchmark"], + win_year, + prepare_returns=False, + ) + for strategy_col in df_strategy_columns + ] + metrics["Beta"] = [str(round(g["beta"], 2)) for g in greeks] + ["-"] + metrics["Alpha"] = [str(round(g["alpha"], 2)) for g in greeks] + ["-"] + metrics["Correlation"] = ( + [ + str(round(df["benchmark"].corr(df[strategy_col]) * pct, 2)) + + "%" + for strategy_col in df_strategy_columns + ] + ) + ["-"] + metrics["Treynor Ratio"] = ( + [ + str( + round( + _get_stats().treynor_ratio( + df[strategy_col], df["benchmark"], win_year, rf + ) + * pct, + 2, + ) + ) + + "%" + for strategy_col in df_strategy_columns + ] + ) + ["-"] + + # Format metrics for display # prepare for display for col in metrics.columns: try: + # Try to convert to float and round metrics[col] = metrics[col].astype(float).round(2) if display or "internal" in kwargs: metrics[col] = metrics[col].astype(str) - except Exception: + except (ValueError, TypeError, AttributeError): pass + # Handle integer columns (marked with *int) + if (display or "internal" in kwargs) and "*int" in col: + metrics[col] = metrics[col].str.replace(".0", "", regex=False) + metrics.rename({col: col.replace("*int", "")}, axis=1, inplace=True) + # Add percentage signs to percentage columns if (display or "internal" in kwargs) and "%" in col: - metrics[col] = metrics[col] + '%' + metrics[col] = metrics[col] + "%" + + # Format drawdown days as integers try: - metrics['Longest DD Days'] = _pd.to_numeric( - metrics['Longest DD Days']).astype('int') - metrics['Avg. Drawdown Days'] = _pd.to_numeric( - metrics['Avg. Drawdown Days']).astype('int') + metrics["Longest DD Days"] = _pd.to_numeric(metrics["Longest DD Days"]).astype( + "int" + ) + metrics["Avg. Drawdown Days"] = _pd.to_numeric( + metrics["Avg. Drawdown Days"] + ).astype("int") if display or "internal" in kwargs: - metrics['Longest DD Days'] = metrics['Longest DD Days'].astype(str) - metrics['Avg. Drawdown Days'] = metrics['Avg. Drawdown Days' - ].astype(str) + metrics["Longest DD Days"] = metrics["Longest DD Days"].astype(str) + metrics["Avg. Drawdown Days"] = metrics["Avg. Drawdown Days"].astype(str) except Exception: - metrics['Longest DD Days'] = '-' - metrics['Avg. Drawdown Days'] = '-' + metrics["Longest DD Days"] = "-" + metrics["Avg. Drawdown Days"] = "-" if display or "internal" in kwargs: - metrics['Longest DD Days'] = '-' - metrics['Avg. Drawdown Days'] = '-' + metrics["Longest DD Days"] = "-" + metrics["Avg. Drawdown Days"] = "-" - metrics.columns = [ - col if '~' not in col else '' for col in metrics.columns] - metrics.columns = [ - col[:-1] if '%' in col else col for col in metrics.columns] + # Clean up column names (remove separators and percentage signs) + metrics.columns = [col if "~" not in col else "" for col in metrics.columns] + metrics.columns = [col[:-1] if "%" in col else col for col in metrics.columns] metrics = metrics.T + # Set appropriate column names if "benchmark" in df: - metrics.columns = ['Strategy', 'Benchmark'] + column_names = [strategy_colname, benchmark_colname] + if isinstance(strategy_colname, list): + metrics.columns = list(_pd.core.common.flatten(column_names)) + else: + metrics.columns = column_names else: - metrics.columns = ['Strategy'] + if isinstance(strategy_colname, list): + metrics.columns = strategy_colname + else: + metrics.columns = [strategy_colname] + + # Final data cleaning + # cleanups + metrics.replace([-0, "-0"], 0, inplace=True) + metrics.replace( + [ + _np.nan, + -_np.nan, + _np.inf, + -_np.inf, + "-nan%", + "nan%", + "-nan", + "nan", + "-inf%", + "inf%", + "-inf", + "inf", + ], + "-", + inplace=True, + ) + + # Reorder columns to put benchmark first if present + # move benchmark to be the first column always if present + if "benchmark" in df: + metrics = metrics[ + [benchmark_colname] + + [col for col in metrics.columns if col != benchmark_colname] + ] + # Handle display vs return if display: - print(_tabulate(metrics, headers="keys", tablefmt='simple')) + # Build and display parameters table (feature #472) + params_data = { + "Parameter": ["Risk-Free Rate", "Periods/Year", "Compounded", "Match Dates"], + "Value": [ + f"{rf:.1%}" if rf != 0 else "0.0%", + str(periods_per_year), + "Yes" if compounded else "No", + "Yes" if match_dates else "No", + ], + } + if benchmark is not None: + params_data["Parameter"].insert(0, "Benchmark") + params_data["Value"].insert(0, benchmark_colname) + params_df = _pd.DataFrame(params_data) + print("\n" + _tabulate(params_df, headers="keys", tablefmt="simple", showindex=False)) + print("\n") + print(_tabulate(metrics, headers="keys", tablefmt="simple")) return None + # Remove separator rows if not requested if not sep: - metrics = metrics[metrics.index != ''] - return metrics + metrics = metrics[metrics.index != ""] + # Final formatting for programmatic use + # remove spaces from column names + metrics = metrics.T + metrics.columns = [ + c.replace(" %", "").replace(" *int", "").strip() for c in metrics.columns + ] + metrics = metrics.T -def plots(returns, benchmark=None, grayscale=False, - figsize=(8, 5), mode='basic', compounded=True): + return metrics - if mode.lower() != 'full': - _plots.snapshot(returns, grayscale=grayscale, - figsize=(figsize[0], figsize[0]), - show=True, mode=("comp" if compounded else "sum")) - _plots.monthly_heatmap(returns, grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.5), - show=True, ylabel=False, - compounded=compounded) +def plots( + returns, + benchmark=None, + grayscale=False, + figsize=(8, 5), + mode="basic", + compounded=True, + periods_per_year=252, + prepare_returns=True, + match_dates=True, + **kwargs, +): + """ + Generate comprehensive visualization plots for portfolio performance. + + This function creates a complete set of performance visualization plots + including returns, drawdowns, distributions, and rolling metrics. It can + generate either basic plots or a full comprehensive suite. + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Daily returns data for the strategy/portfolio + benchmark : pd.Series, str, or None, default None + Benchmark returns for comparison + grayscale : bool, default False + Whether to generate charts in grayscale + figsize : tuple, default (8, 5) + Figure size for plots as (width, height) + mode : str, default "basic" + Plot mode - "basic" for essential plots, "full" for comprehensive suite + compounded : bool, default True + Whether to compound returns for calculations + periods_per_year : int, default 252 + Number of trading periods per year + prepare_returns : bool, default True + Whether to prepare/clean returns data + match_dates : bool, default True + Whether to align returns and benchmark start dates + **kwargs + Additional keyword arguments: + - strategy_title: Custom name for the strategy + - benchmark_title: Custom name for the benchmark + - active: Whether to show active returns vs benchmark + + Returns + ------- + None + Displays various performance plots + + Examples + -------- + >>> plots(returns, benchmark='^GSPC', mode="full") + >>> plots(returns, grayscale=True, figsize=(10, 6)) + """ + # Extract title parameters from kwargs + benchmark_colname = kwargs.get("benchmark_title", "Benchmark") + strategy_colname = kwargs.get("strategy_title", "Strategy") + active = kwargs.get("active", False) + + # Handle multiple strategy columns + if isinstance(returns, _pd.DataFrame): + if len(returns.columns) > 1: + if isinstance(strategy_colname, str): + strategy_colname = list(returns.columns) + + # Get trading periods for rolling window calculations + win_year, win_half_year = _get_trading_periods(periods_per_year) + + # Clean returns data if date matching is enabled + if match_dates is True: + returns = returns.dropna() + + # Prepare returns data if requested + if prepare_returns: + returns = _get_utils()._prepare_returns(returns) + + # Set names for display in plots + if isinstance(returns, _pd.Series): + returns.name = strategy_colname + elif isinstance(returns, _pd.DataFrame): + returns.columns = strategy_colname + + # Generate basic plots (snapshot and heatmap) + if mode.lower() != "full": + # Performance snapshot plot + _get_plots().snapshot( + returns, + grayscale=grayscale, + figsize=(figsize[0], figsize[0]), + show=True, + mode=("comp" if compounded else "sum"), + benchmark_title=benchmark_colname, + strategy_title=strategy_colname, + ) + + # Monthly returns heatmap + if isinstance(returns, _pd.Series): + _get_plots().monthly_heatmap( + returns, + benchmark, + grayscale=grayscale, + figsize=(figsize[0], figsize[0] * 0.5), + show=True, + ylabel="", + compounded=compounded, + active=active, + ) + elif isinstance(returns, _pd.DataFrame): + # Generate heatmap for each strategy column + for col in returns.columns: + _get_plots().monthly_heatmap( + returns[col].dropna(), + benchmark, + grayscale=grayscale, + figsize=(figsize[0], figsize[0] * 0.5), + show=True, + ylabel="", + returns_label=col, + compounded=compounded, + active=active, + ) return - _plots.returns(returns, benchmark, grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.6), - show=True, ylabel=False) - - _plots.log_returns(returns, benchmark, grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.5), - show=True, ylabel=False) - + # prepare timeseries if benchmark is not None: - _plots.returns(returns, benchmark, match_volatility=True, - grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.5), - show=True, ylabel=False) - - _plots.yearly_returns(returns, benchmark, - grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.5), - show=True, ylabel=False) - - _plots.histogram(returns, grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.5), - show=True, ylabel=False) + benchmark = _get_utils()._prepare_benchmark(benchmark, returns.index) + benchmark.name = benchmark_colname + if match_dates is True: + returns, benchmark = _match_dates(returns, benchmark) + + # Generate comprehensive plot suite + # Cumulative returns plot + _get_plots().returns( + returns, + benchmark, + grayscale=grayscale, + figsize=(figsize[0], figsize[0] * 0.6), + show=True, + ylabel="", + prepare_returns=False, + compound=compounded, + ) - _plots.daily_returns(returns, grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.3), - show=True, ylabel=False) + # Log returns plot for better visualization + _get_plots().log_returns( + returns, + benchmark, + grayscale=grayscale, + figsize=(figsize[0], figsize[0] * 0.5), + show=True, + ylabel="", + prepare_returns=False, + compound=compounded, + ) + # Volatility-matched returns (if benchmark exists) if benchmark is not None: - _plots.rolling_beta(returns, benchmark, grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.3), - show=True, ylabel=False) - - _plots.rolling_volatility( - returns, benchmark, grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.3), show=True, ylabel=False) - - _plots.rolling_sharpe(returns, grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.3), - show=True, ylabel=False) - - _plots.rolling_sortino(returns, grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.3), - show=True, ylabel=False) + _get_plots().returns( + returns, + benchmark, + match_volatility=True, + grayscale=grayscale, + figsize=(figsize[0], figsize[0] * 0.5), + show=True, + ylabel="", + prepare_returns=False, + compound=compounded, + ) + + # Yearly returns comparison + _get_plots().yearly_returns( + returns, + benchmark, + grayscale=grayscale, + figsize=(figsize[0], figsize[0] * 0.5), + show=True, + ylabel="", + prepare_returns=False, + compounded=compounded, + ) - _plots.drawdowns_periods(returns, grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.5), - show=True, ylabel=False) + # Returns distribution histogram + _get_plots().histogram( + returns, + benchmark, + grayscale=grayscale, + figsize=(figsize[0], figsize[0] * 0.5), + show=True, + ylabel="", + prepare_returns=False, + compounded=compounded, + ) - _plots.drawdown(returns, grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.4), - show=True, ylabel=False) + # Calculate figure size for smaller plots + small_fig_size = (figsize[0], figsize[0] * 0.35) + if isinstance(returns, _pd.DataFrame) and len(returns.columns) > 1: + small_fig_size = ( + figsize[0], + figsize[0] * (0.33 * (len(returns.columns) * 0.66)), + ) + + # Daily returns scatter plot + _get_plots().daily_returns( + returns, + benchmark, + grayscale=grayscale, + figsize=small_fig_size, + show=True, + ylabel="", + prepare_returns=False, + active=active, + ) - _plots.monthly_heatmap(returns, grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.5), - show=True, ylabel=False) + # Rolling beta analysis (if benchmark exists) + if benchmark is not None: + _get_plots().rolling_beta( + returns, + benchmark, + grayscale=grayscale, + window1=win_half_year, + window2=win_year, + figsize=small_fig_size, + show=True, + ylabel="", + prepare_returns=False, + ) + + # Rolling volatility analysis + _get_plots().rolling_volatility( + returns, + benchmark, + grayscale=grayscale, + figsize=small_fig_size, + show=True, + ylabel="", + period=win_half_year, + ) - _plots.distribution(returns, grayscale=grayscale, - figsize=(figsize[0], figsize[0]*.5), - show=True, ylabel=False) + # Rolling Sharpe ratio analysis + _get_plots().rolling_sharpe( + returns, + grayscale=grayscale, + figsize=small_fig_size, + show=True, + ylabel="", + period=win_half_year, + ) + # Rolling Sortino ratio analysis + _get_plots().rolling_sortino( + returns, + grayscale=grayscale, + figsize=small_fig_size, + show=True, + ylabel="", + period=win_half_year, + ) -def _calc_dd(df, display=True): - dd = _stats.to_drawdown_series(df) - dd_info = _stats.drawdown_details(dd) + # Drawdown periods analysis + if isinstance(returns, _pd.Series): + _get_plots().drawdowns_periods( + returns, + grayscale=grayscale, + figsize=(figsize[0], figsize[0] * 0.5), + show=True, + ylabel="", + prepare_returns=False, + compounded=compounded, + ) + elif isinstance(returns, _pd.DataFrame): + # Handle multiple strategy columns + for col in returns.columns: + _get_plots().drawdowns_periods( + returns[col], + grayscale=grayscale, + figsize=(figsize[0], figsize[0] * 0.5), + show=True, + ylabel="", + title=col, + prepare_returns=False, + compounded=compounded, + ) + + # Underwater (drawdown) plot + _get_plots().drawdown( + returns, + grayscale=grayscale, + figsize=(figsize[0], figsize[0] * 0.4), + show=True, + ylabel="", + compound=compounded, + ) + # Monthly returns heatmap + if isinstance(returns, _pd.Series): + _get_plots().monthly_heatmap( + returns, + benchmark, + grayscale=grayscale, + figsize=(figsize[0], figsize[0] * 0.5), + returns_label=returns.name, + show=True, + ylabel="", + compounded=compounded, + active=active, + ) + elif isinstance(returns, _pd.DataFrame): + # Handle multiple strategy columns + for col in returns.columns: + _get_plots().monthly_heatmap( + returns[col], + benchmark, + grayscale=grayscale, + figsize=(figsize[0], figsize[0] * 0.5), + show=True, + ylabel="", + returns_label=col, + compounded=compounded, + active=active, + ) + + # Returns distribution analysis + if isinstance(returns, _pd.Series): + _get_plots().distribution( + returns, + grayscale=grayscale, + figsize=(figsize[0], figsize[0] * 0.5), + show=True, + title=returns.name, + ylabel="", + prepare_returns=False, + compounded=compounded, + ) + elif isinstance(returns, _pd.DataFrame): + # Handle multiple strategy columns + for col in returns.columns: + _get_plots().distribution( + returns[col], + grayscale=grayscale, + figsize=(figsize[0], figsize[0] * 0.5), + show=True, + title=col, + ylabel="", + prepare_returns=False, + compounded=compounded, + ) + + +def _calc_dd(df, display=True, as_pct=False): + """ + Calculate drawdown statistics for performance analysis. + + This helper function computes comprehensive drawdown statistics including + maximum drawdown, drawdown dates, recovery periods, and average drawdown + metrics. It handles both single strategy and multiple strategy analysis. + + Parameters + ---------- + df : pd.DataFrame + DataFrame containing returns data with columns for strategies + and optionally benchmark + display : bool, default True + Whether the output is for display purposes (affects formatting) + as_pct : bool, default False + Whether to return percentages instead of decimals + + Returns + ------- + pd.DataFrame + DataFrame with drawdown statistics including: + - Max Drawdown %: Maximum drawdown percentage + - Max DD Date: Date of maximum drawdown + - Max DD Period Start: Start date of worst drawdown period + - Max DD Period End: End date of worst drawdown period + - Longest DD Days: Duration of longest drawdown in days + - Avg. Drawdown %: Average drawdown percentage + - Avg. Drawdown Days: Average drawdown duration in days + + Examples + -------- + >>> dd_stats = _calc_dd(returns_df, display=False) + >>> dd_stats = _calc_dd(returns_df, as_pct=True) + """ + # Convert returns to drawdown series + dd = _get_stats().to_drawdown_series(df) + dd_info = _get_stats().drawdown_details(dd) + + # Return empty DataFrame if no drawdowns found if dd_info.empty: return _pd.DataFrame() + # Handle different column structures based on data type if "returns" in dd_info: - ret_dd = dd_info['returns'] + ret_dd = dd_info["returns"] + # to match multiple columns like returns_1, returns_2, ... + elif ( + any(dd_info.columns.get_level_values(0).str.contains("returns")) + and dd_info.columns.get_level_values(0).nunique() > 1 + ): + ret_dd = dd_info.loc[ + :, dd_info.columns.get_level_values(0).str.contains("returns") + ] else: ret_dd = dd_info - # pct multiplier - pct = 1 if display else 100 - - dd_stats = { - 'returns': { - 'Max Drawdown %': ret_dd.sort_values( - by='max drawdown', ascending=True - )['max drawdown'].values[0] / pct, - 'Longest DD Days': str(round(ret_dd.sort_values( - by='days', ascending=False)['days'].values[0])), - 'Avg. Drawdown %': ret_dd['max drawdown'].mean() / pct, - 'Avg. Drawdown Days': str(round(ret_dd['days'].mean())) + # Calculate drawdown statistics based on data structure + if ( + any(ret_dd.columns.get_level_values(0).str.contains("returns")) + and ret_dd.columns.get_level_values(0).nunique() > 1 + ): + # Multiple strategy columns case + dd_stats = { + col: { + "Max Drawdown %": ret_dd[col] + .sort_values(by="max drawdown", ascending=True)["max drawdown"] + .values[0] + / 100, + "Max DD Date": ret_dd[col] + .sort_values(by="max drawdown", ascending=True)["valley"] + .values[0], + "Max DD Period Start": ret_dd[col] + .sort_values(by="max drawdown", ascending=True)["start"] + .values[0], + "Max DD Period End": ret_dd[col] + .sort_values(by="max drawdown", ascending=True)["end"] + .values[0], + "Longest DD Days": str( + _np.round( + ret_dd[col] + .sort_values(by="days", ascending=False)["days"] + .values[0] + ) + ), + "Avg. Drawdown %": ret_dd[col]["max drawdown"].mean() / 100, + "Avg. Drawdown Days": str(_np.round(ret_dd[col]["days"].mean())), + } + for col in ret_dd.columns.get_level_values(0) } - } + else: + # Single strategy case + max_dd = ret_dd.sort_values(by="max drawdown", ascending=True) + dd_stats = { + "returns": { + "Max Drawdown %": max_dd["max drawdown"].values[0] / 100, + "Max DD Date": max_dd["valley"].values[0], + "Max DD Period Start": max_dd["start"].values[0], + "Max DD Period End": max_dd["end"].values[0], + "Longest DD Days": str( + _np.round( + ret_dd.sort_values(by="days", ascending=False)["days"].values[0] + ) + ), + "Avg. Drawdown %": ret_dd["max drawdown"].mean() / 100, + "Avg. Drawdown Days": str(_np.round(ret_dd["days"].mean())), + } + } + + # Add benchmark drawdown statistics if present if "benchmark" in df and (dd_info.columns, _pd.MultiIndex): - bench_dd = dd_info['benchmark'].sort_values(by='max drawdown') - dd_stats['benchmark'] = { - 'Max Drawdown %': bench_dd.sort_values( - by='max drawdown', ascending=True - )['max drawdown'].values[0] / pct, - 'Longest DD Days': str(round(bench_dd.sort_values( - by='days', ascending=False)['days'].values[0])), - 'Avg. Drawdown %': bench_dd['max drawdown'].mean() / pct, - 'Avg. Drawdown Days': str(round(bench_dd['days'].mean())) + bench_dd = dd_info["benchmark"].sort_values(by="max drawdown") + dd_stats["benchmark"] = { + "Max Drawdown %": bench_dd.sort_values(by="max drawdown", ascending=True)[ + "max drawdown" + ].values[0] + / 100, + "Max DD Date": bench_dd.sort_values( + by="max drawdown", ascending=True + )["valley"].values[0], + "Max DD Period Start": bench_dd.sort_values( + by="max drawdown", ascending=True + )["start"].values[0], + "Max DD Period End": bench_dd.sort_values( + by="max drawdown", ascending=True + )["end"].values[0], + "Longest DD Days": str( + _np.round( + bench_dd.sort_values(by="days", ascending=False)["days"].values[0] + ) + ), + "Avg. Drawdown %": bench_dd["max drawdown"].mean() / 100, + "Avg. Drawdown Days": str(_np.round(bench_dd["days"].mean())), } + # Apply percentage multiplier based on display settings + # pct multiplier + pct = 100 if display or as_pct else 1 + + # Convert to DataFrame and apply percentage formatting dd_stats = _pd.DataFrame(dd_stats).T - dd_stats['Max Drawdown %'] = dd_stats['Max Drawdown %'].astype(float) - dd_stats['Avg. Drawdown %'] = dd_stats['Avg. Drawdown %'].astype(float) + dd_stats["Max Drawdown %"] = dd_stats["Max Drawdown %"].astype(float) * pct + dd_stats["Avg. Drawdown %"] = dd_stats["Avg. Drawdown %"].astype(float) * pct + return dd_stats.T def _html_table(obj, showindex="default"): - obj = _tabulate(obj, headers="keys", tablefmt='html', - floatfmt=".2f", showindex=showindex) - obj = obj.replace(' style="text-align: right;"', '') - obj = obj.replace(' style="text-align: left;"', '') - obj = obj.replace(' style="text-align: center;"', '') - obj = _regex.sub(' +', '', obj) - obj = _regex.sub(' +', '', obj) - obj = _regex.sub(' +', '', obj) - obj = _regex.sub(' +', '', obj) + """ + Convert DataFrame to HTML table format for report generation. + + This helper function converts pandas DataFrames to clean HTML table format + suitable for embedding in HTML reports. It removes default tabulate styling + and cleans up spacing for better presentation. + + Parameters + ---------- + obj : pd.DataFrame + DataFrame to convert to HTML table + showindex : str or bool, default "default" + Whether to show the DataFrame index in the HTML table. + "default" uses tabulate's default behavior + + Returns + ------- + str + HTML string containing the formatted table + + Examples + -------- + >>> html_str = _html_table(metrics_df) + >>> html_str = _html_table(metrics_df, showindex=False) + """ + # Convert DataFrame to HTML table using tabulate + obj = _tabulate( + obj, headers="keys", tablefmt="html", floatfmt=".2f", showindex=showindex + ) + + # Remove default tabulate styling attributes + obj = obj.replace(' style="text-align: right;"', "") + obj = obj.replace(' style="text-align: left;"', "") + obj = obj.replace(' style="text-align: center;"', "") + + # Clean up spacing in table cells + obj = _regex.sub(" +", "", obj) + obj = _regex.sub(" +", "", obj) + obj = _regex.sub(" +", "", obj) + obj = _regex.sub(" +", "", obj) + return obj def _download_html(html, filename="quantstats-tearsheet.html"): - jscode = _regex.sub(' +', ' ', """""".replace('\n', '')) - jscode = jscode.replace('{{html}}', _regex.sub( - ' +', ' ', html.replace('\n', ''))) - if _utils._in_notebook(): - iDisplay(iHTML(jscode.replace('{{filename}}', filename))) + a.click();""".replace( + "\n", "" + ), + ) + + # Insert HTML content and clean up formatting + jscode = jscode.replace("{{html}}", _regex.sub(" +", " ", html.replace("\n", ""))) + + # Execute JavaScript in notebook if in notebook environment + if _get_utils()._in_notebook(): + iDisplay(iHTML(jscode.replace("{{filename}}", filename))) def _open_html(html): - jscode = _regex.sub(' +', ' ', """""".replace('\n', '')) - jscode = jscode.replace('{{html}}', _regex.sub( - ' +', ' ', html.replace('\n', ''))) - if _utils._in_notebook(): + """.replace( + "\n", "" + ), + ) + + # Insert HTML content and clean up formatting + jscode = jscode.replace("{{html}}", _regex.sub(" +", " ", html.replace("\n", ""))) + + # Execute JavaScript in notebook if in notebook environment + if _get_utils()._in_notebook(): iDisplay(iHTML(jscode)) + + +def _embed_figure(figfiles, figfmt): + """ + Embed matplotlib figures in HTML format for reports. + + This helper function converts matplotlib figure objects to embedded + HTML format suitable for inclusion in HTML reports. It handles both + SVG and base64-encoded image formats. + + Parameters + ---------- + figfiles : io.StringIO or list of io.StringIO + File-like objects containing figure data. Can be single figure + or list of figures for multiple plots + figfmt : str + Format for the figures ('svg', 'png', 'jpg', etc.) + + Returns + ------- + str + HTML string with embedded figure(s) ready for inclusion in report + + Examples + -------- + >>> embed_str = _embed_figure(figfile, 'svg') + >>> embed_str = _embed_figure([fig1, fig2], 'png') + """ + # Handle multiple figures + if isinstance(figfiles, list): + embed_string = "\n" + for figfile in figfiles: + figbytes = figfile.getvalue() + if figfmt == "svg": + # SVG can be embedded directly as text + return figbytes.decode() + # For other formats, encode as base64 data URI + data_uri = _b64encode(figbytes).decode() + embed_string.join( + ''.format(figfmt, data_uri) + ) + else: + # Handle single figure + figbytes = figfiles.getvalue() + if figfmt == "svg": + # SVG can be embedded directly as text + return figbytes.decode() + # For other formats, encode as base64 data URI + data_uri = _b64encode(figbytes).decode() + embed_string = ''.format(figfmt, data_uri) + + return embed_string diff --git a/quantstats/stats.py b/quantstats/stats.py index 2c348760..5a0c6e36 100644 --- a/quantstats/stats.py +++ b/quantstats/stats.py @@ -1,10 +1,9 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- # # QuantStats: Portfolio analytics for quants # https://github.com/ranaroussi/quantstats # -# Copyright 2019 Ran Aroussi +# Copyright 2019-2025 Ran Aroussi # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,699 +17,3295 @@ # See the License for the specific language governing permissions and # limitations under the License. +""" +Portfolio Statistics Module + +This module provides comprehensive statistical analysis functions for portfolio +performance evaluation, risk assessment, and benchmarking. It includes functions +for calculating various return metrics, risk ratios, drawdown analysis, and +comparison with benchmarks. + +The module is designed to work with pandas Series and DataFrames containing +return data, price data, or performance metrics. +""" + +from warnings import warn +from typing import Literal import pandas as _pd import numpy as _np -from math import ceil as _ceil -from scipy.stats import ( - norm as _norm, linregress as _linregress -) +from numpy.typing import NDArray +from math import ceil as _ceil, sqrt as _sqrt +from scipy.stats import norm as _norm, linregress as _linregress from . import utils as _utils +from ._compat import safe_concat +from .utils import validate_input +# Type aliases for common types (Python 3.10+ syntax) +Returns = _pd.Series | _pd.DataFrame +"""Type alias for returns data: can be a pandas Series or DataFrame.""" # ======== STATS ======== -def pct_rank(prices, window=60): - """ rank prices by window """ + +def pct_rank(prices: _pd.Series, window: int = 60) -> _pd.Series: + """ + Calculate the percentile rank of prices over a rolling window. + + This function computes the percentile rank (0-100) of each price point + within a rolling window, useful for identifying relative position of + current prices compared to recent history. + + Args: + prices (pd.Series): Series of price data + window (int): Rolling window size for rank calculation (default: 60) + + Returns: + pd.Series: Percentile ranks (0-100 scale) + + Example: + >>> prices = pd.Series([100, 105, 110, 95, 120]) + >>> ranks = pct_rank(prices, window=3) + >>> print(ranks) + """ + # Create rolling window shifts and transpose for ranking rank = _utils.multi_shift(prices, window).T.rank(pct=True).T - return rank.iloc[:, 0] * 100. + # Extract first column and convert to percentage scale + return rank.iloc[:, 0] * 100.0 -def compsum(returns): - """ Calculates rolling compounded returns """ - return returns.add(1).cumprod() - 1 +def compsum(returns: Returns) -> Returns: + """ + Calculate rolling compounded returns (cumulative product). + This function computes the cumulative compounded returns by adding 1 + to each return, taking the cumulative product, and subtracting 1. -def comp(returns): - """ Calculates total compounded returns """ - return returns.add(1).prod() - 1 + Args: + returns: Series or DataFrame of returns + Returns: + Cumulative compounded returns (same type as input) -def expected_return(returns, aggregate=None, compounded=True): + Example: + >>> returns = pd.Series([0.01, 0.02, -0.01, 0.03]) + >>> cumulative = compsum(returns) + >>> print(cumulative) """ - returns the expected return for a given period - by calculating the geometric holding period return + # Add 1 to convert returns to growth factors, then cumulative product + return returns.add(1).cumprod(axis=0) - 1 + + +def comp(returns: Returns) -> _pd.Series | float: """ - returns = _utils._prepare_returns(returns) + Calculate total compounded returns (final cumulative return). + + This function computes the total compounded return over the entire period + by converting returns to growth factors and taking their product. + + Args: + returns (pd.Series): Series of returns + + Returns: + float: Total compounded return + + Example: + >>> returns = pd.Series([0.01, 0.02, -0.01, 0.03]) + >>> total_return = comp(returns) + >>> print(total_return) + """ + # Convert returns to growth factors, take product, subtract 1 + return returns.add(1).prod(axis=0) - 1 + + +def distribution( + returns: Returns, + compounded: bool = True, + prepare_returns: bool = True, +) -> dict: + """ + Analyze return distributions across different time periods. + + This function calculates return distributions (including outliers) for + daily, weekly, monthly, quarterly, and yearly periods. It identifies + outliers using the IQR method (1.5 * IQR beyond Q1/Q3). + + Args: + returns (pd.Series): Return series to analyze + compounded (bool): Whether to compound returns (default: True) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + dict: Dictionary containing distribution data for each period + + Example: + >>> returns = pd.Series([0.01, 0.02, -0.01], + ... index=pd.date_range('2023-01-01', periods=3)) + >>> dist = distribution(returns) + >>> print(dist['Daily']['values']) + """ + def get_outliers(data): + """ + Identify outliers using the IQR method. + + Uses 1.5 * IQR rule: values beyond Q1 - 1.5*IQR or Q3 + 1.5*IQR + are considered outliers. + """ + # https://datascience.stackexchange.com/a/57199 + Q1 = data.quantile(0.25) # First quartile + Q3 = data.quantile(0.75) # Third quartile + IQR = Q3 - Q1 # Interquartile range + + # Create filter for non-outlier values + filtered = (data >= Q1 - 1.5 * IQR) & (data <= Q3 + 1.5 * IQR) + + return { + "values": data.loc[filtered].tolist(), + "outliers": data.loc[~filtered].tolist(), + } + + # Handle DataFrame input by selecting appropriate column + if isinstance(returns, _pd.DataFrame): + warn( + "Pandas DataFrame was passed (Series expected). " + "Only first column will be used." + ) + returns = returns.copy() + returns.columns = map(str.lower, returns.columns) + if len(returns.columns) > 1 and "close" in returns.columns: + returns = returns["close"] + else: + returns = returns[returns.columns[0]] + + # Choose aggregation function based on compounded parameter + apply_fnc = comp if compounded else _np.sum + daily = returns.dropna() + + # Prepare returns if requested + if prepare_returns: + daily = _utils._prepare_returns(daily) + + # Calculate distributions for different time periods + return { + "Daily": get_outliers(daily), + "Weekly": get_outliers(daily.resample("W-MON").apply(apply_fnc)), + "Monthly": get_outliers(daily.resample("ME").apply(apply_fnc)), + "Quarterly": get_outliers(daily.resample("QE").apply(apply_fnc)), + "Yearly": get_outliers(daily.resample("YE").apply(apply_fnc)), + } + + +def expected_return( + returns: Returns, + aggregate: str | None = None, + compounded: bool = True, + prepare_returns: bool = True, +) -> float: + """ + Calculate the expected return (geometric mean) for a given period. + + This function computes the geometric holding period return, which represents + the expected return per period based on historical data. It's calculated + as the nth root of the product of (1 + returns) minus 1. + + Args: + returns (pd.Series): Return series + aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y') + compounded (bool): Whether to compound returns (default: True) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Expected return per period + + Example: + >>> returns = pd.Series([0.01, 0.02, -0.01, 0.03]) + >>> expected = expected_return(returns) + >>> print(f"Expected return: {expected:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Aggregate returns if period specified returns = _utils.aggregate_returns(returns, aggregate, compounded) - return _np.product(1 + returns) ** (1 / len(returns)) - 1 + # Calculate geometric mean: (product of (1 + returns))^(1/n) - 1 + return _np.prod(1 + returns, axis=0) ** (1 / len(returns)) - 1 + + +def geometric_mean( + returns: Returns, + aggregate: str | None = None, + compounded: bool = True, +) -> float: + """ + Calculate geometric mean of returns. + + This is a shorthand function for expected_return() with the same parameters. + + Args: + returns (pd.Series): Return series + aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y') + compounded (bool): Whether to compound returns (default: True) + + Returns: + float: Geometric mean of returns + """ + return expected_return(returns, aggregate, compounded) + + +def ghpr( + returns: Returns, + aggregate: str | None = None, + compounded: bool = True, +) -> float: + """ + Calculate Geometric Holding Period Return. + + This is a shorthand function for expected_return() with the same parameters. + GHPR represents the average rate of return per period. + + Args: + returns (pd.Series): Return series + aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y') + compounded (bool): Whether to compound returns (default: True) + + Returns: + float: Geometric holding period return + """ + return expected_return(returns, aggregate, compounded) + + +def outliers(returns: Returns, quantile: float = 0.95) -> Returns: + """ + Identify and return outlier returns above a specified quantile. + + This function filters returns to show only those above the specified + quantile threshold, helping identify extreme positive performance periods. -def geometric_mean(retruns, aggregate=None, compounded=True): - """ shorthand for expected_return() """ - return expected_return(retruns, aggregate, compounded) + Args: + returns (pd.Series): Return series to analyze + quantile (float): Quantile threshold (default: 0.95 for 95th percentile) + + Returns: + pd.Series: Returns above the quantile threshold + + Example: + >>> returns = pd.Series([0.01, 0.02, 0.05, -0.01, 0.10]) + >>> outlier_returns = outliers(returns, quantile=0.90) + >>> print(outlier_returns) + """ + # Filter returns above the specified quantile and remove NaN values + return returns[returns > returns.quantile(quantile)].dropna(how="all") -def ghpr(retruns, aggregate=None, compounded=True): - """ shorthand for expected_return() """ - return expected_return(retruns, aggregate, compounded) +def remove_outliers(returns: Returns, quantile: float = 0.95) -> Returns: + """ + Remove outlier returns above a specified quantile. + This function filters out extreme returns above the quantile threshold, + useful for robust statistical analysis by removing extreme values. -def outliers(returns, quantile=.95): - """returns series of outliers """ - return returns[returns > returns.quantile(quantile)].dropna(how='all') + Args: + returns (pd.Series): Return series to filter + quantile (float): Quantile threshold (default: 0.95 for 95th percentile) + Returns: + pd.Series: Returns below the quantile threshold -def remove_outliers(returns, quantile=.95): - """ returns series of returns without the outliers """ + Example: + >>> returns = pd.Series([0.01, 0.02, 0.05, -0.01, 0.10]) + >>> filtered = remove_outliers(returns, quantile=0.90) + >>> print(filtered) + """ + # Keep only returns below the specified quantile threshold return returns[returns < returns.quantile(quantile)] -def best(returns, aggregate=None, compounded=True): - """ returns the best day/month/week/quarter/year's return """ - returns = _utils._prepare_returns(returns) +def best( + returns: Returns, + aggregate: str | None = None, + compounded: bool = True, + prepare_returns: bool = True, +) -> float: + """ + Find the best (highest) return for a given period. + + This function identifies the maximum return over the specified aggregation + period, helping identify the best performing period in the dataset. + + Args: + returns (pd.Series): Return series to analyze + aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y') + compounded (bool): Whether to compound returns (default: True) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Best (maximum) return for the period + + Example: + >>> returns = pd.Series([0.01, 0.02, -0.01, 0.03]) + >>> best_return = best(returns) + >>> print(f"Best return: {best_return:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Aggregate returns and find maximum return _utils.aggregate_returns(returns, aggregate, compounded).max() -def worst(returns, aggregate=None, compounded=True): - """ returns the worst day/month/week/quarter/year's return """ - returns = _utils._prepare_returns(returns) +def worst( + returns: Returns, + aggregate: str | None = None, + compounded: bool = True, + prepare_returns: bool = True, +) -> float: + """ + Find the worst (lowest) return for a given period. + + This function identifies the minimum return over the specified aggregation + period, helping identify the worst performing period in the dataset. + + Args: + returns (pd.Series): Return series to analyze + aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y') + compounded (bool): Whether to compound returns (default: True) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Worst (minimum) return for the period + + Example: + >>> returns = pd.Series([0.01, 0.02, -0.01, 0.03]) + >>> worst_return = worst(returns) + >>> print(f"Worst return: {worst_return:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Aggregate returns and find minimum return _utils.aggregate_returns(returns, aggregate, compounded).min() -def consecutive_wins(returns, aggregate=None, compounded=True): - """ returns the maximum consecutive wins by day/month/week/quarter/year """ - returns = _utils._prepare_returns(returns) +def consecutive_wins( + returns: Returns, + aggregate: str | None = None, + compounded: bool = True, + prepare_returns: bool = True, +) -> int: + """ + Calculate the maximum number of consecutive winning periods. + + This function identifies the longest streak of positive returns, which + helps assess the consistency of positive performance. + + Args: + returns (pd.Series): Return series to analyze + aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y') + compounded (bool): Whether to compound returns (default: True) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + int: Maximum number of consecutive winning periods + + Example: + >>> returns = pd.Series([0.01, 0.02, 0.03, -0.01, 0.02]) + >>> max_wins = consecutive_wins(returns) + >>> print(f"Max consecutive wins: {max_wins}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Aggregate returns and convert to boolean (positive = True) returns = _utils.aggregate_returns(returns, aggregate, compounded) > 0 - return _utils.count_consecutive(returns).max() + # Count consecutive True values and return maximum + return _utils._count_consecutive(returns).max() -def consecutive_losses(returns, aggregate=None, compounded=True): + +def consecutive_losses( + returns: Returns, + aggregate: str | None = None, + compounded: bool = True, + prepare_returns: bool = True, +) -> int: """ - returns the maximum consecutive losses by - day/month/week/quarter/year + Calculate the maximum number of consecutive losing periods. + + This function identifies the longest streak of negative returns, which + helps assess the potential for extended drawdown periods. + + Args: + returns (pd.Series): Return series to analyze + aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y') + compounded (bool): Whether to compound returns (default: True) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + int: Maximum number of consecutive losing periods + + Example: + >>> returns = pd.Series([0.01, -0.02, -0.01, -0.01, 0.02]) + >>> max_losses = consecutive_losses(returns) + >>> print(f"Max consecutive losses: {max_losses}") """ - returns = _utils._prepare_returns(returns) + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Aggregate returns and convert to boolean (negative = True) returns = _utils.aggregate_returns(returns, aggregate, compounded) < 0 - return _utils.count_consecutive(returns).max() + # Count consecutive True values and return maximum + return _utils._count_consecutive(returns).max() -def exposure(returns): - """ returns the market exposure time (returns != 0) """ - returns = _utils._prepare_returns(returns) + +def exposure( + returns: Returns, + prepare_returns: bool = True, +) -> float | _pd.Series: + """ + Calculate market exposure time as percentage of periods with non-zero returns. + + This function measures how often the strategy was actually invested + (had non-zero returns) versus being in cash or having zero positions. + + Args: + returns (pd.Series or pd.DataFrame): Return series or DataFrame + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float or pd.Series: Exposure percentage (0-1 scale) + + Example: + >>> returns = pd.Series([0.01, 0.00, 0.02, 0.00, 0.03]) + >>> exp = exposure(returns) + >>> print(f"Market exposure: {exp:.2%}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) def _exposure(ret): + """ + Calculate exposure for a single return series. + + Counts non-NaN, non-zero returns and divides by total periods. + Rounds up to nearest percent to avoid zero exposure from rounding. + """ + # Count non-NaN and non-zero returns ex = len(ret[(~_np.isnan(ret)) & (ret != 0)]) / len(ret) + # Round up to nearest percent return _ceil(ex * 100) / 100 + # Handle DataFrame input by calculating exposure for each column if isinstance(returns, _pd.DataFrame): _df = {} for col in returns.columns: _df[col] = _exposure(returns[col]) return _pd.Series(_df) + return _exposure(returns) -def win_rate(returns, aggregate=None, compounded=True): - """ calculates the win ratio for a period """ +def win_rate( + returns: Returns, + aggregate: str | None = None, + compounded: bool = True, + prepare_returns: bool = True, +) -> float | _pd.Series: + """ + Calculate the win rate (percentage of profitable periods). + + This function computes the ratio of positive returns to total non-zero + returns, providing a measure of how often the strategy generates profits. + + Args: + returns (pd.Series or pd.DataFrame): Return series or DataFrame + aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y') + compounded (bool): Whether to compound returns (default: True) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float or pd.Series: Win rate as decimal (0-1 scale) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> wr = win_rate(returns) + >>> print(f"Win rate: {wr:.2%}") + """ def _win_rate(series): - try: - return len(series[series > 0]) / len(series[series != 0]) - except Exception: - return 0. + """ + Calculate win rate for a single return series. - returns = _utils._prepare_returns(returns) + Handles edge cases like no non-zero returns and provides + error handling for calculation issues. + """ + try: + # Filter out zero returns (periods with no trading) + non_zero_returns = series[series != 0] + if len(non_zero_returns) == 0: + warn("No non-zero returns found for win rate calculation, returning 0.0") + return 0.0 + + # Calculate ratio of positive returns to non-zero returns + return len(series[series > 0]) / len(non_zero_returns) + except (ValueError, TypeError) as e: + warn(f"Error calculating win rate: {e}, returning 0.0") + return 0.0 + + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Aggregate returns if period specified if aggregate: returns = _utils.aggregate_returns(returns, aggregate, compounded) + # Handle DataFrame input by calculating win rate for each column if isinstance(returns, _pd.DataFrame): _df = {} for col in returns.columns: _df[col] = _win_rate(returns[col]) - return _pd.Series(_df) return _win_rate(returns) -def avg_return(returns, aggregate=None, compounded=True): +def avg_return( + returns: Returns, + aggregate: str | None = None, + compounded: bool = True, + prepare_returns: bool = True, +) -> float: """ - calculates the average return/trade return for a period - returns = _utils._prepare_returns(returns) + Calculate the average return per period (excluding zero returns). + + This function computes the mean of non-zero returns, providing insight + into the typical magnitude of returns when the strategy is active. + + Args: + returns (pd.Series): Return series to analyze + aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y') + compounded (bool): Whether to compound returns (default: True) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Average return per period + + Example: + >>> returns = pd.Series([0.01, 0.00, 0.02, -0.01, 0.03]) + >>> avg_ret = avg_return(returns) + >>> print(f"Average return: {avg_ret:.4f}") """ - returns = _utils._prepare_returns(returns) + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Aggregate returns if period specified if aggregate: returns = _utils.aggregate_returns(returns, aggregate, compounded) + + # Calculate mean of non-zero returns return returns[returns != 0].dropna().mean() -def avg_win(returns, aggregate=None, compounded=True): +def avg_win( + returns: Returns, + aggregate: str | None = None, + compounded: bool = True, + prepare_returns: bool = True, +) -> float: """ - calculates the average winning - return/trade return for a period + Calculate the average winning return (mean of positive returns). + + This function computes the mean of positive returns only, showing + the typical magnitude of profitable periods. + + Args: + returns (pd.Series): Return series to analyze + aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y') + compounded (bool): Whether to compound returns (default: True) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Average winning return + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> avg_win_ret = avg_win(returns) + >>> print(f"Average win: {avg_win_ret:.4f}") """ - returns = _utils._prepare_returns(returns) + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Aggregate returns if period specified if aggregate: returns = _utils.aggregate_returns(returns, aggregate, compounded) + + # Calculate mean of positive returns only return returns[returns > 0].dropna().mean() -def avg_loss(returns, aggregate=None, compounded=True): +def avg_loss( + returns: Returns, + aggregate: str | None = None, + compounded: bool = True, + prepare_returns: bool = True, +) -> float: """ - calculates the average low if - return/trade return for a period + Calculate the average losing return (mean of negative returns). + + This function computes the mean of negative returns only, showing + the typical magnitude of losing periods. + + Args: + returns (pd.Series): Return series to analyze + aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y') + compounded (bool): Whether to compound returns (default: True) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Average losing return (negative value) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> avg_loss_ret = avg_loss(returns) + >>> print(f"Average loss: {avg_loss_ret:.4f}") """ - returns = _utils._prepare_returns(returns) + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Aggregate returns if period specified if aggregate: returns = _utils.aggregate_returns(returns, aggregate, compounded) + + # Calculate mean of negative returns only return returns[returns < 0].dropna().mean() -def volatility(returns, periods=252, annualize=True): - """ calculates the volatility of returns for a period """ - std = _utils._prepare_returns(returns).std() +def volatility( + returns: Returns, + periods: int = 252, + annualize: bool = True, + prepare_returns: bool = True, +) -> float | _pd.Series: + """ + Calculate volatility (standard deviation) of returns. + + This function computes the volatility of returns, which measures the + degree of variation in returns over time. Higher volatility indicates + more uncertainty and risk. + + Args: + returns: Return series or DataFrame to analyze + periods: Number of periods per year for annualization (default: 252) + annualize: Whether to annualize the volatility (default: True) + prepare_returns: Whether to prepare returns first (default: True) + + Returns: + Volatility (annualized if annualize=True) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> vol = volatility(returns) + >>> print(f"Annualized volatility: {vol:.4f}") + """ + validate_input(returns) + + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Calculate standard deviation of returns + std = returns.std() + + # Annualize by multiplying by square root of periods per year if annualize: return std * _np.sqrt(periods) return std -def implied_volatility(returns, periods=252, annualize=True): - """ calculates the implied volatility of returns for a period """ +def rolling_volatility( + returns: Returns, + rolling_period: int = 126, + periods_per_year: int = 252, + prepare_returns: bool = True, +) -> _pd.Series: + """ + Calculate rolling volatility over a specified window. + + This function computes volatility using a rolling window, providing + a time-varying measure of risk that adapts to changing market conditions. + + Args: + returns (pd.Series): Return series to analyze + rolling_period (int): Rolling window size (default: 126, ~6 months) + periods_per_year (int): Periods per year for annualization (default: 252) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + pd.Series: Rolling volatility series (annualized) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> rolling_vol = rolling_volatility(returns, rolling_period=3) + >>> print(rolling_vol) + """ + if prepare_returns: + returns = _utils._prepare_returns(returns, rolling_period) + + # Calculate rolling standard deviation and annualize + return returns.rolling(rolling_period).std() * _np.sqrt(periods_per_year) + + +def implied_volatility( + returns: Returns, + periods: int = 252, + annualize: bool = True, +) -> float | _pd.Series: + """ + Calculate implied volatility using log returns. + + This function computes volatility using log returns instead of simple + returns, which is mathematically more appropriate for continuous compounding. + + Args: + returns (pd.Series): Return series to analyze + periods (int): Number of periods for rolling calculation (default: 252) + annualize (bool): Whether to annualize the volatility (default: True) + + Returns: + float or pd.Series: Implied volatility + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> impl_vol = implied_volatility(returns) + >>> print(f"Implied volatility: {impl_vol:.4f}") + """ + # Convert to log returns for continuous compounding logret = _utils.log_returns(returns) + if annualize: + # Calculate rolling volatility and annualize return logret.rolling(periods).std() * _np.sqrt(periods) - return logret.std() + # Return simple standard deviation + return logret.std() -# ======= METRICS ======= -def sharpe(returns, rf=0., periods=252, annualize=True): +def autocorr_penalty( + returns: Returns, + prepare_returns: bool = False, +) -> float: """ - calculates the sharpe ratio of access returns + Calculate autocorrelation penalty for risk-adjusted metrics. - If rf is non-zero, you must specify periods. - In this case, rf is assumed to be expressed in yearly (annualized) terms + This function computes a penalty factor that accounts for autocorrelation + in returns, which can inflate risk-adjusted ratios. Used to adjust + Sharpe and Sortino ratios for more realistic risk assessment. Args: - * returns (Series, DataFrame): Input return series - * rf (float): Risk-free rate expressed as a yearly (annualized) return - * periods (int): Frequency of returns (252 for daily, 12 for monthly) - * annualize: return annualize sharpe? + returns (pd.Series): Return series to analyze + prepare_returns (bool): Whether to prepare returns first (default: False) + + Returns: + float: Autocorrelation penalty factor (>= 1) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> penalty = autocorr_penalty(returns) + >>> print(f"Autocorrelation penalty: {penalty:.4f}") """ + if prepare_returns: + returns = _utils._prepare_returns(returns) - if rf != 0 and periods is None: - raise Exception('Must provide periods if rf != 0') + # Handle DataFrame input by selecting first column + if isinstance(returns, _pd.DataFrame): + returns = returns[returns.columns[0]] - returns = _utils._prepare_returns(returns, rf, periods) - res = returns.mean() / returns.std() + # returns.to_csv('/Users/ran/Desktop/test.csv') + num = len(returns) - if annualize: - return res * _np.sqrt(1 if periods is None else periods) + # Calculate autocorrelation coefficient between consecutive returns + coef = _np.abs(_np.corrcoef(returns[:-1], returns[1:])[0, 1]) - return res + # Vectorized calculation instead of list comprehension + x = _np.arange(1, num) + # Calculate weighted correlation effects over time + corr = ((num - x) / num) * (coef**x) + # Return penalty factor (square root of 1 + 2 * sum of correlations) + return _np.sqrt(1 + 2 * _np.sum(corr)) -def sortino(returns, rf=0, periods=252, annualize=True): + +# ======= METRICS ======= + + +def sharpe( + returns: Returns, + rf: float = 0.0, + periods: int = 252, + annualize: bool = True, + smart: bool = False, +) -> float | _pd.Series: """ - calculates the sortino ratio of access returns + Calculate the Sharpe ratio of excess returns. - If rf is non-zero, you must specify periods. - In this case, rf is assumed to be expressed in yearly (annualized) terms + The Sharpe ratio measures risk-adjusted returns by dividing excess returns + (returns - risk-free rate) by the standard deviation of returns. + Higher values indicate better risk-adjusted performance. - Calculation is based on this paper by Red Rock Capital - http://www.redrockcapital.com/Sortino__A__Sharper__Ratio_Red_Rock_Capital.pdf + Args: + returns: Return series or DataFrame to analyze + rf: Risk-free rate (annualized if periods specified, default: 0.0) + periods: Periods per year for annualization (default: 252) + annualize: Whether to annualize the ratio (default: True) + smart: Whether to apply autocorrelation penalty (default: False) + + Returns: + Sharpe ratio (float for Series input, Series for DataFrame input) + + Raises: + ValueError: If rf is non-zero but periods is None + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> sharpe_ratio = sharpe(returns, rf=0.02) + >>> print(f"Sharpe ratio: {sharpe_ratio:.4f}") """ + validate_input(returns) + # Validate parameters for risk-free rate handling if rf != 0 and periods is None: - raise Exception('Must provide periods if rf != 0') + raise ValueError("periods parameter is required when risk-free rate (rf) is non-zero. " + "This is needed to properly annualize the risk-free rate.") + # Prepare returns (subtract risk-free rate if applicable) returns = _utils._prepare_returns(returns, rf, periods) - downside = (returns[returns < 0] ** 2).sum() / len(returns) - res = returns.mean() / _np.sqrt(downside) + # Calculate standard deviation as denominator + divisor = returns.std(ddof=1) + + # Apply autocorrelation penalty if smart mode enabled + if smart: + # penalize sharpe with auto correlation + divisor = divisor * autocorr_penalty(returns) + # Calculate base Sharpe ratio + res = returns.mean() / divisor + + # Annualize if requested if annualize: return res * _np.sqrt(1 if periods is None else periods) return res -def cagr(returns, rf=0., compounded=True): +def smart_sharpe( + returns: Returns, + rf: float = 0.0, + periods: int = 252, + annualize: bool = True, +) -> float | _pd.Series: """ - calculates the communicative annualized growth return - (CAGR%) of access returns + Calculate the Smart Sharpe ratio (Sharpe with autocorrelation penalty). + + This is a wrapper for the sharpe() function with smart=True, which + applies an autocorrelation penalty to provide more realistic risk-adjusted + returns for strategies with autocorrelated returns. - If rf is non-zero, you must specify periods. - In this case, rf is assumed to be expressed in yearly (annualized) terms + Args: + returns (pd.Series): Return series to analyze + rf (float): Risk-free rate (annualized, default: 0.0) + periods (int): Periods per year for annualization (default: 252) + annualize (bool): Whether to annualize the ratio (default: True) + + Returns: + float: Smart Sharpe ratio + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> smart_sharpe_ratio = smart_sharpe(returns) + >>> print(f"Smart Sharpe ratio: {smart_sharpe_ratio:.4f}") """ + return sharpe(returns, rf, periods, annualize, True) - total = _utils._prepare_returns(returns, rf) - if compounded: - total = comp(total) - else: - total = _np.sum(total) - years = (returns.index[-1] - returns.index[0]).days / 365. +def rolling_sharpe( + returns: Returns, + rf: float = 0.0, + rolling_period: int = 126, + annualize: bool = True, + periods_per_year: int = 252, + prepare_returns: bool = True, +) -> _pd.Series: + """ + Calculate rolling Sharpe ratio over a specified window. - res = abs(total + 1.0) ** (1.0 / years) - 1 + This function computes the Sharpe ratio using a rolling window, providing + a time-varying measure of risk-adjusted performance that adapts to + changing market conditions. - if isinstance(returns, _pd.DataFrame): - res = _pd.Series(res) - res.index = returns.columns + Args: + returns (pd.Series): Return series to analyze + rf (float): Risk-free rate (annualized, default: 0.0) + rolling_period (int): Rolling window size (default: 126, ~6 months) + annualize (bool): Whether to annualize the ratio (default: True) + periods_per_year (int): Periods per year for annualization (default: 252) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + pd.Series: Rolling Sharpe ratio series + + Raises: + Exception: If rf != 0 and rolling_period is None + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> rolling_sharpe_ratio = rolling_sharpe(returns, rolling_period=3) + >>> print(rolling_sharpe_ratio) + """ + # Validate parameters for risk-free rate handling + if rf != 0 and rolling_period is None: + raise Exception("Must provide periods if rf != 0") - return res + if prepare_returns: + returns = _utils._prepare_returns(returns, rf, rolling_period) + # Calculate rolling mean and standard deviation + res = returns.rolling(rolling_period).mean() / returns.rolling(rolling_period).std() -def rar(returns, rf=0.): - """ - calculates the risk-adjusted return of access returns - (CAGR / exposure. takes time into account.) + # Annualize if requested + if annualize: + res = res * _np.sqrt(1 if periods_per_year is None else periods_per_year) - If rf is non-zero, you must specify periods. - In this case, rf is assumed to be expressed in yearly (annualized) terms - """ - returns = _utils._prepare_returns(returns, rf) - return cagr(returns) / exposure(returns) + return res -def skew(returns): - """ - calculates returns' skewness - (the degree of asymmetry of a distribution around its mean) +def sortino( + returns: Returns, + rf: float = 0, + periods: int = 252, + annualize: bool = True, + smart: bool = False, +) -> float | _pd.Series: """ - return _utils._prepare_returns(returns).skew() + Calculate the Sortino ratio of excess returns. + The Sortino ratio is similar to the Sharpe ratio but uses downside deviation + instead of total volatility, focusing only on harmful volatility. + This provides a more accurate measure of risk-adjusted returns. -def kurtosis(returns): - """ - calculates returns' kurtosis - (the degree to which a distribution peak compared to a normal distribution) + Args: + returns (pd.Series): Return series to analyze + rf (float): Risk-free rate (annualized, default: 0.0) + periods (int): Periods per year for annualization (default: 252) + annualize (bool): Whether to annualize the ratio (default: True) + smart (bool): Whether to apply autocorrelation penalty (default: False) + + Returns: + float: Sortino ratio + + Raises: + ValueError: If rf is non-zero but periods is None + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> sortino_ratio = sortino(returns, rf=0.02) + >>> print(f"Sortino ratio: {sortino_ratio:.4f}") + + Note: + Calculation is based on this paper by Red Rock Capital: + http://www.redrockcapital.com/Sortino__A__Sharper__Ratio_Red_Rock_Capital.pdf """ - return _utils._prepare_returns(returns).kurtosis() + validate_input(returns) + # Validate parameters for risk-free rate handling + if rf != 0 and periods is None: + raise ValueError("periods parameter is required when risk-free rate (rf) is non-zero. " + "This is needed to properly annualize the risk-free rate.") -def calmar(returns): - """ calculates the calmar ratio (CAGR% / MaxDD%) """ - returns = _utils._prepare_returns(returns) - cagr_ratio = cagr(returns) - max_dd = max_drawdown(returns) - return cagr_ratio / abs(max_dd) - + # Prepare returns (subtract risk-free rate if applicable) + returns = _utils._prepare_returns(returns, rf, periods) -def ulcer_index(returns, rf=0): - """ calculates the ulcer index score (downside risk measurment) """ - returns = _utils._prepare_returns(returns, rf) - dd = 1. - returns/returns.cummax() - return _np.sqrt(_np.divide((dd**2).sum(), returns.shape[0] - 1)) + # Calculate downside deviation (only negative returns) + downside = _np.sqrt((returns[returns < 0] ** 2).sum() / len(returns)) + # Apply autocorrelation penalty if smart mode enabled + if smart: + # penalize sortino with auto correlation + downside = downside * autocorr_penalty(returns) -def ulcer_performance_index(returns, rf=0): - """ - calculates the ulcer index score - (downside risk measurment) - """ - returns = _utils._prepare_returns(returns, rf) - dd = 1. - returns/returns.cummax() - ulcer = _np.sqrt(_np.divide((dd**2).sum(), returns.shape[0] - 1)) - return returns.mean() / ulcer + # Calculate base Sortino ratio + # Handle both Series (DataFrame input) and scalar (Series input) cases + if isinstance(downside, _pd.Series): + res = returns.mean() / downside.replace(0, _np.nan) + else: + if downside == 0: + res = _np.nan + else: + res = returns.mean() / downside + # Annualize if requested + if annualize: + return res * _np.sqrt(1 if periods is None else periods) -def upi(returns, rf=0): - """ shorthand for ulcer_performance_index() """ - return ulcer_performance_index(returns, rf) + return res -def risk_of_ruin(returns): - """ - calculates the risk of ruin - (the likelihood of losing all one's investment capital) +def smart_sortino( + returns: Returns, + rf: float = 0, + periods: int = 252, + annualize: bool = True, +) -> float | _pd.Series: """ - returns = _utils._prepare_returns(returns) - wins = win_rate(returns) - return ((1 - wins) / (1 + wins)) ** len(returns) + Calculate the Smart Sortino ratio (Sortino with autocorrelation penalty). + This is a wrapper for the sortino() function with smart=True, which + applies an autocorrelation penalty to provide more realistic risk-adjusted + returns for strategies with autocorrelated returns. -def ror(returns): - """ shorthand for risk_of_ruin() """ - return risk_of_ruin(returns) + Args: + returns (pd.Series): Return series to analyze + rf (float): Risk-free rate (annualized, default: 0.0) + periods (int): Periods per year for annualization (default: 252) + annualize (bool): Whether to annualize the ratio (default: True) + + Returns: + float: Smart Sortino ratio + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> smart_sortino_ratio = smart_sortino(returns) + >>> print(f"Smart Sortino ratio: {smart_sortino_ratio:.4f}") + """ + return sortino(returns, rf, periods, annualize, True) -def value_at_risk(returns, sigma=1, confidence=0.95): - """ - calculats the daily value-at-risk - (variance-covariance calculation with confidence n) +def rolling_sortino( + returns: Returns, + rf: float = 0, + rolling_period: int = 126, + annualize: bool = True, + periods_per_year: int = 252, + **kwargs, +) -> _pd.Series: """ - returns = _utils._prepare_returns(returns) - mu = returns.mean() - sigma *= returns.std() - - if confidence > 1: - confidence = confidence/100 + Calculate rolling Sortino ratio over a specified window. - return _norm.ppf(1-confidence, mu, sigma) + This function computes the Sortino ratio using a rolling window, providing + a time-varying measure of downside risk-adjusted performance. + Args: + returns (pd.Series): Return series to analyze + rf (float): Risk-free rate (annualized, default: 0.0) + rolling_period (int): Rolling window size (default: 126, ~6 months) + annualize (bool): Whether to annualize the ratio (default: True) + periods_per_year (int): Periods per year for annualization (default: 252) + **kwargs: Additional keyword arguments (e.g., prepare_returns) + + Returns: + pd.Series: Rolling Sortino ratio series + + Raises: + Exception: If rf != 0 and rolling_period is None + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> rolling_sortino_ratio = rolling_sortino(returns, rolling_period=3) + >>> print(rolling_sortino_ratio) + """ + # Validate parameters for risk-free rate handling + if rf != 0 and rolling_period is None: + raise Exception("Must provide periods if rf != 0") -def var(returns, sigma=1, confidence=0.95): - """ shorthand for value_at_risk() """ - return value_at_risk(returns, sigma, confidence) + if kwargs.get("prepare_returns", True): + returns = _utils._prepare_returns(returns, rf, rolling_period) + # Optimized downside calculation using vectorized operations + def calc_downside(x): + """ + Calculate downside variance more efficiently. -def conditional_value_at_risk(returns, sigma=1, confidence=0.95): - """ - calculats the conditional daily value-at-risk (aka expected shortfall) - quantifies the amount of tail risk an investment - """ - returns = _utils._prepare_returns(returns) - var = value_at_risk(returns, sigma, confidence) - c_var = returns[returns < var].mean()[0] - return c_var if ~_np.isnan(c_var) else var + This function computes the sum of squared negative returns, + which is used to calculate downside deviation. + """ + negative_returns = x[x < 0] + return (negative_returns**2).sum() if len(negative_returns) > 0 else 0 + # Calculate rolling downside deviation + downside = ( + returns.rolling(rolling_period).apply(calc_downside, raw=True) / rolling_period + ) -def cvar(returns, sigma=1, confidence=0.95): - """ shorthand for conditional_value_at_risk() """ - return conditional_value_at_risk(returns, sigma, confidence) + # Calculate rolling Sortino ratio + res = returns.rolling(rolling_period).mean() / _np.sqrt(downside) + # Annualize if requested + if annualize: + res = res * _np.sqrt(1 if periods_per_year is None else periods_per_year) -def expected_shortfall(returns, sigma=1, confidence=0.95): - """ shorthand for conditional_value_at_risk() """ - return conditional_value_at_risk(returns, sigma, confidence) + return res -def tail_ratio(returns, cutoff=0.95): +def adjusted_sortino( + returns: Returns, + rf: float = 0, + periods: int = 252, + annualize: bool = True, + smart: bool = False, +) -> float | _pd.Series: """ - measures the ratio between the right - (95%) and left tail (5%). + Calculate Jack Schwager's adjusted Sortino ratio. + + This version of the Sortino ratio is adjusted by dividing by sqrt(2) + to allow for direct comparisons with the Sharpe ratio. This adjustment + accounts for the difference in calculation methods. + + Args: + returns (pd.Series): Return series to analyze + rf (float): Risk-free rate (annualized, default: 0.0) + periods (int): Periods per year for annualization (default: 252) + annualize (bool): Whether to annualize the ratio (default: True) + smart (bool): Whether to apply autocorrelation penalty (default: False) + + Returns: + float: Adjusted Sortino ratio + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> adj_sortino = adjusted_sortino(returns) + >>> print(f"Adjusted Sortino ratio: {adj_sortino:.4f}") + + Note: + See here for more info: https://archive.is/wip/2rwFW """ - returns = _utils._prepare_returns(returns) - return abs(returns.quantile(cutoff) / returns.quantile(1-cutoff)) + # Calculate standard Sortino ratio + data = sortino(returns, rf, periods=periods, annualize=annualize, smart=smart) + # Apply Schwager's adjustment factor + return data / _sqrt(2) -def payoff_ratio(returns): - """ measures the payoff ratio (average win/average loss) """ - returns = _utils._prepare_returns(returns) - return avg_win(returns) / abs(avg_loss(returns)) +def probabilistic_ratio( + series: Returns, + rf: float = 0.0, + base: str = "sharpe", + periods: int = 252, + annualize: bool = False, + smart: bool = False, +) -> float: + """ + Calculate the probabilistic ratio for a given base metric. -def win_loss_ratio(returns): - """ shorthand for payoff_ratio() """ - return payoff_ratio(returns) + This function computes the probabilistic version of risk-adjusted ratios, + which accounts for the statistical uncertainty in the ratio estimation. + It considers skewness and kurtosis to provide more robust estimates. + Args: + series (pd.Series): Return series to analyze + rf (float): Risk-free rate (annualized, default: 0.0) + base (str): Base metric ('sharpe', 'sortino', 'adjusted_sortino') + periods (int): Periods per year for annualization (default: 252) + annualize (bool): Whether to annualize the result (default: False) + smart (bool): Whether to apply autocorrelation penalty (default: False) + + Returns: + float: Probabilistic ratio (0-1 scale representing probability) + + Raises: + ValueError: If invalid base metric is provided + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> prob_ratio = probabilistic_ratio(returns, base="sharpe") + >>> print(f"Probabilistic Sharpe ratio: {prob_ratio:.4f}") + """ + # Calculate the base ratio depending on the selected metric + if base.lower() == "sharpe": + base = sharpe(series, periods=periods, annualize=False, smart=smart) + elif base.lower() == "sortino": + base = sortino(series, periods=periods, annualize=False, smart=smart) + elif base.lower() == "adjusted_sortino": + base = adjusted_sortino(series, periods=periods, annualize=False, smart=smart) + else: + raise ValueError( + f"Invalid metric '{base}'. Must be one of: 'sharpe', 'sortino', or 'adjusted_sortino'" + ) -def profit_ratio(returns): - """ measures the profit ratio (win ratio / loss ratio) """ - returns = _utils._prepare_returns(returns) - wins = returns[returns >= 0] - loss = returns[returns < 0] + # Calculate higher moments for adjustment + skew_no = skew(series, prepare_returns=False) + kurtosis_no = kurtosis(series, prepare_returns=False) - win_ratio = abs(wins.mean() / wins.count()) - loss_ratio = abs(loss.mean() / loss.count()) - try: - return win_ratio / loss_ratio - except Exception: - return 0. + n = len(series) + # Calculate standard error of the ratio incorporating higher moments + # Formula accounts for skewness and kurtosis effects on ratio distribution + sigma_sr = _np.sqrt( + (1 + (0.5 * base**2) - (skew_no * base) + (((kurtosis_no - 3) / 4) * base**2)) + / (n - 1) + ) -def profit_factor(returns): - """ measures the profit ratio (wins/loss) """ - returns = _utils._prepare_returns(returns) - return abs(returns[returns >= 0].sum() / returns[returns < 0].sum()) + # Calculate standardized ratio and convert to probability + ratio = (base - rf) / sigma_sr + psr = _norm.cdf(ratio) + # Annualize if requested + if annualize: + return psr * (252**0.5) -def gain_to_pain_ratio(returns): - """ shorthand for profit_factor() """ - return profit_factor(returns) + return psr -def cpc_index(returns): +def probabilistic_sharpe_ratio( + series: Returns, + rf: float = 0.0, + periods: int = 252, + annualize: bool = False, + smart: bool = False, +) -> float: """ - measures the cpc ratio - (profit factor * win % * win loss ratio) + Calculate the Probabilistic Sharpe Ratio (PSR). + + This function computes the PSR, which represents the probability that + the observed Sharpe ratio is statistically greater than a benchmark. + It accounts for higher moments to provide more robust estimates. + + Args: + series (pd.Series): Return series to analyze + rf (float): Risk-free rate (annualized, default: 0.0) + periods (int): Periods per year for annualization (default: 252) + annualize (bool): Whether to annualize the result (default: False) + smart (bool): Whether to apply autocorrelation penalty (default: False) + + Returns: + float: Probabilistic Sharpe ratio (0-1 scale) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> psr = probabilistic_sharpe_ratio(returns) + >>> print(f"Probabilistic Sharpe ratio: {psr:.4f}") + """ + return probabilistic_ratio( + series, rf, base="sharpe", periods=periods, annualize=annualize, smart=smart + ) + + +def probabilistic_sortino_ratio( + series, rf=0.0, periods=252, annualize=False, smart=False +): + """ + Calculate the Probabilistic Sortino Ratio. + + This function computes the probabilistic version of the Sortino ratio, + which accounts for statistical uncertainty in the ratio estimation. + + Args: + series (pd.Series): Return series to analyze + rf (float): Risk-free rate (annualized, default: 0.0) + periods (int): Periods per year for annualization (default: 252) + annualize (bool): Whether to annualize the result (default: False) + smart (bool): Whether to apply autocorrelation penalty (default: False) + + Returns: + float: Probabilistic Sortino ratio (0-1 scale) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> psr = probabilistic_sortino_ratio(returns) + >>> print(f"Probabilistic Sortino ratio: {psr:.4f}") + """ + return probabilistic_ratio( + series, rf, base="sortino", periods=periods, annualize=annualize, smart=smart + ) + + +def probabilistic_adjusted_sortino_ratio( + series, rf=0.0, periods=252, annualize=False, smart=False +): + """ + Calculate the Probabilistic Adjusted Sortino Ratio. + + This function computes the probabilistic version of the adjusted Sortino + ratio, accounting for statistical uncertainty in the ratio estimation. + + Args: + series (pd.Series): Return series to analyze + rf (float): Risk-free rate (annualized, default: 0.0) + periods (int): Periods per year for annualization (default: 252) + annualize (bool): Whether to annualize the result (default: False) + smart (bool): Whether to apply autocorrelation penalty (default: False) + + Returns: + float: Probabilistic adjusted Sortino ratio (0-1 scale) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> psr = probabilistic_adjusted_sortino_ratio(returns) + >>> print(f"Probabilistic adjusted Sortino ratio: {psr:.4f}") + """ + return probabilistic_ratio( + series, + rf, + base="adjusted_sortino", + periods=periods, + annualize=annualize, + smart=smart, + ) + + +def treynor_ratio(returns, benchmark, periods=252.0, rf=0.0): + """ + Calculate the Treynor ratio. + + The Treynor ratio measures risk-adjusted returns relative to systematic risk + (beta) rather than total risk (volatility). It's calculated as excess return + divided by beta, useful for comparing portfolios with different market exposure. + + Args: + returns (pd.Series): Return series to analyze + benchmark (pd.Series): Benchmark return series for beta calculation + periods (float): Periods per year for annualization (default: 252.0) + rf (float): Risk-free rate (annualized, default: 0.0) + + Returns: + float: Treynor ratio + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> benchmark = pd.Series([0.005, -0.01, 0.02, -0.005, 0.015]) + >>> treynor = treynor_ratio(returns, benchmark) + >>> print(f"Treynor ratio: {treynor:.4f}") + """ + # Handle DataFrame input by selecting first column + if isinstance(returns, _pd.DataFrame): + returns = returns[returns.columns[0]] + + # Calculate beta from the Greeks (alpha, beta analysis) + beta = greeks(returns, benchmark, periods=periods).to_dict().get("beta", 0) + + # Prevent division by zero + if beta == 0: + warn("Beta is zero, cannot calculate Treynor ratio, returning 0") + return 0 + + # Calculate excess return over risk-free rate divided by beta + return (comp(returns) - rf) / beta + + +def omega( + returns: Returns, + rf: float = 0.0, + required_return: float = 0.0, + periods: int = 252, +) -> float: + """ + Calculate the Omega ratio of a strategy. + + The Omega ratio measures the probability-weighted ratio of gains to losses + above and below a threshold return. It provides a comprehensive view of + the return distribution's characteristics. + + Args: + returns (pd.Series): Return series to analyze + rf (float): Risk-free rate (annualized, default: 0.0) + required_return (float): Required return threshold (default: 0.0) + periods (int): Periods per year for annualization (default: 252) + + Returns: + float: Omega ratio + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> omega_ratio = omega(returns, required_return=0.01) + >>> print(f"Omega ratio: {omega_ratio:.4f}") + + Note: + See https://en.wikipedia.org/wiki/Omega_ratio for more details. + """ + validate_input(returns) + + # Validate minimum data requirements + if len(returns) < 2: + warn("Insufficient data for omega ratio calculation (need at least 2 returns), returning NaN") + return _np.nan + + # Validate required return parameter + if required_return <= -1: + warn(f"Invalid required_return ({required_return}) for omega ratio, must be > -1, returning NaN") + return _np.nan + + # Prepare returns (subtract risk-free rate if applicable) + returns = _utils._prepare_returns(returns, rf, periods) + + # Convert annualized required return to per-period if needed + if periods == 1: + return_threshold = required_return + else: + return_threshold = (1 + required_return) ** (1.0 / periods) - 1 + + # Calculate deviations from threshold + returns_less_thresh = returns - return_threshold + + # Sum of positive deviations (gains above threshold) + numer = returns_less_thresh[returns_less_thresh > 0.0].sum() + + # Sum of negative deviations (losses below threshold) + denom = -1.0 * returns_less_thresh[returns_less_thresh < 0.0].sum() + + # Handle both Series and scalar cases + if isinstance(denom, _pd.Series): + result = numer / denom + # Return NaN where denominator is zero + result = result.where(denom > 0.0, _np.nan) + return result + else: + if denom > 0.0: + return numer / denom + return _np.nan + + +def gain_to_pain_ratio(returns, rf=0, resolution="D"): + """ + Calculate Jack Schwager's Gain-to-Pain Ratio (GPR). + + This ratio measures the total gains divided by the total losses, + providing a simple measure of how much profit is generated per + unit of loss. Higher values indicate better performance. + + Args: + returns (pd.Series): Return series to analyze + rf (float): Risk-free rate (default: 0) + resolution (str): Resampling frequency ('D', 'W', 'M', etc.) + + Returns: + float: Gain-to-Pain ratio + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> gpr = gain_to_pain_ratio(returns) + >>> print(f"Gain-to-Pain ratio: {gpr:.4f}") + + Note: + See here for more info: https://archive.is/wip/2rwFW + """ + # Prepare returns and resample to specified frequency + returns = _utils._prepare_returns(returns, rf).resample(resolution).sum() + + # Calculate absolute sum of negative returns (pain) + downside = abs(returns[returns < 0].sum()) + + # Handle both Series (DataFrame input) and scalar (Series input) cases + if isinstance(downside, _pd.Series): + # DataFrame input - element-wise division with zero protection + return returns.sum() / downside.replace(0, _np.nan) + else: + # Series input - scalar division + if downside == 0: + return _np.nan + return returns.sum() / downside + + +def cagr( + returns: Returns, + rf: float = 0.0, + compounded: bool = True, + periods: int = 252, +) -> float | _pd.Series: + """ + Calculate the Compound Annual Growth Rate (CAGR) of excess returns. + + CAGR represents the geometric mean annual growth rate, providing a + smoothed annualized return that accounts for compounding effects. + + Args: + returns (pd.Series): Return series to analyze + rf (float): Risk-free rate (annualized, default: 0.0) + compounded (bool): Whether to compound returns (default: True) + periods (int): Periods per year for annualization (default: 252) + + Returns: + float or pd.Series: CAGR percentage + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02], + ... index=pd.date_range('2023-01-01', periods=5)) + >>> cagr_value = cagr(returns) + >>> print(f"CAGR: {cagr_value:.4f}") + """ + validate_input(returns) + + # Prepare returns (subtract risk-free rate if applicable) + total = _utils._prepare_returns(returns, rf) + + # Calculate total return + if compounded: + total = comp(total) + else: + total = _np.sum(total, axis=0) + + # Calculate time period in years using trading periods + # This is consistent with how Sharpe, Sortino, and other metrics + # handle annualization in quantstats + years = len(returns) / periods + + # Calculate CAGR using geometric mean formula + res = abs(total + 1.0) ** (1.0 / years) - 1 + + # Handle DataFrame input + if isinstance(returns, _pd.DataFrame): + res = _pd.Series(res) + res.index = returns.columns + + return res + + +def rar(returns, rf=0.0): + """ + Calculate the Risk-Adjusted Return (RAR). + + RAR is calculated as CAGR divided by exposure, taking into account + the time the strategy was actually invested. This provides a more + accurate measure of returns adjusted for actual market participation. + + Args: + returns (pd.Series): Return series to analyze + rf (float): Risk-free rate (annualized, default: 0.0) + + Returns: + float: Risk-adjusted return + + Example: + >>> returns = pd.Series([0.01, 0.00, 0.03, 0.00, 0.02]) + >>> rar_value = rar(returns) + >>> print(f"Risk-adjusted return: {rar_value:.4f}") + """ + # Prepare returns (subtract risk-free rate if applicable) + returns = _utils._prepare_returns(returns, rf) + + # Calculate CAGR and divide by exposure time + return cagr(returns) / exposure(returns) + + +def skew(returns, prepare_returns=True): + """ + Calculate returns' skewness. + + Skewness measures the degree of asymmetry of a distribution around its mean. + Positive skewness indicates a longer tail on the positive side, + while negative skewness indicates a longer tail on the negative side. + + Args: + returns (pd.Series): Return series to analyze + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Skewness value + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> skewness = skew(returns) + >>> print(f"Skewness: {skewness:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Calculate skewness using pandas built-in method + return returns.skew() + + +def kurtosis(returns, prepare_returns=True): + """ + Calculate returns' kurtosis. + + Kurtosis measures the degree to which a distribution is peaked compared + to a normal distribution. Higher kurtosis indicates more extreme returns + (fat tails), while lower kurtosis indicates fewer extreme returns. + + Args: + returns (pd.Series): Return series to analyze + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Kurtosis value (excess kurtosis, normal distribution = 0) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> kurt = kurtosis(returns) + >>> print(f"Kurtosis: {kurt:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Calculate kurtosis using pandas built-in method (excess kurtosis) + return returns.kurtosis() + + +def calmar( + returns: Returns, + prepare_returns: bool = True, + periods: int = 252, + compounded: bool = True, +) -> float: + """ + Calculate the Calmar ratio (CAGR / Maximum Drawdown). + + The Calmar ratio measures risk-adjusted returns by dividing the CAGR + by the absolute value of the maximum drawdown. It provides insight + into returns relative to the worst-case scenario. + + Args: + returns (pd.Series): Return series to analyze + prepare_returns (bool): Whether to prepare returns first (default: True) + periods (int): Periods per year for annualization (default: 252) + compounded (bool): Whether to use compounded (geometric) returns + for the CAGR calculation (default: True). Set to False for + intraday or non-compounded return streams. + + Returns: + float: Calmar ratio + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> calmar_ratio = calmar(returns) + >>> print(f"Calmar ratio: {calmar_ratio:.4f}") + """ + validate_input(returns) + + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Calculate CAGR and maximum drawdown + cagr_ratio = cagr(returns, compounded=compounded, periods=periods) + max_dd = max_drawdown(returns) + + # Return ratio of CAGR to absolute maximum drawdown + return cagr_ratio / abs(max_dd) + + +def ulcer_index(returns): + """ + Calculate the Ulcer Index (downside risk measurement). + + The Ulcer Index measures the depth and duration of drawdowns, + providing a comprehensive measure of downside risk. It's calculated + as the square root of the mean of squared drawdowns. + + Args: + returns (pd.Series): Return series to analyze + + Returns: + float: Ulcer Index value + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> ulcer = ulcer_index(returns) + >>> print(f"Ulcer Index: {ulcer:.4f}") + """ + # Convert returns to drawdown series + dd = to_drawdown_series(returns) + + # Calculate root mean square of drawdowns + return _np.sqrt(_np.divide((dd**2).sum(), returns.shape[0] - 1)) + + +def ulcer_performance_index(returns, rf=0): + """ + Calculate the Ulcer Performance Index (UPI). + + The UPI measures risk-adjusted returns using the Ulcer Index as the + risk measure instead of standard deviation. It provides a better + measure for strategies with significant drawdowns. + + Args: + returns (pd.Series): Return series to analyze + rf (float): Risk-free rate (default: 0) + + Returns: + float: Ulcer Performance Index + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> upi_value = ulcer_performance_index(returns) + >>> print(f"Ulcer Performance Index: {upi_value:.4f}") + """ + # Calculate excess return divided by Ulcer Index + ulcer = ulcer_index(returns) + + # Handle both Series (DataFrame input) and scalar (Series input) cases + if isinstance(ulcer, _pd.Series): + # DataFrame input - element-wise division with zero protection + return (comp(returns) - rf) / ulcer.replace(0, _np.nan) + else: + # Series input - scalar division + if ulcer == 0: + return _np.nan + return (comp(returns) - rf) / ulcer + + +def upi(returns, rf=0): + """ + Calculate the Ulcer Performance Index (UPI). + + This is a shorthand function for ulcer_performance_index() with + the same parameters and functionality. + + Args: + returns (pd.Series): Return series to analyze + rf (float): Risk-free rate (default: 0) + + Returns: + float: Ulcer Performance Index + """ + return ulcer_performance_index(returns, rf) + + +def serenity_index(returns, rf=0): + """ + Calculate the Serenity Index. + + The Serenity Index is a comprehensive risk-adjusted return measure + that combines the Ulcer Index with downside risk considerations. + It provides a more holistic view of strategy performance. + + Args: + returns (pd.Series): Return series to analyze + rf (float): Risk-free rate (default: 0) + + Returns: + float: Serenity Index + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> serenity = serenity_index(returns) + >>> print(f"Serenity Index: {serenity:.4f}") + + Note: + Based on KeyQuant whitepaper: + https://www.keyquant.com/Download/GetFile?Filename=%5CPublications%5CKeyQuant_WhitePaper_APT_Part1.pdf + """ + # Convert returns to drawdown series + dd = to_drawdown_series(returns) + + # Calculate pitfall measure using conditional value at risk of drawdowns + std_returns = returns.std() + + # Handle both Series (DataFrame input) and scalar (Series input) cases + if isinstance(std_returns, _pd.Series): + # DataFrame input - element-wise operations + pitfall = -cvar(dd) / std_returns.replace(0, _np.nan) + denominator = ulcer_index(returns) * pitfall + return (returns.sum() - rf) / denominator.replace(0, _np.nan) + else: + # Series input - scalar operations + if std_returns == 0: + return _np.nan + + cvar_val = cvar(dd) + ulcer_val = ulcer_index(returns) + + # Handle cases where these might return Series/array + if hasattr(cvar_val, '__len__') and len(cvar_val) == 1: + cvar_val = float(cvar_val.iloc[0] if hasattr(cvar_val, 'iloc') else cvar_val[0]) + if hasattr(ulcer_val, '__len__') and len(ulcer_val) == 1: + ulcer_val = float(ulcer_val.iloc[0] if hasattr(ulcer_val, 'iloc') else ulcer_val[0]) + + pitfall = -cvar_val / std_returns + denominator = ulcer_val * pitfall + + if denominator == 0: + return _np.nan + return (returns.sum() - rf) / denominator + + +def risk_of_ruin(returns, prepare_returns=True): + """ + Calculate the risk of ruin (probability of losing all capital). + + This function estimates the likelihood of losing all investment capital + based on the win rate and the number of trades/periods. It's useful + for position sizing and risk management. + + Args: + returns (pd.Series): Return series to analyze + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Risk of ruin probability (0-1 scale) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> ror_value = risk_of_ruin(returns) + >>> print(f"Risk of ruin: {ror_value:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Calculate win rate + wins = win_rate(returns) + + # Calculate risk of ruin using gambler's ruin formula + return ((1 - wins) / (1 + wins)) ** len(returns) + + +def ror(returns): + """ + Calculate the risk of ruin (probability of losing all capital). + + This is a shorthand function for risk_of_ruin() with the same + parameters and functionality. + + Args: + returns (pd.Series): Return series to analyze + + Returns: + float: Risk of ruin probability (0-1 scale) + """ + return risk_of_ruin(returns) + + +def value_at_risk( + returns: Returns, + sigma: float = 1, + confidence: float = 0.95, + prepare_returns: bool = True, +) -> float | _pd.Series: + """ + Calculate the daily Value at Risk (VaR). + + VaR estimates the maximum expected loss over a given time horizon + at a specified confidence level, using the variance-covariance method. + + Args: + returns (pd.Series): Return series to analyze + sigma (float): Volatility multiplier (default: 1) + confidence (float): Confidence level (0.95 = 95%, default: 0.95) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Value at Risk (negative value representing loss) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> var_value = value_at_risk(returns, confidence=0.95) + >>> print(f"95% VaR: {var_value:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Calculate mean and adjust volatility + mu = returns.mean() + sigma *= returns.std() + + # Convert percentage confidence to decimal if needed + if confidence > 1: + confidence = confidence / 100 + + # Calculate VaR using normal distribution inverse CDF + return _norm.ppf(1 - confidence, mu, sigma) + + +def var(returns, sigma=1, confidence=0.95, prepare_returns=True): + """ + Calculate the daily Value at Risk (VaR). + + This is a shorthand function for value_at_risk() with the same + parameters and functionality. + + Args: + returns (pd.Series): Return series to analyze + sigma (float): Volatility multiplier (default: 1) + confidence (float): Confidence level (0.95 = 95%, default: 0.95) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Value at Risk (negative value representing loss) + """ + return value_at_risk(returns, sigma, confidence, prepare_returns) + + +def conditional_value_at_risk( + returns: Returns, + sigma: float = 1, + confidence: float = 0.95, + prepare_returns: bool = True, +) -> float | _pd.Series: + """ + Calculate the Conditional Value at Risk (CVaR), also known as Expected Shortfall. + + CVaR measures the expected loss given that a loss exceeds the VaR threshold. + It quantifies the amount of tail risk an investment faces, providing a more + comprehensive risk measure than VaR alone. + + Args: + returns (pd.Series): Return series to analyze + sigma (float): Volatility multiplier (default: 1) + confidence (float): Confidence level (0.95 = 95%, default: 0.95) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Conditional Value at Risk (expected loss beyond VaR) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> cvar_value = conditional_value_at_risk(returns, confidence=0.95) + >>> print(f"95% CVaR: {cvar_value:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Handle both Series and DataFrame inputs + if isinstance(returns, _pd.DataFrame): + # For DataFrame, calculate CVaR for each column separately + result = {} + for col in returns.columns: + col_returns = returns[col] + # Calculate VaR for this specific column + col_var = value_at_risk(col_returns, sigma, confidence, prepare_returns=False) + below_var = col_returns[col_returns < col_var] + c_var_col = below_var.mean() if len(below_var) > 0 else _np.nan + result[col] = c_var_col if not _np.isnan(c_var_col) else col_var + return _pd.Series(result) + else: + # For Series, calculate VaR threshold + var = value_at_risk(returns, sigma, confidence) + c_var = returns[returns < var].values.mean() + # Return CVaR if valid, otherwise return VaR + return c_var if ~_np.isnan(c_var) else var + + +def cvar(returns, sigma=1, confidence=0.95, prepare_returns=True): + """ + Calculate the Conditional Value at Risk (CVaR). + + This is a shorthand function for conditional_value_at_risk() with + the same parameters and functionality. + + Args: + returns (pd.Series): Return series to analyze + sigma (float): Volatility multiplier (default: 1) + confidence (float): Confidence level (0.95 = 95%, default: 0.95) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Conditional Value at Risk + """ + return conditional_value_at_risk(returns, sigma, confidence, prepare_returns) + + +def expected_shortfall(returns, sigma=1, confidence=0.95): + """ + Calculate the Expected Shortfall (ES), also known as CVaR. + + This is a shorthand function for conditional_value_at_risk() with + the same parameters and functionality. + + Args: + returns (pd.Series): Return series to analyze + sigma (float): Volatility multiplier (default: 1) + confidence (float): Confidence level (0.95 = 95%, default: 0.95) + + Returns: + float: Expected Shortfall + """ + return conditional_value_at_risk(returns, sigma, confidence) + + +def tail_ratio(returns, cutoff=0.95, prepare_returns=True): + """ + Calculate the tail ratio between right and left tails. + + This function measures the ratio between the right (95%) and left (5%) tails + of the return distribution, providing insight into the asymmetry of extreme + returns. Higher values indicate more favorable tail characteristics. + + Args: + returns (pd.Series): Return series to analyze + cutoff (float): Percentile cutoff for tail analysis (default: 0.95) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Tail ratio (right tail / left tail) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> tail_r = tail_ratio(returns) + >>> print(f"Tail ratio: {tail_r:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Calculate ratio of right tail to left tail + upper_quantile = returns.quantile(cutoff) + lower_quantile = returns.quantile(1 - cutoff) + + # Handle edge cases: NaN values or zero denominator + # Check if result is a Series (DataFrame input) or scalar (Series input) + if isinstance(upper_quantile, _pd.Series): + # Handle DataFrame input - apply element-wise + result = _pd.Series(index=upper_quantile.index, dtype=float) + for col in upper_quantile.index: + if _pd.isna(upper_quantile[col]) or _pd.isna(lower_quantile[col]) or lower_quantile[col] == 0: + result[col] = _np.nan + else: + result[col] = abs(upper_quantile[col] / lower_quantile[col]) + return result + else: + # Handle Series input - scalar values + if _pd.isna(upper_quantile) or _pd.isna(lower_quantile) or lower_quantile == 0: + return _np.nan + return abs(upper_quantile / lower_quantile) + + +def payoff_ratio(returns, prepare_returns=True): + """ + Calculate the payoff ratio (average win / average loss). + + This function measures the ratio of average winning returns to average + losing returns, providing insight into the reward-to-risk profile + of individual trades or periods. + + Args: + returns (pd.Series): Return series to analyze + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Payoff ratio (average win / absolute average loss) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> payoff_r = payoff_ratio(returns) + >>> print(f"Payoff ratio: {payoff_r:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Calculate ratio of average win to absolute average loss + avg_loss_val = avg_loss(returns) + avg_win_val = avg_win(returns) + + # Handle both Series (DataFrame input) and scalar (Series input) cases + if isinstance(avg_loss_val, _pd.Series): + # DataFrame input - element-wise division with zero protection + # Use abs() before replace to handle negative values properly + return avg_win_val / abs(avg_loss_val).replace(0, _np.nan) + else: + # Series input - scalar division + if avg_loss_val == 0: + return _np.nan + return avg_win_val / abs(avg_loss_val) + + +def win_loss_ratio(returns, prepare_returns=True): + """ + Calculate the win-loss ratio (average win / average loss). + + This is a shorthand function for payoff_ratio() with the same + parameters and functionality. + + Args: + returns (pd.Series): Return series to analyze + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Win-loss ratio + """ + return payoff_ratio(returns, prepare_returns) + + +def profit_ratio(returns, prepare_returns=True): + """ + Calculate the profit ratio (win ratio / loss ratio). + + This function measures the ratio of win frequency to loss frequency, + providing insight into the consistency of profitable periods. + + Args: + returns (pd.Series or pd.DataFrame): Return series to analyze + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float or pd.Series: Profit ratio + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> profit_r = profit_ratio(returns) + >>> print(f"Profit ratio: {profit_r:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + def _profit_ratio(ret): + # Separate wins and losses + wins = ret[ret >= 0] + loss = ret[ret < 0] + + # Handle edge cases + win_count = len(wins) + loss_count = len(loss) + + if win_count == 0: + return 0.0 + if loss_count == 0: + return _np.nan + + # Calculate win and loss ratios + win_ratio = abs(wins.mean() / win_count) if win_count > 0 else 0 + loss_ratio = abs(loss.mean() / loss_count) if loss_count > 0 else 0 + + if loss_ratio == 0: + return _np.nan + return win_ratio / loss_ratio + + # Handle DataFrame by applying to each column + if isinstance(returns, _pd.DataFrame): + return returns.apply(_profit_ratio) + + return _profit_ratio(returns) + + +def profit_factor(returns, prepare_returns=True): """ - returns = _utils._prepare_returns(returns) - return profit_factor(returns) * win_rate(returns) * \ - win_loss_ratio(returns) + Calculate the profit factor (total wins / total losses). + This function measures the ratio of total winning returns to total + losing returns, providing insight into the overall profitability + of the strategy. -def common_sense_ratio(returns): - """ measures the common sense ratio (profit factor * tail ratio) """ - returns = _utils._prepare_returns(returns) + Args: + returns (pd.Series): Return series to analyze + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Profit factor (total wins / total losses) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> pf = profit_factor(returns) + >>> print(f"Profit factor: {pf:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Calculate total wins and losses + wins_sum = returns[returns >= 0].sum() + losses_sum = abs(returns[returns < 0].sum()) + + # Handle both Series and scalar cases + if isinstance(losses_sum, _pd.Series): + result = wins_sum / losses_sum + # Replace infinite values with 0 + result = result.replace([_np.inf, -_np.inf], 0) + return result + else: + # Handle division by zero case + if losses_sum == 0: + return 0.0 if wins_sum == 0 else float('inf') + return wins_sum / losses_sum + + +def cpc_index(returns, prepare_returns=True): + """ + Calculate the CPC Index (Profit Factor * Win Rate * Win-Loss Ratio). + + The CPC Index is a comprehensive performance measure that combines + profit factor, win rate, and win-loss ratio to provide a single + metric for strategy evaluation. + + Args: + returns (pd.Series): Return series to analyze + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: CPC Index + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> cpc = cpc_index(returns) + >>> print(f"CPC Index: {cpc:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Calculate composite metric + return profit_factor(returns) * win_rate(returns) * win_loss_ratio(returns) + + +def common_sense_ratio(returns, prepare_returns=True): + """ + Calculate the Common Sense Ratio (Profit Factor * Tail Ratio). + + This ratio combines profit factor with tail ratio to provide a + measure that considers both profitability and tail risk characteristics. + + Args: + returns (pd.Series): Return series to analyze + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Common Sense Ratio + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> csr = common_sense_ratio(returns) + >>> print(f"Common Sense Ratio: {csr:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Calculate composite metric return profit_factor(returns) * tail_ratio(returns) -def outlier_win_ratio(returns, quantile=.99): +def outlier_win_ratio(returns, quantile=0.99, prepare_returns=True): + """ + Calculate the outlier winners ratio. + + This function computes the ratio of the 99th percentile of returns + to the mean positive return, showing how much outlier wins contribute + to overall performance. + + Args: + returns (pd.Series): Return series to analyze + quantile (float): Quantile for outlier threshold (default: 0.99) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Outlier win ratio + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> outlier_win_r = outlier_win_ratio(returns) + >>> print(f"Outlier win ratio: {outlier_win_r:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Calculate ratio of high quantile to mean positive return + positive_mean = returns[returns >= 0].mean() + quantile_val = returns.quantile(quantile) # Series for DataFrame, scalar for Series + + # Handle both Series (DataFrame input) and scalar (Series input) cases + if isinstance(positive_mean, _pd.Series): + # DataFrame input - element-wise division with zero protection + return quantile_val / positive_mean.replace(0, _np.nan) + else: + # Series input - scalar division + if _pd.isna(positive_mean) or positive_mean == 0: + return _np.nan + return quantile_val / positive_mean + + +def outlier_loss_ratio(returns, quantile=0.01, prepare_returns=True): """ - calculates the outlier winners ratio - 99th percentile of returns / mean positive return + Calculate the outlier losers ratio. + + This function computes the ratio of the 1st percentile of returns + to the mean negative return, showing how much outlier losses contribute + to overall risk. + + Args: + returns (pd.Series): Return series to analyze + quantile (float): Quantile for outlier threshold (default: 0.01) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Outlier loss ratio + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> outlier_loss_r = outlier_loss_ratio(returns) + >>> print(f"Outlier loss ratio: {outlier_loss_r:.4f}") """ - returns = _utils._prepare_returns(returns) - return returns.quantile(quantile).mean() / returns[returns >= 0].mean() + if prepare_returns: + returns = _utils._prepare_returns(returns) + # Calculate ratio of low quantile to mean negative return + negative_mean = returns[returns < 0].mean() + quantile_val = returns.quantile(quantile) # Series for DataFrame, scalar for Series -def outlier_loss_ratio(returns, quantile=.01): + # Handle both Series (DataFrame input) and scalar (Series input) cases + if isinstance(negative_mean, _pd.Series): + # DataFrame input - element-wise division with zero protection + return quantile_val / negative_mean.replace(0, _np.nan) + else: + # Series input - scalar division + if _pd.isna(negative_mean) or negative_mean == 0: + return _np.nan + return quantile_val / negative_mean + + +def recovery_factor(returns, rf=0.0, prepare_returns=True): """ - calculates the outlier losers ratio - 1st percentile of returns / mean negative return + Calculate the recovery factor (total returns / maximum drawdown). + + This function measures how fast the strategy recovers from drawdowns + by comparing total returns to the maximum drawdown experienced. + Higher values indicate better recovery characteristics. + + Args: + returns (pd.Series): Return series to analyze + rf (float): Risk-free rate (default: 0.0) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Recovery factor + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> rf_value = recovery_factor(returns) + >>> print(f"Recovery factor: {rf_value:.4f}") """ - returns = _utils._prepare_returns(returns) - return returns.quantile(quantile).mean() / returns[returns < 0].mean() + if prepare_returns: + returns = _utils._prepare_returns(returns) + # Calculate total excess returns + total_returns = returns.sum() - rf -def recovery_factor(returns): - """ measures how fast the strategy recovers from drawdowns """ - returns = _utils._prepare_returns(returns) - total_returns = comp(returns) + # Calculate maximum drawdown max_dd = max_drawdown(returns) - return total_returns / abs(max_dd) + # Handle both Series (DataFrame input) and scalar (Series input) cases + if isinstance(max_dd, _pd.Series): + # DataFrame input - element-wise division with zero protection + return abs(total_returns) / abs(max_dd).replace(0, _np.nan) + else: + # Series input - scalar division + if max_dd == 0: + return _np.nan + return abs(total_returns) / abs(max_dd) + + +def risk_return_ratio(returns, prepare_returns=True): + """ + Calculate the risk-return ratio (mean return / standard deviation). + + This function calculates the Sharpe ratio without factoring in the + risk-free rate, providing a simple measure of return per unit of risk. + + Args: + returns (pd.Series): Return series to analyze + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Risk-return ratio -def risk_return_ratio(returns): + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> rrr = risk_return_ratio(returns) + >>> print(f"Risk-return ratio: {rrr:.4f}") """ - calculates the return / risk ratio - (sharpe ratio without factoring in the risk-free rate) + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Calculate mean return divided by standard deviation + std = returns.std() + + # Handle both Series (DataFrame input) and scalar (Series input) cases + if isinstance(std, _pd.Series): + # DataFrame input - element-wise division with zero protection + return returns.mean() / std.replace(0, _np.nan) + else: + # Series input - scalar division + if std == 0: + return _np.nan + return returns.mean() / std + + +def _get_baseline_value(prices): """ - returns = _utils._prepare_returns(returns) - return returns.mean() / returns.std() + Determine the appropriate baseline value for drawdown calculations. + This function analyzes the price series to determine the correct baseline + value that should represent "no drawdown" (i.e., the starting equity). -def max_drawdown(prices): - """ calculates the maximum drawdown """ - prices = _utils._prepare_prices(prices) - return (prices / prices.expanding(min_periods=0).max()).min() - 1 + Args: + prices (pd.Series): Price series + + Returns: + float: Baseline value for drawdown calculations + """ + if len(prices) == 0: + return 1.0 + + # Handle both Series and DataFrame cases + if isinstance(prices, _pd.DataFrame): + # If prices is a DataFrame, ensure it has at least one column + if prices.shape[1] == 0: + return 1.0 # Default baseline for empty DataFrame with no columns + # Get the first value of the first column + first_price = prices.iat[0, 0] + else: + # If prices is a Series, get the first value directly + first_price = prices.iloc[0] + + # If the first price is much larger than 1, it's likely from to_prices conversion + # The to_prices function uses base * (1 + compsum), so we determine the appropriate baseline + if first_price > 1000: + # This suggests it came from to_prices with a large base (default 1e5) + # However, we should use a more reasonable baseline for drawdown calculations + # We'll use the same scale as the prices but represent the "no loss" baseline + return 1e5 + elif first_price > 10: + # Smaller base value scale + return 100.0 + else: + # Normal price scale, use 1.0 as baseline + return 1.0 + + +def max_drawdown(prices: Returns) -> float: + """ + Calculate the maximum drawdown from peak to trough. + + This function calculates the maximum observed loss from a peak to a + subsequent trough, expressed as a percentage. It handles the edge case + where the first return is negative by establishing a proper baseline. + Args: + prices (pd.Series): Price series or cumulative returns + + Returns: + float: Maximum drawdown (negative value) + + Example: + >>> prices = pd.Series([100, 110, 105, 120, 115]) + >>> max_dd = max_drawdown(prices) + >>> print(f"Maximum drawdown: {max_dd:.4f}") + """ + validate_input(prices) -def to_drawdown_series(prices): - """ convert price series to drawdown series """ + # Prepare prices (convert from returns if needed) prices = _utils._prepare_prices(prices) - dd = prices / _np.maximum.accumulate(prices) - 1. - return dd.replace([_np.inf, -_np.inf, -0], 0) + if len(prices) == 0: + return 0.0 -def drawdown_details(drawdown): + # Handle edge case: if first value represents a loss from baseline + # Add a phantom baseline value to ensure proper drawdown calculation + try: + time_delta = prices.index.freq or _pd.Timedelta(days=1) + except Exception: + time_delta = _pd.Timedelta(days=1) + + phantom_date = prices.index[0] - time_delta + + # Determine appropriate baseline value + baseline_value = _get_baseline_value(prices) + + # Create extended series with phantom baseline + extended_prices = prices.copy() + extended_prices.loc[phantom_date] = baseline_value + extended_prices = extended_prices.sort_index() + + # Calculate drawdown with phantom baseline + return (extended_prices / extended_prices.expanding(min_periods=0).max()).min() - 1 + + +def to_drawdown_series(returns): """ - calculates drawdown details, including start/end/valley dates, - duration, max drawdown and max dd for 99% of the dd period - for every drawdown period + Convert returns series to drawdown series. + + This function converts a return series to a drawdown series showing + the decline from peak equity at each point in time. It handles the + edge case where the first return is negative by establishing a proper baseline. + + Args: + returns (pd.Series): Return series to convert + + Returns: + pd.Series: Drawdown series (negative values showing decline from peak) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> dd_series = to_drawdown_series(returns) + >>> print(dd_series) """ - def _drawdown_details(drawdown): - # mark no drawdown - no_dd = drawdown == 0 + validate_input(returns) - # extract dd start dates - starts = ~no_dd & no_dd.shift(1) - starts = list(starts[starts].index) + # Convert returns to prices + prices = _utils._prepare_prices(returns) - # extract end dates - ends = no_dd & (~no_dd).shift(1) - ends = list(ends[ends].index) + if len(prices) == 0: + return _pd.Series([], dtype=float, index=returns.index) - # no drawdown :) - if not starts: - return _pd.DataFrame( - index=[], columns=('start', 'valley', 'end', 'days', - 'max drawdown', '99% max drawdown')) + # Handle edge case: if first value represents a loss from baseline + # Add a phantom baseline value to ensure proper drawdown calculation + try: + time_delta = prices.index.freq or _pd.Timedelta(days=1) + except Exception: + time_delta = _pd.Timedelta(days=1) - # drawdown series begins in a drawdown - if ends and starts[0] > ends[0]: - starts.insert(0, drawdown.index[0]) + phantom_date = prices.index[0] - time_delta - # series ends in a drawdown fill with last date - if not ends or starts[-1] > ends[-1]: - ends.append(drawdown.index[-1]) + # Determine appropriate baseline value + baseline_value = _get_baseline_value(prices) - # build dataframe from results - data = [] - for i, _ in enumerate(starts): - dd = drawdown[starts[i]:ends[i]] - clean_dd = -remove_outliers(-dd, .99) - data.append((starts[i], dd.idxmin(), ends[i], - (ends[i] - starts[i]).days, - dd.min() * 100, clean_dd.min() * 100)) - - df = _pd.DataFrame(data=data, - columns=('start', 'valley', 'end', 'days', - 'max drawdown', - '99% max drawdown')) - df['days'] = df['days'].astype(int) - df['max drawdown'] = df['max drawdown'].astype(float) - df['99% max drawdown'] = df['99% max drawdown'].astype(float) - - df['start'] = df['start'].dt.strftime('%Y-%m-%d') - df['end'] = df['end'].dt.strftime('%Y-%m-%d') - df['valley'] = df['valley'].dt.strftime('%Y-%m-%d') + # Create extended series with phantom baseline + extended_prices = prices.copy() + extended_prices.loc[phantom_date] = baseline_value + extended_prices = extended_prices.sort_index() - return df + # Calculate drawdown series with phantom baseline + dd = extended_prices / _np.maximum.accumulate(extended_prices) - 1.0 - if isinstance(drawdown, _pd.DataFrame): - _dfs = {} - for col in drawdown.columns: - _dfs[col] = _drawdown_details(drawdown[col]) - return _pd.concat(_dfs, axis=1) + # Remove phantom point and return original time series + dd = dd.drop(phantom_date) - return _drawdown_details(drawdown) + # Clean up infinite and zero values + return dd.replace([_np.inf, -_np.inf, -0], 0) # type: ignore[attr-defined] -def kelly_criterion(returns): +def kelly_criterion(returns, prepare_returns=True): """ - calculates the recommended maximum amount of capital that + Calculates the recommended maximum amount of capital that should be allocated to the given strategy, based on the Kelly Criterion (http://en.wikipedia.org/wiki/Kelly_criterion) """ - returns = _utils._prepare_returns(returns) + if prepare_returns: + returns = _utils._prepare_returns(returns) win_loss_ratio = payoff_ratio(returns) win_prob = win_rate(returns) lose_prob = 1 - win_prob - return ((win_loss_ratio * win_prob) - lose_prob) / win_loss_ratio + # Handle both Series (DataFrame input) and scalar (Series input) cases + if isinstance(win_loss_ratio, _pd.Series): + # DataFrame input - element-wise operations with zero/nan protection + # Replace 0 and NaN values with NaN to avoid division issues + win_loss_ratio_safe = win_loss_ratio.replace(0, _np.nan) + return ((win_loss_ratio_safe * win_prob) - lose_prob) / win_loss_ratio_safe + else: + # Series input - scalar operations + if win_loss_ratio == 0 or _pd.isna(win_loss_ratio): + return _np.nan + return ((win_loss_ratio * win_prob) - lose_prob) / win_loss_ratio # ==== VS. BENCHMARK ==== -def r_squared(returns, benchmark): - """ measures the straight line fit of the equity curve """ - # slope, intercept, r_val, p_val, std_err = _linregress( + +def r_squared(returns, benchmark, prepare_returns=True): + """ + Calculate the R-squared (coefficient of determination) versus benchmark. + + R-squared measures how well the returns fit a straight line relationship + with the benchmark. Values closer to 1 indicate higher correlation with + the benchmark, while values closer to 0 indicate more independent movement. + + Args: + returns (pd.Series): Return series to analyze + benchmark (pd.Series): Benchmark return series for comparison + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: R-squared value (0-1 scale) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> benchmark = pd.Series([0.005, -0.01, 0.02, -0.005, 0.015]) + >>> r_sq = r_squared(returns, benchmark) + >>> print(f"R-squared: {r_sq:.4f}") + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Prepare benchmark to match returns index + benchmark = _utils._prepare_benchmark(benchmark, returns.index) + + # Perform linear regression and extract correlation coefficient _, _, r_val, _, _ = _linregress( - _utils._prepare_returns(returns), - _utils._prepare_benchmark(benchmark, returns.index)) + returns, _utils._prepare_benchmark(benchmark, returns.index) + ) + + # Square the correlation coefficient to get R-squared return r_val**2 def r2(returns, benchmark): - """ shorthand for r_squared() """ + """ + Calculate the R-squared (coefficient of determination) versus benchmark. + + This is a shorthand function for r_squared() with the same parameters + and functionality. + + Args: + returns (pd.Series): Return series to analyze + benchmark (pd.Series): Benchmark return series for comparison + + Returns: + float: R-squared value (0-1 scale) + """ return r_squared(returns, benchmark) -def information_ratio(returns, benchmark): +def information_ratio(returns, benchmark, prepare_returns=True): """ - calculates the information ratio - (basically the risk return ratio of the net profits) + Calculate the Information Ratio. + + The Information Ratio measures the risk-adjusted excess return of a + portfolio relative to a benchmark. It's calculated as the active return + (return - benchmark) divided by the tracking error (standard deviation + of active returns). + + Args: + returns (pd.Series): Return series to analyze + benchmark (pd.Series): Benchmark return series for comparison + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + float: Information Ratio + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> benchmark = pd.Series([0.005, -0.01, 0.02, -0.005, 0.015]) + >>> info_ratio = information_ratio(returns, benchmark) + >>> print(f"Information Ratio: {info_ratio:.4f}") """ - diff_rets = _utils._prepare_returns(returns) - \ - _utils._prepare_benchmark(benchmark, returns.index) + if prepare_returns: + returns = _utils._prepare_returns(returns) - return diff_rets.mean() / diff_rets.std() + # Prepare benchmark to match returns index + benchmark = _utils._prepare_benchmark(benchmark, returns.index) + # Calculate active returns (returns - benchmark) + diff_rets = returns - _utils._prepare_benchmark(benchmark, returns.index) -def greeks(returns, benchmark, periods=252.): - """ calculates alpha and beta of the portfolio """ + # Calculate tracking error (standard deviation of active returns) + std = diff_rets.std() - # ---------------------------- - # data cleanup - returns = _utils._prepare_returns(returns) + # Return Information Ratio (active return / tracking error) + if std != 0: + return diff_rets.mean() / diff_rets.std() + return 0 + + +def greeks(returns, benchmark, periods=252.0, prepare_returns=True): + """ + Calculate portfolio Greeks (alpha and beta) relative to benchmark. + + This function calculates the key portfolio metrics for benchmark comparison: + - Alpha: Excess return after adjusting for systematic risk (beta) + - Beta: Sensitivity to benchmark movements (systematic risk) + + Args: + returns (pd.Series): Return series to analyze + benchmark (pd.Series): Benchmark return series for comparison + periods (float): Periods per year for alpha annualization (default: 252.0) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + pd.Series: Series containing 'alpha' and 'beta' values + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> benchmark = pd.Series([0.005, -0.01, 0.02, -0.005, 0.015]) + >>> portfolio_greeks = greeks(returns, benchmark) + >>> print(f"Alpha: {portfolio_greeks['alpha']:.4f}") + >>> print(f"Beta: {portfolio_greeks['beta']:.4f}") + """ + # Data preparation + if prepare_returns: + returns = _utils._prepare_returns(returns) benchmark = _utils._prepare_benchmark(benchmark, returns.index) # ---------------------------- - # find covariance + # Calculate covariance matrix between returns and benchmark matrix = _np.cov(returns, benchmark) - beta = matrix[0, 1] / matrix[1, 1] - # calculates measures now + # Calculate beta (sensitivity to benchmark movements) + if matrix[1, 1] == 0: + beta = _np.nan + else: + beta = matrix[0, 1] / matrix[1, 1] + + # Calculate alpha (excess return after adjusting for beta) alpha = returns.mean() - beta * benchmark.mean() + + # Annualize alpha alpha = alpha * periods - return _pd.Series({ - "beta": beta, - "alpha": alpha, - # "vol": _np.sqrt(matrix[0, 0]) * _np.sqrt(periods) - }).fillna(0) + # Return results as Series + return _pd.Series( + { + "beta": beta, + "alpha": alpha, + # "vol": _np.sqrt(matrix[0, 0]) * _np.sqrt(periods) + } + ).fillna(0) + + +def rolling_greeks(returns, benchmark, periods=252, prepare_returns=True): + """ + Calculate rolling Greeks (alpha and beta) over time. + This function calculates time-varying alpha and beta using a rolling + window, showing how portfolio sensitivity to the benchmark changes + over time. Useful for analyzing strategy stability and regime changes. -def rolling_greeks(returns, benchmark, periods=252): - """ calculates rolling alpha and beta of the portfolio """ - df = _pd.DataFrame(data={ - "returns": _utils._prepare_returns(returns), - "benchmark": _utils._prepare_benchmark(benchmark, returns.index) - }) - corr = df.rolling(int(periods)).corr().unstack()['returns']['benchmark'] + Args: + returns (pd.Series): Return series to analyze + benchmark (pd.Series): Benchmark return series for comparison + periods (int): Rolling window size (default: 252, ~1 year) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + pd.DataFrame: DataFrame with 'alpha' and 'beta' columns over time + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> benchmark = pd.Series([0.005, -0.01, 0.02, -0.005, 0.015]) + >>> rolling_greeks_df = rolling_greeks(returns, benchmark, periods=3) + >>> print(rolling_greeks_df) + """ + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Create combined DataFrame for rolling calculations + df = _pd.DataFrame( + data={ + "returns": returns, + "benchmark": _utils._prepare_benchmark(benchmark, returns.index), + } + ) + + # Fill NaN values with 0 for calculation stability + df = df.fillna(0) + + # Calculate rolling correlation and standard deviations + corr = df.rolling(int(periods)).corr().unstack()["returns"]["benchmark"] std = df.rolling(int(periods)).std() - beta = corr * std['returns'] / std['benchmark'] - alpha = df['returns'].mean() - beta * df['benchmark'].mean() + # Calculate rolling beta (protect against division by zero) + beta = corr * std["returns"] / std["benchmark"].replace(0, _np.nan) - # alpha = alpha * periods - return _pd.DataFrame(index=returns.index, data={ - "beta": beta, - "alpha": alpha - }).fillna(0) + # Calculate rolling alpha (not annualized for rolling version) + alpha = df["returns"].mean() - beta * df["benchmark"].mean() + # Return DataFrame with rolling Greeks + return _pd.DataFrame(index=returns.index, data={"beta": beta, "alpha": alpha}) -def compare(returns, benchmark, aggregate=None, compounded=True, - round_vals=None): + +def compare( + returns, + benchmark, + aggregate=None, + compounded=True, + round_vals=None, + prepare_returns=True, +): """ - compare returns to benchmark on a - day/week/month/quarter/year basis + Compare returns to benchmark across different time periods. + + This function provides a comprehensive comparison of portfolio returns + versus benchmark performance across various aggregation periods + (daily, weekly, monthly, quarterly, yearly). + + Args: + returns (pd.Series or pd.DataFrame): Return series to analyze + benchmark (pd.Series): Benchmark return series for comparison + aggregate (str): Aggregation period ('D', 'W', 'M', 'Q', 'Y') + compounded (bool): Whether to compound returns (default: True) + round_vals (int): Number of decimal places to round (default: None) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + pd.DataFrame: Comparison DataFrame with columns: + - Benchmark: Benchmark returns for each period + - Returns: Portfolio returns for each period + - Multiplier: Portfolio return / Benchmark return + - Won: '+' if portfolio outperformed, '-' if underperformed + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> benchmark = pd.Series([0.005, -0.01, 0.02, -0.005, 0.015]) + >>> comparison = compare(returns, benchmark) + >>> print(comparison) """ - returns = _utils._prepare_returns(returns) + # Normalize timezone for returns to ensure consistent comparisons + # Convert to UTC if timezone-aware, then make naive + # This must happen before prepare_returns to avoid issues + if hasattr(returns.index, 'tz') and returns.index.tz is not None: + returns = returns.tz_convert('UTC').tz_localize(None) + + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Normalize benchmark timezone first if it's not a string + if benchmark is not None and not isinstance(benchmark, str): + if hasattr(benchmark.index if isinstance(benchmark, _pd.Series) else benchmark[benchmark.columns[0]].index, 'tz'): + if isinstance(benchmark, _pd.Series) and benchmark.index.tz is not None: + benchmark = benchmark.tz_convert('UTC').tz_localize(None) + elif isinstance(benchmark, _pd.DataFrame) and benchmark[benchmark.columns[0]].index.tz is not None: + for col in benchmark.columns: + benchmark[col] = benchmark[col].tz_convert('UTC').tz_localize(None) + + # Store original benchmark for proper aggregation + # This preserves returns that may fall on non-trading days + if isinstance(benchmark, str): + benchmark_original = _utils.download_returns(benchmark) + elif isinstance(benchmark, _pd.DataFrame): + benchmark_original = benchmark[benchmark.columns[0]].copy() + else: + benchmark_original = benchmark.copy() if benchmark is not None else None + + # Normalize timezone for benchmark_original as well (in case it was downloaded) + if benchmark_original is not None and hasattr(benchmark_original.index, 'tz') and benchmark_original.index.tz is not None: + benchmark_original = benchmark_original.tz_convert('UTC').tz_localize(None) + + # Prepare benchmark to match returns index for other calculations benchmark = _utils._prepare_benchmark(benchmark, returns.index) - data = _pd.DataFrame(data={ - 'Benchmark': _utils.aggregate_returns( - benchmark, aggregate, compounded) * 100, - 'Returns': _utils.aggregate_returns( - returns, aggregate, compounded) * 100 - }) + # Handle Series input + if isinstance(returns, _pd.Series): + # Aggregate returns and use original benchmark for aggregation + # This ensures we don't lose benchmark returns on non-trading days + if benchmark_original is not None: + benchmark_agg = _utils.aggregate_returns(benchmark_original, aggregate, compounded) * 100 + else: + benchmark_agg = _utils.aggregate_returns(benchmark, aggregate, compounded) * 100 + returns_agg = _utils.aggregate_returns(returns, aggregate, compounded) * 100 + + # Create comparison DataFrame + data = _pd.DataFrame( + data={ + "Benchmark": benchmark_agg, + "Returns": returns_agg, + } + ) + + # Calculate performance multiplier and win/loss indicator + # Protect against division by zero in benchmark + data["Multiplier"] = data["Returns"] / data["Benchmark"].replace(0, _np.nan) + data["Won"] = _np.where(data["Returns"] >= data["Benchmark"], "+", "-") + + # Handle DataFrame input (multiple strategies) + elif isinstance(returns, _pd.DataFrame): + # Aggregate benchmark using original data to preserve non-trading day returns + if benchmark_original is not None: + bench = { + "Benchmark": _utils.aggregate_returns(benchmark_original, aggregate, compounded) * 100 + } + else: + bench = { + "Benchmark": _utils.aggregate_returns(benchmark, aggregate, compounded) * 100 + } - data['Multiplier'] = data['Returns'] / data['Benchmark'] - data['Won'] = _np.where(data['Returns'] >= data['Benchmark'], '+', '-') + # Aggregate each strategy column + strategy = { + "Returns_" + str(i): _utils.aggregate_returns(returns[col], aggregate, compounded) * 100 + for i, col in enumerate(returns.columns) + } + # Combine into single DataFrame + data = _pd.DataFrame(data={**bench, **strategy}) + + # Apply rounding if specified if round_vals is not None: return _np.round(data, round_vals) return data -def monthly_returns(returns, eoy=True, compounded=True): - """ calculates monthly returns """ +def monthly_returns(returns, eoy=True, compounded=True, prepare_returns=True): + """ + Calculate monthly returns in a pivot table format. + + This function creates a matrix showing returns for each month across + different years, making it easy to identify seasonal patterns and + compare performance across time periods. + + Args: + returns (pd.Series or pd.DataFrame): Return series to analyze + eoy (bool): Whether to include end-of-year totals (default: True) + compounded (bool): Whether to compound returns (default: True) + prepare_returns (bool): Whether to prepare returns first (default: True) + + Returns: + pd.DataFrame: Monthly returns matrix with years as rows and months + as columns. If eoy=True, includes 'EOY' column with + annual returns. + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02], + ... index=pd.date_range('2023-01-01', periods=5, freq='M')) + >>> monthly_rets = monthly_returns(returns) + >>> print(monthly_rets) + """ + # Handle DataFrame input by selecting appropriate column if isinstance(returns, _pd.DataFrame): + warn( + "Pandas DataFrame was passed (Series expected). " + "Only first column will be used." + ) + returns = returns.copy() returns.columns = map(str.lower, returns.columns) - if len(returns.columns) > 1 and 'close' in returns.columns: - returns = returns['close'] + if len(returns.columns) > 1 and "close" in returns.columns: + returns = returns["close"] else: returns = returns[returns.columns[0]] - returns = _utils._prepare_returns(returns) + if prepare_returns: + returns = _utils._prepare_returns(returns) + + # Store original returns for end-of-year calculations original_returns = returns.copy() + # Group returns by month-year and aggregate returns = _pd.DataFrame( - _utils.group_returns(returns, - returns.index.strftime('%Y-%m-01'), - compounded)) + _utils.group_returns(returns, returns.index.strftime("%Y-%m-01"), compounded) + ) - returns.columns = ['Returns'] + # Set up DataFrame structure + returns.columns = ["Returns"] returns.index = _pd.to_datetime(returns.index) - # get returnsframe - returns['Year'] = returns.index.strftime('%Y') - returns['Month'] = returns.index.strftime('%b') + # Extract year and month for pivot table + returns["Year"] = returns.index.strftime("%Y") + returns["Month"] = returns.index.strftime("%b") - # make pivot table - returns = returns.pivot('Year', 'Month', 'Returns').fillna(0) + # Create pivot table with years as rows and months as columns + returns = returns.pivot(index="Year", columns="Month", values="Returns").fillna(0) - # handle missing months - for month in ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']: + # Ensure all months are present in the DataFrame + for month in [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]: if month not in returns.columns: returns.loc[:, month] = 0 - # order columns by month - returns = returns[['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']] + # Order columns by calendar month + returns = returns[ + [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ] + ] + # Add end-of-year totals if requested if eoy: - returns['eoy'] = _utils.group_returns( - original_returns, original_returns.index.year).values + returns["eoy"] = _utils.group_returns( + original_returns, original_returns.index.year, compounded=compounded # type: ignore + ).values - returns.columns = map(lambda x: str(x).upper(), returns.columns) + # Format column names to uppercase + returns.columns = map(lambda x: str(x).upper(), returns.columns) # type: ignore returns.index.name = None return returns + + +def drawdown_details(drawdown): + """ + Calculate detailed drawdown statistics for each drawdown period. + + This function analyzes a drawdown series to provide comprehensive statistics + for each individual drawdown period, including start/end dates, duration, + maximum drawdown, and 99th percentile drawdown. + + Args: + drawdown (pd.Series or pd.DataFrame): Drawdown series to analyze + + Returns: + pd.DataFrame: Detailed drawdown statistics with columns: + - start: Start date of drawdown period + - valley: Date of maximum drawdown + - end: End date of drawdown period + - days: Duration in days + - max drawdown: Maximum drawdown percentage + - 99% max drawdown: 99th percentile drawdown (excludes outliers) + + Example: + >>> returns = pd.Series([0.01, -0.02, 0.03, -0.01, 0.02]) + >>> dd_series = to_drawdown_series(returns) + >>> dd_details = drawdown_details(dd_series) + >>> print(dd_details) + """ + + def _drawdown_details(drawdown): + """ + Calculate drawdown details for a single drawdown series. + + This internal function processes a single drawdown series to extract + detailed statistics about each drawdown period. + """ + # Mark periods with no drawdown (drawdown = 0) + no_dd = drawdown == 0 + + # Extract drawdown start dates (first date of each drawdown period) + starts = ~no_dd & no_dd.shift(1) + starts = list(starts[starts.values].index) + + # Extract drawdown end dates (last date of each drawdown period) + ends = no_dd & (~no_dd).shift(1) + ends = ends.shift(-1, fill_value=False) + ends = list(ends[ends.values].index) + + # Return empty DataFrame if no drawdowns found + if not starts: + return _pd.DataFrame( + index=[], + columns=( + "start", + "valley", + "end", + "days", + "max drawdown", + "99% max drawdown", + ), + ) + + # Handle edge case: drawdown series begins in a drawdown + if ends and starts[0] > ends[0]: + starts.insert(0, drawdown.index[0]) + + # Handle edge case: series ends in a drawdown + if not ends or starts[-1] > ends[-1]: + ends.append(drawdown.index[-1]) + + # Build detailed statistics for each drawdown period + data = [] + for i, _ in enumerate(starts): + # Extract drawdown for this period + dd = drawdown[starts[i]:ends[i]] + + # Calculate 99% drawdown (excluding outliers) + clean_dd = -remove_outliers(-dd, 0.99) + + # Compile statistics for this drawdown period + data.append( + ( + starts[i], # Start date + dd.idxmin(), # Valley date (max drawdown) + ends[i], # End date + (ends[i] - starts[i]).days + 1, # Duration in days + dd.min() * 100, # Max drawdown % + clean_dd.min() * 100, # 99% max drawdown % + ) + ) + + # Create DataFrame with results + df = _pd.DataFrame( + data=data, + columns=( + "start", + "valley", + "end", + "days", + "max drawdown", + "99% max drawdown", + ), + ) + + # Format data types + df["days"] = df["days"].astype(int) + df["max drawdown"] = df["max drawdown"].astype(float) + df["99% max drawdown"] = df["99% max drawdown"].astype(float) + + # Format dates as strings + df["start"] = df["start"].dt.strftime("%Y-%m-%d") + df["end"] = df["end"].dt.strftime("%Y-%m-%d") + df["valley"] = df["valley"].dt.strftime("%Y-%m-%d") + + return df + + # Handle DataFrame input by processing each column separately + if isinstance(drawdown, _pd.DataFrame): + _dfs = {} + for col in drawdown.columns: + _dfs[col] = _drawdown_details(drawdown[col]) + return safe_concat(_dfs, axis=1) + + return _drawdown_details(drawdown) + + +# ======== MONTE CARLO ======== + + +def montecarlo(returns, sims=1000, bust=None, goal=None, seed=None): + """ + Run Monte Carlo simulation by shuffling returns. + + This function creates multiple simulated return paths by randomly + shuffling the historical returns. This preserves the return distribution + while breaking any time-series dependencies, allowing probability-based + risk assessment. + + Args: + returns (pd.Series or pd.DataFrame): Daily returns (not prices) + sims (int): Number of simulations to run (default: 1000) + bust (float, optional): Drawdown threshold for "bust" probability + (e.g., -0.1 for -10% drawdown) + goal (float, optional): Return threshold for "goal" probability + (e.g., 1.0 for +100% return) + seed (int, optional): Random seed for reproducibility + + Returns: + MonteCarloResult: Object containing simulation results with: + - .data: DataFrame of all simulation paths + - .stats: Terminal value statistics (min, max, mean, median, std) + - .maxdd: Max drawdown statistics across simulations + - .bust_probability: Probability of exceeding bust threshold + - .goal_probability: Probability of reaching goal threshold + - .plot(): Visualize simulation paths + + Example: + >>> import quantstats as qs + >>> returns = qs.utils.download_returns("SPY") + >>> mc = qs.stats.montecarlo(returns, sims=1000, bust=-0.2, goal=0.5) + >>> print(mc.stats) + >>> print(f"Bust probability: {mc.bust_probability:.1%}") + >>> print(f"Goal probability: {mc.goal_probability:.1%}") + >>> mc.plot() + """ + from ._montecarlo import run_montecarlo + + # Validate and prepare returns + validate_input(returns) + returns = _utils._prepare_returns(returns) + + # Handle DataFrame by processing first column (or extend for multi-column) + if isinstance(returns, _pd.DataFrame): + if returns.shape[1] == 1: + returns = returns.iloc[:, 0] + else: + # For multi-column DataFrame, run on first column + # Future: could return dict of MonteCarloResults + returns = returns.iloc[:, 0] + + return run_montecarlo(returns, sims=sims, bust=bust, goal=goal, seed=seed) + + +def montecarlo_sharpe(returns, sims=1000, rf=0, periods=252, seed=None): + """ + Distribution of Sharpe ratios across Monte Carlo simulations. + + This function runs Monte Carlo simulations and calculates the Sharpe + ratio for each simulated path, providing a distribution of possible + Sharpe ratio outcomes. + + Args: + returns (pd.Series): Daily returns + sims (int): Number of simulations (default: 1000) + rf (float): Risk-free rate (default: 0) + periods (int): Periods per year for annualization (default: 252) + seed (int, optional): Random seed for reproducibility + + Returns: + dict: Statistics of Sharpe ratio distribution including + min, max, mean, median, std, percentile_5, percentile_95 + + Example: + >>> sharpe_dist = qs.stats.montecarlo_sharpe(returns, sims=1000) + >>> print(f"Expected Sharpe: {sharpe_dist['mean']:.2f}") + >>> print(f"Sharpe range: {sharpe_dist['percentile_5']:.2f} to " + ... f"{sharpe_dist['percentile_95']:.2f}") + """ + from ._montecarlo import run_montecarlo + + validate_input(returns) + returns = _utils._prepare_returns(returns) + + if isinstance(returns, _pd.DataFrame): + returns = returns.iloc[:, 0] + + mc = run_montecarlo(returns, sims=sims, seed=seed) + + # Calculate Sharpe for each simulation path + sharpe_values = [] + for col in mc.data.columns: + # Convert cumulative returns back to simple returns + cumret = mc.data[col] + 1 + sim_returns = cumret.pct_change().dropna() + if len(sim_returns) > 0 and sim_returns.std() > 0: + excess = sim_returns.mean() - rf / periods + sharpe_val = excess / sim_returns.std() * _np.sqrt(periods) + sharpe_values.append(sharpe_val) + + sharpe_series = _pd.Series(sharpe_values) + return { + "min": sharpe_series.min(), + "max": sharpe_series.max(), + "mean": sharpe_series.mean(), + "median": sharpe_series.median(), + "std": sharpe_series.std(), + "percentile_5": sharpe_series.quantile(0.05), + "percentile_95": sharpe_series.quantile(0.95), + } + + +def montecarlo_drawdown(returns, sims=1000, seed=None): + """ + Distribution of maximum drawdowns across Monte Carlo simulations. + + This function runs Monte Carlo simulations and returns statistics + about the distribution of maximum drawdowns across all paths. + + Args: + returns (pd.Series): Daily returns + sims (int): Number of simulations (default: 1000) + seed (int, optional): Random seed for reproducibility + + Returns: + dict: Statistics of max drawdown distribution including + min, max, mean, median, std, percentile_5, percentile_95 + + Example: + >>> dd_dist = qs.stats.montecarlo_drawdown(returns, sims=1000) + >>> print(f"Expected max drawdown: {dd_dist['mean']:.1%}") + >>> print(f"Worst case (5th pct): {dd_dist['percentile_5']:.1%}") + """ + mc = montecarlo(returns, sims=sims, seed=seed) + return mc.maxdd + + +def montecarlo_cagr(returns, sims=1000, seed=None): + """ + Distribution of CAGR across Monte Carlo simulations. + + This function runs Monte Carlo simulations and calculates the + Compound Annual Growth Rate for each simulated path. + + Args: + returns (pd.Series): Daily returns + sims (int): Number of simulations (default: 1000) + seed (int, optional): Random seed for reproducibility + + Returns: + dict: Statistics of CAGR distribution including + min, max, mean, median, std, percentile_5, percentile_95 + + Example: + >>> cagr_dist = qs.stats.montecarlo_cagr(returns, sims=1000) + >>> print(f"Expected CAGR: {cagr_dist['mean']:.1%}") + """ + from ._montecarlo import run_montecarlo + + validate_input(returns) + returns = _utils._prepare_returns(returns) + + if isinstance(returns, _pd.DataFrame): + returns = returns.iloc[:, 0] + + mc = run_montecarlo(returns, sims=sims, seed=seed) + + # Calculate CAGR for each simulation path + n_periods = len(mc.data) + years = n_periods / 252 # Assume daily data + + cagr_values = [] + for col in mc.data.columns: + terminal = mc.data[col].iloc[-1] + # CAGR = (1 + total_return)^(1/years) - 1 + if terminal > -1: # Avoid invalid values + cagr_val = (1 + terminal) ** (1 / years) - 1 + cagr_values.append(cagr_val) + + cagr_series = _pd.Series(cagr_values) + return { + "min": cagr_series.min(), + "max": cagr_series.max(), + "mean": cagr_series.mean(), + "median": cagr_series.median(), + "std": cagr_series.std(), + "percentile_5": cagr_series.quantile(0.05), + "percentile_95": cagr_series.quantile(0.95), + } diff --git a/quantstats/utils.py b/quantstats/utils.py index e8f9bbfa..14c235da 100644 --- a/quantstats/utils.py +++ b/quantstats/utils.py @@ -1,10 +1,9 @@ #!/usr/bin/env python -# -*- coding: UTF-8 -*- # # QuantStats: Portfolio analytics for quants # https://github.com/ranaroussi/quantstats # -# Copyright 2019 Ran Aroussi +# Copyright 2019-2025 Ran Aroussi # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -21,88 +20,383 @@ import datetime as _dt import pandas as _pd import numpy as _np -import yfinance as _yf -from . import stats as _stats +from ._compat import safe_yfinance_download +from ._compat import safe_concat, safe_resample +import inspect +import threading + +# Type alias for return data +Returns = _pd.Series | _pd.DataFrame +"""Type alias for returns data: can be a pandas Series or DataFrame.""" + + +# Custom exception classes for QuantStats +class QuantStatsError(Exception): + """Base exception class for QuantStats""" + + pass + + +class DataValidationError(QuantStatsError): + """Raised when input data validation fails""" + + pass + + +class CalculationError(QuantStatsError): + """Raised when a calculation fails""" + + pass + + +class PlottingError(QuantStatsError): + """Raised when plotting operations fail""" + + pass + + +class BenchmarkError(QuantStatsError): + """Raised when benchmark data issues occur""" + + pass + + +def validate_input(data, allow_empty=False): + """ + Validate input data for QuantStats functions + + Parameters + ---------- + data : pd.Series or pd.DataFrame + Input data to validate + allow_empty : bool, default False + Whether to allow empty datasets + + Raises + ------ + DataValidationError + If data validation fails + """ + if data is None: + raise DataValidationError("Input data cannot be None") + + if not isinstance(data, (_pd.Series, _pd.DataFrame)): + raise DataValidationError( + f"Input data must be pandas Series or DataFrame, got {type(data)}" + ) + + if not allow_empty and len(data) == 0: + raise DataValidationError("Input data cannot be empty") + + if not allow_empty and data.dropna().empty: + raise DataValidationError("Input data contains only NaN values") + + # Check for valid date index + if not isinstance(data.index, (_pd.DatetimeIndex, _pd.RangeIndex)): + try: + data.index = _pd.to_datetime(data.index) + except Exception: + raise DataValidationError("Input data must have a valid datetime index") + + return True + + +# Cache for _prepare_returns function with thread safety +_PREPARE_RETURNS_CACHE = {} +_CACHE_MAX_SIZE = 100 +_cache_lock = threading.Lock() + + +def _generate_cache_key(data, rf, nperiods): + """ + Generate a cache key for the _prepare_returns function + + Parameters + ---------- + data : pd.Series or pd.DataFrame + Input data to generate hash from + rf : float + Risk-free rate parameter + nperiods : int + Number of periods parameter + + Returns + ------- + str or None + Cache key string or None if hashing fails + """ + try: + # Create a hash from the data + if isinstance(data, _pd.Series): + data_hash = _pd.util.hash_pandas_object(data).sum() + elif isinstance(data, _pd.DataFrame): + data_hash = _pd.util.hash_pandas_object(data).sum() + else: + data_hash = hash(str(data)) + + # Include parameters in the key + key = f"{data_hash}_{rf}_{nperiods}" + return key + except (ValueError, TypeError, AttributeError, MemoryError): + # If hashing fails, return None to skip caching + return None + + +def _clear_cache_if_full(): + """ + Clear cache if it exceeds maximum size + + Uses a simple FIFO strategy, keeping the most recent half of entries + when cache size limit is exceeded. + """ + with _cache_lock: + if len(_PREPARE_RETURNS_CACHE) >= _CACHE_MAX_SIZE: + # Remove oldest entries (simple FIFO) - keep the most recent half + keys_to_remove = list(_PREPARE_RETURNS_CACHE.keys())[:-(_CACHE_MAX_SIZE // 2)] + for key in keys_to_remove: + del _PREPARE_RETURNS_CACHE[key] def _mtd(df): - return df[df.index >= _dt.datetime.now( - ).strftime('%Y-%m-01')] + """ + Filter dataframe to month-to-date data + + Parameters + ---------- + df : pd.DataFrame or pd.Series + Input data with datetime index + + Returns + ------- + pd.DataFrame or pd.Series + Filtered data from start of current month + """ + # Get first day of current month as string + return df[df.index >= _dt.datetime.now().strftime("%Y-%m-01")] def _qtd(df): + """ + Filter dataframe to quarter-to-date data + + Parameters + ---------- + df : pd.DataFrame or pd.Series + Input data with datetime index + + Returns + ------- + pd.DataFrame or pd.Series + Filtered data from start of current quarter + """ date = _dt.datetime.now() - for q in [1, 4, 7, 10]: + # Check which quarter we're in (Q1: Jan-Mar, Q2: Apr-Jun, Q3: Jul-Sep, Q4: Oct-Dec) + for q in [1, 4, 7, 10]: # First month of each quarter if date.month <= q: - return df[df.index >= _dt.datetime( - date.year, q, 1).strftime('%Y-%m-01')] - return df[df.index >= date.strftime('%Y-%m-01')] + return df[df.index >= _dt.datetime(date.year, q, 1).strftime("%Y-%m-01")] + # Default to current month if no quarter match + return df[df.index >= date.strftime("%Y-%m-01")] def _ytd(df): - return df[df.index >= _dt.datetime.now( - ).strftime('%Y-01-01')] + """ + Filter dataframe to year-to-date data + + Parameters + ---------- + df : pd.DataFrame or pd.Series + Input data with datetime index + + Returns + ------- + pd.DataFrame or pd.Series + Filtered data from start of current year + """ + # Get first day of current year as string + return df[df.index >= _dt.datetime.now().strftime("%Y-01-01")] def _pandas_date(df, dates): + """ + Filter dataframe to specific dates + + Parameters + ---------- + df : pd.DataFrame or pd.Series + Input data with datetime index + dates : list or single date + Date(s) to filter by + + Returns + ------- + pd.DataFrame or pd.Series + Filtered data for specified dates + """ + # Ensure dates is a list for consistent processing if not isinstance(dates, list): dates = [dates] return df[df.index.isin(dates)] def _pandas_current_month(df): + """ + Filter dataframe to current month's data + + Parameters + ---------- + df : pd.DataFrame or pd.Series + Input data with datetime index + + Returns + ------- + pd.DataFrame or pd.Series + Filtered data for current month + """ n = _dt.datetime.now() + # Create date range from first day of current month to now daterange = _pd.date_range(_dt.date(n.year, n.month, 1), n) return df[df.index.isin(daterange)] def multi_shift(df, shift=3): - """ get last N rows relative to another row in pandas """ + """Get last N rows relative to another row in pandas - optimized for memory usage""" if isinstance(df, _pd.Series): df = _pd.DataFrame(df) - dfs = [df.shift(i) for i in _np.arange(shift)] - for ix, dfi in enumerate(dfs[1:]): - dfs[ix + 1].columns = [str(col) for col in dfi.columns + str(ix + 1)] - return _pd.concat(dfs, 1, sort=True) + # More memory-efficient approach using dictionary comprehension + # and direct column assignment + result = df.copy() + # Create lagged versions of the data + for i in range(1, shift): + shifted = df.shift(i) + # Rename columns to avoid conflicts + shifted.columns = [f"{col}{i}" for col in shifted.columns] + result = safe_concat([result, shifted], axis=1, sort=True) -def to_returns(prices, rf=0.): - """ Calculates the simple arithmetic returns of a price series """ + return result + + +def to_returns(prices: Returns, rf: float = 0.0) -> Returns: + """ + Calculate simple arithmetic returns from price series + + Parameters + ---------- + prices : pd.Series or pd.DataFrame + Price data + rf : float, default 0.0 + Risk-free rate + + Returns + ------- + pd.Series or pd.DataFrame + Simple arithmetic returns + """ return _prepare_returns(prices, rf) -def to_prices(returns, base=1e5): - """ Converts returns series to price data """ - returns = returns.copy().fillna(0).replace( - [_np.inf, -_np.inf], float('NaN')) +def to_prices(returns: Returns, base: float = 1e5) -> Returns: + """ + Convert returns series to price data + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Returns data + base : float, default 1e5 + Starting base value for price series + + Returns + ------- + pd.Series or pd.DataFrame + Price data calculated from returns + """ + from . import stats as _stats # deferred import to avoid circular dependency + + # Clean returns data by filling NaN and replacing infinite values + returns = returns.copy().fillna(0).replace([_np.inf, -_np.inf], float("NaN")) + # Convert returns to prices using compounded sum return base + base * _stats.compsum(returns) -def log_returns(returns, rf=0., nperiods=None): - """ shorthand for to_log_returns """ +def log_returns(returns: Returns, rf: float = 0.0, nperiods: int | None = None) -> Returns: + """ + Shorthand for to_log_returns function + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Returns data + rf : float, default 0.0 + Risk-free rate + nperiods : int, optional + Number of periods for risk-free rate conversion + + Returns + ------- + pd.Series or pd.DataFrame + Log returns + """ return to_log_returns(returns, rf, nperiods) -def to_log_returns(returns, rf=0., nperiods=None): - """ Converts returns series to log returns """ +def to_log_returns(returns: Returns, rf: float = 0.0, nperiods: int | None = None) -> Returns: + """ + Convert returns series to log returns + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Returns data + rf : float, default 0.0 + Risk-free rate + nperiods : int, optional + Number of periods for risk-free rate conversion + + Returns + ------- + pd.Series or pd.DataFrame + Log returns calculated as ln(1 + returns) + """ returns = _prepare_returns(returns, rf, nperiods) try: - return _np.log(returns+1).replace([_np.inf, -_np.inf], float('NaN')) - except Exception: - return 0. + # Calculate log returns: ln(1 + returns) + return _np.log(returns + 1).replace([_np.inf, -_np.inf], float("NaN")) # type: ignore + except (ValueError, TypeError, AttributeError, OverflowError) as e: + from warnings import warn + warn(f"Error converting to log returns: {type(e).__name__}: {e}, returning 0.0") + return 0.0 def exponential_stdev(returns, window=30, is_halflife=False): - """ Returns series representing exponential volatility of returns """ + """ + Calculate exponential weighted standard deviation (volatility) of returns + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Returns data + window : int, default 30 + Window size for exponential weighting + is_halflife : bool, default False + Whether window parameter represents halflife + + Returns + ------- + pd.Series or pd.DataFrame + Exponential weighted standard deviation + """ returns = _prepare_returns(returns) + # Set halflife parameter based on is_halflife flag halflife = window if is_halflife else None - return returns.ewm(com=None, span=window, - halflife=halflife, min_periods=window).std() + return returns.ewm( + com=None, span=window, halflife=halflife, min_periods=window + ).std() -def rebase(prices, base=100.): +def rebase(prices: Returns, base: float = 100.0) -> Returns: """ Rebase all series to a given intial base. This makes comparing/plotting different series together easier. @@ -110,57 +404,110 @@ def rebase(prices, base=100.): * prices: Expects a price series/dataframe * base (number): starting value for all series. """ + # Normalize prices to start at the base value return prices.dropna() / prices.dropna().iloc[0] * base -def group_returns(returns, groupby, compounded=False): - """ summarize returns +def group_returns(returns: Returns, groupby, compounded: bool = False) -> Returns: + """ + Summarize returns by grouping criteria + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Returns data + groupby : grouper object + Pandas groupby object or criteria + compounded : bool, default False + Whether to compound returns or use simple sum + + Returns + ------- + pd.Series or pd.DataFrame + Grouped returns + + Examples + -------- group_returns(df, df.index.year) group_returns(df, [df.index.year, df.index.month]) """ if compounded: + from . import stats as _stats # deferred import to avoid circular dependency + + # Use compounded returns calculation return returns.groupby(groupby).apply(_stats.comp) + # Use simple sum for non-compounded returns return returns.groupby(groupby).sum() -def aggregate_returns(returns, period=None, compounded=True): - """ Aggregates returns based on date periods """ - - if period is None or 'day' in period: +def aggregate_returns(returns: Returns, period: str | None = None, compounded: bool = True) -> Returns: + """ + Aggregate returns based on specified time periods + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Returns data + period : str, optional + Time period for aggregation ('month', 'quarter', 'year', etc.) + compounded : bool, default True + Whether to compound returns + + Returns + ------- + pd.Series or pd.DataFrame + Aggregated returns for specified period + """ + # Normalize timezone for consistency before aggregation + # Convert to UTC if timezone-aware, then make naive + if hasattr(returns.index, 'tz') and returns.index.tz is not None: + returns = returns.tz_convert('UTC').tz_localize(None) + + # Return original data if no period specified or daily period + if period is None or "day" in period: return returns + index = returns.index - if 'month' in period: + # Group by month + if "month" in period: return group_returns(returns, index.month, compounded=compounded) - if 'quarter' in period: + # Group by quarter + if "quarter" in period: return group_returns(returns, index.quarter, compounded=compounded) - if period == "A" or any(x in period for x in ['year', 'eoy', 'yoy']): + # Group by year (multiple possible period strings) + if period == "YE" or any(x in period for x in ["year", "eoy", "yoy"]): return group_returns(returns, index.year, compounded=compounded) - if 'week' in period: + # Group by week + if "week" in period: return group_returns(returns, index.week, compounded=compounded) - if 'eow' in period or period == "W": - return group_returns(returns, [index.year, index.week], - compounded=compounded) + # End of week grouping + if "eow" in period or period == "W": + return group_returns(returns, [index.year, index.week], compounded=compounded) - if 'eom' in period or period == "M": - return group_returns(returns, [index.year, index.month], - compounded=compounded) + # End of month grouping + if "eom" in period or period == "ME": + return group_returns(returns, [index.year, index.month], compounded=compounded) - if 'eoq' in period or period == "Q": - return group_returns(returns, [index.year, index.quarter], - compounded=compounded) + # End of quarter grouping + if "eoq" in period or period == "QE": + return group_returns( + returns, [index.year, index.quarter], compounded=compounded + ) + # Custom period grouping (non-string) if not isinstance(period, str): return group_returns(returns, period, compounded) + # Default: return original data return returns -def to_excess_returns(returns, rf, nperiods=None): +def to_excess_returns(returns: Returns, rf: float, nperiods: int | None = None) -> Returns: """ Calculates excess returns by subtracting risk-free returns from total returns @@ -173,70 +520,188 @@ def to_excess_returns(returns, rf, nperiods=None): Returns: * excess_returns (Series, DataFrame): Returns - rf """ + # Convert integer rf to float for consistency + if isinstance(rf, int): + rf = float(rf) + + # Align rf with returns index if rf is a series/dataframe if not isinstance(rf, float): - rf = rf[rf.index.isin(returns.index)] + rf = rf[rf.index.isin(returns.index)] # type: ignore + # Deannualize rf if nperiods is provided if nperiods is not None: # deannualize - rf = _np.power(1 + returns, 1. / nperiods) - 1. + rf = _np.power(1 + rf, 1.0 / nperiods) - 1.0 - return returns - rf + # Calculate excess returns + df = returns - rf + df = df.tz_localize(None) + return df -def _prepare_prices(data, base=1.): - """ Converts return data into prices + cleanup """ +def _prepare_prices(data, base=1.0): + """ + Convert return data into prices and perform cleanup + + Parameters + ---------- + data : pd.Series or pd.DataFrame + Input data (returns or prices) + base : float, default 1.0 + Base value for price conversion + + Returns + ------- + pd.Series or pd.DataFrame + Cleaned price data + """ data = data.copy() if isinstance(data, _pd.DataFrame): for col in data.columns: - if data[col].dropna().min() <= 0 or data[col].dropna().max() < 1: + # Cache dropna operation to avoid repeated computation + col_clean = data[col].dropna() + # Check if data looks like returns (negative values or values < 1) + if col_clean.min() <= 0 or col_clean.max() < 1: data[col] = to_prices(data[col], base) - # is it returns? + # Check if series looks like returns data # elif data.min() < 0 and data.max() < 1: elif data.min() < 0 or data.max() < 1: data = to_prices(data, base) + # Clean data by filling NaN and replacing infinite values if isinstance(data, (_pd.DataFrame, _pd.Series)): - data = data.fillna(0).replace( - [_np.inf, -_np.inf], float('NaN')) + data = data.fillna(0).replace([_np.inf, -_np.inf], float("NaN")) + # Normalize timezone information for consistency + # Convert to UTC if timezone-aware, then make naive + if hasattr(data.index, 'tz') and data.index.tz is not None: + data = data.tz_convert('UTC').tz_localize(None) return data -def _prepare_returns(data, rf=0., nperiods=None): - """ Converts price data into returns + cleanup """ +def _prepare_returns(data, rf=0.0, nperiods=None): + """ + Convert price data into returns and perform cleanup + + Parameters + ---------- + data : pd.Series or pd.DataFrame + Input data (prices or returns) + rf : float, default 0.0 + Risk-free rate + nperiods : int, optional + Number of periods for risk-free rate conversion + + Returns + ------- + pd.Series or pd.DataFrame + Cleaned returns data + """ + # Try to get from cache first + cache_key = _generate_cache_key(data, rf, nperiods) + if cache_key: + with _cache_lock: + if cache_key in _PREPARE_RETURNS_CACHE: + return _PREPARE_RETURNS_CACHE[cache_key].copy() + data = data.copy() + # Get calling function name for conditional processing + function = inspect.stack()[1][3] + # Process DataFrame columns if isinstance(data, _pd.DataFrame): for col in data.columns: - if data[col].dropna().min() >= 0 or data[col].dropna().max() > 1: - data[col] = data[col].pct_change() + # Cache dropna operation to avoid repeated computation + col_clean = data[col].dropna() + # Check if data looks like prices (positive values > 1) + if col_clean.min() >= 0 and col_clean.max() > 1: + data[col] = data[col].pct_change(fill_method=None) + # Process Series data elif data.min() >= 0 and data.max() > 1: - data = data.pct_change() + data = data.pct_change(fill_method=None) - # cleanup data - data = data.replace([_np.inf, -_np.inf], float('NaN')) + # cleanup data - replace infinite values with NaN + data = data.replace([_np.inf, -_np.inf], float("NaN")) + # Fill NaN values with 0 and replace infinite values if isinstance(data, (_pd.DataFrame, _pd.Series)): - data = data.fillna(0).replace( - [_np.inf, -_np.inf], float('NaN')) + data = data.fillna(0).replace([_np.inf, -_np.inf], float("NaN")) + + # Functions that don't need excess returns calculation + unnecessary_function_calls = [ + "_prepare_benchmark", + "cagr", + "gain_to_pain_ratio", + "rolling_volatility", + ] + + # Calculate excess returns if rf > 0 and function needs it + if function not in unnecessary_function_calls: + if rf > 0: + result = to_excess_returns(data, rf, nperiods) + # Cache the result + if cache_key: + _clear_cache_if_full() + with _cache_lock: + _PREPARE_RETURNS_CACHE[cache_key] = result.copy() + return result + + # Normalize timezone information for consistency + # Convert to UTC if timezone-aware, then make naive + if hasattr(data.index, 'tz') and data.index.tz is not None: + data = data.tz_convert('UTC').tz_localize(None) + + # Cache the result + if cache_key: + _clear_cache_if_full() + with _cache_lock: + _PREPARE_RETURNS_CACHE[cache_key] = data.copy() - if rf > 0: - return to_excess_returns(data, rf, nperiods) return data -def download_returns(ticker, period="max"): +def download_returns(ticker, period="max", proxy=None): + """ + Download returns data for a given ticker using yfinance + + Parameters + ---------- + ticker : str + Stock ticker symbol + period : str or pd.DatetimeIndex, default "max" + Time period for data download + proxy : str, optional + Proxy server for download + + Returns + ------- + pd.Series + Daily returns data for the ticker + """ + # Set up parameters for yfinance download + params = { + "tickers": ticker, + "auto_adjust": True, + "multi_level_index": False, + "progress": False, + } + + # Handle different period types if isinstance(period, _pd.DatetimeIndex): - p = {"start": period[0]} + params["start"] = period[0] else: - p = {"period": period} - return _yf.Ticker(ticker).history(**p)['Close'].pct_change() + params["period"] = period + + # Download data and calculate returns + df = safe_yfinance_download(proxy=proxy, **params)["Close"].pct_change(fill_method=None) # type: ignore + df = df.fillna(0).tz_localize(None) + return df -def _prepare_benchmark(benchmark=None, period="max", rf=0.): +def _prepare_benchmark(benchmark=None, period="max", rf=0.0, prepare_returns=True): """ - fetch benchmark if ticker is provided, and pass through + Fetch benchmark if ticker is provided, and pass through _prepare_returns() period can be options or (expected) _pd.DatetimeIndex range @@ -244,41 +709,99 @@ def _prepare_benchmark(benchmark=None, period="max", rf=0.): if benchmark is None: return None + # Download benchmark data if ticker string provided if isinstance(benchmark, str): benchmark = download_returns(benchmark) + # Extract first column if DataFrame provided elif isinstance(benchmark, _pd.DataFrame): benchmark = benchmark[benchmark.columns[0]].copy() - if isinstance(period, _pd.DatetimeIndex): + # Align benchmark with strategy period if needed + if isinstance(period, _pd.DatetimeIndex) and set(period) != set(benchmark.index): + + # Adjust Benchmark to Strategy frequency + benchmark_prices = to_prices(benchmark, base=1) + new_index = _pd.date_range(start=period[0], end=period[-1], freq="D") + benchmark = ( + benchmark_prices.reindex(new_index, method="bfill") + .reindex(period) + .pct_change(fill_method=None) + .fillna(0) + ) benchmark = benchmark[benchmark.index.isin(period)] - return _prepare_returns(benchmark.dropna(), rf=rf) + # Normalize timezone information for consistent comparisons + # Convert to UTC if timezone-aware, then make naive + if hasattr(benchmark.index, 'tz') and benchmark.index.tz is not None: + benchmark = benchmark.tz_convert('UTC').tz_localize(None) + # If already timezone-naive, no action needed + + # Prepare returns or return raw data + if prepare_returns: + return _prepare_returns(benchmark.dropna(), rf=rf) + return benchmark.dropna() def _round_to_closest(val, res, decimals=None): - """ round to closest resolution """ + """ + Round value to closest resolution + + Parameters + ---------- + val : float + Value to round + res : float + Resolution to round to + decimals : int, optional + Number of decimal places + + Returns + ------- + float + Rounded value + """ + # Auto-detect decimals from resolution if not provided if decimals is None and "." in str(res): - decimals = len(str(res).split('.')[1]) + decimals = len(str(res).split(".")[1]) return round(round(val / res) * res, decimals) def _file_stream(): - """ Returns a file stream """ + """ + Create and return a file stream object + + Returns + ------- + io.BytesIO + File stream object for handling bytes + """ return _io.BytesIO() def _in_notebook(matplotlib_inline=False): - """ Identify enviroment (notebook, terminal, etc) """ + """ + Identify current environment (notebook, terminal, etc.) + + Parameters + ---------- + matplotlib_inline : bool, default False + Whether to enable matplotlib inline mode + + Returns + ------- + bool + True if running in Jupyter notebook, False otherwise + """ try: - from IPython import get_ipython - shell = get_ipython().__class__.__name__ - if shell == 'ZMQInteractiveShell': + # Get IPython shell class name + shell = get_ipython().__class__.__name__ # type: ignore + if shell == "ZMQInteractiveShell": # Jupyter notebook or qtconsole if matplotlib_inline: - shell.magic("matplotlib inline") + get_ipython().run_line_magic("matplotlib", "inline") # type: ignore return True - if shell == 'TerminalInteractiveShell': + if shell == "TerminalInteractiveShell": # Terminal running IPython return False # Other type (?) @@ -289,11 +812,25 @@ def _in_notebook(matplotlib_inline=False): def _count_consecutive(data): - """ Counts consecutive data (like cumsum() with reset on zeroes) """ + """ + Count consecutive occurrences in data (like cumsum() with reset on zeroes) + + Parameters + ---------- + data : pd.Series or pd.DataFrame + Input data to count consecutive occurrences + + Returns + ------- + pd.Series or pd.DataFrame + Data with consecutive counts + """ + def _count(data): - return data * (data.groupby( - (data != data.shift(1)).cumsum()).cumcount() + 1) + # Group by consecutive values and count occurrences + return data * (data.groupby((data != data.shift(1)).cumsum()).cumcount() + 1) + # Handle DataFrame by processing each column if isinstance(data, _pd.DataFrame): for col in data.columns: data[col] = _count(data[col]) @@ -302,35 +839,134 @@ def _count(data): def _score_str(val): - """ Returns + sign for positive values (used in plots) """ + """ + Format value string with appropriate sign (used in plots) + + Parameters + ---------- + val : str or numeric + Value to format + + Returns + ------- + str + Formatted string with + or - sign + """ + # Add + sign for positive values, - is already included for negative return ("" if "-" in val else "+") + str(val) -def make_portfolio(returns, start_balance=1e5, - mode="comp", round_to=None): - """ Calculates compounded value of portfolio """ +def make_index( + ticker_weights, rebalance="1ME", period="max", returns=None, match_dates=False +): + """ + Makes an index out of the given tickers and weights. + Optionally you can pass a dataframe with the returns. + If returns is not given it try to download them with yfinance + + Args: + * ticker_weights (Dict): A python dict with tickers as keys + and weights as values + * rebalance: Pandas resample interval or None for never + * period: time period of the returns to be downloaded + * returns (Series, DataFrame): Optional. Returns If provided, + it will fist check if returns for the given ticker are in + this dataframe, if not it will try to download them with + yfinance + Returns: + * index_returns (Series, DataFrame): Returns for the index + """ + # Declare a returns variable + index = None + portfolio = {} + + # Iterate over weights and get returns for each ticker + for ticker in ticker_weights.keys(): + if (returns is None) or (ticker not in returns.columns): + # Download the returns for this ticker, e.g. GOOG + ticker_returns = download_returns(ticker, period) + else: + ticker_returns = returns[ticker] + + portfolio[ticker] = ticker_returns + + # Create index members time-series + index = _pd.DataFrame(portfolio).dropna() + + # Match dates to start from first non-zero date + if match_dates: + index = index[max(index.ne(0).idxmax()):] + + # Handle case with no rebalancing + if rebalance is None: + # Apply weights directly to returns + for ticker, weight in ticker_weights.items(): + index[ticker] = weight * index[ticker] + return index.sum(axis=1) + + last_day = index.index[-1] + + # Apply weights to each ticker's returns + # For a weighted portfolio, each day's portfolio return is sum of (weight * asset_return) + for ticker, weight in ticker_weights.items(): + index[ticker] = weight * index[ticker] + + # Calculate daily portfolio returns as sum of weighted asset returns + portfolio_returns = index.sum(axis=1) + + # Remove rows where all values are NaN + portfolio_returns = portfolio_returns.dropna() + return portfolio_returns[portfolio_returns.index <= last_day] + + +def make_portfolio(returns, start_balance=1e5, mode="comp", round_to=None): + """ + Calculate compounded value of portfolio from returns + + Parameters + ---------- + returns : pd.Series or pd.DataFrame + Returns data + start_balance : float, default 1e5 + Starting portfolio balance + mode : str, default "comp" + Calculation mode ("comp", "cumsum", "sum", or other) + round_to : int, optional + Number of decimal places to round to + + Returns + ------- + pd.Series or pd.DataFrame + Portfolio values over time + """ returns = _prepare_returns(returns) + # Calculate portfolio values based on mode if mode.lower() in ["cumsum", "sum"]: + # Simple cumulative sum approach p1 = start_balance + start_balance * returns.cumsum() elif mode.lower() in ["compsum", "comp"]: + # Compounded returns approach p1 = to_prices(returns, start_balance) else: - # fixed amount every day - comp_rev = (start_balance + start_balance * - returns.shift(1)).fillna(start_balance) * returns + # Fixed amount every day approach + comp_rev = (start_balance + start_balance * returns.shift(1)).fillna( + start_balance + ) * returns p1 = start_balance + comp_rev.cumsum() - # add day before with starting balance - p0 = _pd.Series(data=start_balance, - index=p1.index + _pd.Timedelta(days=-1))[:1] + # Add day before with starting balance + p0 = _pd.Series(data=start_balance, index=p1.index + _pd.Timedelta(days=-1))[:1] - portfolio = _pd.concat([p0, p1]) + # Combine starting balance with portfolio values + portfolio = safe_concat([p0, p1]) + # Handle DataFrame case if isinstance(returns, _pd.DataFrame): - portfolio.loc[:1, :] = start_balance + portfolio.iloc[:1, :] = start_balance portfolio.drop(columns=[0], inplace=True) + # Round if requested if round_to: portfolio = _np.round(portfolio, round_to) @@ -338,11 +974,27 @@ def make_portfolio(returns, start_balance=1e5, def _flatten_dataframe(df, set_index=None): - """ Dirty method for flattening multi-index dataframe """ + """ + Flatten multi-index dataframe using CSV conversion method + + Parameters + ---------- + df : pd.DataFrame + Multi-index dataframe to flatten + set_index : str, optional + Column to use as index after flattening + + Returns + ------- + pd.DataFrame + Flattened dataframe + """ + # Use string buffer to convert to CSV and back to flatten structure s_buf = _io.StringIO() df.to_csv(s_buf) s_buf.seek(0) + # Read back from CSV to get flattened structure df = _pd.read_csv(s_buf) if set_index is not None: df.set_index(set_index, inplace=True) diff --git a/quantstats/version.py b/quantstats/version.py new file mode 100644 index 00000000..968cfb8a --- /dev/null +++ b/quantstats/version.py @@ -0,0 +1 @@ +version = "0.0.81" diff --git a/requirements.txt b/requirements.txt index 25ab8f59..a5e60f4c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,10 @@ -pandas>=0.24.0 -numpy>=1.15.0 -seaborn>=0.9.0 -matplotlib>=3.0.0 -scipy>=1.2.0 -tabulate>=0.8.0 -yfinance>=0.1.44 - +# Core dependencies - see pyproject.toml for full list +# This file is kept for backwards compatibility with pip install -r +pandas>=2.0.0 +numpy>=1.24.0 +scipy>=1.11.0 +matplotlib>=3.7.0 +seaborn>=0.13.0 +tabulate>=0.9.0 +yfinance>=0.2.40 +python-dateutil>=2.8.0 diff --git a/setup.py b/setup.py deleted file mode 100644 index 7a0c5025..00000000 --- a/setup.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- - -"""QuantStats: Portfolio analytics for quants -https://github.com/ranaroussi/quantstats -QuantStats performs portfolio profiling, to allow quants and -portfolio managers to understand their performance better, -by providing them with in-depth analytics and risk metrics. -""" - -from setuptools import setup, find_packages -# from codecs import open -import io -from os import path - -here = path.abspath(path.dirname(__file__)) - -# Get the long description from the README file -with io.open(path.join(here, 'README.rst'), encoding='utf-8') as f: - long_description = f.read() - -setup( - name='QuantStats', - version='0.0.24', - description='Portfolio analytics for quants', - long_description=long_description, - url='https://github.com/ranaroussi/quantstats', - author='Ran Aroussi', - author_email='ran@aroussi.com', - license='Apache Software License', - classifiers=[ - 'License :: OSI Approved :: Apache Software License', - # 'Development Status :: 1 - Planning', - # 'Development Status :: 2 - Pre-Alpha', - # 'Development Status :: 3 - Alpha', - 'Development Status :: 4 - Beta', - # 'Development Status :: 5 - Production/Stable', - - 'Operating System :: OS Independent', - - 'Intended Audience :: Developers', - 'Intended Audience :: Financial and Insurance Industry', - 'Intended Audience :: Science/Research', - - 'Topic :: Office/Business :: Financial', - 'Topic :: Office/Business :: Financial :: Investment', - 'Topic :: Software Development :: Libraries', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Scientific/Engineering', - 'Topic :: Scientific/Engineering :: Information Analysis', - 'Topic :: Scientific/Engineering :: Mathematics', - - # 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - ], - - platforms=['any'], - keywords="""quant algotrading algorithmic-trading quantitative-trading - quantitative-analysis algo-trading visualization plotting""", - packages=find_packages(exclude=['contrib', 'docs', 'tests', 'examples']), - install_requires=['pandas>=0.24.0', 'numpy>=1.15.0', 'scipy>=1.2.0', - 'matplotlib>=3.0.0', 'seaborn>=0.9.0', - 'tabulate>=0.8.0', 'yfinance>=0.1.44'], - entry_points={ - 'console_scripts': [ - 'sample=sample:main', - ], - }, - - include_package_data=True, - # package_data={ - # 'static': 'quantstats/report.html*' - # }, -) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..aff3341e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# QuantStats test suite diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 00000000..7ec809b5 --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,133 @@ +""" +Tests for quantstats._compat module +""" + +import pytest +import pandas as pd +import numpy as np + +from quantstats._compat import ( + get_frequency_alias, + normalize_timezone, + safe_resample, + safe_concat, + safe_append, +) + + +@pytest.fixture +def sample_series(): + """Generate sample time series data.""" + dates = pd.date_range("2020-01-01", periods=100, freq="D") + return pd.Series(np.random.randn(100), index=dates) + + +@pytest.fixture +def sample_series_tz(): + """Generate sample time series with timezone.""" + dates = pd.date_range("2020-01-01", periods=100, freq="D", tz="US/Eastern") + return pd.Series(np.random.randn(100), index=dates) + + +class TestFrequencyAlias: + """Test frequency alias compatibility.""" + + def test_monthly_alias(self): + """Test monthly frequency alias.""" + result = get_frequency_alias("M") + assert result in ["M", "ME"] # Depends on pandas version + + def test_quarterly_alias(self): + """Test quarterly frequency alias.""" + result = get_frequency_alias("Q") + assert result in ["Q", "QE"] + + def test_yearly_alias(self): + """Test yearly frequency alias.""" + result = get_frequency_alias("Y") + assert result in ["Y", "YE"] + + def test_daily_unchanged(self): + """Test daily frequency unchanged.""" + result = get_frequency_alias("D") + assert result == "D" + + +class TestNormalizeTimezone: + """Test timezone normalization.""" + + def test_normalize_tz_aware(self, sample_series_tz): + """Test normalizing timezone-aware series.""" + result = normalize_timezone(sample_series_tz) + assert result.index.tz is None + + def test_normalize_tz_naive(self, sample_series): + """Test that tz-naive series passes through.""" + result = normalize_timezone(sample_series) + assert result.index.tz is None + pd.testing.assert_series_equal(result, sample_series) + + +class TestSafeResample: + """Test safe resample function.""" + + def test_resample_sum(self, sample_series): + """Test resample with sum aggregation.""" + result = safe_resample(sample_series, "M", "sum") + assert len(result) < len(sample_series) + + def test_resample_mean(self, sample_series): + """Test resample with mean aggregation.""" + result = safe_resample(sample_series, "M", "mean") + assert len(result) < len(sample_series) + + def test_resample_no_func(self, sample_series): + """Test resample without aggregation function.""" + result = safe_resample(sample_series, "M", None) + # Should return resampler object + assert hasattr(result, "sum") + + +class TestSafeConcat: + """Test safe concatenation.""" + + def test_concat_series(self, sample_series): + """Test concatenating series.""" + s1 = sample_series[:50] + s2 = sample_series[50:] + result = safe_concat([s1, s2]) + assert len(result) == len(sample_series) + + def test_concat_dataframes(self, sample_series): + """Test concatenating DataFrames.""" + df1 = pd.DataFrame({"A": sample_series[:50]}) + df2 = pd.DataFrame({"A": sample_series[50:]}) + result = safe_concat([df1, df2]) + assert len(result) == len(sample_series) + + def test_concat_axis1(self, sample_series): + """Test concatenating along axis 1.""" + s1 = sample_series.copy() + s1.name = "A" + s2 = sample_series.copy() + s2.name = "B" + result = safe_concat([s1, s2], axis=1) + assert result.shape[1] == 2 + + +class TestSafeAppend: + """Test safe append function.""" + + def test_append_dataframes(self, sample_series): + """Test appending DataFrames.""" + df1 = pd.DataFrame({"A": sample_series[:50]}) + df2 = pd.DataFrame({"A": sample_series[50:]}) + result = safe_append(df1, df2) + assert len(result) == len(sample_series) + + def test_append_ignore_index(self, sample_series): + """Test append with ignore_index.""" + df1 = pd.DataFrame({"A": [1, 2, 3]}) + df2 = pd.DataFrame({"A": [4, 5, 6]}) + result = safe_append(df1, df2, ignore_index=True) + assert list(result.index) == [0, 1, 2, 3, 4, 5] diff --git a/tests/test_extend_pandas.py b/tests/test_extend_pandas.py new file mode 100644 index 00000000..a0a12085 --- /dev/null +++ b/tests/test_extend_pandas.py @@ -0,0 +1,89 @@ +""" +Tests for quantstats.extend_pandas functionality +""" + +import pytest +import pandas as pd +import numpy as np + +import quantstats as qs + + +@pytest.fixture +def sample_returns(): + """Generate sample returns as a pandas Series.""" + np.random.seed(42) + dates = pd.date_range("2020-01-01", periods=252, freq="D") + returns = pd.Series(np.random.randn(252) * 0.02, index=dates, name="Strategy") + return returns + + +class TestExtendPandas: + """Test extend_pandas functionality.""" + + def test_extend_pandas_adds_methods(self, sample_returns): + """Test that extend_pandas adds methods to Series.""" + qs.extend_pandas() + + # Check that quantstats methods are now available on Series + assert hasattr(sample_returns, "sharpe") + assert hasattr(sample_returns, "sortino") + assert hasattr(sample_returns, "max_drawdown") + assert hasattr(sample_returns, "cagr") + + def test_sharpe_via_pandas(self, sample_returns): + """Test Sharpe ratio via pandas extension.""" + qs.extend_pandas() + result = sample_returns.sharpe() + assert np.isfinite(result) + + def test_sortino_via_pandas(self, sample_returns): + """Test Sortino ratio via pandas extension.""" + qs.extend_pandas() + result = sample_returns.sortino() + assert np.isfinite(result) + + def test_max_drawdown_via_pandas(self, sample_returns): + """Test max drawdown via pandas extension.""" + qs.extend_pandas() + result = sample_returns.max_drawdown() + assert result <= 0 + + def test_cagr_via_pandas(self, sample_returns): + """Test CAGR via pandas extension.""" + qs.extend_pandas() + result = sample_returns.cagr() + assert np.isfinite(result) + + def test_volatility_via_pandas(self, sample_returns): + """Test volatility via pandas extension.""" + qs.extend_pandas() + result = sample_returns.volatility() + assert result > 0 + + def test_calmar_via_pandas(self, sample_returns): + """Test Calmar ratio via pandas extension.""" + qs.extend_pandas() + result = sample_returns.calmar() + assert np.isfinite(result) + + +class TestExtendPandasWithParams: + """Test extend_pandas with parameters.""" + + def test_sharpe_with_rf(self, sample_returns): + """Test Sharpe with risk-free rate via pandas.""" + qs.extend_pandas() + result_no_rf = sample_returns.sharpe(rf=0) + result_with_rf = sample_returns.sharpe(rf=0.02) + # Should be different + assert result_no_rf != result_with_rf + + def test_cagr_compounded(self, sample_returns): + """Test CAGR with compounded option via pandas.""" + qs.extend_pandas() + result_comp = sample_returns.cagr(compounded=True) + result_simple = sample_returns.cagr(compounded=False) + # May or may not be different depending on returns + assert np.isfinite(result_comp) + assert np.isfinite(result_simple) diff --git a/tests/test_montecarlo.py b/tests/test_montecarlo.py new file mode 100644 index 00000000..24a6147a --- /dev/null +++ b/tests/test_montecarlo.py @@ -0,0 +1,197 @@ +""" +Tests for quantstats Monte Carlo simulation functionality +""" + +import pytest +import pandas as pd +import numpy as np + +import quantstats as qs +from quantstats import stats +from quantstats._montecarlo import MonteCarloResult, run_montecarlo + + +@pytest.fixture +def sample_returns(): + """Generate sample daily returns for testing.""" + np.random.seed(42) + dates = pd.date_range("2020-01-01", periods=252, freq="D") + returns = pd.Series(np.random.randn(252) * 0.02, index=dates, name="Strategy") + return returns + + +class TestMonteCarloResult: + """Test MonteCarloResult dataclass.""" + + def test_stats_property(self, sample_returns): + """Test stats property returns correct keys.""" + mc = run_montecarlo(sample_returns, sims=100, seed=42) + stats_dict = mc.stats + + assert "min" in stats_dict + assert "max" in stats_dict + assert "mean" in stats_dict + assert "median" in stats_dict + assert "std" in stats_dict + assert "percentile_5" in stats_dict + assert "percentile_95" in stats_dict + + def test_maxdd_property(self, sample_returns): + """Test maxdd property returns correct keys.""" + mc = run_montecarlo(sample_returns, sims=100, seed=42) + maxdd_dict = mc.maxdd + + assert "min" in maxdd_dict + assert "max" in maxdd_dict + assert "mean" in maxdd_dict + assert "median" in maxdd_dict + # Max drawdown should be negative + assert maxdd_dict["min"] < 0 + assert maxdd_dict["max"] <= 0 + + def test_bust_probability(self, sample_returns): + """Test bust probability calculation.""" + mc = run_montecarlo(sample_returns, sims=100, bust=-0.05, seed=42) + + assert mc.bust_probability is not None + assert 0 <= mc.bust_probability <= 1 + + def test_bust_probability_none_without_threshold(self, sample_returns): + """Test bust probability is None when no threshold set.""" + mc = run_montecarlo(sample_returns, sims=100, seed=42) + assert mc.bust_probability is None + + def test_goal_probability(self, sample_returns): + """Test goal probability calculation.""" + mc = run_montecarlo(sample_returns, sims=100, goal=0.1, seed=42) + + assert mc.goal_probability is not None + assert 0 <= mc.goal_probability <= 1 + + def test_goal_probability_none_without_threshold(self, sample_returns): + """Test goal probability is None when no threshold set.""" + mc = run_montecarlo(sample_returns, sims=100, seed=42) + assert mc.goal_probability is None + + def test_percentile(self, sample_returns): + """Test percentile method.""" + mc = run_montecarlo(sample_returns, sims=100, seed=42) + + p50 = mc.percentile(50) + assert isinstance(p50, pd.Series) + assert len(p50) == len(sample_returns) + + def test_confidence_band(self, sample_returns): + """Test confidence band method.""" + mc = run_montecarlo(sample_returns, sims=100, seed=42) + + lower, upper = mc.confidence_band(0.95) + assert isinstance(lower, pd.Series) + assert isinstance(upper, pd.Series) + # Lower should be less than upper at all points + assert (lower <= upper).all() + + +class TestRunMontecarlo: + """Test run_montecarlo function.""" + + def test_basic_simulation(self, sample_returns): + """Test basic Monte Carlo simulation.""" + mc = run_montecarlo(sample_returns, sims=100, seed=42) + + assert isinstance(mc, MonteCarloResult) + assert mc.data.shape[1] == 100 # 100 simulations + assert len(mc.original) == len(sample_returns) + + def test_reproducibility_with_seed(self, sample_returns): + """Test that seed produces reproducible results.""" + mc1 = run_montecarlo(sample_returns, sims=50, seed=123) + mc2 = run_montecarlo(sample_returns, sims=50, seed=123) + + pd.testing.assert_frame_equal(mc1.data, mc2.data) + + def test_different_seeds_produce_different_results(self, sample_returns): + """Test that different seeds produce different results.""" + mc1 = run_montecarlo(sample_returns, sims=50, seed=123) + mc2 = run_montecarlo(sample_returns, sims=50, seed=456) + + # Results should be different + assert not mc1.data.equals(mc2.data) + + def test_first_column_is_original(self, sample_returns): + """Test that first simulation column matches original.""" + mc = run_montecarlo(sample_returns, sims=100, seed=42) + + # First column should be the original (unshuffled) returns compounded + pd.testing.assert_series_equal( + mc.data.iloc[:, 0], mc.original, check_names=False + ) + + def test_handles_nan_values(self): + """Test that NaN values are handled properly.""" + returns = pd.Series([0.01, np.nan, -0.02, 0.03, np.nan, 0.01]) + mc = run_montecarlo(returns, sims=50, seed=42) + + # Should have 4 periods (NaN dropped) + assert mc.data.shape[0] == 4 + + +class TestStatsMonteCarlo: + """Test Monte Carlo functions exposed via stats module.""" + + def test_montecarlo_function(self, sample_returns): + """Test stats.montecarlo function.""" + mc = stats.montecarlo(sample_returns, sims=100, seed=42) + + assert isinstance(mc, MonteCarloResult) + assert mc.data.shape[1] == 100 + + def test_montecarlo_with_bust_and_goal(self, sample_returns): + """Test stats.montecarlo with bust and goal thresholds.""" + mc = stats.montecarlo( + sample_returns, sims=100, bust=-0.1, goal=0.5, seed=42 + ) + + assert mc.bust_threshold == -0.1 + assert mc.goal_threshold == 0.5 + assert mc.bust_probability is not None + assert mc.goal_probability is not None + + +class TestMonteCarloEdgeCases: + """Test edge cases for Monte Carlo simulations.""" + + def test_single_simulation(self, sample_returns): + """Test with single simulation.""" + mc = run_montecarlo(sample_returns, sims=1, seed=42) + + assert mc.data.shape[1] == 1 + + def test_large_number_of_simulations(self, sample_returns): + """Test with large number of simulations.""" + mc = run_montecarlo(sample_returns, sims=1000, seed=42) + + assert mc.data.shape[1] == 1000 + + def test_short_return_series(self): + """Test with short return series.""" + returns = pd.Series([0.01, -0.02, 0.03]) + mc = run_montecarlo(returns, sims=50, seed=42) + + assert mc.data.shape[0] == 3 + + def test_all_positive_returns(self): + """Test with all positive returns.""" + returns = pd.Series([0.01, 0.02, 0.01, 0.03, 0.02]) + mc = run_montecarlo(returns, sims=50, seed=42) + + # Terminal values should all be positive + assert (mc.data.iloc[-1] > 0).all() + + def test_all_negative_returns(self): + """Test with all negative returns.""" + returns = pd.Series([-0.01, -0.02, -0.01, -0.03, -0.02]) + mc = run_montecarlo(returns, sims=50, seed=42) + + # Terminal values should all be negative + assert (mc.data.iloc[-1] < 0).all() diff --git a/tests/test_plots.py b/tests/test_plots.py new file mode 100644 index 00000000..8fa5b509 --- /dev/null +++ b/tests/test_plots.py @@ -0,0 +1,157 @@ +""" +Tests for quantstats.plots module +""" + +import pytest +import pandas as pd +import numpy as np +import tempfile +import os + +import quantstats as qs +from quantstats import plots + + +@pytest.fixture +def sample_returns(): + """Generate sample daily returns for testing.""" + np.random.seed(42) + dates = pd.date_range("2020-01-01", periods=500, freq="D") + returns = pd.Series(np.random.randn(500) * 0.02, index=dates, name="Strategy") + return returns + + +@pytest.fixture +def sample_benchmark(): + """Generate sample benchmark returns for testing.""" + np.random.seed(123) + dates = pd.date_range("2020-01-01", periods=500, freq="D") + returns = pd.Series(np.random.randn(500) * 0.015, index=dates, name="Benchmark") + return returns + + +class TestPlotFunctions: + """Test that plot functions run without errors.""" + + def test_snapshot(self, sample_returns): + """Test snapshot plot generates without error.""" + # snapshot should work with show=False + fig = plots.snapshot(sample_returns, show=False) + assert fig is not None + + def test_returns_plot(self, sample_returns, sample_benchmark): + """Test returns plot.""" + fig = plots.returns(sample_returns, sample_benchmark, show=False) + assert fig is not None + + def test_log_returns_plot(self, sample_returns): + """Test log returns plot.""" + fig = plots.log_returns(sample_returns, show=False) + assert fig is not None + + def test_yearly_returns(self, sample_returns): + """Test yearly returns bar chart.""" + fig = plots.yearly_returns(sample_returns, show=False) + assert fig is not None + + def test_histogram(self, sample_returns, sample_benchmark): + """Test histogram plot.""" + fig = plots.histogram(sample_returns, sample_benchmark, show=False) + assert fig is not None + + def test_daily_returns(self, sample_returns, sample_benchmark): + """Test daily returns scatter plot.""" + fig = plots.daily_returns(sample_returns, sample_benchmark, show=False) + assert fig is not None + + def test_drawdown(self, sample_returns): + """Test drawdown plot.""" + fig = plots.drawdown(sample_returns, show=False) + assert fig is not None + + def test_drawdowns_periods(self, sample_returns): + """Test drawdown periods plot.""" + fig = plots.drawdowns_periods(sample_returns, show=False) + assert fig is not None + + def test_rolling_volatility(self, sample_returns): + """Test rolling volatility plot.""" + fig = plots.rolling_volatility(sample_returns, show=False) + assert fig is not None + + def test_rolling_sharpe(self, sample_returns): + """Test rolling Sharpe plot.""" + fig = plots.rolling_sharpe(sample_returns, show=False) + assert fig is not None + + def test_rolling_sortino(self, sample_returns): + """Test rolling Sortino plot.""" + fig = plots.rolling_sortino(sample_returns, show=False) + assert fig is not None + + def test_rolling_beta(self, sample_returns, sample_benchmark): + """Test rolling beta plot.""" + fig = plots.rolling_beta(sample_returns, sample_benchmark, show=False) + assert fig is not None + + def test_monthly_heatmap(self, sample_returns): + """Test monthly heatmap.""" + fig = plots.monthly_heatmap(sample_returns, show=False) + assert fig is not None + + def test_distribution(self, sample_returns): + """Test distribution box plot.""" + fig = plots.distribution(sample_returns, show=False) + assert fig is not None + + +class TestPlotSaving: + """Test plot saving functionality.""" + + def test_save_to_file(self, sample_returns): + """Test saving plot to file.""" + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + output_path = f.name + + try: + plots.returns(sample_returns, savefig=output_path, show=False) + assert os.path.exists(output_path) + assert os.path.getsize(output_path) > 0 + finally: + if os.path.exists(output_path): + os.remove(output_path) + + def test_save_with_dict_params(self, sample_returns): + """Test saving plot with dict parameters.""" + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + output_path = f.name + + try: + plots.returns( + sample_returns, + savefig={"fname": output_path, "dpi": 100}, + show=False, + ) + assert os.path.exists(output_path) + finally: + if os.path.exists(output_path): + os.remove(output_path) + + +class TestPlotOptions: + """Test plot configuration options.""" + + def test_grayscale_mode(self, sample_returns): + """Test grayscale plotting mode.""" + fig = plots.returns(sample_returns, grayscale=True, show=False) + assert fig is not None + + def test_custom_figsize(self, sample_returns): + """Test custom figure size.""" + fig = plots.returns(sample_returns, figsize=(12, 8), show=False) + assert fig is not None + + def test_no_subtitle(self, sample_returns): + """Test plot without subtitle.""" + fig = plots.returns(sample_returns, subtitle=False, show=False) + assert fig is not None diff --git a/tests/test_reports.py b/tests/test_reports.py new file mode 100644 index 00000000..e03bcc97 --- /dev/null +++ b/tests/test_reports.py @@ -0,0 +1,256 @@ +""" +Tests for quantstats.reports module +""" + +import pytest +import pandas as pd +import numpy as np +import tempfile +import os + +import quantstats as qs +from quantstats import reports + + +@pytest.fixture +def sample_returns(): + """Generate sample daily returns for testing.""" + np.random.seed(42) + dates = pd.date_range("2020-01-01", periods=500, freq="D") + returns = pd.Series(np.random.randn(500) * 0.02, index=dates, name="Strategy") + return returns + + +@pytest.fixture +def sample_benchmark(): + """Generate sample benchmark returns for testing.""" + np.random.seed(123) + dates = pd.date_range("2020-01-01", periods=500, freq="D") + returns = pd.Series(np.random.randn(500) * 0.015, index=dates, name="Benchmark") + return returns + + +class TestHTMLReport: + """Test HTML report generation.""" + + def test_html_generates_file(self, sample_returns, sample_benchmark): + """Test that HTML report generates a file.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: + output_path = f.name + + try: + reports.html(sample_returns, sample_benchmark, output=output_path) + assert os.path.exists(output_path) + # Check file has content + with open(output_path, "r") as f: + content = f.read() + assert len(content) > 1000 # Should have substantial content + assert "" in content + finally: + if os.path.exists(output_path): + os.remove(output_path) + + def test_html_contains_title(self, sample_returns): + """Test that HTML report contains proper title.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: + output_path = f.name + + try: + reports.html(sample_returns, output=output_path, title="My Strategy") + with open(output_path, "r") as f: + content = f.read() + assert "My Strategy" in content + assert "(Compounded)" in content + finally: + if os.path.exists(output_path): + os.remove(output_path) + + def test_html_simple_returns_title(self, sample_returns): + """Test title does NOT show (Compounded) when compounded=False.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: + output_path = f.name + + try: + reports.html(sample_returns, output=output_path, compounded=False) + with open(output_path, "r") as f: + content = f.read() + assert "(Compounded)" not in content + finally: + if os.path.exists(output_path): + os.remove(output_path) + + def test_html_contains_benchmark_param(self, sample_returns, sample_benchmark): + """Test that HTML report shows benchmark in parameters.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: + output_path = f.name + + try: + reports.html(sample_returns, sample_benchmark, output=output_path) + with open(output_path, "r") as f: + content = f.read() + assert "Benchmark:" in content + assert "Periods/Year: 252" in content + assert "RF:" in content + finally: + if os.path.exists(output_path): + os.remove(output_path) + + def test_html_matched_dates_indicator(self, sample_returns, sample_benchmark): + """Test matched dates indicator appears when appropriate.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: + output_path = f.name + + try: + reports.html( + sample_returns, sample_benchmark, output=output_path, match_dates=True + ) + with open(output_path, "r") as f: + content = f.read() + assert "(matched dates)" in content + finally: + if os.path.exists(output_path): + os.remove(output_path) + + def test_html_no_dark_mode(self, sample_returns): + """Test that dark mode CSS is not present.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: + output_path = f.name + + try: + reports.html(sample_returns, output=output_path) + with open(output_path, "r") as f: + content = f.read() + assert "prefers-color-scheme:dark" not in content + assert "color-scheme" not in content + finally: + if os.path.exists(output_path): + os.remove(output_path) + + def test_html_custom_rf(self, sample_returns): + """Test custom risk-free rate in parameters.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: + output_path = f.name + + try: + reports.html(sample_returns, output=output_path, rf=0.05) + with open(output_path, "r") as f: + content = f.read() + assert "RF: 5.0%" in content + finally: + if os.path.exists(output_path): + os.remove(output_path) + + +class TestMetrics: + """Test metrics function.""" + + def test_metrics_basic_mode(self, sample_returns): + """Test metrics in basic mode.""" + result = reports.metrics(sample_returns, display=False, mode="basic") + assert isinstance(result, pd.DataFrame) + assert len(result) > 0 + + def test_metrics_full_mode(self, sample_returns): + """Test metrics in full mode.""" + result = reports.metrics(sample_returns, display=False, mode="full") + assert isinstance(result, pd.DataFrame) + # Full mode should have more metrics than basic + basic_result = reports.metrics(sample_returns, display=False, mode="basic") + assert len(result) >= len(basic_result) + + def test_metrics_with_benchmark(self, sample_returns, sample_benchmark): + """Test metrics with benchmark comparison.""" + result = reports.metrics( + sample_returns, benchmark=sample_benchmark, display=False + ) + assert isinstance(result, pd.DataFrame) + # Should have at least two columns (strategy + benchmark) + assert result.shape[1] >= 2 + + def test_metrics_with_rf(self, sample_returns): + """Test metrics with non-zero risk-free rate.""" + result_no_rf = reports.metrics(sample_returns, rf=0, display=False) + result_with_rf = reports.metrics(sample_returns, rf=0.02, display=False) + # Results should be different + assert not result_no_rf.equals(result_with_rf) + + +class TestMatchDates: + """Test date matching functionality.""" + + def test_match_dates_aligns_series(self): + """Test that _match_dates aligns returns and benchmark.""" + # Create returns starting later than benchmark + dates1 = pd.date_range("2020-01-10", periods=100, freq="D") + dates2 = pd.date_range("2020-01-01", periods=110, freq="D") + + returns = pd.Series(np.random.randn(100) * 0.01, index=dates1) + benchmark = pd.Series(np.random.randn(110) * 0.01, index=dates2) + + # Set first values to non-zero + returns.iloc[0] = 0.01 + benchmark.iloc[0] = 0.01 + + aligned_ret, aligned_bench = reports._match_dates(returns, benchmark) + + # Both should now start from the same date + assert aligned_ret.index[0] == aligned_bench.index[0] + + +class TestParametersTable: + """Test parameters table printing.""" + + def test_print_parameters_table_with_benchmark(self, capsys): + """Test parameters table output with benchmark.""" + reports._print_parameters_table( + benchmark_title="SPY", + periods_per_year=252, + rf=0.02, + compounded=True, + match_dates=True, + ) + captured = capsys.readouterr() + assert "SPY" in captured.out + assert "252" in captured.out + assert "2.0%" in captured.out + assert "Yes" in captured.out + + def test_print_parameters_table_no_benchmark(self, capsys): + """Test parameters table output without benchmark.""" + reports._print_parameters_table( + benchmark_title=None, + periods_per_year=252, + rf=0.0, + compounded=False, + match_dates=True, + ) + captured = capsys.readouterr() + assert "Benchmark" not in captured.out + assert "252" in captured.out + assert "No" in captured.out # Compounded = No + + +class TestEdgeCases: + """Test edge cases in reports.""" + + def test_html_with_dataframe(self, sample_returns, sample_benchmark): + """Test HTML generation with DataFrame input.""" + df = pd.DataFrame({"Strategy": sample_returns}) + with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: + output_path = f.name + + try: + reports.html(df, sample_benchmark, output=output_path) + assert os.path.exists(output_path) + finally: + if os.path.exists(output_path): + os.remove(output_path) + + def test_metrics_empty_benchmark_title(self, sample_returns, sample_benchmark): + """Test metrics when benchmark has no name.""" + benchmark_no_name = sample_benchmark.copy() + benchmark_no_name.name = None + result = reports.metrics( + sample_returns, benchmark=benchmark_no_name, display=False + ) + assert isinstance(result, pd.DataFrame) diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 00000000..5a22f857 --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,273 @@ +""" +Tests for quantstats.stats module +""" + +import pytest +import pandas as pd +import numpy as np +from datetime import datetime + +import quantstats as qs +from quantstats import stats + + +@pytest.fixture +def sample_returns(): + """Generate sample daily returns for testing.""" + np.random.seed(42) + dates = pd.date_range("2020-01-01", periods=500, freq="D") + returns = pd.Series(np.random.randn(500) * 0.02, index=dates, name="Strategy") + return returns + + +@pytest.fixture +def sample_benchmark(): + """Generate sample benchmark returns for testing.""" + np.random.seed(123) + dates = pd.date_range("2020-01-01", periods=500, freq="D") + returns = pd.Series(np.random.randn(500) * 0.015, index=dates, name="Benchmark") + return returns + + +@pytest.fixture +def positive_returns(): + """Generate strictly positive returns for testing edge cases.""" + dates = pd.date_range("2020-01-01", periods=100, freq="D") + returns = pd.Series(np.abs(np.random.randn(100) * 0.01) + 0.001, index=dates) + return returns + + +@pytest.fixture +def negative_returns(): + """Generate strictly negative returns for testing edge cases.""" + dates = pd.date_range("2020-01-01", periods=100, freq="D") + returns = pd.Series(-np.abs(np.random.randn(100) * 0.01) - 0.001, index=dates) + return returns + + +class TestBasicStats: + """Test basic statistical functions.""" + + def test_comp(self, sample_returns): + """Test compound returns calculation.""" + result = stats.comp(sample_returns) + assert isinstance(result, float) + # Compound return should be finite + assert np.isfinite(result) + + def test_compsum(self, sample_returns): + """Test cumulative compound returns.""" + result = stats.compsum(sample_returns) + assert isinstance(result, pd.Series) + assert len(result) == len(sample_returns) + # Final value should match comp() + np.testing.assert_almost_equal(result.iloc[-1], stats.comp(sample_returns), decimal=10) + + def test_exposure(self, sample_returns): + """Test exposure calculation.""" + result = stats.exposure(sample_returns) + assert 0 <= result <= 1 + + def test_win_rate(self, sample_returns): + """Test win rate calculation.""" + result = stats.win_rate(sample_returns) + assert 0 <= result <= 1 + + def test_win_rate_all_positive(self, positive_returns): + """Test win rate with all positive returns.""" + result = stats.win_rate(positive_returns) + assert result == 1.0 + + def test_win_rate_all_negative(self, negative_returns): + """Test win rate with all negative returns.""" + result = stats.win_rate(negative_returns) + assert result == 0.0 + + +class TestRiskMetrics: + """Test risk-related metrics.""" + + def test_volatility(self, sample_returns): + """Test volatility calculation.""" + result = stats.volatility(sample_returns) + assert result > 0 + assert np.isfinite(result) + + def test_volatility_annualized(self, sample_returns): + """Test annualized volatility.""" + daily_vol = stats.volatility(sample_returns, annualize=False) + annual_vol = stats.volatility(sample_returns, annualize=True) + # Annualized should be approximately sqrt(252) times daily + expected_ratio = np.sqrt(252) + actual_ratio = annual_vol / daily_vol + np.testing.assert_almost_equal(actual_ratio, expected_ratio, decimal=5) + + def test_max_drawdown(self, sample_returns): + """Test maximum drawdown calculation.""" + result = stats.max_drawdown(sample_returns) + assert result <= 0 # Drawdown is always negative or zero + assert result >= -1 # Cannot lose more than 100% + + def test_var(self, sample_returns): + """Test Value at Risk calculation.""" + result = stats.var(sample_returns) + assert result < 0 # VaR is typically negative (loss) + + def test_cvar(self, sample_returns): + """Test Conditional Value at Risk (Expected Shortfall).""" + var = stats.var(sample_returns) + cvar = stats.cvar(sample_returns) + # CVaR should be more extreme than VaR + assert cvar <= var + + +class TestRatios: + """Test risk-adjusted return ratios.""" + + def test_sharpe(self, sample_returns): + """Test Sharpe ratio calculation.""" + result = stats.sharpe(sample_returns) + assert np.isfinite(result) + + def test_sharpe_with_rf(self, sample_returns): + """Test Sharpe ratio with risk-free rate.""" + result_no_rf = stats.sharpe(sample_returns, rf=0) + result_with_rf = stats.sharpe(sample_returns, rf=0.02) + # Handle both scalar and Series results + if isinstance(result_no_rf, pd.Series): + result_no_rf = result_no_rf.iloc[0] + result_with_rf = result_with_rf.iloc[0] + # Higher rf should lower Sharpe ratio + assert result_with_rf < result_no_rf + + def test_sortino(self, sample_returns): + """Test Sortino ratio calculation.""" + result = stats.sortino(sample_returns) + # Handle both scalar and Series results + if isinstance(result, pd.Series): + result = result.iloc[0] + assert np.isfinite(result) + + def test_sortino_vs_sharpe(self, sample_returns): + """Test that Sortino uses downside deviation.""" + sharpe = stats.sharpe(sample_returns) + sortino = stats.sortino(sample_returns) + # Handle both scalar and Series results + if hasattr(sharpe, 'values'): + sharpe = float(sharpe.values[0]) if len(sharpe) > 0 else float(sharpe) + if hasattr(sortino, 'values'): + sortino = float(sortino.values[0]) if len(sortino) > 0 else float(sortino) + # They should be different (Sortino only penalizes downside) + assert sharpe != sortino + + def test_calmar(self, sample_returns): + """Test Calmar ratio calculation.""" + result = stats.calmar(sample_returns) + assert np.isfinite(result) + + def test_omega(self, sample_returns): + """Test Omega ratio calculation.""" + result = stats.omega(sample_returns) + assert result > 0 # Omega is always positive + + def test_cagr(self, sample_returns): + """Test CAGR calculation.""" + result = stats.cagr(sample_returns) + assert np.isfinite(result) + + +class TestBenchmarkComparison: + """Test benchmark comparison functions.""" + + def test_greeks(self, sample_returns, sample_benchmark): + """Test alpha/beta calculation.""" + result = stats.greeks(sample_returns, sample_benchmark) + assert "alpha" in result + assert "beta" in result + assert np.isfinite(result["alpha"]) + assert np.isfinite(result["beta"]) + + def test_r_squared(self, sample_returns, sample_benchmark): + """Test R-squared calculation.""" + result = stats.r_squared(sample_returns, sample_benchmark) + assert 0 <= result <= 1 + + def test_information_ratio(self, sample_returns, sample_benchmark): + """Test Information Ratio calculation.""" + result = stats.information_ratio(sample_returns, sample_benchmark) + assert np.isfinite(result) + + def test_treynor_ratio(self, sample_returns, sample_benchmark): + """Test Treynor Ratio calculation.""" + result = stats.treynor_ratio(sample_returns, sample_benchmark) + assert np.isfinite(result) + + +class TestDrawdown: + """Test drawdown-related functions.""" + + def test_to_drawdown_series(self, sample_returns): + """Test drawdown series conversion.""" + result = stats.to_drawdown_series(sample_returns) + assert isinstance(result, pd.Series) + assert len(result) == len(sample_returns) + # All values should be <= 0 (drawdowns are negative) + assert (result <= 0).all() + + def test_drawdown_details(self, sample_returns): + """Test drawdown details extraction.""" + dd = stats.to_drawdown_series(sample_returns) + result = stats.drawdown_details(dd) + assert isinstance(result, pd.DataFrame) + # Should have standard columns + assert "start" in result.columns + assert "end" in result.columns + assert "max drawdown" in result.columns + + +class TestConsecutive: + """Test consecutive wins/losses functions.""" + + def test_consecutive_wins(self, sample_returns): + """Test consecutive wins calculation.""" + result = stats.consecutive_wins(sample_returns) + assert isinstance(result, (int, np.integer)) + assert result >= 0 + + def test_consecutive_losses(self, sample_returns): + """Test consecutive losses calculation.""" + result = stats.consecutive_losses(sample_returns) + assert isinstance(result, (int, np.integer)) + assert result >= 0 + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_empty_returns(self): + """Test handling of empty returns.""" + empty = pd.Series([], dtype=float) + with pytest.raises(Exception): + stats.sharpe(empty) + + def test_single_return(self): + """Test handling of single return value.""" + single = pd.Series([0.01], index=pd.date_range("2020-01-01", periods=1)) + # Should not raise error + result = stats.comp(single) + assert np.isfinite(result) + + def test_nan_handling(self, sample_returns): + """Test that NaN values are handled properly.""" + returns_with_nan = sample_returns.copy() + returns_with_nan.iloc[10:20] = np.nan + # Should still compute without error + result = stats.sharpe(returns_with_nan) + assert np.isfinite(result) + + def test_dataframe_input(self, sample_returns, sample_benchmark): + """Test DataFrame input handling.""" + df = pd.DataFrame({"A": sample_returns, "B": sample_benchmark}) + result = stats.sharpe(df) + assert isinstance(result, pd.Series) + assert len(result) == 2 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..e42d0e72 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,183 @@ +""" +Tests for quantstats.utils module +""" + +import pytest +import pandas as pd +import numpy as np + +import quantstats as qs +from quantstats import utils + + +@pytest.fixture +def sample_prices(): + """Generate sample price data.""" + np.random.seed(42) + dates = pd.date_range("2020-01-01", periods=100, freq="D") + prices = pd.Series(100 * np.cumprod(1 + np.random.randn(100) * 0.02), index=dates) + return prices + + +@pytest.fixture +def sample_returns(): + """Generate sample return data.""" + np.random.seed(42) + dates = pd.date_range("2020-01-01", periods=100, freq="D") + returns = pd.Series(np.random.randn(100) * 0.02, index=dates) + return returns + + +class TestPrepareReturns: + """Test _prepare_returns function.""" + + def test_converts_prices_to_returns(self, sample_prices): + """Test that prices are converted to returns.""" + result = utils._prepare_returns(sample_prices) + # First value should be NaN or 0 (pct_change result) + assert len(result) == len(sample_prices) + # Returns should be small values (not prices) + assert result.dropna().abs().max() < 1 + + def test_passes_through_returns(self, sample_returns): + """Test that returns pass through unchanged in magnitude.""" + result = utils._prepare_returns(sample_returns) + # Should still be returns (small values) + assert result.abs().max() < 1 + + def test_handles_dataframe(self, sample_prices): + """Test DataFrame handling.""" + df = pd.DataFrame({"A": sample_prices, "B": sample_prices * 1.1}) + result = utils._prepare_returns(df) + assert isinstance(result, pd.DataFrame) + assert len(result.columns) == 2 + + +class TestToReturns: + """Test to_returns function.""" + + def test_to_returns_from_prices(self, sample_prices): + """Test conversion from prices to returns.""" + result = utils.to_returns(sample_prices) + assert isinstance(result, pd.Series) + # Should be small values (returns) + assert result.dropna().abs().max() < 1 + + +class TestToPrices: + """Test to_prices function.""" + + def test_to_prices_from_returns(self, sample_returns): + """Test conversion from returns to prices.""" + result = utils.to_prices(sample_returns, base=100) + assert isinstance(result, pd.Series) + # First value should be close to base + assert abs(result.iloc[0] - 100) < 10 + + +class TestLogReturns: + """Test log returns conversion.""" + + def test_log_returns(self, sample_returns): + """Test log returns calculation.""" + result = utils.log_returns(sample_returns) + assert isinstance(result, pd.Series) + # Log returns should be close to simple returns for small values + np.testing.assert_array_almost_equal( + result.dropna().values, sample_returns.dropna().values, decimal=2 + ) + + +class TestGroupReturns: + """Test group_returns function.""" + + def test_group_by_year(self, sample_returns): + """Test grouping returns by year.""" + # Create returns spanning multiple years + dates = pd.date_range("2019-01-01", periods=400, freq="D") + returns = pd.Series(np.random.randn(400) * 0.01, index=dates) + result = utils.group_returns(returns, returns.index.year) + assert len(result) >= 2 # Should have at least 2 years + + +class TestAggregateReturns: + """Test aggregate_returns function.""" + + def test_aggregate_monthly(self, sample_returns): + """Test monthly aggregation.""" + # Need returns spanning multiple months + dates = pd.date_range("2020-01-01", periods=100, freq="D") + returns = pd.Series(np.random.randn(100) * 0.01, index=dates) + result = utils.aggregate_returns(returns, "month") + assert len(result) <= len(returns) + + def test_aggregate_yearly(self, sample_returns): + """Test yearly aggregation.""" + dates = pd.date_range("2019-01-01", periods=400, freq="D") + returns = pd.Series(np.random.randn(400) * 0.01, index=dates) + result = utils.aggregate_returns(returns, "year") + assert len(result) <= len(returns) + + +class TestRebase: + """Test rebase function.""" + + def test_rebase_to_100(self, sample_prices): + """Test rebasing prices to 100.""" + result = utils.rebase(sample_prices, base=100) + assert abs(result.iloc[0] - 100) < 0.01 + + +class TestMakePortfolio: + """Test make_portfolio function.""" + + def test_make_portfolio_comp(self, sample_returns): + """Test portfolio creation with compounding.""" + result = utils.make_portfolio(sample_returns, start_balance=10000, mode="comp") + assert isinstance(result, pd.Series) + # Should start near the initial balance + assert abs(result.iloc[0] - 10000) < 1000 + + +class TestValidation: + """Test input validation.""" + + def test_validate_series(self, sample_returns): + """Test validation accepts valid Series.""" + assert utils.validate_input(sample_returns) is True + + def test_validate_dataframe(self, sample_returns): + """Test validation accepts valid DataFrame.""" + df = pd.DataFrame({"A": sample_returns}) + assert utils.validate_input(df) is True + + def test_validate_none_raises(self): + """Test validation raises for None input.""" + with pytest.raises(utils.DataValidationError): + utils.validate_input(None) + + def test_validate_empty_raises(self): + """Test validation raises for empty input.""" + with pytest.raises(utils.DataValidationError): + utils.validate_input(pd.Series([], dtype=float)) + + +class TestInNotebook: + """Test notebook detection.""" + + def test_in_notebook_returns_bool(self): + """Test that _in_notebook returns a boolean.""" + result = utils._in_notebook() + assert isinstance(result, bool) + # In pytest, should return False + assert result is False + + +class TestFileStream: + """Test file stream creation.""" + + def test_file_stream_creation(self): + """Test _file_stream returns BytesIO object.""" + result = utils._file_stream() + assert hasattr(result, "read") + assert hasattr(result, "write")