Skip to content

cds2.finance

Quantitative finance: returns, risk metrics, Black-Scholes, VaR.

Quantitative finance basics: returns, drawdowns, risk metrics and Black-Scholes.

DrawdownResult dataclass

Deepest peak-to-trough decline of a price series.

Source code in src/cds2/finance.py
57
58
59
60
61
62
63
@dataclass(frozen=True)
class DrawdownResult:
    """Deepest peak-to-trough decline of a price series."""

    max_drawdown: float
    peak_index: int
    trough_index: int

BSResult dataclass

Black-Scholes option price and first-order Greeks.

Source code in src/cds2/finance.py
127
128
129
130
131
132
133
134
@dataclass(frozen=True)
class BSResult:
    """Black-Scholes option price and first-order Greeks."""

    price: float
    delta: float
    gamma: float
    vega: float

log_returns(prices)

Logarithmic returns ln(P_t / P_{t-1}); length n - 1.

Source code in src/cds2/finance.py
45
46
47
48
def log_returns(prices: ArrayLike) -> FloatArray:
    """Logarithmic returns ``ln(P_t / P_{t-1})``; length ``n - 1``."""
    values = _as_prices(prices)
    return np.diff(np.log(values))

simple_returns(prices)

Simple percentage returns P_t / P_{t-1} - 1; length n - 1.

Source code in src/cds2/finance.py
51
52
53
54
def simple_returns(prices: ArrayLike) -> FloatArray:
    """Simple percentage returns ``P_t / P_{t-1} - 1``; length ``n - 1``."""
    values = _as_prices(prices)
    return values[1:] / values[:-1] - 1.0

max_drawdown(prices)

Most severe drawdown P_t / running_max_t - 1 with its peak/trough indices.

Source code in src/cds2/finance.py
66
67
68
69
70
71
72
73
74
75
76
77
def max_drawdown(prices: ArrayLike) -> DrawdownResult:
    """Most severe drawdown ``P_t / running_max_t - 1`` with its peak/trough indices."""
    values = _as_prices(prices)
    running_max = np.maximum.accumulate(values)
    drawdowns = values / running_max - 1.0
    trough = int(np.argmin(drawdowns))
    peak = int(np.argmax(values[: trough + 1]))
    return DrawdownResult(
        max_drawdown=float(drawdowns[trough]),
        peak_index=peak,
        trough_index=trough,
    )

annualized_volatility(returns, periods_per_year=252)

Sample volatility scaled by sqrt(periods_per_year).

Source code in src/cds2/finance.py
86
87
88
89
90
def annualized_volatility(returns: ArrayLike, periods_per_year: int = 252) -> float:
    """Sample volatility scaled by ``sqrt(periods_per_year)``."""
    _validate_periods(periods_per_year)
    values = np.asarray(returns, dtype=np.float64)
    return float(np.std(values, ddof=1) * np.sqrt(periods_per_year))

sharpe_ratio(returns, risk_free_annual=0.0, periods_per_year=252)

Annualized mean excess return over per-period sample volatility.

Source code in src/cds2/finance.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
def sharpe_ratio(
    returns: ArrayLike,
    risk_free_annual: float = 0.0,
    periods_per_year: int = 252,
) -> float:
    """Annualized mean excess return over per-period sample volatility."""
    _validate_periods(periods_per_year)
    values = np.asarray(returns, dtype=np.float64)
    excess = values - risk_free_annual / periods_per_year
    return float(np.mean(excess) / np.std(values, ddof=1) * np.sqrt(periods_per_year))

sortino_ratio(returns, risk_free_annual=0.0, periods_per_year=252, target=0.0)

Mean excess return divided by downside deviation against a per-period target.

The downside deviation averages the squared negative deviations min(r - target, 0) over all periods; if there are none it is zero and the ratio is infinite.

Source code in src/cds2/finance.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def sortino_ratio(
    returns: ArrayLike,
    risk_free_annual: float = 0.0,
    periods_per_year: int = 252,
    target: float = 0.0,
) -> float:
    """Mean excess return divided by downside deviation against a per-period target.

    The downside deviation averages the squared negative deviations
    ``min(r - target, 0)`` over *all* periods; if there are none it is zero
    and the ratio is infinite.
    """
    _validate_periods(periods_per_year)
    values = np.asarray(returns, dtype=np.float64)
    deviations = np.minimum(values - target, 0.0)
    downside = float(np.sqrt(np.mean(deviations**2)))
    if downside == 0.0:
        return float("inf")
    mean_excess = float(np.mean(values)) - risk_free_annual / periods_per_year
    return mean_excess / downside

black_scholes(spot, strike, rate, volatility, maturity, option='call')

Black-Scholes European call/put price with delta, gamma and vega.

Vega is quoted per unit change in volatility (not per percent). A zero maturity collapses to intrinsic value with step-function delta.

Source code in src/cds2/finance.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
def black_scholes(
    spot: float,
    strike: float,
    rate: float,
    volatility: float,
    maturity: float,
    option: str = "call",
) -> BSResult:
    """Black-Scholes European call/put price with delta, gamma and vega.

    Vega is quoted per unit change in volatility (not per percent).
    A zero maturity collapses to intrinsic value with step-function delta.
    """
    if spot <= 0 or strike <= 0:
        msg = "spot and strike must be positive"
        raise ValueError(msg)
    if volatility <= 0:
        msg = "volatility must be positive"
        raise ValueError(msg)
    if maturity < 0:
        msg = "maturity must be non-negative"
        raise ValueError(msg)
    if option not in {"call", "put"}:
        msg = "option must be call or put"
        raise ValueError(msg)
    if maturity == 0.0:
        if option == "call":
            intrinsic = max(spot - strike, 0.0)
            delta = 1.0 if spot > strike else 0.0
        else:
            intrinsic = max(strike - spot, 0.0)
            delta = -1.0 if spot < strike else 0.0
        return BSResult(price=intrinsic, delta=delta, gamma=0.0, vega=0.0)
    root_t = float(np.sqrt(maturity))
    d1 = (float(np.log(spot / strike)) + (rate + 0.5 * volatility**2) * maturity) / (
        volatility * root_t
    )
    d2 = d1 - volatility * root_t
    density = float(sp_stats.norm.pdf(d1))
    gamma = density / (spot * volatility * root_t)
    vega = spot * density * root_t
    discount = float(np.exp(-rate * maturity))
    if option == "call":
        price = spot * float(sp_stats.norm.cdf(d1)) - strike * discount * float(
            sp_stats.norm.cdf(d2)
        )
        delta = float(sp_stats.norm.cdf(d1))
    else:
        price = strike * discount * float(sp_stats.norm.cdf(-d2)) - spot * float(
            sp_stats.norm.cdf(-d1)
        )
        delta = float(sp_stats.norm.cdf(d1)) - 1.0
    return BSResult(price=price, delta=delta, gamma=gamma, vega=vega)

historical_var(returns, confidence=0.95)

Historical Value-at-Risk as the positive loss at the given confidence level.

Source code in src/cds2/finance.py
192
193
194
195
196
197
198
def historical_var(returns: ArrayLike, confidence: float = 0.95) -> float:
    """Historical Value-at-Risk as the positive loss at the given confidence level."""
    if not 0.0 < confidence < 1.0:
        msg = "confidence must lie strictly between 0 and 1"
        raise ValueError(msg)
    values = np.asarray(returns, dtype=np.float64)
    return float(-np.quantile(values, 1.0 - confidence))

monte_carlo_var(current_value, mean_return, sigma, horizon_days=10, confidence=0.99, simulations=50000, seed=None)

Monte-Carlo Value-at-Risk of a lognormal terminal value over the horizon.

Terminal prices follow V0 * exp((mu - sigma^2/2) h + sigma sqrt(h) Z); the reported number is the loss percentile of that distribution.

Source code in src/cds2/finance.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def monte_carlo_var(
    current_value: float,
    mean_return: float,
    sigma: float,
    horizon_days: int = 10,
    confidence: float = 0.99,
    simulations: int = 50_000,
    seed: int | None = None,
) -> float:
    """Monte-Carlo Value-at-Risk of a lognormal terminal value over the horizon.

    Terminal prices follow ``V0 * exp((mu - sigma^2/2) h + sigma sqrt(h) Z)``;
    the reported number is the loss percentile of that distribution.
    """
    if current_value <= 0:
        msg = "current_value must be positive"
        raise ValueError(msg)
    if sigma <= 0:
        msg = "sigma must be positive"
        raise ValueError(msg)
    if horizon_days < 1:
        msg = "horizon_days must be at least 1"
        raise ValueError(msg)
    if not 0.0 < confidence < 1.0:
        msg = "confidence must lie strictly between 0 and 1"
        raise ValueError(msg)
    if simulations < 100:
        msg = "simulations must be at least 100"
        raise ValueError(msg)
    rng = np.random.default_rng(seed)
    shocks = rng.standard_normal(simulations)
    drift = (mean_return - 0.5 * sigma**2) * horizon_days
    diffusion = sigma * float(np.sqrt(horizon_days))
    terminal = current_value * np.exp(drift + diffusion * shocks)
    losses = current_value - terminal
    return float(np.quantile(losses, confidence))