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.



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)



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.



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



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
  }
)


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



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



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



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.



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