Metadata-Version: 2.4
Name: fin-eda
Version: 2.1.0
Summary: Comprehensive financial Exploratory Data Analysis for tickers, price series, and portfolios
Author-email: JR Concepcion <jr1concepcion@gmail.com>
License-Expression: MIT
Keywords: finance,eda,stocks,risk,portfolio,yfinance
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Office/Business :: Financial
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pandas>=1.3.0
Requires-Dist: numpy>=1.21.0
Requires-Dist: scipy>=1.7.0
Requires-Dist: yfinance>=0.2.0
Requires-Dist: rich>=12.0.0
Provides-Extra: plot
Requires-Dist: matplotlib>=3.5.0; extra == "plot"
Provides-Extra: pdf
Requires-Dist: fpdf2>=2.7.0; extra == "pdf"
Dynamic: license-file

# fin-eda

Comprehensive financial Exploratory Data Analysis for any stock ticker, price series, or portfolio of tickers.
Produces a numerical tearsheet (`eda`) and a visual tearsheet (`eda_plot`) covering returns, risk, drawdowns, benchmark comparison, volatility, liquidity, and more - all in one call.

## Installation

```bash
pip install fin-eda
```

## Quick Start - Numerical (`eda`)

```python
from fin_eda import eda

# Fetch data automatically via yfinance
eda("AAPL")

# Custom date range
eda("MSFT", start="2020-01-01", end="2024-01-01")

# Custom period
eda("TSLA", period="5y")

# Different benchmark
eda("QQQ", benchmark_ticker="SPY")

# Include a risk-free rate
eda("AAPL", risk_free_rate=0.05)

# Pass your own price series
import pandas as pd
prices = pd.Series(...)
eda(prices, benchmark_ticker="SPY")

# Return results as a dict (no print)
results = eda("AAPL", return_results=True, quiet=True)

# Portfolio of tickers - equal-weight by default
eda(["AAPL", "MSFT"])                                    # 50% / 50%

# Portfolio with custom weights (must sum to 1)
eda(["AAPL", "MSFT", "GOOGL"], weights=[.4, .3, .3])

# Export the report to PDF - requires pip install fin-eda[pdf]
eda("AAPL", save_path="aapl_report.pdf")

# PDF only, no terminal output
eda("AAPL", quiet=True, save_path="aapl_report.pdf")
```

## Quick Start - Visual (`eda_plot`)

Requires `pip install fin-eda[plot]`.

```python
from fin_eda import eda_plot

# Full tearsheet
eda_plot("AAPL")

# Select specific panels
eda_plot("AAPL", panels=["price", "drawdown", "distribution", "heatmap"])

# Save to file (PNG, PDF, SVG - format inferred from extension)
eda_plot("MSFT", save_path="msft_tearsheet.png")
eda_plot("MSFT", save_path="msft_tearsheet.pdf")

# Return the Figure for notebook embedding or further customization
fig = eda_plot("TSLA", period="5y", return_fig=True)

# Custom benchmark and risk-free rate
eda_plot("QQQ", benchmark_ticker="^GSPC", risk_free_rate=0.05)

# Portfolio of tickers, same weighting rules as eda()
eda_plot(["AAPL", "MSFT", "GOOGL"], weights=[.4, .3, .3])
```

### Available panels

| Name | Description |
|---|---|
| `price` | Price history with 50D/200D moving averages and volume |
| `cumulative` | Cumulative return vs benchmark with shaded outperformance gap |
| `drawdown` | Underwater drawdown chart with top-3 trough annotations |
| `heatmap` | Monthly returns heatmap (year x month grid) |
| `annual_returns` | Year-by-year return bars (green/red) with benchmark overlay |
| `distribution` | Daily return histogram with normal overlay and VaR lines |
| `rolling_beta` | Rolling 1Y (252D) beta vs benchmark, shaded above/below beta = 1 |
| `rolling_sharpe` | Rolling 252D and 126D annualized Sharpe ratio |

Benchmark-dependent panels (`cumulative`, `rolling_beta`) are automatically
skipped if no benchmark data is available.

## Portfolio Support

Both `eda()` and `eda_plot()` accept a list of tickers instead of a single ticker or price
Series, combining them into a portfolio:

```python
eda(["AAPL", "MSFT"])                                     # equal weight: 50% / 50%
eda(["AAPL", "MSFT", "GOOGL"], weights=[.4, .3, .3])       # custom weights
```

- **Weighting.** `weights` defaults to equal weight (`1/n`) when omitted. When given, it must
  have one entry per ticker (matched by position) and sum to 1.0, or a `ValueError` is raised.
  **Weights must all be non-negative** - long/short portfolios (negative weights) aren't
  supported yet; a leveraged short leg can push the portfolio's net value to zero or negative,
  which breaks percentage-return math throughout the report. Support for this is planned for a
  future update.
- **Buy-and-hold, not rebalanced.** Weights are applied once - converted into an implied share
  count at the first date common to every ticker's history - and each holding's dollar value
  then drifts independently with its own price path. Actual portfolio weights drift away from
  the values passed in as constituents over- or under-perform each other over time; there is no
  daily rebalancing.
- **Date alignment.** Tickers are combined over the trading dates common to *all* of them
  (intersection), not a union with forward-filled gaps - the same alignment strategy `eda()`
  already uses for benchmark comparison. If a constituent's own history falls short of the
  requested `period` (e.g. a young IPO under a long lookback), a `Note:` line states so before
  the report - see Data Coverage Notices below.
- **Price is rebased.** The combined portfolio price series starts at 100 (an arbitrary base
  value). This only affects absolute price/level readouts - every percentage-based metric
  (returns, CAGR, drawdown, volatility, Sharpe, ...) is unaffected by the rebasing. Because of
  this, `eda()`'s header panel drops the absolute-level fields entirely for a portfolio (price,
  50D/100D/200D moving average levels, the 50D-200D spread, 52-week high/low) - they'd otherwise
  show synthetic index points, not real prices. The percentage-based header fields (price vs
  200D MA, trend persistence, drawdown from 52W high) stay, since those are unaffected by the
  rebasing.
- **No OHLCV.** There's no single meaningful High/Low/Volume for a weighted basket of tickers,
  so Parkinson volatility, Liquidity metrics, and the `price` panel's volume bars are
  unavailable for a portfolio - same as when a raw price Series is passed instead of a ticker.
- **Everything else works unchanged**, including benchmark comparison (beta, correlation,
  capture ratios, Treynor, Jensen's alpha, `cumulative`/`rolling_beta` panels) against the
  portfolio's combined return series.

## Output

`eda()` renders in the terminal using [Rich](https://github.com/Textualize/rich) with
color-coded values (green = positive/good, yellow = neutral, red = negative/risk).

The header panel shows:

- Ticker or series label, current price, and the benchmark symbol actually used
- Data coverage: number of trading days and date range
- 50D/100D/200D moving average levels and the 50D-200D spread
- Price distance from the 200D MA and trend persistence (% days above 200D MA)
- 52W price range (low - high), days since the 52W high, and drawdown from the 52W high

For a portfolio, the absolute-level fields (price, moving averages, spread, 52W range) are
omitted - see Portfolio Support above.

Below the header, one table is printed per section. Period columns (e.g. 1M, 1Y, 5Y, 10Y)
are shown only when the available data is sufficient to support them - longer columns appear
automatically as data coverage grows, up to 30Y. Sections built on statistical estimators
(Risk-Adjusted Performance, Relative Performance, Beta/Correlation, Capture Ratios, Return
Distribution, Tail Risk) don't offer columns shorter than 6M, since a handful of daily
observations makes those figures mostly noise; Core Return & Risk and Drawdown still start
at 1M/3M, since those are observed rather than fitted statistics.

Every table also carries a column reporting on the full fetched history, labeled with the
actual window that was requested (e.g. `period='19y'` displays as `19Y`) rather than a generic
`All Time` - falls back to the literal `All Time` label only when that window isn't known (a
raw price `Series` or an explicit `start`/`end` range). If that label would exactly duplicate
an already-showing named tenor (e.g. `period='5y'` against a ticker with 5+ years of real
history), the duplicate is dropped and only the precisely-defined named tenor is kept, so a
header never appears twice with two different numbers under it.

`YTD`'s column position moves with the calendar instead of sitting in a fixed slot - it's
placed among the fixed tenors according to how much of the current year has actually elapsed
(e.g. before `1M` in mid-January, between `6M` and `1Y` by August).

### Data Coverage Notices

Before the report, `eda()` prints a `Note:` line for any ticker (single or, for a portfolio,
any constituent) whose returned history starts later than the requested `period` implies - e.g.
`period='19y'` against a stock that only IPO'd 14 years ago. This only fires for the `Nd`/`Nmo`/`Ny`
period forms; open-ended requests (`'max'`, `'ytd'`) or an explicit `start`/`end` have no fixed
lookback to fall short of, so they're never flagged. The report itself already reflects however
much history actually exists - the notice just makes that explicit instead of leaving it implicit
in a shorter-than-expected date range.

`eda_plot()` produces a single dark-themed `matplotlib` figure with up to 8 panels.
Use `save_path` to export, or `return_fig=True` to get the `Figure` object.

## Metrics Covered

| Section | Key Metrics |
|---|---|
| **Core Return & Risk** | Cumulative return, CAGR, arithmetic and geometric mean daily return, median daily return, excess return over risk-free rate, standard deviation, annualized realized volatility, annualized variance - across 1M, 3M, 6M, 1Y, 3Y, 5Y, 10Y, 15Y, 20Y, 25Y, 30Y, All Time, YTD |
| **Risk-Adjusted Performance** | Sharpe ratio, Sortino ratio, Omega ratio, Calmar ratio, Treynor ratio, Jensen's alpha, downside deviation, semi-variance, profit factor, win rate, average return on up days, average return on down days, gain-loss ratio - from 6M+ for period ratios |
| **Drawdown & Capital Destruction** | Max drawdown, average drawdown, time to recovery, max consecutive loss days - from 3M+ |
| **Trend Structure & Price Health** | 50D/100D/200D moving average levels, price vs 200D MA, golden/death cross spread, trend persistence, 52W high price, 52W low price, time since 52W high, drawdown from 52W high |
| **Relative Performance vs Benchmark** | Geometric excess return vs benchmark, tracking error, information ratio - from 6M+ |
| **Beta, Correlation & Market Dependence** | Beta vs market, correlation vs market, R-squared vs market - from 6M+ |
| **Capture Ratios** | Up-market and down-market capture, as the ratio of mean asset return to mean benchmark return over up/down days - from 6M+ |
| **Return Distribution & Non-Normality** | Skewness, excess kurtosis - from 6M+ |
| **Tail Risk & Stress** | Historical VaR (95% and 99%) from 6M+, Expected Shortfall/CVaR (95%) for 1Y+, best and worst daily/weekly/monthly return, extreme loss frequency beyond -2 sigma and -3 sigma |
| **Volatility Metrics** | Parkinson (high-low) volatility (21D/63D/126D), rolling volatility percentile, volatility of volatility, current 21D vs 1Y volatility ratio |
| **Liquidity Metrics** | Average daily volume (30D/90D) |
| **Annual Returns** | Calendar-year return for each year in the data, partial current-year return, best and worst calendar year |

Period columns scale automatically with available data. With 1Y of data the output caps at 1Y
columns; with 30Y of data all columns through 30Y render.

## Parameters

### `eda()` - shared with `eda_plot()`

| Parameter | Type | Default | Description |
|---|---|---|---|
| `ticker_or_prices` | `str`, `pd.Series`, or `list[str]` | - | Yahoo Finance ticker, a Series of close prices indexed by date, or a list of tickers to combine into a buy-and-hold portfolio (see Portfolio Support above and `weights` below) |
| `benchmark_ticker` | `str` or `None` | `'SPY'` | Benchmark symbol. Falls back to `^GSPC` only if the requested ticker fails to fetch or returns no data. Pass `None` to disable benchmark metrics entirely |
| `risk_free_rate` | `float` | `0.0` | Annual risk-free rate used in Sharpe, Sortino, Omega, Treynor, and Jensen's alpha (e.g. `0.05` for 5%) |
| `weights` | `list[float]` or `None` | `None` | Only valid when `ticker_or_prices` is a list of tickers. Per-ticker weights matched 1:1 by position, must sum to 1.0 (raises `ValueError` otherwise). Defaults to equal weight (`1/n`) when omitted |
| `period` | `str` or `None` | `'10y'` | yfinance history period (`'1y'`, `'5y'`, `'10y'`, `'max'`, etc.). Ignored when `start`/`end` are provided |
| `start` | `str` or `None` | `None` | Start date in `YYYY-MM-DD` format |
| `end` | `str` or `None` | `None` | End date in `YYYY-MM-DD` format |

`quiet` is **not** shared - it has different scope in each function (see below).

### `eda()` only

| Parameter | Type | Default | Description |
|---|---|---|---|
| `return_results` | `bool` | `False` | When `True`, return the full metrics dict in addition to (or instead of, if `quiet=True`) printing the report |
| `quiet` | `bool` | `False` | Suppress the entire printed report (including error messages), not just status output. Use this with `return_results=True` when you only want the metrics dict - e.g. to pull one figure out of it - without the terminal tearsheet |
| `save_path` | `str` or `None` | `None` | Export the report to this PDF path. Independent of `quiet` - a PDF is written even when the terminal report is suppressed, including for error results. Requires `pip install fin-eda[pdf]` |

### `eda_plot()` only

| Parameter | Type | Default | Description |
|---|---|---|---|
| `panels` | `list[str]` or `None` | `None` | Panels to render. `None` renders all available panels. See panel name table above |
| `save_path` | `str` or `None` | `None` | Save the figure to this path before displaying. Format inferred from the extension (`.png`, `.pdf`, `.svg`, or any matplotlib-supported format) - unlike `eda()`'s `save_path`, which is always a PDF report, not a figure |
| `return_fig` | `bool` | `False` | Return the `matplotlib.Figure` object instead of calling `plt.show()` |
| `quiet` | `bool` | `False` | Suppress status/warning messages only (benchmark fetch, panel render failures, save confirmation). Never suppresses the figure itself - there's no "just the numbers" mode for a visual tearsheet, so this only quiets console noise, not the plot |

## Calculation Notes

**Returns and CAGR.** Cumulative returns use log-sum form (`expm1(sum(log1p(r)))`) for numerical stability on long histories. CAGR is derived from the same log-sum: `expm1(log_sum x 252 / n)`.

**Excess return vs benchmark.** Reported as the geometric excess: `(1 + asset_return) / (1 + benchmark_return) - 1`. This avoids the distortion of arithmetic differences over long compounding periods.

**Sharpe ratio.** Daily excess returns divided by their standard deviation, annualized by `sqrt(252)`, per Sharpe (1994).

**Sortino ratio.** Uses `sqrt(E[min(r - rf, 0)^2])` as the downside deviation denominator - all periods enter the average, with positive days contributing zero. This is the formulation from Sortino and Price (1994) and differs from implementations that take the standard deviation only of the negative tail.

**Downside deviation and semi-variance.** Same RMS-over-all-periods formula as the Sortino denominator, annualized.

**Omega ratio.** `sum(max(r - rf, 0)) / sum(max(rf - r, 0))` - a full-distribution gain/loss ratio that does not assume normality.

**Calmar ratio.** CAGR divided by the absolute value of the maximum drawdown for the same period.

**Treynor ratio.** `(CAGR - annual_rf) / beta`.

**Jensen's alpha.** `asset_CAGR - (rf + beta x (bench_CAGR - rf))` - CAPM-expected return removed from realized CAGR.

**Capture ratios.** Ratio of mean asset return to mean benchmark return, computed separately over up-market and down-market days: `mean(asset_up) / mean(bench_up)` and the down-market equivalent. Deliberately not a ratio of compounded/annualized returns - that alternative introduces a systematic distortion that worsens with longer windows, while the ratio of arithmetic means stays unbiased at any window length.

**CVaR / Expected Shortfall.** Mean of all daily returns at or below the historical 5th percentile (95% confidence). Reported for periods of 1Y and above.

**Parkinson volatility.** `sqrt((1 / (4T ln 2)) x sum(ln(H/L)^2) x 252)` per Parkinson (1980).

## Dependencies

**Core** (installed automatically):

- [pandas](https://pandas.pydata.org/)
- [numpy](https://numpy.org/)
- [scipy](https://scipy.org/)
- [yfinance](https://github.com/ranaroussi/yfinance)
- [rich](https://github.com/Textualize/rich)

**Optional** (for `eda_plot`, installed via `pip install fin-eda[plot]`):

- [matplotlib](https://matplotlib.org/) >= 3.5

**Optional** (for PDF export from `eda`, installed via `pip install fin-eda[pdf]`):

- [fpdf2](https://github.com/py-pdf/fpdf2) >= 2.7

## License

MIT

---

## Changelog

### 2.1.0

**New**

- **Portfolio header drops misleading price-level fields** - current price, 50D/100D/200D moving average levels, the 50D-200D spread, and 52-week high/low are no longer shown for a portfolio, since they were read off the synthetic $100-rebased index, not real prices. Percentage-based header fields (price vs 200D MA, trend persistence, drawdown from 52W high) are unaffected by the rebasing and remain shown.
- **Data coverage shortfall notice** - `eda()` and `eda_plot()` now print a `Note:` line before the report whenever a ticker's returned history is shorter than the requested `period` implies (e.g. `period='19y'` against a stock that only IPO'd 14 years ago), for both single tickers and each constituent of a portfolio.
- **Period column display** - several related changes:
  - The full-history column is now labeled with the literal requested window (e.g. `period='19y'` displays `19Y`) instead of the generic `All Time`, falling back to `All Time` only when that window isn't known (a raw price `Series` or explicit `start`/`end`).
  - When that label would exactly duplicate an already-showing named tenor (e.g. `period='5y'` against a ticker with 5+ years of real history), the duplicate is dropped and only the precisely-defined named tenor is kept - a header no longer appears twice with two different numbers under it.
  - `YTD`'s column position now tracks the calendar (before `1M` in mid-January, between `6M` and `1Y` by August) instead of a fixed slot.
  - Fixed a Rich console width issue that silently truncated wide tables (missing `10Y`, `YTD`, and other trailing columns) in non-terminal output contexts (notebooks, IDE panels); a real terminal is unaffected.
- **Higher data-sufficiency floor for statistical sections** - Beta/Correlation, Return Distribution, and Tail Risk no longer offer `1M`/`3M` columns; with only 21-63 daily observations, fitted statistics like beta, skewness, and VaR are dominated by noise. Risk-Adjusted Performance, Relative Performance, and Capture Ratios already started at `6M`. Core Return & Risk and Drawdown are unchanged (`1M`/`3M`), since those are observed rather than fitted statistics.

**Calculation corrections**

- **Capture ratios** - replaced a subset-day-count-annualized compounding formula with a simple ratio of arithmetic means (mean asset return / mean benchmark return, computed separately over up-market and down-market days). The previous formula was systematically biased and unstable across window lengths, not just imprecise: Monte Carlo testing showed a clean synthetic 1.10x-per-up-day asset reporting a stable ~1.26 up-capture *regardless* of window length, and a simpler unannualized alternative drifted even further (to 1.70 at 5Y) from real compounding effects. The new formula reproduces the same synthetic case exactly at every window length from 6M to All Time.
- **"Return kurtosis" relabeled "return kurtosis (excess)"** - no computed value changed; this only clarifies that scipy's default (0 = normal distribution) is *excess* kurtosis, not raw kurtosis (3 = normal), which is easy to mismatch against a tool that reports the raw figure.

**Bug fixes**

- **Long/short portfolios (negative weights) now raise a clear `ValueError`** instead of silently running. A leveraged short leg can push a portfolio's net asset value to zero or negative, which breaks percentage-return math throughout the report - this was previously allowed (and documented as supported) but never actually validated for correctness. Support for negative weights is planned for a future update.

**Documentation**

- Portfolio Support, Output, Metrics Covered, and Calculation Notes sections updated for the changes above, including a new "Data Coverage Notices" subsection.

### 2.0.0

**New**

- **Portfolio / multi-ticker support** - both `eda()` and `eda_plot()` now accept a list of tickers instead of a single ticker or price Series, combining them into a buy-and-hold portfolio. Equal weight (`1/n`) by default; pass `weights=[...]` for custom allocations (must sum to 1.0). Weights are applied once, converted into an implied share count at the first date common to every ticker's history, and each holding then drifts independently with its own price path - not rebalanced daily. Tickers are aligned on their common trading dates (intersection). High/Low/Volume-based metrics (Parkinson volatility, liquidity, price-panel volume bars) are unavailable for a portfolio, same as when a raw price Series is passed. See the new "Portfolio Support" section above.
- **PDF export from `eda()`** - pass `save_path='report.pdf'` to export the numerical tearsheet as a PDF, independent of `quiet` (so `eda(..., quiet=True, save_path=...)` produces a silent, PDF-only export). Requires the new optional `fpdf2` dependency: `pip install fin-eda[pdf]`. The PDF mirrors the printed report exactly - same header, same section/period/scalar tables, same red/green/yellow color coding - since both renderers now share the same underlying formatting and classification logic. (`eda_plot()` already supported PDF export via its existing `save_path`, which infers the format - PNG/PDF/SVG - from the file extension.)
- **`All Time` period column** - `eda()`'s period-based tables now always include an `All Time` column reporting on the full fetched history, regardless of whether it exactly clears a round-number tenor threshold. Previously, a `period='10y'` fetch that yielded a few trading days short of the exact 2,520-day `10Y` threshold (common, since real calendars rarely land on an exact multiple of 252 trading days/year) would silently drop the `10Y` column with no alternative - `All Time` now always renders for whatever window was actually fetched.

**Bug fixes**

- **`eda()` silently produced no output on a bad ticker or insufficient data** - a failed fetch or empty return series returned an `{'error': ...}` dict directly, bypassing the report printer entirely (including `_print_eda_report`'s own dedicated error-display branch, which was unreachable dead code as a result). Called the normal way (`eda("BADTICKER")`, return value not captured), this was a silent no-op. Both error paths now route through the printer (respecting `quiet`) and now also respect `return_results` consistently, matching the function's own documented return contract.
- **Non-`DatetimeIndex` price `Series` crashed `eda()`/`eda_plot()`** - a benchmark-window helper introduced in 1.2.5 called `.strftime()` unconditionally on a raw price Series' index, before any benchmark-enabled check, so passing a `Series` with a non-date index (e.g. a default `RangeIndex`) crashed immediately even with `benchmark_ticker=None`. Now falls back to an unbounded benchmark window instead of crashing.
- **`ANNUAL_RETURNS` double-counted and mislabeled the current year** - the still-open calendar year's resampled bin was reported both as an unqualified `"{year} annual return"` (implying a complete year) and correctly as `"{year} annual return (partial)"`, and the mislabeled version could win/lose `Best`/`Worst annual return` against genuinely complete years. Now excluded from the complete-year bucket and reported only under the `(partial)` label - corrected further to test the last data year against *today's real-world year* (not just "whichever year is last in the data"), so a call bounded by `end='2023-12-31'` made in 2026 correctly treats 2023 as complete rather than partial.
- **`eda_plot()`'s `annual_returns` panel didn't distinguish the partial year at all** - silently plotted it as an ordinary bar, indistinguishable from a complete year. Now rendered hatched, at reduced opacity, with a `(YTD)` suffix on its tick label - matching the table's `(partial)` treatment.
- **`UnicodeEncodeError` on Windows terminals using a legacy codepage** - `eda()`'s "Extreme loss frequency (beyond −2σ)" labels used a Unicode minus sign and Greek sigma, neither in the `cp1252` codepage many Windows consoles still default to. Relabeled to plain ASCII (`-2 sigma`), matching wording the README already used.
- **Zero-display values (`0.00%`, `-0.00%`) got an arbitrary red or green** - sign-dependent color coding (e.g. Jensen's alpha) was driven by the raw value's sign even when that sign was too small to actually show at the displayed precision, so visually-identical `0.00%` cells could render as red, green, or yellow depending on invisible floating-point noise. These now render as plain/neutral text.
- **`eda_plot()`'s distribution panel still showed a Jarque-Bera p-value** that `eda()` deliberately dropped in 1.2.0 (near-certain to reject normality for financial return series regardless of economic significance) - removed for parity.
- **Benchmark fetch status message didn't say what it was fetching for** - `"Fetching benchmark data for SPY..."` gave no indication of the underlying ticker or portfolio being analyzed when multiple `eda()`/`eda_plot()` calls' output interleaved. Now includes it: `"Fetching benchmark data for SPY (underlying: AAPL)..."`.

**Documentation**

- Removed `Amihud illiquidity ratio` and `volume trend` from the README's Liquidity Metrics description and Calculation Notes - documented but never implemented in code.
- Removed stale `EDA_PLOT_PLAN.md` references to the `beta_scatter` and `rolling_vol` panels, both removed from `eda_plot()` in 1.2.5.
- Clarified that `quiet` is *not* a shared parameter despite the identical name in both functions: `eda(quiet=True)` suppresses the entire report (useful with `return_results=True` when only the metrics dict is wanted), while `eda_plot(quiet=True)` only suppresses status/warning messages - never the figure itself, since there's no "just the numbers" mode for a visual tearsheet.

**Code quality**

- Extracted `_classify_value()` (color decision) and `_split_section_metrics()` (period/scalar splitting, NaN-row dropping, active-column filtering) out of `_print_eda_report()` as shared, output-format-agnostic helpers, so the Rich console renderer and the new PDF renderer derive identical table structure and coloring decisions from one source instead of two independently-drifting copies.
- Removed several small dead-code instances: an unreachable defensive check in `_calculate_time_to_recovery`, a benchmark-returns variable unconditionally overwritten before ever being read, and two unused variable captures in `eda_plot()`'s histogram and heatmap panels.

### 1.2.5

**Bug fixes**

- **Benchmark alignment silently narrowed unrelated metrics** - in both `eda()` and `eda_plot()`, attaching a benchmark (the default `SPY`) intersected the asset's return series down to only the dates the benchmark also traded on, and that narrowed series then fed every metric/panel, not just the benchmark-comparison ones. Two tickers with a mismatched trading calendar (e.g. different exchange holidays) could silently shift CAGR, Sharpe, volatility, skewness, VaR, and drawdown numbers depending on whether a benchmark was attached at all. Core return/risk metrics and panels now always use the asset's full history; only the benchmark-comparison metrics/panels (beta, correlation, capture ratios, Treynor, Jensen's alpha, `cumulative`, `rolling_beta`) use the calendar-aligned pair.
- **Benchmark fetch ignored `period`/`start`/`end`** - the benchmark ticker was always fetched with `period='max'` regardless of what window was requested for the primary asset, so `eda('AAPL', period='5y')` correctly pulled ~1,260 days of AAPL but then downloaded SPY's entire trading history (8,000+ days) before intersecting it down. The benchmark fetch now mirrors the primary ticker's requested window (or, when a raw price `Series` is passed instead of a ticker, is bounded to that series' own date range).
- **OHLCV auxiliary series reindexed to the narrowed calendar** - `high`/`low`/`volume` were reindexed to the benchmark-shrunk return index rather than the asset's own price index, so Parkinson volatility and liquidity metrics (and the `price` panel's volume bars) could show gaps that had nothing to do with missing asset data. Now reindexed to the asset's own price history.
- **`eda_plot()` mislabeled the benchmark on fallback** - if the requested benchmark failed to fetch and the code fell back to `^GSPC`, chart legends still displayed the originally-requested symbol (e.g. `SPY`) instead of the benchmark actually plotted. `eda()` already tracked this correctly; `eda_plot()` now does too.
- **`eda_plot()` crashed on a non-`DatetimeIndex` price `Series`** - the figure title's date-range formatting called `.strftime()` unconditionally, outside the per-panel error handling, so passing a raw `Series` without a datetime index (e.g. a default `RangeIndex`) crashed the whole call instead of degrading gracefully.

**`eda_plot()` - panel changes**

- **Removed `beta_scatter`** (Daily Return Scatter vs Benchmark) and **`rolling_vol`** (Rolling Volatility Regime). Default tearsheet is now 8 panels.

**Code quality**

- Extracted the benchmark-fetch-with-`^GSPC`-fallback logic (previously duplicated near-verbatim in `eda.py` and `plot.py`, which is how the alignment and period bugs above ended up in both places independently) into a shared internal module, `fin_eda._market_data`.
- `eda_plot()`'s panel-render loop no longer special-cases `rolling_sharpe` by name; render failures are now also surfaced as a console warning (in addition to the in-panel placeholder) instead of failing silently.
- Silenced a spurious `RuntimeWarning` from the rolling-Sharpe calculation on zero-volatility (flat-return) stretches.

### 1.2.0

**New metrics**

- **CAGR** - annualized geometric return (`expm1(log_sum x 252 / n)`) added to Core Return & Risk for every period.
- **Calmar ratio** - CAGR divided by absolute max drawdown, per period.
- **Treynor ratio** - annualized excess return per unit of beta, per period (requires benchmark).
- **Jensen's alpha** - CAPM-adjusted outperformance (asset CAGR minus the CAPM-predicted return), per period (requires benchmark).
- **Omega ratio** - full-distribution gain/loss ratio above the risk-free threshold, per period.
- **Win rate** - percentage of trading days with a positive return (full history scalar).
- **Average return on up days / down days** - mean daily return on positive and negative days separately (full history scalars).
- **Gain-loss ratio** - average daily gain divided by the absolute average daily loss (full history scalar).
- **Tracking error** - annualized standard deviation of active (asset minus benchmark) daily returns, surfaced explicitly alongside the information ratio.
- **R-squared vs market** - square of the correlation with the benchmark, per period.
- **Best daily / weekly / monthly return** - counterpart to the existing worst-period metrics.
- **52W high price and 52W low price** - absolute price levels added to Trend Structure & Price Health.
- **Annual returns** - new section with a calendar-year-by-year breakdown, partial current-year return, and best/worst calendar year summary.

**Calculation corrections**

- **Sortino ratio and downside deviation** - corrected to use `sqrt(E[min(r - rf, 0)^2])` averaged over all periods (Sortino and Price, 1994). The previous implementation used `std(negative_tail_returns)`, which divides by the count of negative days only and measures dispersion around the negative-tail mean rather than around the threshold - both incorrect.
- **Excess return vs benchmark** - changed from arithmetic (`asset_cum - bench_cum`) to geometric (`(1 + asset_cum) / (1 + bench_cum) - 1`). Arithmetic excess is misleading for periods beyond 2-3 years.
- **52W high date comparison** - replaced `is not pd.NaT` with `not pd.isna()` for correct behavior with timezone-aware timestamps returned by recent yfinance versions.

**Removed metrics**

- **Jarque-Bera statistic** - removed from Return Distribution & Non-Normality. For most financial return series the statistic is near-certain to reject normality regardless of sample size or economic significance; skewness and kurtosis convey the distributional shape more directly.
- **Return autocorrelation** - the Regime & Time-Series Behavior section (1D, 5D, 21D autocorrelation lags) has been removed from the numerical tearsheet.

**Display improvements**

- Header now shows: current price, the benchmark symbol actually used (including when the fallback to ^GSPC is triggered), and the full data coverage line (trading day count and date range).
- 52W section in the header now shows actual high and low prices alongside the existing days-since-high and drawdown figures.
- Period columns are suppressed automatically when all values for that column are NaN, so the output scales from the shortest available window up to 30Y without manual configuration. Long-period columns (15Y, 20Y, 25Y, 30Y) appear as data coverage grows.
- Each section table uses a descriptive row label (e.g. "Risk-Adjusted Metric", "Benchmark Comparison", "Calendar Year") instead of the generic "Metric".
- Best-period metrics (best daily, weekly, monthly, annual) are always colored green; worst-period metrics remain always red.

### 1.1.1

**`eda_plot()` - panel changes**

- **Added `annual_returns`:** Year-by-year geometric return bars (green/red) with optional benchmark overlay. Annotates each bar when 15 or fewer years of data are shown.
- **Added `rolling_beta`:** Rolling 1Y (252D) beta vs benchmark computed as `cov(asset, bench) / var(bench)`. Shaded above beta = 1, shaded below. Current beta annotated.
- Rolling Sharpe primary window clarified: 252D (1Y) is primary, 126D (6M) is the secondary overlay.

**Bug fixes**

- **Benchmark fetch threshold** - the previous check (`len > 25 x 252 trading days`) silently fell back to ^GSPC for any benchmark with less than roughly 25 years of history (e.g. GLD, sector ETFs, international funds). Now accepts any non-empty result; `period='max'` is used to retrieve full available history. Fallback to ^GSPC only triggers on a failed fetch.
- **Distribution normal overlay** - `std` now uses `ddof=1` (sample standard deviation) to match the convention used by `stats.skew` and `stats.kurtosis` in the same panel.

### 1.1.0

**New: `eda_plot()` - visual tearsheet**

- 10-panel dark-themed matplotlib tearsheet mirroring all `eda()` inputs.
- Panels: price history, cumulative return vs benchmark, underwater drawdown, monthly returns heatmap, annual returns, return distribution, beta scatter, rolling beta, rolling Sharpe, and rolling volatility regime.
- `panels=[...]` parameter for rendering any subset of panels in one call.
- `save_path` parameter to export to PNG, PDF, SVG, or any matplotlib-supported format.
- `return_fig=True` for notebook embedding or programmatic figure composition.
- Benchmark-dependent panels degrade gracefully when data is unavailable.
- Style applied via `mpl.rc_context` - does not pollute the caller's global matplotlib state.
- Added as an optional dependency: `pip install fin-eda[plot]`.

### 1.0.1

**Bug fixes**

- **YTD period** - corrected to use the current calendar year at runtime rather than the last year present in the data, which produced wrong results when analyzing historical series ending before the current year.
- **Average drawdown** - now returns no data when a period has no negative drawdown observations, instead of incorrectly showing `0.0`.
- **Time to recovery** - now returns no data when a period has no drawdown to recover from, instead of showing `0` (which implied an instantaneous recovery had occurred).
- **Volatility of volatility key name** - internal key mismatch between the success and failure paths caused the metric to appear twice in the output under different names. Now consistently labeled.
- **`quiet=True` not fully respected** - benchmark data-fetch status messages were always printed to stdout regardless of the `quiet` flag. They now correctly respect `quiet=True` and route through the Rich console for consistent formatting.
- **Monthly return resampling** - added compatibility fallback for pandas < 2.2, where the `'ME'` month-end alias was not yet available.

**Numerical improvements**

- **Cumulative and geometric mean returns** - switched from chained `.prod()` to log-sum form (`np.expm1(np.log1p(r).sum())`). Mathematically equivalent, but avoids floating-point overflow on very long return histories (20Y+) and resolves a pandas type-stub incompatibility with newer versions.
- **Capture ratios** - same log-sum refactor applied to up- and down-market annualization, eliminating potential overflow for assets with extreme up-market streaks.

**Code quality**

- `period`, `start`, `end`, and `benchmark_ticker` parameters now carry correct `Optional[str]` type annotations (previously typed as `str` despite accepting `None`).
- Period-pattern regex precompiled at module load time instead of on every report render.
- Removed stale internal comment referencing previously deleted metrics.
- Docstrings added to all internal helper functions.
