Class: SQA::RiskManager

Inherits:
Object
  • Object
show all
Defined in:
lib/sqa/risk_manager.rb

Overview

RiskManager - Comprehensive risk management and position sizing

Provides methods for:

  • Value at Risk (VaR): Historical, Parametric, Monte Carlo

  • Conditional VaR (CVaR / Expected Shortfall)

  • Position sizing: Kelly Criterion, Fixed Fractional, Percent Volatility

  • Risk metrics: Sharpe, Sortino, Calmar, Maximum Drawdown

  • Stop loss calculations

Examples:

Basic VaR calculation

returns = stock.df["adj_close_price"].to_a.each_cons(2).map { |a, b| (b - a) / a }
var_95 = SQA::RiskManager.var(returns, confidence: 0.95)
puts "95% VaR: #{var_95}%"

Position sizing with Kelly Criterion

position = SQA::RiskManager.kelly_criterion(
  win_rate: 0.55,
  avg_win: 0.15,
  avg_loss: 0.10,
  capital: 10_000
)
puts "Optimal position size: $#{position}"

Class Method Summary collapse

Class Method Details

.atr_stop_loss(current_price:, atr:, multiplier: 2.0, direction: :long) ⇒ Float

Calculate stop loss price based on ATR (Average True Range)

Examples:

stop = SQA::RiskManager.atr_stop_loss(
  current_price: 150.0,
  atr: 3.5,
  multiplier: 2.0,
  direction: :long
)
# => 143.0 (stop at current - 2*ATR)

Parameters:

  • current_price (Float)

    Current asset price

  • atr (Float)

    Average True Range

  • multiplier (Float) (defaults to: 2.0)

    ATR multiplier (default: 2.0)

  • direction (Symbol) (defaults to: :long)

    :long or :short

Returns:

  • (Float)

    Stop loss price



204
205
206
207
208
209
210
# File 'lib/sqa/risk_manager.rb', line 204

def atr_stop_loss(current_price:, atr:, multiplier: 2.0, direction: :long)
  if direction == :long
    current_price - (atr * multiplier)
  else
    current_price + (atr * multiplier)
  end
end

.calmar_ratio(returns, periods_per_year: 252) ⇒ Float

Calculate Calmar Ratio

Ratio of annualized return to maximum drawdown.

Examples:

calmar = SQA::RiskManager.calmar_ratio(returns)

Parameters:

  • returns (Array<Float>)

    Array of period returns

  • periods_per_year (Integer) (defaults to: 252)

    Number of periods per year (default: 252)

Returns:

  • (Float)

    Calmar ratio



325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
# File 'lib/sqa/risk_manager.rb', line 325

def calmar_ratio(returns, periods_per_year: 252)
  return 0.0 if returns.empty?

  # Annualized return
  total_return = returns.inject(1.0) { |product, r| product * (1 + r) }
  periods = returns.size
  annualized_return = (total_return ** (periods_per_year.to_f / periods)) - 1.0

  # Convert returns to prices for drawdown calculation
  prices = returns.inject([100.0]) { |acc, r| acc << acc.last * (1 + r) }
  max_dd = max_drawdown(prices)[:max_drawdown].abs

  return 0.0 if max_dd.zero?

  annualized_return / max_dd
end

.cvar(returns, confidence: 0.95) ⇒ Float

Calculate Conditional Value at Risk (CVaR / Expected Shortfall)

CVaR is the expected loss given that the loss exceeds the VaR threshold. It provides a more conservative risk measure than VaR.

Examples:

cvar = SQA::RiskManager.cvar(returns, confidence: 0.95)
# => -0.025 (2.5% expected loss in worst 5% of cases)

Parameters:

  • returns (Array<Float>)

    Array of period returns

  • confidence (Float) (defaults to: 0.95)

    Confidence level (default: 0.95)

Returns:

  • (Float)

    CVaR as a percentage



77
78
79
80
81
82
83
84
85
# File 'lib/sqa/risk_manager.rb', line 77

def cvar(returns, confidence: 0.95)
  return nil if returns.empty?

  var_threshold = var(returns, confidence: confidence, method: :historical)
  tail_losses = returns.select { |r| r <= var_threshold }

  return nil if tail_losses.empty?
  tail_losses.sum / tail_losses.size.to_f
end

.fixed_fractional(capital:, risk_fraction: 0.02) ⇒ Float

Calculate position size using Fixed Fractional method

Risk a fixed percentage of capital on each trade. Simple and conservative approach.

Examples:

position = SQA::RiskManager.fixed_fractional(capital: 10_000, risk_fraction: 0.02)
# => 200.0 (risk $200 per trade)

Parameters:

  • capital (Float)

    Total capital

  • risk_fraction (Float) (defaults to: 0.02)

    Fraction to risk (e.g., 0.02 for 2%)

Returns:

  • (Float)

    Dollar amount to risk



146
147
148
# File 'lib/sqa/risk_manager.rb', line 146

def fixed_fractional(capital:, risk_fraction: 0.02)
  capital * risk_fraction
end

.kelly_criterion(win_rate:, avg_win:, avg_loss:, capital:, max_fraction: 0.25) ⇒ Float

Calculate position size using Kelly Criterion

Kelly Criterion calculates the optimal fraction of capital to risk based on win rate and win/loss ratio.

Formula: f = (p * b - q) / b where:

f = fraction of capital to bet
p = probability of winning
q = probability of losing (1 - p)
b = win/loss ratio (avg_win / avg_loss)

Examples:

position = SQA::RiskManager.kelly_criterion(
  win_rate: 0.60,
  avg_win: 0.10,
  avg_loss: 0.05,
  capital: 10_000,
  max_fraction: 0.25
)

Parameters:

  • win_rate (Float)

    Win rate (0.0 to 1.0)

  • avg_win (Float)

    Average win size (as percentage)

  • avg_loss (Float)

    Average loss size (as percentage)

  • capital (Float)

    Total capital available

  • max_fraction (Float) (defaults to: 0.25)

    Maximum fraction to risk (default: 0.25 for 25%)

Returns:

  • (Float)

    Dollar amount to risk



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/sqa/risk_manager.rb', line 116

def kelly_criterion(win_rate:, avg_win:, avg_loss:, capital:, max_fraction: 0.25)
  return 0.0 if avg_loss.zero? || win_rate >= 1.0 || win_rate <= 0.0

  lose_rate = 1.0 - win_rate
  win_loss_ratio = avg_win / avg_loss

  # Kelly formula
  kelly_fraction = (win_rate * win_loss_ratio - lose_rate) / win_loss_ratio

  # Cap at max_fraction (Kelly can be aggressive)
  kelly_fraction = [kelly_fraction, max_fraction].min
  kelly_fraction = [kelly_fraction, 0.0].max  # No negative positions

  capital * kelly_fraction
end

.max_drawdown(prices) ⇒ Hash

Calculate maximum drawdown from price series

Drawdown is the peak-to-trough decline in portfolio value.

Examples:

dd = SQA::RiskManager.max_drawdown([100, 110, 105, 95, 100])
# => { max_drawdown: -0.136, peak_idx: 1, trough_idx: 3 }

Parameters:

  • prices (Array<Float>)

    Array of prices or portfolio values

Returns:

  • (Hash)

    { max_drawdown: Float, peak_idx: Integer, trough_idx: Integer }



224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/sqa/risk_manager.rb', line 224

def max_drawdown(prices)
  return { max_drawdown: 0.0, peak_idx: 0, trough_idx: 0 } if prices.size < 2

  max_dd = 0.0
  peak_idx = 0
  trough_idx = 0
  running_peak = prices.first
  running_peak_idx = 0

  prices.each_with_index do |price, idx|
    if price > running_peak
      running_peak = price
      running_peak_idx = idx
    end

    drawdown = (price - running_peak) / running_peak
    if drawdown < max_dd
      max_dd = drawdown
      peak_idx = running_peak_idx
      trough_idx = idx
    end
  end

  {
    max_drawdown: max_dd,
    peak_idx: peak_idx,
    trough_idx: trough_idx,
    peak_value: prices[peak_idx],
    trough_value: prices[trough_idx]
  }
end

.monte_carlo_simulation(initial_capital:, returns:, periods:, simulations: 1000) ⇒ Hash

Monte Carlo simulation for portfolio value

Examples:

results = SQA::RiskManager.monte_carlo_simulation(
  initial_capital: 10_000,
  returns: historical_returns,
  periods: 252,
  simulations: 1000
)
puts "95th percentile: $#{results[:percentile_95]}"

Parameters:

  • initial_capital (Float)

    Starting capital

  • returns (Array<Float>)

    Historical returns to sample from

  • periods (Integer)

    Number of periods to simulate

  • simulations (Integer) (defaults to: 1000)

    Number of simulation paths

Returns:

  • (Hash)

    Simulation results with percentiles



360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# File 'lib/sqa/risk_manager.rb', line 360

def monte_carlo_simulation(initial_capital:, returns:, periods:, simulations: 1000)
  return nil if returns.empty?

  final_values = simulations.times.map do
    value = initial_capital
    periods.times do
      random_return = returns.sample
      value *= (1 + random_return)
    end
    value
  end

  final_values.sort!

  {
    mean: final_values.sum / final_values.size.to_f,
    median: final_values[final_values.size / 2],
    percentile_5: final_values[(final_values.size * 0.05).floor],
    percentile_25: final_values[(final_values.size * 0.25).floor],
    percentile_75: final_values[(final_values.size * 0.75).floor],
    percentile_95: final_values[(final_values.size * 0.95).floor],
    min: final_values.first,
    max: final_values.last,
    all_values: final_values
  }
end

.percent_volatility(capital:, returns:, target_volatility: 0.15, current_price:) ⇒ Integer

Calculate position size using Percent Volatility method

Adjust position size based on recent volatility. Higher volatility = smaller position size.

Examples:

shares = SQA::RiskManager.percent_volatility(
  capital: 10_000,
  returns: recent_returns,
  target_volatility: 0.15,
  current_price: 150.0
)

Parameters:

  • capital (Float)

    Total capital

  • returns (Array<Float>)

    Recent returns

  • target_volatility (Float) (defaults to: 0.15)

    Target portfolio volatility (annualized)

  • current_price (Float)

    Current asset price

Returns:

  • (Integer)

    Number of shares to buy



170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/sqa/risk_manager.rb', line 170

def percent_volatility(capital:, returns:, target_volatility: 0.15, current_price:)
  return 0 if returns.empty? || current_price.zero?

  # Calculate recent volatility (annualized)
  std_dev = standard_deviation(returns)
  annualized_volatility = std_dev * Math.sqrt(252)  # Assume 252 trading days

  return 0 if annualized_volatility.zero?

  # Calculate position size
  position_value = capital * (target_volatility / annualized_volatility)
  shares = (position_value / current_price).floor

  [shares, 0].max  # No negative shares
end

.sharpe_ratio(returns, risk_free_rate: 0.02, periods_per_year: 252) ⇒ Float

Calculate Sharpe Ratio

Measures risk-adjusted return (excess return per unit of risk).

Examples:

sharpe = SQA::RiskManager.sharpe_ratio(returns, risk_free_rate: 0.02)

Parameters:

  • returns (Array<Float>)

    Array of period returns

  • risk_free_rate (Float) (defaults to: 0.02)

    Risk-free rate (annualized, default: 0.02)

  • periods_per_year (Integer) (defaults to: 252)

    Number of periods per year (default: 252 for daily)

Returns:

  • (Float)

    Sharpe ratio



269
270
271
272
273
274
275
276
277
278
279
# File 'lib/sqa/risk_manager.rb', line 269

def sharpe_ratio(returns, risk_free_rate: 0.02, periods_per_year: 252)
  return 0.0 if returns.empty?

  excess_returns = returns.map { |r| r - (risk_free_rate / periods_per_year) }
  mean_excess = excess_returns.sum / excess_returns.size.to_f
  std_excess = standard_deviation(excess_returns)

  return 0.0 if std_excess.zero?

  (mean_excess / std_excess) * Math.sqrt(periods_per_year)
end

.sortino_ratio(returns, target_return: 0.0, periods_per_year: 252) ⇒ Float

Calculate Sortino Ratio

Like Sharpe ratio but only penalizes downside volatility.

Examples:

sortino = SQA::RiskManager.sortino_ratio(returns)

Parameters:

  • returns (Array<Float>)

    Array of period returns

  • target_return (Float) (defaults to: 0.0)

    Target return (default: 0.0)

  • periods_per_year (Integer) (defaults to: 252)

    Number of periods per year (default: 252)

Returns:

  • (Float)

    Sortino ratio



294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/sqa/risk_manager.rb', line 294

def sortino_ratio(returns, target_return: 0.0, periods_per_year: 252)
  return 0.0 if returns.empty?

  excess_returns = returns.map { |r| r - target_return }
  mean_excess = excess_returns.sum / excess_returns.size.to_f

  # Downside deviation (only negative returns)
  downside_returns = excess_returns.select { |r| r < 0 }
  return Float::INFINITY if downside_returns.empty?

  downside_deviation = Math.sqrt(
    downside_returns.map { |r| r**2 }.sum / downside_returns.size.to_f
  )

  return 0.0 if downside_deviation.zero?

  (mean_excess / downside_deviation) * Math.sqrt(periods_per_year)
end

.var(returns, confidence: 0.95, method: :historical, simulations: 10_000) ⇒ Float

Calculate Value at Risk (VaR) using historical method

VaR represents the maximum expected loss over a given time period at a specified confidence level.

Examples:

returns = [0.01, -0.02, 0.015, -0.01, 0.005]
var = SQA::RiskManager.var(returns, confidence: 0.95)
# => -0.02 (2% maximum expected loss at 95% confidence)

Parameters:

  • returns (Array<Float>)

    Array of period returns (e.g., daily returns)

  • confidence (Float) (defaults to: 0.95)

    Confidence level (default: 0.95 for 95%)

  • method (Symbol) (defaults to: :historical)

    Method to use (:historical, :parametric, :monte_carlo)

  • simulations (Integer) (defaults to: 10_000)

    Number of Monte Carlo simulations (if method is :monte_carlo)

Returns:

  • (Float)

    Value at Risk as a percentage



48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/sqa/risk_manager.rb', line 48

def var(returns, confidence: 0.95, method: :historical, simulations: 10_000)
  return nil if returns.empty?

  case method
  when :historical
    historical_var(returns, confidence)
  when :parametric
    parametric_var(returns, confidence)
  when :monte_carlo
    monte_carlo_var(returns, confidence, simulations)
  else
    raise ArgumentError, "Unknown VaR method: #{method}"
  end
end