Descriptive stats: min, max, mean, median, mode

Opens Part 14. Every strategy evaluation in this course (backtests, journal reviews, slippage checks) ultimately reduces to descriptive statistics on some series of numbers — trade P&Ls, returns, fill prices, holding periods. This chapter builds the foundational toolkit once, correctly, so every later chapter reuses it rather than reimplementing ad hoc.

import pandas as pd
import numpy as np
from scipy import stats as scipy_stats

def describe_series(data: pd.Series) -> dict:
    return {
        "count": data.count(),
        "min": data.min(),
        "max": data.max(),
        "mean": data.mean(),
        "median": data.median(),
        "mode": data.mode().iloc[0] if not data.mode().empty else None,
        "std": data.std(),
        "variance": data.var(),
        "range": data.max() - data.min(),
    }

Mode — the trickiest of these for continuous financial data

def robust_mode(data: pd.Series, bins: int = 50) -> float:
    """For continuous data (e.g. trade P&L, slippage in rupees), raw .mode() often
    returns a near-meaningless single value since exact duplicates are rare.
    Bin the data first, then find the most frequent bin's midpoint."""
    counts, bin_edges = np.histogram(data.dropna(), bins=bins)
    max_bin_idx = counts.argmax()
    return (bin_edges[max_bin_idx] + bin_edges[max_bin_idx + 1]) / 2

Pandas' .mode() finds *exact value* repeats — fine for discrete data (e.g. "which exit reason occurred most often") but nearly useless for continuous data like P&L or slippage in rupees, where two trades rarely have the *exact* same value down to the paisa. robust_mode (binned mode, effectively "the most common range of outcomes") is almost always what you actually want for continuous trading data.

Applying this to the trade journal (chapter 89)

def journal_descriptive_stats(journal: TradeJournal, strategy: str) -> dict:
    df = pd.read_sql(f"SELECT * FROM trades WHERE strategy='{strategy}' AND exit_time IS NOT NULL", journal.conn)
    return {
        "pnl": describe_series(df["pnl"]),
        "holding_period_minutes": describe_series(
            (pd.to_datetime(df["exit_time"]) - pd.to_datetime(df["entry_time"])).dt.total_seconds() / 60
        ),
    }

Why min/max alone are dangerous without context

def flag_outlier_dependence(data: pd.Series, outlier_threshold_std: float = 3.0) -> dict:
    mean, std = data.mean(), data.std()
    outliers = data[(data - mean).abs() > outlier_threshold_std * std]
    return {
        "outlier_count": len(outliers),
        "outlier_contribution_pct": outliers.sum() / data.sum() * 100 if data.sum() != 0 else 0,
    }

A strategy's max P&L trade contributing 40%+ of total profit is directly the chapter 85 "concentration" red flag — always compute min/ max alongside outlier contribution, not as isolated headline numbers.

Weighted statistics — when raw values shouldn't be treated equally

def weighted_mean(values: pd.Series, weights: pd.Series) -> float:
    return (values * weights).sum() / weights.sum()

Useful, for example, when averaging fill prices across trades of different sizes (chapter 19's VWAP-fill pattern is a specific instance of this general weighted-mean calculation) — a plain unweighted mean across trades of very different sizes can misrepresent your actual average execution.

Next: 127 — Distribution shape: skew, kurtosis, percentiles