Class: SQA::PortfolioOptimizer

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

Overview

PortfolioOptimizer - Multi-objective portfolio optimization

Provides methods for:

  • Mean-Variance Optimization (Markowitz)
  • Multi-objective optimization (return vs risk vs drawdown)
  • Efficient Frontier calculation
  • Risk Parity allocation
  • Minimum Variance portfolio
  • Maximum Sharpe portfolio

Examples:

Find optimal portfolio weights

returns_matrix = [
  [0.01, -0.02, 0.015],  # Stock 1 returns
  [0.02, 0.01, -0.01],   # Stock 2 returns
  [-0.01, 0.03, 0.02]    # Stock 3 returns
]
weights = SQA::PortfolioOptimizer.maximum_sharpe(returns_matrix)
# => [0.4, 0.3, 0.3]

Class Method Summary collapse

Class Method Details

.efficient_frontier(returns_matrix, num_portfolios: 50) ⇒ Array<Hash>

Calculate Efficient Frontier

Generate portfolios along the efficient frontier.

Parameters:

  • returns_matrix (Array<Array<Float>>)

    Returns for each asset

  • num_portfolios (Integer) (defaults to: 50)

    Number of portfolios to generate

Returns:

  • (Array<Hash>)

    Array of portfolio hashes



175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/sqa/portfolio_optimizer.rb', line 175

def efficient_frontier(returns_matrix, num_portfolios: 50)
  portfolios = []

  num_portfolios.times do
    weights = random_weights(returns_matrix.size, {})

    port_returns = portfolio_returns(returns_matrix, weights)
    mean_return = port_returns.sum / port_returns.size.to_f
    variance = portfolio_variance(returns_matrix, weights)
    volatility = Math.sqrt(variance)

    portfolios << {
      weights: weights,
      return: mean_return * 252,
      volatility: volatility * Math.sqrt(252),
      sharpe: SQA::RiskManager.sharpe_ratio(port_returns)
    }
  end

  # Sort by volatility
  portfolios.sort_by { |p| p[:volatility] }
end

.equal_weight(num_assets) ⇒ Array<Float>

Equal weight portfolio (1/N rule)

Parameters:

  • num_assets (Integer)

    Number of assets

Returns:

  • (Array<Float>)

    Equal weights



285
286
287
288
# File 'lib/sqa/portfolio_optimizer.rb', line 285

def equal_weight(num_assets)
  weight = 1.0 / num_assets
  Array.new(num_assets, weight)
end

.maximum_sharpe(returns_matrix, risk_free_rate: 0.02, constraints: {}) ⇒ Hash

Find Maximum Sharpe Ratio portfolio

Uses numerical optimization to find weights that maximize Sharpe ratio.

Parameters:

  • returns_matrix (Array<Array<Float>>)

    Returns for each asset

  • risk_free_rate (Float) (defaults to: 0.02)

    Risk-free rate (default: 0.02)

  • constraints (Hash) (defaults to: {})

    Optimization constraints

Returns:

  • (Hash)

    { weights: Array, sharpe: Float, return: Float, volatility: Float }



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/sqa/portfolio_optimizer.rb', line 75

def maximum_sharpe(returns_matrix, risk_free_rate: 0.02, constraints: {})
  num_assets = returns_matrix.size

  # Grid search optimization (simple but effective)
  best_sharpe = -Float::INFINITY
  best_weights = nil

  # Try random portfolios
  10_000.times do
    weights = random_weights(num_assets, constraints)

    port_returns = portfolio_returns(returns_matrix, weights)
    sharpe = SQA::RiskManager.sharpe_ratio(port_returns, risk_free_rate: risk_free_rate)

    if sharpe > best_sharpe
      best_sharpe = sharpe
      best_weights = weights
    end
  end

  port_returns = portfolio_returns(returns_matrix, best_weights)
  mean_return = port_returns.sum / port_returns.size.to_f
  volatility = Math.sqrt(portfolio_variance(returns_matrix, best_weights))

  {
    weights: best_weights,
    sharpe: best_sharpe,
    return: mean_return * 252,  # Annualized
    volatility: volatility * Math.sqrt(252)  # Annualized
  }
end

.minimum_variance(returns_matrix, constraints: {}) ⇒ Hash

Find Minimum Variance portfolio

Parameters:

  • returns_matrix (Array<Array<Float>>)

    Returns for each asset

  • constraints (Hash) (defaults to: {})

    Optimization constraints

Returns:

  • (Hash)

    { weights: Array, variance: Float, volatility: Float }



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/sqa/portfolio_optimizer.rb', line 114

def minimum_variance(returns_matrix, constraints: {})
  num_assets = returns_matrix.size

  best_variance = Float::INFINITY
  best_weights = nil

  # Grid search
  10_000.times do
    weights = random_weights(num_assets, constraints)
    variance = portfolio_variance(returns_matrix, weights)

    if variance < best_variance
      best_variance = variance
      best_weights = weights
    end
  end

  {
    weights: best_weights,
    variance: best_variance,
    volatility: Math.sqrt(best_variance) * Math.sqrt(252)  # Annualized
  }
end

.multi_objective(returns_matrix, objectives: {}) ⇒ Hash

Multi-objective optimization

Optimize portfolio for multiple objectives simultaneously.

Examples:

result = SQA::PortfolioOptimizer.multi_objective(
  returns_matrix,
  objectives: {
    maximize_return: 0.4,
    minimize_volatility: 0.3,
    minimize_drawdown: 0.3
  }
)

Parameters:

  • returns_matrix (Array<Array<Float>>)

    Returns for each asset

  • objectives (Hash) (defaults to: {})

    Objectives with weights

Returns:

  • (Hash)

    Optimal portfolio



217
218
219
220
221
222
223
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
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/sqa/portfolio_optimizer.rb', line 217

def multi_objective(returns_matrix, objectives: {})
  num_assets = returns_matrix.size

  best_score = -Float::INFINITY
  best_portfolio = nil

  # Default objectives
  objectives = {
    maximize_return: 0.33,
    minimize_volatility: 0.33,
    minimize_drawdown: 0.34
  } if objectives.empty?

  # Normalize objective weights
  total_weight = objectives.values.sum
  objectives = objectives.transform_values { |v| v / total_weight }

  # Grid search
  10_000.times do
    weights = random_weights(num_assets, {})

    port_returns = portfolio_returns(returns_matrix, weights)
    mean_return = port_returns.sum / port_returns.size.to_f
    variance = portfolio_variance(returns_matrix, weights)
    volatility = Math.sqrt(variance)

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

    # Calculate composite score
    score = 0.0

    # Normalize and combine objectives
    if objectives[:maximize_return]
      score += (mean_return * 252) * objectives[:maximize_return] * 10  # Scale up
    end

    if objectives[:minimize_volatility]
      score -= (volatility * Math.sqrt(252)) * objectives[:minimize_volatility] * 10
    end

    if objectives[:minimize_drawdown]
      score -= max_dd * objectives[:minimize_drawdown] * 10
    end

    if score > best_score
      best_score = score
      best_portfolio = {
        weights: weights,
        return: mean_return * 252,
        volatility: volatility * Math.sqrt(252),
        max_drawdown: max_dd,
        sharpe: SQA::RiskManager.sharpe_ratio(port_returns),
        composite_score: score
      }
    end
  end

  best_portfolio
end

.portfolio_returns(returns_matrix, weights) ⇒ Array<Float>

Calculate portfolio returns given weights

Parameters:

  • returns_matrix (Array<Array<Float>>)

    Returns for each asset (rows = assets, cols = periods)

  • weights (Array<Float>)

    Portfolio weights (must sum to 1.0)

Returns:

  • (Array<Float>)

    Portfolio returns over time



34
35
36
37
38
39
40
41
42
# File 'lib/sqa/portfolio_optimizer.rb', line 34

def portfolio_returns(returns_matrix, weights)
  num_periods = returns_matrix.first.size

  num_periods.times.map do |period_idx|
    returns_matrix.each_with_index.sum do |asset_returns, asset_idx|
      asset_returns[period_idx] * weights[asset_idx]
    end
  end
end

.portfolio_variance(returns_matrix, weights) ⇒ Float

Calculate portfolio variance

Parameters:

  • returns_matrix (Array<Array<Float>>)

    Returns for each asset

  • weights (Array<Float>)

    Portfolio weights

Returns:

  • (Float)

    Portfolio variance



51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/sqa/portfolio_optimizer.rb', line 51

def portfolio_variance(returns_matrix, weights)
  covariance_matrix = calculate_covariance_matrix(returns_matrix)

  # Portfolio variance = w^T * Σ * w
  variance = 0.0
  weights.each_with_index do |wi, i|
    weights.each_with_index do |wj, j|
      variance += wi * wj * covariance_matrix[i][j]
    end
  end

  variance
end

.rebalance(current_values:, target_weights:, total_value:, prices:) ⇒ Hash

Rebalance portfolio to target weights

Parameters:

  • current_values (Hash)

    Current holdings { ticker => value }

  • target_weights (Hash)

    Target weights { ticker => weight }

  • total_value (Float)

    Total portfolio value

Returns:

  • (Hash)

    Rebalancing trades { ticker => { action: :buy/:sell, shares: N, value: $ } }



298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
# File 'lib/sqa/portfolio_optimizer.rb', line 298

def rebalance(current_values:, target_weights:, total_value:, prices:)
  trades = {}

  target_weights.each do |ticker, target_weight|
    current_value = current_values[ticker] || 0.0
    target_value = total_value * target_weight
    difference = target_value - current_value

    next if difference.abs < 1.0  # Skip tiny adjustments

    price = prices[ticker]
    next if price.nil? || price.zero?

    shares = (difference / price).round

    trades[ticker] = {
      action: shares > 0 ? :buy : :sell,
      shares: shares.abs,
      value: shares * price,
      current_weight: current_value / total_value,
      target_weight: target_weight
    }
  end

  trades
end

.risk_parity(returns_matrix) ⇒ Hash

Calculate Risk Parity portfolio

Allocate weights so each asset contributes equally to portfolio risk.

Parameters:

  • returns_matrix (Array<Array<Float>>)

    Returns for each asset

Returns:

  • (Hash)

    { weights: Array, volatility: Float }



146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/sqa/portfolio_optimizer.rb', line 146

def risk_parity(returns_matrix)
  # Calculate individual volatilities
  volatilities = returns_matrix.map do |asset_returns|
    mean = asset_returns.sum / asset_returns.size.to_f
    variance = asset_returns.map { |r| (r - mean)**2 }.sum / asset_returns.size.to_f
    Math.sqrt(variance)
  end

  # Inverse volatility weighting (approximation of risk parity)
  inv_vols = volatilities.map { |v| 1.0 / v }
  sum_inv_vols = inv_vols.sum

  weights = inv_vols.map { |iv| iv / sum_inv_vols }

  {
    weights: weights,
    volatility: Math.sqrt(portfolio_variance(returns_matrix, weights)) * Math.sqrt(252)
  }
end