sqa 0.0.24 → 0.0.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.goose/memory/development.txt +3 -0
- data/.semver +6 -0
- data/ARCHITECTURE.md +648 -0
- data/CHANGELOG.md +82 -0
- data/CLAUDE.md +653 -0
- data/COMMITS.md +196 -0
- data/DATAFRAME_ARCHITECTURE_REVIEW.md +421 -0
- data/NEXT-STEPS.md +154 -0
- data/README.md +812 -262
- data/TASKS.md +358 -0
- data/TEST_RESULTS.md +140 -0
- data/TODO.md +42 -0
- data/_notes.txt +25 -0
- data/bin/sqa-console +11 -0
- data/data/talk_talk.json +103284 -0
- data/develop_summary.md +313 -0
- data/docs/advanced/backtesting.md +206 -0
- data/docs/advanced/ensemble.md +68 -0
- data/docs/advanced/fpop.md +153 -0
- data/docs/advanced/index.md +112 -0
- data/docs/advanced/multi-timeframe.md +67 -0
- data/docs/advanced/pattern-matcher.md +75 -0
- data/docs/advanced/portfolio-optimizer.md +79 -0
- data/docs/advanced/portfolio.md +166 -0
- data/docs/advanced/risk-management.md +210 -0
- data/docs/advanced/strategy-generator.md +158 -0
- data/docs/advanced/streaming.md +209 -0
- data/docs/ai_and_ml.md +80 -0
- data/docs/api/dataframe.md +1115 -0
- data/docs/api/index.md +126 -0
- data/docs/assets/css/custom.css +88 -0
- data/docs/assets/js/mathjax.js +18 -0
- data/docs/concepts/index.md +68 -0
- data/docs/contributing/index.md +60 -0
- data/docs/data-sources/index.md +66 -0
- data/docs/data_frame.md +317 -97
- data/docs/factors_that_impact_price.md +26 -0
- data/docs/finviz.md +11 -0
- data/docs/fx_pro_bit.md +25 -0
- data/docs/genetic_programming.md +104 -0
- data/docs/getting-started/index.md +123 -0
- data/docs/getting-started/installation.md +229 -0
- data/docs/getting-started/quick-start.md +244 -0
- data/docs/i_gotta_an_idea.md +22 -0
- data/docs/index.md +163 -0
- data/docs/indicators/index.md +97 -0
- data/docs/indicators.md +110 -24
- data/docs/options.md +8 -0
- data/docs/strategies/bollinger-bands.md +146 -0
- data/docs/strategies/consensus.md +64 -0
- data/docs/strategies/custom.md +310 -0
- data/docs/strategies/ema.md +53 -0
- data/docs/strategies/index.md +92 -0
- data/docs/strategies/kbs.md +164 -0
- data/docs/strategies/macd.md +96 -0
- data/docs/strategies/market-profile.md +54 -0
- data/docs/strategies/mean-reversion.md +58 -0
- data/docs/strategies/rsi.md +95 -0
- data/docs/strategies/sma.md +55 -0
- data/docs/strategies/stochastic.md +63 -0
- data/docs/strategies/volume-breakout.md +54 -0
- data/docs/tags.md +7 -0
- data/docs/true_strength_index.md +46 -0
- data/docs/weighted_moving_average.md +48 -0
- data/examples/README.md +354 -0
- data/examples/advanced_features_example.rb +350 -0
- data/examples/fpop_analysis_example.rb +191 -0
- data/examples/genetic_programming_example.rb +148 -0
- data/examples/kbs_strategy_example.rb +208 -0
- data/examples/pattern_context_example.rb +300 -0
- data/examples/rails_app/Gemfile +34 -0
- data/examples/rails_app/README.md +416 -0
- data/examples/rails_app/app/assets/javascripts/application.js +107 -0
- data/examples/rails_app/app/assets/stylesheets/application.css +659 -0
- data/examples/rails_app/app/controllers/analysis_controller.rb +11 -0
- data/examples/rails_app/app/controllers/api/v1/stocks_controller.rb +227 -0
- data/examples/rails_app/app/controllers/application_controller.rb +22 -0
- data/examples/rails_app/app/controllers/backtest_controller.rb +11 -0
- data/examples/rails_app/app/controllers/dashboard_controller.rb +21 -0
- data/examples/rails_app/app/controllers/portfolio_controller.rb +7 -0
- data/examples/rails_app/app/views/analysis/show.html.erb +209 -0
- data/examples/rails_app/app/views/backtest/show.html.erb +171 -0
- data/examples/rails_app/app/views/dashboard/index.html.erb +118 -0
- data/examples/rails_app/app/views/dashboard/show.html.erb +408 -0
- data/examples/rails_app/app/views/errors/show.html.erb +17 -0
- data/examples/rails_app/app/views/layouts/application.html.erb +60 -0
- data/examples/rails_app/app/views/portfolio/index.html.erb +33 -0
- data/examples/rails_app/bin/rails +6 -0
- data/examples/rails_app/config/application.rb +45 -0
- data/examples/rails_app/config/boot.rb +5 -0
- data/examples/rails_app/config/database.yml +18 -0
- data/examples/rails_app/config/environment.rb +11 -0
- data/examples/rails_app/config/routes.rb +26 -0
- data/examples/rails_app/config.ru +8 -0
- data/examples/realtime_stream_example.rb +274 -0
- data/examples/sinatra_app/Gemfile +22 -0
- data/examples/sinatra_app/QUICKSTART.md +159 -0
- data/examples/sinatra_app/README.md +461 -0
- data/examples/sinatra_app/app.rb +344 -0
- data/examples/sinatra_app/config.ru +5 -0
- data/examples/sinatra_app/public/css/style.css +659 -0
- data/examples/sinatra_app/public/js/app.js +107 -0
- data/examples/sinatra_app/views/analyze.erb +306 -0
- data/examples/sinatra_app/views/backtest.erb +325 -0
- data/examples/sinatra_app/views/dashboard.erb +419 -0
- data/examples/sinatra_app/views/error.erb +58 -0
- data/examples/sinatra_app/views/index.erb +118 -0
- data/examples/sinatra_app/views/layout.erb +61 -0
- data/examples/sinatra_app/views/portfolio.erb +43 -0
- data/examples/strategy_generator_example.rb +346 -0
- data/hsa_portfolio.csv +11 -0
- data/justfile +0 -0
- data/lib/api/alpha_vantage_api.rb +462 -0
- data/lib/sqa/backtest.rb +329 -0
- data/lib/sqa/data_frame/alpha_vantage.rb +43 -65
- data/lib/sqa/data_frame/data.rb +92 -0
- data/lib/sqa/data_frame/yahoo_finance.rb +35 -43
- data/lib/sqa/data_frame.rb +148 -243
- data/lib/sqa/ensemble.rb +359 -0
- data/lib/sqa/fpop.rb +199 -0
- data/lib/sqa/gp.rb +259 -0
- data/lib/sqa/indicator.rb +5 -8
- data/lib/sqa/init.rb +15 -8
- data/lib/sqa/market_regime.rb +240 -0
- data/lib/sqa/multi_timeframe.rb +379 -0
- data/lib/sqa/pattern_matcher.rb +497 -0
- data/lib/sqa/portfolio.rb +260 -6
- data/lib/sqa/portfolio_optimizer.rb +377 -0
- data/lib/sqa/risk_manager.rb +442 -0
- data/lib/sqa/seasonal_analyzer.rb +209 -0
- data/lib/sqa/sector_analyzer.rb +300 -0
- data/lib/sqa/stock.rb +67 -125
- data/lib/sqa/strategy/bollinger_bands.rb +42 -0
- data/lib/sqa/strategy/consensus.rb +5 -2
- data/lib/sqa/strategy/kbs_strategy.rb +470 -0
- data/lib/sqa/strategy/macd.rb +46 -0
- data/lib/sqa/strategy/mp.rb +1 -1
- data/lib/sqa/strategy/stochastic.rb +60 -0
- data/lib/sqa/strategy/volume_breakout.rb +57 -0
- data/lib/sqa/strategy.rb +5 -0
- data/lib/sqa/strategy_generator.rb +947 -0
- data/lib/sqa/stream.rb +361 -0
- data/lib/sqa/version.rb +1 -7
- data/lib/sqa.rb +23 -16
- data/main.just +81 -0
- data/mkdocs.yml +288 -0
- data/trace.log +0 -0
- metadata +261 -51
- data/bin/sqa +0 -6
- data/lib/patches/dry-cli.rb +0 -228
- data/lib/sqa/activity.rb +0 -10
- data/lib/sqa/cli.rb +0 -62
- data/lib/sqa/commands/analysis.rb +0 -309
- data/lib/sqa/commands/base.rb +0 -139
- data/lib/sqa/commands/web.rb +0 -199
- data/lib/sqa/commands.rb +0 -22
- data/lib/sqa/constants.rb +0 -23
- data/lib/sqa/indicator/average_true_range.rb +0 -33
- data/lib/sqa/indicator/bollinger_bands.rb +0 -28
- data/lib/sqa/indicator/candlestick_pattern_recognizer.rb +0 -60
- data/lib/sqa/indicator/donchian_channel.rb +0 -29
- data/lib/sqa/indicator/double_top_bottom_pattern.rb +0 -34
- data/lib/sqa/indicator/elliott_wave_theory.rb +0 -57
- data/lib/sqa/indicator/exponential_moving_average.rb +0 -25
- data/lib/sqa/indicator/exponential_moving_average_trend.rb +0 -36
- data/lib/sqa/indicator/fibonacci_retracement.rb +0 -23
- data/lib/sqa/indicator/head_and_shoulders_pattern.rb +0 -26
- data/lib/sqa/indicator/market_profile.rb +0 -32
- data/lib/sqa/indicator/mean_reversion.rb +0 -37
- data/lib/sqa/indicator/momentum.rb +0 -28
- data/lib/sqa/indicator/moving_average_convergence_divergence.rb +0 -29
- data/lib/sqa/indicator/peaks_and_valleys.rb +0 -29
- data/lib/sqa/indicator/predict_next_value.rb +0 -202
- data/lib/sqa/indicator/relative_strength_index.rb +0 -47
- data/lib/sqa/indicator/simple_moving_average.rb +0 -24
- data/lib/sqa/indicator/simple_moving_average_trend.rb +0 -32
- data/lib/sqa/indicator/stochastic_oscillator.rb +0 -68
- data/lib/sqa/indicator/true_range.rb +0 -39
- data/lib/sqa/trade.rb +0 -26
data/lib/sqa/portfolio.rb
CHANGED
|
@@ -1,11 +1,265 @@
|
|
|
1
1
|
# lib/sqa/portfolio.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'date'
|
|
5
|
+
require 'csv'
|
|
2
6
|
|
|
3
7
|
class SQA::Portfolio
|
|
4
|
-
|
|
8
|
+
attr_accessor :positions, :trades, :cash, :initial_cash
|
|
9
|
+
|
|
10
|
+
# Represents a single position in the portfolio
|
|
11
|
+
Position = Struct.new(:ticker, :shares, :avg_cost, :total_cost) do
|
|
12
|
+
def value(current_price)
|
|
13
|
+
shares * current_price
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def profit_loss(current_price)
|
|
17
|
+
(current_price - avg_cost) * shares
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def profit_loss_percent(current_price)
|
|
21
|
+
return 0.0 if avg_cost.zero?
|
|
22
|
+
((current_price - avg_cost) / avg_cost) * 100.0
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Represents a single trade
|
|
27
|
+
Trade = Struct.new(:ticker, :action, :shares, :price, :date, :total, :commission) do
|
|
28
|
+
def to_h
|
|
29
|
+
{
|
|
30
|
+
ticker: ticker,
|
|
31
|
+
action: action,
|
|
32
|
+
shares: shares,
|
|
33
|
+
price: price,
|
|
34
|
+
date: date,
|
|
35
|
+
total: total,
|
|
36
|
+
commission: commission
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(initial_cash: 10_000.0, commission: 0.0)
|
|
42
|
+
@initial_cash = initial_cash
|
|
43
|
+
@cash = initial_cash
|
|
44
|
+
@commission = commission # Commission per trade (flat fee or percentage)
|
|
45
|
+
@positions = {} # { ticker => Position }
|
|
46
|
+
@trades = [] # Array of Trade objects
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Buy shares of a stock
|
|
50
|
+
# @param ticker [String] Stock ticker symbol
|
|
51
|
+
# @param shares [Integer] Number of shares to buy
|
|
52
|
+
# @param price [Float] Price per share
|
|
53
|
+
# @param date [Date] Date of trade
|
|
54
|
+
# @return [Trade] The executed trade
|
|
55
|
+
def buy(ticker, shares:, price:, date: Date.today)
|
|
56
|
+
raise BadParameterError, "Shares must be positive" if shares <= 0
|
|
57
|
+
raise BadParameterError, "Price must be positive" if price <= 0
|
|
58
|
+
|
|
59
|
+
total_cost = shares * price
|
|
60
|
+
commission = calculate_commission(total_cost)
|
|
61
|
+
total_with_commission = total_cost + commission
|
|
62
|
+
|
|
63
|
+
raise "Insufficient funds: need #{total_with_commission}, have #{@cash}" if total_with_commission > @cash
|
|
64
|
+
|
|
65
|
+
# Update or create position
|
|
66
|
+
if @positions[ticker]
|
|
67
|
+
pos = @positions[ticker]
|
|
68
|
+
total_shares = pos.shares + shares
|
|
69
|
+
total_cost_basis = pos.total_cost + total_cost
|
|
70
|
+
pos.shares = total_shares
|
|
71
|
+
pos.avg_cost = total_cost_basis / total_shares
|
|
72
|
+
pos.total_cost = total_cost_basis
|
|
73
|
+
else
|
|
74
|
+
@positions[ticker] = Position.new(
|
|
75
|
+
ticker,
|
|
76
|
+
shares,
|
|
77
|
+
price,
|
|
78
|
+
total_cost
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Deduct cash
|
|
83
|
+
@cash -= total_with_commission
|
|
84
|
+
|
|
85
|
+
# Record trade
|
|
86
|
+
trade = Trade.new(ticker, :buy, shares, price, date, total_cost, commission)
|
|
87
|
+
@trades << trade
|
|
88
|
+
|
|
89
|
+
trade
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Sell shares of a stock
|
|
93
|
+
# @param ticker [String] Stock ticker symbol
|
|
94
|
+
# @param shares [Integer] Number of shares to sell
|
|
95
|
+
# @param price [Float] Price per share
|
|
96
|
+
# @param date [Date] Date of trade
|
|
97
|
+
# @return [Trade] The executed trade
|
|
98
|
+
def sell(ticker, shares:, price:, date: Date.today)
|
|
99
|
+
raise BadParameterError, "Shares must be positive" if shares <= 0
|
|
100
|
+
raise BadParameterError, "Price must be positive" if price <= 0
|
|
101
|
+
raise "No position in #{ticker}" unless @positions[ticker]
|
|
102
|
+
|
|
103
|
+
pos = @positions[ticker]
|
|
104
|
+
raise "Insufficient shares: trying to sell #{shares}, have #{pos.shares}" if shares > pos.shares
|
|
105
|
+
|
|
106
|
+
total_sale = shares * price
|
|
107
|
+
commission = calculate_commission(total_sale)
|
|
108
|
+
net_proceeds = total_sale - commission
|
|
109
|
+
|
|
110
|
+
# Update position
|
|
111
|
+
if shares == pos.shares
|
|
112
|
+
# Selling entire position
|
|
113
|
+
@positions.delete(ticker)
|
|
114
|
+
else
|
|
115
|
+
# Partial sale - reduce shares and total cost proportionally
|
|
116
|
+
cost_per_share = pos.total_cost / pos.shares
|
|
117
|
+
pos.shares -= shares
|
|
118
|
+
pos.total_cost -= (cost_per_share * shares)
|
|
119
|
+
# avg_cost stays the same
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Add cash
|
|
123
|
+
@cash += net_proceeds
|
|
124
|
+
|
|
125
|
+
# Record trade
|
|
126
|
+
trade = Trade.new(ticker, :sell, shares, price, date, total_sale, commission)
|
|
127
|
+
@trades << trade
|
|
128
|
+
|
|
129
|
+
trade
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Get current position for a ticker
|
|
133
|
+
# @param ticker [String] Stock ticker symbol
|
|
134
|
+
# @return [Position, nil] The position or nil if not found
|
|
135
|
+
def position(ticker)
|
|
136
|
+
@positions[ticker]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Get all current positions
|
|
140
|
+
# @return [Hash] Hash of ticker => Position
|
|
141
|
+
def all_positions
|
|
142
|
+
@positions
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Calculate total portfolio value
|
|
146
|
+
# @param current_prices [Hash] Hash of ticker => current_price
|
|
147
|
+
# @return [Float] Total portfolio value (cash + positions)
|
|
148
|
+
def value(current_prices = {})
|
|
149
|
+
positions_value = @positions.sum do |ticker, pos|
|
|
150
|
+
current_price = current_prices[ticker] || pos.avg_cost
|
|
151
|
+
pos.value(current_price)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
@cash + positions_value
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Calculate total profit/loss across all positions
|
|
158
|
+
# @param current_prices [Hash] Hash of ticker => current_price
|
|
159
|
+
# @return [Float] Total P&L
|
|
160
|
+
def profit_loss(current_prices = {})
|
|
161
|
+
value(current_prices) - @initial_cash
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Calculate profit/loss percentage
|
|
165
|
+
# @param current_prices [Hash] Hash of ticker => current_price
|
|
166
|
+
# @return [Float] P&L percentage
|
|
167
|
+
def profit_loss_percent(current_prices = {})
|
|
168
|
+
return 0.0 if @initial_cash.zero?
|
|
169
|
+
(profit_loss(current_prices) / @initial_cash) * 100.0
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Calculate total return (including dividends if tracked)
|
|
173
|
+
# @param current_prices [Hash] Hash of ticker => current_price
|
|
174
|
+
# @return [Float] Total return as decimal (e.g., 0.15 for 15%)
|
|
175
|
+
def total_return(current_prices = {})
|
|
176
|
+
return 0.0 if @initial_cash.zero?
|
|
177
|
+
profit_loss(current_prices) / @initial_cash
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Get trade history
|
|
181
|
+
# @return [Array<Trade>] Array of all trades
|
|
182
|
+
def trade_history
|
|
183
|
+
@trades
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Get summary statistics
|
|
187
|
+
# @param current_prices [Hash] Hash of ticker => current_price
|
|
188
|
+
# @return [Hash] Summary statistics
|
|
189
|
+
def summary(current_prices = {})
|
|
190
|
+
{
|
|
191
|
+
initial_cash: @initial_cash,
|
|
192
|
+
current_cash: @cash,
|
|
193
|
+
positions_count: @positions.size,
|
|
194
|
+
total_value: value(current_prices),
|
|
195
|
+
profit_loss: profit_loss(current_prices),
|
|
196
|
+
profit_loss_percent: profit_loss_percent(current_prices),
|
|
197
|
+
total_return: total_return(current_prices),
|
|
198
|
+
total_trades: @trades.size,
|
|
199
|
+
buy_trades: @trades.count { |t| t.action == :buy },
|
|
200
|
+
sell_trades: @trades.count { |t| t.action == :sell }
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Save portfolio to CSV file
|
|
205
|
+
# @param filename [String] Path to CSV file
|
|
206
|
+
def save_to_csv(filename)
|
|
207
|
+
CSV.open(filename, 'wb') do |csv|
|
|
208
|
+
csv << ['ticker', 'shares', 'avg_cost', 'total_cost']
|
|
209
|
+
@positions.each do |ticker, pos|
|
|
210
|
+
csv << [ticker, pos.shares, pos.avg_cost, pos.total_cost]
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Save trade history to CSV file
|
|
216
|
+
# @param filename [String] Path to CSV file
|
|
217
|
+
def save_trades_to_csv(filename)
|
|
218
|
+
CSV.open(filename, 'wb') do |csv|
|
|
219
|
+
csv << ['date', 'ticker', 'action', 'shares', 'price', 'total', 'commission']
|
|
220
|
+
@trades.each do |trade|
|
|
221
|
+
csv << [
|
|
222
|
+
trade.date,
|
|
223
|
+
trade.ticker,
|
|
224
|
+
trade.action,
|
|
225
|
+
trade.shares,
|
|
226
|
+
trade.price,
|
|
227
|
+
trade.total,
|
|
228
|
+
trade.commission
|
|
229
|
+
]
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Load portfolio from CSV file
|
|
235
|
+
# @param filename [String] Path to CSV file
|
|
236
|
+
def self.load_from_csv(filename)
|
|
237
|
+
portfolio = new(initial_cash: 0)
|
|
238
|
+
|
|
239
|
+
CSV.foreach(filename, headers: true) do |row|
|
|
240
|
+
ticker = row['ticker']
|
|
241
|
+
shares = row['shares'].to_i
|
|
242
|
+
avg_cost = row['avg_cost'].to_f
|
|
243
|
+
total_cost = row['total_cost'].to_f
|
|
244
|
+
|
|
245
|
+
portfolio.positions[ticker] = Position.new(
|
|
246
|
+
ticker,
|
|
247
|
+
shares,
|
|
248
|
+
avg_cost,
|
|
249
|
+
total_cost
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
portfolio
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
private
|
|
5
257
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
258
|
+
# Calculate commission for a trade
|
|
259
|
+
# @param total [Float] Total trade value
|
|
260
|
+
# @return [Float] Commission amount
|
|
261
|
+
def calculate_commission(total)
|
|
262
|
+
# Simple flat commission model
|
|
263
|
+
@commission
|
|
264
|
+
end
|
|
11
265
|
end
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# lib/sqa/portfolio_optimizer.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module SQA
|
|
5
|
+
##
|
|
6
|
+
# PortfolioOptimizer - Multi-objective portfolio optimization
|
|
7
|
+
#
|
|
8
|
+
# Provides methods for:
|
|
9
|
+
# - Mean-Variance Optimization (Markowitz)
|
|
10
|
+
# - Multi-objective optimization (return vs risk vs drawdown)
|
|
11
|
+
# - Efficient Frontier calculation
|
|
12
|
+
# - Risk Parity allocation
|
|
13
|
+
# - Minimum Variance portfolio
|
|
14
|
+
# - Maximum Sharpe portfolio
|
|
15
|
+
#
|
|
16
|
+
# @example Find optimal portfolio weights
|
|
17
|
+
# returns_matrix = [
|
|
18
|
+
# [0.01, -0.02, 0.015], # Stock 1 returns
|
|
19
|
+
# [0.02, 0.01, -0.01], # Stock 2 returns
|
|
20
|
+
# [-0.01, 0.03, 0.02] # Stock 3 returns
|
|
21
|
+
# ]
|
|
22
|
+
# weights = SQA::PortfolioOptimizer.maximum_sharpe(returns_matrix)
|
|
23
|
+
# # => [0.4, 0.3, 0.3]
|
|
24
|
+
#
|
|
25
|
+
class PortfolioOptimizer
|
|
26
|
+
class << self
|
|
27
|
+
##
|
|
28
|
+
# Calculate portfolio returns given weights
|
|
29
|
+
#
|
|
30
|
+
# @param returns_matrix [Array<Array<Float>>] Returns for each asset (rows = assets, cols = periods)
|
|
31
|
+
# @param weights [Array<Float>] Portfolio weights (must sum to 1.0)
|
|
32
|
+
# @return [Array<Float>] Portfolio returns over time
|
|
33
|
+
#
|
|
34
|
+
def portfolio_returns(returns_matrix, weights)
|
|
35
|
+
num_periods = returns_matrix.first.size
|
|
36
|
+
|
|
37
|
+
num_periods.times.map do |period_idx|
|
|
38
|
+
returns_matrix.each_with_index.sum do |asset_returns, asset_idx|
|
|
39
|
+
asset_returns[period_idx] * weights[asset_idx]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# Calculate portfolio variance
|
|
46
|
+
#
|
|
47
|
+
# @param returns_matrix [Array<Array<Float>>] Returns for each asset
|
|
48
|
+
# @param weights [Array<Float>] Portfolio weights
|
|
49
|
+
# @return [Float] Portfolio variance
|
|
50
|
+
#
|
|
51
|
+
def portfolio_variance(returns_matrix, weights)
|
|
52
|
+
covariance_matrix = calculate_covariance_matrix(returns_matrix)
|
|
53
|
+
|
|
54
|
+
# Portfolio variance = w^T * Σ * w
|
|
55
|
+
variance = 0.0
|
|
56
|
+
weights.each_with_index do |wi, i|
|
|
57
|
+
weights.each_with_index do |wj, j|
|
|
58
|
+
variance += wi * wj * covariance_matrix[i][j]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
variance
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
##
|
|
66
|
+
# Find Maximum Sharpe Ratio portfolio
|
|
67
|
+
#
|
|
68
|
+
# Uses numerical optimization to find weights that maximize Sharpe ratio.
|
|
69
|
+
#
|
|
70
|
+
# @param returns_matrix [Array<Array<Float>>] Returns for each asset
|
|
71
|
+
# @param risk_free_rate [Float] Risk-free rate (default: 0.02)
|
|
72
|
+
# @param constraints [Hash] Optimization constraints
|
|
73
|
+
# @return [Hash] { weights: Array, sharpe: Float, return: Float, volatility: Float }
|
|
74
|
+
#
|
|
75
|
+
def maximum_sharpe(returns_matrix, risk_free_rate: 0.02, constraints: {})
|
|
76
|
+
num_assets = returns_matrix.size
|
|
77
|
+
|
|
78
|
+
# Grid search optimization (simple but effective)
|
|
79
|
+
best_sharpe = -Float::INFINITY
|
|
80
|
+
best_weights = nil
|
|
81
|
+
|
|
82
|
+
# Try random portfolios
|
|
83
|
+
10_000.times do
|
|
84
|
+
weights = random_weights(num_assets, constraints)
|
|
85
|
+
|
|
86
|
+
port_returns = portfolio_returns(returns_matrix, weights)
|
|
87
|
+
sharpe = SQA::RiskManager.sharpe_ratio(port_returns, risk_free_rate: risk_free_rate)
|
|
88
|
+
|
|
89
|
+
if sharpe > best_sharpe
|
|
90
|
+
best_sharpe = sharpe
|
|
91
|
+
best_weights = weights
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
port_returns = portfolio_returns(returns_matrix, best_weights)
|
|
96
|
+
mean_return = port_returns.sum / port_returns.size.to_f
|
|
97
|
+
volatility = Math.sqrt(portfolio_variance(returns_matrix, best_weights))
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
weights: best_weights,
|
|
101
|
+
sharpe: best_sharpe,
|
|
102
|
+
return: mean_return * 252, # Annualized
|
|
103
|
+
volatility: volatility * Math.sqrt(252) # Annualized
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
##
|
|
108
|
+
# Find Minimum Variance portfolio
|
|
109
|
+
#
|
|
110
|
+
# @param returns_matrix [Array<Array<Float>>] Returns for each asset
|
|
111
|
+
# @param constraints [Hash] Optimization constraints
|
|
112
|
+
# @return [Hash] { weights: Array, variance: Float, volatility: Float }
|
|
113
|
+
#
|
|
114
|
+
def minimum_variance(returns_matrix, constraints: {})
|
|
115
|
+
num_assets = returns_matrix.size
|
|
116
|
+
|
|
117
|
+
best_variance = Float::INFINITY
|
|
118
|
+
best_weights = nil
|
|
119
|
+
|
|
120
|
+
# Grid search
|
|
121
|
+
10_000.times do
|
|
122
|
+
weights = random_weights(num_assets, constraints)
|
|
123
|
+
variance = portfolio_variance(returns_matrix, weights)
|
|
124
|
+
|
|
125
|
+
if variance < best_variance
|
|
126
|
+
best_variance = variance
|
|
127
|
+
best_weights = weights
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
weights: best_weights,
|
|
133
|
+
variance: best_variance,
|
|
134
|
+
volatility: Math.sqrt(best_variance) * Math.sqrt(252) # Annualized
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
##
|
|
139
|
+
# Calculate Risk Parity portfolio
|
|
140
|
+
#
|
|
141
|
+
# Allocate weights so each asset contributes equally to portfolio risk.
|
|
142
|
+
#
|
|
143
|
+
# @param returns_matrix [Array<Array<Float>>] Returns for each asset
|
|
144
|
+
# @return [Hash] { weights: Array, volatility: Float }
|
|
145
|
+
#
|
|
146
|
+
def risk_parity(returns_matrix)
|
|
147
|
+
# Calculate individual volatilities
|
|
148
|
+
volatilities = returns_matrix.map do |asset_returns|
|
|
149
|
+
mean = asset_returns.sum / asset_returns.size.to_f
|
|
150
|
+
variance = asset_returns.map { |r| (r - mean)**2 }.sum / asset_returns.size.to_f
|
|
151
|
+
Math.sqrt(variance)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Inverse volatility weighting (approximation of risk parity)
|
|
155
|
+
inv_vols = volatilities.map { |v| 1.0 / v }
|
|
156
|
+
sum_inv_vols = inv_vols.sum
|
|
157
|
+
|
|
158
|
+
weights = inv_vols.map { |iv| iv / sum_inv_vols }
|
|
159
|
+
|
|
160
|
+
{
|
|
161
|
+
weights: weights,
|
|
162
|
+
volatility: Math.sqrt(portfolio_variance(returns_matrix, weights)) * Math.sqrt(252)
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
##
|
|
167
|
+
# Calculate Efficient Frontier
|
|
168
|
+
#
|
|
169
|
+
# Generate portfolios along the efficient frontier.
|
|
170
|
+
#
|
|
171
|
+
# @param returns_matrix [Array<Array<Float>>] Returns for each asset
|
|
172
|
+
# @param num_portfolios [Integer] Number of portfolios to generate
|
|
173
|
+
# @return [Array<Hash>] Array of portfolio hashes
|
|
174
|
+
#
|
|
175
|
+
def efficient_frontier(returns_matrix, num_portfolios: 50)
|
|
176
|
+
portfolios = []
|
|
177
|
+
|
|
178
|
+
num_portfolios.times do
|
|
179
|
+
weights = random_weights(returns_matrix.size, {})
|
|
180
|
+
|
|
181
|
+
port_returns = portfolio_returns(returns_matrix, weights)
|
|
182
|
+
mean_return = port_returns.sum / port_returns.size.to_f
|
|
183
|
+
variance = portfolio_variance(returns_matrix, weights)
|
|
184
|
+
volatility = Math.sqrt(variance)
|
|
185
|
+
|
|
186
|
+
portfolios << {
|
|
187
|
+
weights: weights,
|
|
188
|
+
return: mean_return * 252,
|
|
189
|
+
volatility: volatility * Math.sqrt(252),
|
|
190
|
+
sharpe: SQA::RiskManager.sharpe_ratio(port_returns)
|
|
191
|
+
}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Sort by volatility
|
|
195
|
+
portfolios.sort_by { |p| p[:volatility] }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
##
|
|
199
|
+
# Multi-objective optimization
|
|
200
|
+
#
|
|
201
|
+
# Optimize portfolio for multiple objectives simultaneously.
|
|
202
|
+
#
|
|
203
|
+
# @param returns_matrix [Array<Array<Float>>] Returns for each asset
|
|
204
|
+
# @param objectives [Hash] Objectives with weights
|
|
205
|
+
# @return [Hash] Optimal portfolio
|
|
206
|
+
#
|
|
207
|
+
# @example
|
|
208
|
+
# result = SQA::PortfolioOptimizer.multi_objective(
|
|
209
|
+
# returns_matrix,
|
|
210
|
+
# objectives: {
|
|
211
|
+
# maximize_return: 0.4,
|
|
212
|
+
# minimize_volatility: 0.3,
|
|
213
|
+
# minimize_drawdown: 0.3
|
|
214
|
+
# }
|
|
215
|
+
# )
|
|
216
|
+
#
|
|
217
|
+
def multi_objective(returns_matrix, objectives: {})
|
|
218
|
+
num_assets = returns_matrix.size
|
|
219
|
+
|
|
220
|
+
best_score = -Float::INFINITY
|
|
221
|
+
best_portfolio = nil
|
|
222
|
+
|
|
223
|
+
# Default objectives
|
|
224
|
+
objectives = {
|
|
225
|
+
maximize_return: 0.33,
|
|
226
|
+
minimize_volatility: 0.33,
|
|
227
|
+
minimize_drawdown: 0.34
|
|
228
|
+
} if objectives.empty?
|
|
229
|
+
|
|
230
|
+
# Normalize objective weights
|
|
231
|
+
total_weight = objectives.values.sum
|
|
232
|
+
objectives = objectives.transform_values { |v| v / total_weight }
|
|
233
|
+
|
|
234
|
+
# Grid search
|
|
235
|
+
10_000.times do
|
|
236
|
+
weights = random_weights(num_assets, {})
|
|
237
|
+
|
|
238
|
+
port_returns = portfolio_returns(returns_matrix, weights)
|
|
239
|
+
mean_return = port_returns.sum / port_returns.size.to_f
|
|
240
|
+
variance = portfolio_variance(returns_matrix, weights)
|
|
241
|
+
volatility = Math.sqrt(variance)
|
|
242
|
+
|
|
243
|
+
# Convert to prices for drawdown
|
|
244
|
+
prices = port_returns.inject([100.0]) { |acc, r| acc << acc.last * (1 + r) }
|
|
245
|
+
max_dd = SQA::RiskManager.max_drawdown(prices)[:max_drawdown].abs
|
|
246
|
+
|
|
247
|
+
# Calculate composite score
|
|
248
|
+
score = 0.0
|
|
249
|
+
|
|
250
|
+
# Normalize and combine objectives
|
|
251
|
+
if objectives[:maximize_return]
|
|
252
|
+
score += (mean_return * 252) * objectives[:maximize_return] * 10 # Scale up
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
if objectives[:minimize_volatility]
|
|
256
|
+
score -= (volatility * Math.sqrt(252)) * objectives[:minimize_volatility] * 10
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
if objectives[:minimize_drawdown]
|
|
260
|
+
score -= max_dd * objectives[:minimize_drawdown] * 10
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
if score > best_score
|
|
264
|
+
best_score = score
|
|
265
|
+
best_portfolio = {
|
|
266
|
+
weights: weights,
|
|
267
|
+
return: mean_return * 252,
|
|
268
|
+
volatility: volatility * Math.sqrt(252),
|
|
269
|
+
max_drawdown: max_dd,
|
|
270
|
+
sharpe: SQA::RiskManager.sharpe_ratio(port_returns),
|
|
271
|
+
composite_score: score
|
|
272
|
+
}
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
best_portfolio
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
##
|
|
280
|
+
# Equal weight portfolio (1/N rule)
|
|
281
|
+
#
|
|
282
|
+
# @param num_assets [Integer] Number of assets
|
|
283
|
+
# @return [Array<Float>] Equal weights
|
|
284
|
+
#
|
|
285
|
+
def equal_weight(num_assets)
|
|
286
|
+
weight = 1.0 / num_assets
|
|
287
|
+
Array.new(num_assets, weight)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
##
|
|
291
|
+
# Rebalance portfolio to target weights
|
|
292
|
+
#
|
|
293
|
+
# @param current_values [Hash] Current holdings { ticker => value }
|
|
294
|
+
# @param target_weights [Hash] Target weights { ticker => weight }
|
|
295
|
+
# @param total_value [Float] Total portfolio value
|
|
296
|
+
# @return [Hash] Rebalancing trades { ticker => { action: :buy/:sell, shares: N, value: $ } }
|
|
297
|
+
#
|
|
298
|
+
def rebalance(current_values:, target_weights:, total_value:, prices:)
|
|
299
|
+
trades = {}
|
|
300
|
+
|
|
301
|
+
target_weights.each do |ticker, target_weight|
|
|
302
|
+
current_value = current_values[ticker] || 0.0
|
|
303
|
+
target_value = total_value * target_weight
|
|
304
|
+
difference = target_value - current_value
|
|
305
|
+
|
|
306
|
+
next if difference.abs < 1.0 # Skip tiny adjustments
|
|
307
|
+
|
|
308
|
+
price = prices[ticker]
|
|
309
|
+
next if price.nil? || price.zero?
|
|
310
|
+
|
|
311
|
+
shares = (difference / price).round
|
|
312
|
+
|
|
313
|
+
trades[ticker] = {
|
|
314
|
+
action: shares > 0 ? :buy : :sell,
|
|
315
|
+
shares: shares.abs,
|
|
316
|
+
value: shares * price,
|
|
317
|
+
current_weight: current_value / total_value,
|
|
318
|
+
target_weight: target_weight
|
|
319
|
+
}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
trades
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
private
|
|
326
|
+
|
|
327
|
+
##
|
|
328
|
+
# Calculate covariance matrix
|
|
329
|
+
def calculate_covariance_matrix(returns_matrix)
|
|
330
|
+
num_assets = returns_matrix.size
|
|
331
|
+
num_periods = returns_matrix.first.size
|
|
332
|
+
|
|
333
|
+
# Calculate means
|
|
334
|
+
means = returns_matrix.map do |returns|
|
|
335
|
+
returns.sum / returns.size.to_f
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Calculate covariance
|
|
339
|
+
covariance = Array.new(num_assets) { Array.new(num_assets, 0.0) }
|
|
340
|
+
|
|
341
|
+
num_assets.times do |i|
|
|
342
|
+
num_assets.times do |j|
|
|
343
|
+
cov = 0.0
|
|
344
|
+
num_periods.times do |t|
|
|
345
|
+
cov += (returns_matrix[i][t] - means[i]) * (returns_matrix[j][t] - means[j])
|
|
346
|
+
end
|
|
347
|
+
covariance[i][j] = cov / (num_periods - 1).to_f
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
covariance
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
##
|
|
355
|
+
# Generate random portfolio weights
|
|
356
|
+
def random_weights(num_assets, constraints)
|
|
357
|
+
# Generate random weights that sum to 1.0
|
|
358
|
+
weights = num_assets.times.map { rand }
|
|
359
|
+
sum = weights.sum
|
|
360
|
+
weights = weights.map { |w| w / sum }
|
|
361
|
+
|
|
362
|
+
# Apply constraints
|
|
363
|
+
min_weight = constraints[:min_weight] || 0.0
|
|
364
|
+
max_weight = constraints[:max_weight] || 1.0
|
|
365
|
+
|
|
366
|
+
# Adjust if constraints violated
|
|
367
|
+
weights = weights.map do |w|
|
|
368
|
+
[[w, min_weight].max, max_weight].min
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Renormalize
|
|
372
|
+
sum = weights.sum
|
|
373
|
+
weights.map { |w| w / sum }
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|