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/backtest.rb
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# lib/sqa/backtest.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'date'
|
|
5
|
+
require_relative 'portfolio'
|
|
6
|
+
|
|
7
|
+
class SQA::Backtest
|
|
8
|
+
attr_reader :stock, :strategy, :portfolio, :results, :equity_curve
|
|
9
|
+
|
|
10
|
+
# Represents the results of a backtest
|
|
11
|
+
class Results
|
|
12
|
+
attr_accessor :total_return, :annualized_return, :sharpe_ratio, :max_drawdown,
|
|
13
|
+
:total_trades, :winning_trades, :losing_trades, :win_rate,
|
|
14
|
+
:average_win, :average_loss, :profit_factor,
|
|
15
|
+
:start_date, :end_date, :initial_capital, :final_value
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@total_return = 0.0
|
|
19
|
+
@annualized_return = 0.0
|
|
20
|
+
@sharpe_ratio = 0.0
|
|
21
|
+
@max_drawdown = 0.0
|
|
22
|
+
@total_trades = 0
|
|
23
|
+
@winning_trades = 0
|
|
24
|
+
@losing_trades = 0
|
|
25
|
+
@win_rate = 0.0
|
|
26
|
+
@average_win = 0.0
|
|
27
|
+
@average_loss = 0.0
|
|
28
|
+
@profit_factor = 0.0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
{
|
|
33
|
+
total_return: total_return,
|
|
34
|
+
annualized_return: annualized_return,
|
|
35
|
+
sharpe_ratio: sharpe_ratio,
|
|
36
|
+
max_drawdown: max_drawdown,
|
|
37
|
+
total_trades: total_trades,
|
|
38
|
+
winning_trades: winning_trades,
|
|
39
|
+
losing_trades: losing_trades,
|
|
40
|
+
win_rate: win_rate,
|
|
41
|
+
average_win: average_win,
|
|
42
|
+
average_loss: average_loss,
|
|
43
|
+
profit_factor: profit_factor,
|
|
44
|
+
start_date: start_date,
|
|
45
|
+
end_date: end_date,
|
|
46
|
+
initial_capital: initial_capital,
|
|
47
|
+
final_value: final_value
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def summary
|
|
52
|
+
<<~SUMMARY
|
|
53
|
+
Backtest Results
|
|
54
|
+
================
|
|
55
|
+
Period: #{start_date} to #{end_date}
|
|
56
|
+
Initial Capital: $#{initial_capital.round(2)}
|
|
57
|
+
Final Value: $#{final_value.round(2)}
|
|
58
|
+
|
|
59
|
+
Performance Metrics:
|
|
60
|
+
- Total Return: #{(total_return * 100).round(2)}%
|
|
61
|
+
- Annualized Return: #{(annualized_return * 100).round(2)}%
|
|
62
|
+
- Sharpe Ratio: #{sharpe_ratio.round(2)}
|
|
63
|
+
- Maximum Drawdown: #{(max_drawdown * 100).round(2)}%
|
|
64
|
+
|
|
65
|
+
Trade Statistics:
|
|
66
|
+
- Total Trades: #{total_trades}
|
|
67
|
+
- Winning Trades: #{winning_trades}
|
|
68
|
+
- Losing Trades: #{losing_trades}
|
|
69
|
+
- Win Rate: #{(win_rate * 100).round(2)}%
|
|
70
|
+
- Average Win: $#{average_win.round(2)}
|
|
71
|
+
- Average Loss: $#{average_loss.round(2)}
|
|
72
|
+
- Profit Factor: #{profit_factor.round(2)}
|
|
73
|
+
SUMMARY
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Initialize a backtest
|
|
78
|
+
# @param stock [SQA::Stock] Stock to backtest
|
|
79
|
+
# @param strategy [SQA::Strategy, Proc] Strategy or callable that returns :buy, :sell, or :hold
|
|
80
|
+
# @param start_date [Date, String] Start date for backtest
|
|
81
|
+
# @param end_date [Date, String] End date for backtest
|
|
82
|
+
# @param initial_capital [Float] Starting capital
|
|
83
|
+
# @param commission [Float] Commission per trade
|
|
84
|
+
# @param position_size [Symbol, Float] :all_cash or fraction of portfolio per trade
|
|
85
|
+
def initialize(stock:, strategy:, start_date: nil, end_date: nil,
|
|
86
|
+
initial_capital: 10_000.0, commission: 0.0, position_size: :all_cash)
|
|
87
|
+
@stock = stock
|
|
88
|
+
@strategy = strategy
|
|
89
|
+
@start_date = start_date ? Date.parse(start_date.to_s) : Date.parse(stock.df["timestamp"].first)
|
|
90
|
+
@end_date = end_date ? Date.parse(end_date.to_s) : Date.parse(stock.df["timestamp"].last)
|
|
91
|
+
@initial_capital = initial_capital
|
|
92
|
+
@commission = commission
|
|
93
|
+
@position_size = position_size
|
|
94
|
+
|
|
95
|
+
@portfolio = SQA::Portfolio.new(initial_cash: initial_capital, commission: commission)
|
|
96
|
+
@equity_curve = [] # Track portfolio value over time
|
|
97
|
+
@results = Results.new
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Run the backtest
|
|
101
|
+
# @return [Results] Backtest results
|
|
102
|
+
def run
|
|
103
|
+
# Get data for the backtest period
|
|
104
|
+
df = @stock.df.data
|
|
105
|
+
|
|
106
|
+
# Filter to backtest period
|
|
107
|
+
timestamps = df["timestamp"].to_a
|
|
108
|
+
start_idx = timestamps.index { |t| Date.parse(t) >= @start_date } || 0
|
|
109
|
+
end_idx = timestamps.rindex { |t| Date.parse(t) <= @end_date } || timestamps.length - 1
|
|
110
|
+
|
|
111
|
+
prices = df["adj_close_price"].to_a
|
|
112
|
+
ticker = @stock.ticker.upcase
|
|
113
|
+
|
|
114
|
+
# Track current position
|
|
115
|
+
current_position = nil # :long, :short, or nil
|
|
116
|
+
|
|
117
|
+
# Run through each day
|
|
118
|
+
(start_idx..end_idx).each do |i|
|
|
119
|
+
date = Date.parse(timestamps[i])
|
|
120
|
+
price = prices[i]
|
|
121
|
+
|
|
122
|
+
# Get historical prices up to this point for strategy
|
|
123
|
+
historical_prices = prices[0..i]
|
|
124
|
+
|
|
125
|
+
# Generate signal from strategy
|
|
126
|
+
signal = generate_signal(historical_prices)
|
|
127
|
+
|
|
128
|
+
# Execute trades based on signal
|
|
129
|
+
case signal
|
|
130
|
+
when :buy
|
|
131
|
+
if current_position.nil? && can_buy?(price)
|
|
132
|
+
shares = calculate_shares_to_buy(price)
|
|
133
|
+
@portfolio.buy(ticker, shares: shares, price: price, date: date)
|
|
134
|
+
current_position = :long
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
when :sell
|
|
138
|
+
if current_position == :long
|
|
139
|
+
pos = @portfolio.position(ticker)
|
|
140
|
+
@portfolio.sell(ticker, shares: pos.shares, price: price, date: date) if pos
|
|
141
|
+
current_position = nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Record equity curve
|
|
146
|
+
current_value = @portfolio.value(ticker => price)
|
|
147
|
+
@equity_curve << { date: date, value: current_value, price: price }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Close any open positions at end
|
|
151
|
+
if current_position == :long
|
|
152
|
+
final_price = prices[end_idx]
|
|
153
|
+
final_date = Date.parse(timestamps[end_idx])
|
|
154
|
+
pos = @portfolio.position(ticker)
|
|
155
|
+
@portfolio.sell(ticker, shares: pos.shares, price: final_price, date: final_date) if pos
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Calculate results
|
|
159
|
+
calculate_results
|
|
160
|
+
|
|
161
|
+
@results
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
# Generate trading signal from strategy
|
|
167
|
+
# @param historical_prices [Array<Float>] Price history
|
|
168
|
+
# @return [Symbol] :buy, :sell, or :hold
|
|
169
|
+
def generate_signal(historical_prices)
|
|
170
|
+
if @strategy.respond_to?(:execute)
|
|
171
|
+
# SQA::Strategy object
|
|
172
|
+
require 'ostruct'
|
|
173
|
+
|
|
174
|
+
# Calculate indicators for strategy
|
|
175
|
+
vector = OpenStruct.new
|
|
176
|
+
vector.prices = historical_prices
|
|
177
|
+
|
|
178
|
+
# Add common indicators if we have enough data
|
|
179
|
+
if historical_prices.length >= 14
|
|
180
|
+
vector.rsi = SQAI.rsi(historical_prices, period: 14).last rescue nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
if historical_prices.length >= 20
|
|
184
|
+
vector.sma_20 = SQAI.sma(historical_prices, period: 20).last rescue nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
if historical_prices.length >= 50
|
|
188
|
+
vector.sma_50 = SQAI.sma(historical_prices, period: 50).last rescue nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
signals = @strategy.execute(vector)
|
|
192
|
+
signals.first || :hold
|
|
193
|
+
elsif @strategy.respond_to?(:call)
|
|
194
|
+
# Proc or lambda
|
|
195
|
+
@strategy.call(historical_prices)
|
|
196
|
+
elsif @strategy.respond_to?(:trade)
|
|
197
|
+
# Strategy class
|
|
198
|
+
require 'ostruct'
|
|
199
|
+
vector = OpenStruct.new(prices: historical_prices)
|
|
200
|
+
|
|
201
|
+
if historical_prices.length >= 14
|
|
202
|
+
vector.rsi = SQAI.rsi(historical_prices, period: 14).last rescue nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
@strategy.trade(vector)
|
|
206
|
+
else
|
|
207
|
+
:hold
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Check if we can afford to buy at given price
|
|
212
|
+
# @param price [Float] Stock price
|
|
213
|
+
# @return [Boolean] True if we can buy
|
|
214
|
+
def can_buy?(price)
|
|
215
|
+
shares = calculate_shares_to_buy(price)
|
|
216
|
+
shares > 0 && (shares * price + @commission) <= @portfolio.cash
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Calculate how many shares to buy
|
|
220
|
+
# @param price [Float] Stock price
|
|
221
|
+
# @return [Integer] Number of shares
|
|
222
|
+
def calculate_shares_to_buy(price)
|
|
223
|
+
if @position_size == :all_cash
|
|
224
|
+
# Use all available cash
|
|
225
|
+
max_shares = (@portfolio.cash - @commission) / price
|
|
226
|
+
max_shares.floor
|
|
227
|
+
else
|
|
228
|
+
# Use fraction of portfolio
|
|
229
|
+
capital_to_use = @portfolio.cash * @position_size
|
|
230
|
+
max_shares = (capital_to_use - @commission) / price
|
|
231
|
+
max_shares.floor
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Calculate backtest results and metrics
|
|
236
|
+
def calculate_results
|
|
237
|
+
@results.initial_capital = @initial_capital
|
|
238
|
+
@results.final_value = @equity_curve.last[:value]
|
|
239
|
+
@results.start_date = @start_date
|
|
240
|
+
@results.end_date = @end_date
|
|
241
|
+
|
|
242
|
+
# Total return
|
|
243
|
+
@results.total_return = (@results.final_value - @initial_capital) / @initial_capital
|
|
244
|
+
|
|
245
|
+
# Annualized return
|
|
246
|
+
days = (@end_date - @start_date).to_i
|
|
247
|
+
years = days / 365.0
|
|
248
|
+
if years > 0
|
|
249
|
+
@results.annualized_return = ((1 + @results.total_return) ** (1.0 / years)) - 1
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Sharpe ratio (simplified - assumes risk-free rate of 0)
|
|
253
|
+
returns = calculate_daily_returns
|
|
254
|
+
if returns.any? && returns.map { |r| r ** 2 }.sum > 0
|
|
255
|
+
avg_return = returns.sum / returns.size
|
|
256
|
+
std_dev = Math.sqrt(returns.map { |r| (r - avg_return) ** 2 }.sum / returns.size)
|
|
257
|
+
@results.sharpe_ratio = std_dev > 0 ? (avg_return / std_dev) * Math.sqrt(252) : 0.0
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Maximum drawdown
|
|
261
|
+
@results.max_drawdown = calculate_max_drawdown
|
|
262
|
+
|
|
263
|
+
# Trade statistics
|
|
264
|
+
calculate_trade_statistics
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Calculate daily returns from equity curve
|
|
268
|
+
# @return [Array<Float>] Array of daily returns
|
|
269
|
+
def calculate_daily_returns
|
|
270
|
+
returns = []
|
|
271
|
+
@equity_curve.each_cons(2) do |prev, curr|
|
|
272
|
+
returns << (curr[:value] - prev[:value]) / prev[:value]
|
|
273
|
+
end
|
|
274
|
+
returns
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Calculate maximum drawdown
|
|
278
|
+
# @return [Float] Maximum drawdown as decimal
|
|
279
|
+
def calculate_max_drawdown
|
|
280
|
+
peak = @equity_curve.first[:value]
|
|
281
|
+
max_dd = 0.0
|
|
282
|
+
|
|
283
|
+
@equity_curve.each do |point|
|
|
284
|
+
if point[:value] > peak
|
|
285
|
+
peak = point[:value]
|
|
286
|
+
else
|
|
287
|
+
dd = (peak - point[:value]) / peak
|
|
288
|
+
max_dd = dd if dd > max_dd
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
max_dd
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Calculate trade statistics
|
|
296
|
+
def calculate_trade_statistics
|
|
297
|
+
trades = @portfolio.trade_history
|
|
298
|
+
@results.total_trades = trades.count { |t| t.action == :sell }
|
|
299
|
+
|
|
300
|
+
# Match buys with sells to calculate P&L per trade
|
|
301
|
+
trade_pls = []
|
|
302
|
+
buy_trades = trades.select { |t| t.action == :buy }
|
|
303
|
+
sell_trades = trades.select { |t| t.action == :sell }
|
|
304
|
+
|
|
305
|
+
sell_trades.each_with_index do |sell, i|
|
|
306
|
+
if i < buy_trades.size
|
|
307
|
+
buy = buy_trades[i]
|
|
308
|
+
pl = (sell.price - buy.price) * sell.shares - sell.commission - buy.commission
|
|
309
|
+
trade_pls << pl
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
if trade_pls.any?
|
|
314
|
+
winning = trade_pls.select { |pl| pl > 0 }
|
|
315
|
+
losing = trade_pls.select { |pl| pl < 0 }
|
|
316
|
+
|
|
317
|
+
@results.winning_trades = winning.size
|
|
318
|
+
@results.losing_trades = losing.size
|
|
319
|
+
@results.win_rate = winning.size.to_f / trade_pls.size
|
|
320
|
+
@results.average_win = winning.any? ? winning.sum / winning.size : 0.0
|
|
321
|
+
@results.average_loss = losing.any? ? losing.sum / losing.size : 0.0
|
|
322
|
+
|
|
323
|
+
# Profit factor
|
|
324
|
+
total_wins = winning.sum
|
|
325
|
+
total_losses = losing.sum.abs
|
|
326
|
+
@results.profit_factor = total_losses > 0 ? total_wins / total_losses : 0.0
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
@@ -3,58 +3,42 @@
|
|
|
3
3
|
#
|
|
4
4
|
# Using the Alpha Vantage JSON interface
|
|
5
5
|
#
|
|
6
|
-
|
|
6
|
+
require 'polars'
|
|
7
7
|
|
|
8
8
|
class SQA::DataFrame
|
|
9
9
|
class AlphaVantage
|
|
10
10
|
CONNECTION = Faraday.new(url: 'https://www.alphavantage.co')
|
|
11
11
|
HEADERS = YahooFinance::HEADERS
|
|
12
12
|
|
|
13
|
-
# The Alpha Vantage
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# The Alpha Vantage CSV format uses these exact column names:
|
|
14
|
+
# timestamp, open, high, low, close, volume
|
|
15
|
+
# We remap them to match Yahoo Finance column names for consistency
|
|
16
16
|
HEADER_MAPPING = {
|
|
17
|
-
"
|
|
18
|
-
"open"
|
|
19
|
-
"high"
|
|
20
|
-
"low"
|
|
21
|
-
"close"
|
|
22
|
-
"
|
|
23
|
-
"volume" => HEADERS[6]
|
|
17
|
+
"timestamp" => HEADERS[0], # :timestamp (already matches, but explicit)
|
|
18
|
+
"open" => HEADERS[1], # :open_price
|
|
19
|
+
"high" => HEADERS[2], # :high_price
|
|
20
|
+
"low" => HEADERS[3], # :low_price
|
|
21
|
+
"close" => HEADERS[4], # :close_price (AND :adj_close_price - AV doesn't split these)
|
|
22
|
+
"volume" => HEADERS[6] # :volume
|
|
24
23
|
}
|
|
25
24
|
|
|
25
|
+
# Transformers applied AFTER column renaming
|
|
26
|
+
# Alpha Vantage CSV doesn't have adjusted_close, so we only transform what exists
|
|
26
27
|
TRANSFORMERS = {
|
|
27
|
-
HEADERS[1] => -> (v) { v.to_f.round(3) },
|
|
28
|
-
HEADERS[2] => -> (v) { v.to_f.round(3) },
|
|
29
|
-
HEADERS[3] => -> (v) { v.to_f.round(3) },
|
|
30
|
-
HEADERS[4] => -> (v) { v.to_f.round(3) },
|
|
31
|
-
HEADERS[5]
|
|
32
|
-
HEADERS[6] => -> (v) { v.to_i }
|
|
28
|
+
HEADERS[1] => -> (v) { v.to_f.round(3) }, # :open_price
|
|
29
|
+
HEADERS[2] => -> (v) { v.to_f.round(3) }, # :high_price
|
|
30
|
+
HEADERS[3] => -> (v) { v.to_f.round(3) }, # :low_price
|
|
31
|
+
HEADERS[4] => -> (v) { v.to_f.round(3) }, # :close_price
|
|
32
|
+
# HEADERS[5] - :adj_close_price doesn't exist in Alpha Vantage CSV
|
|
33
|
+
HEADERS[6] => -> (v) { v.to_i } # :volume
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
################################################################
|
|
36
37
|
|
|
37
38
|
# Get recent data from JSON API
|
|
38
|
-
#
|
|
39
39
|
# ticker String the security to retrieve
|
|
40
|
-
# returns a DataFrame
|
|
41
|
-
#
|
|
42
|
-
# NOTE: The function=TIME_SERIES_DAILY_ADJUSTED
|
|
43
|
-
# is not a free API endpoint from Alpha Vantange.
|
|
44
|
-
# So we are just using the free API endpoint
|
|
45
|
-
# function=TIME_SERIES_DAILY
|
|
46
|
-
# This means that we are not getting the
|
|
47
|
-
# real adjusted closing price. To sync
|
|
48
|
-
# the columns with those from Yahoo Finance
|
|
49
|
-
# we are duplicating the unadjusted clossing price
|
|
50
|
-
# and adding that to the data frame as if it were
|
|
51
|
-
# adjusted.
|
|
52
|
-
#
|
|
40
|
+
# returns a Polars DataFrame
|
|
53
41
|
def self.recent(ticker, full: false, from_date: nil)
|
|
54
|
-
|
|
55
|
-
# NOTE: Using the CSV format because the JSON format has
|
|
56
|
-
# really silly key values. The column names for the
|
|
57
|
-
# CSV format are much better.
|
|
58
42
|
response = CONNECTION.get(
|
|
59
43
|
"/query?" +
|
|
60
44
|
"function=TIME_SERIES_DAILY&" +
|
|
@@ -68,41 +52,35 @@ class SQA::DataFrame
|
|
|
68
52
|
raise "Bad Response: #{response[:status]}"
|
|
69
53
|
end
|
|
70
54
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
aofh = raw.map do |e|
|
|
85
|
-
e2 = e.split(',')
|
|
86
|
-
e2[1..-2] = e2[1..-2].map(&:to_f) # converting open, high, low, close
|
|
87
|
-
e2[-1] = e2[-1].to_i # converting volumn
|
|
88
|
-
e2.insert(adj_close_inx, e2[close_inx]) # duplicate the close price as a fake adj close price
|
|
89
|
-
headers.zip(e2).to_h
|
|
90
|
-
end
|
|
91
|
-
|
|
55
|
+
# Read CSV into Polars DataFrame directly
|
|
56
|
+
df = Polars.read_csv(
|
|
57
|
+
StringIO.new(response[:body]),
|
|
58
|
+
dtypes: {
|
|
59
|
+
"open" => :f64,
|
|
60
|
+
"high" => :f64,
|
|
61
|
+
"low" => :f64,
|
|
62
|
+
"close" => :f64,
|
|
63
|
+
"volume" => :i64
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Handle date criteria if applicable
|
|
92
68
|
if from_date
|
|
93
|
-
|
|
69
|
+
# Use Polars.col() to create an expression for filtering
|
|
70
|
+
df = df.filter(Polars.col("timestamp") >= from_date.to_s)
|
|
94
71
|
end
|
|
95
72
|
|
|
96
|
-
|
|
73
|
+
# Wrap in SQA::DataFrame with proper transformers
|
|
74
|
+
# Note: mapping is applied first (renames columns), then transformers
|
|
75
|
+
sqa_df = SQA::DataFrame.new(df, transformers: TRANSFORMERS, mapping: HEADER_MAPPING)
|
|
97
76
|
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
end
|
|
77
|
+
# Alpha Vantage doesn't split close/adjusted_close, so duplicate for compatibility
|
|
78
|
+
# This ensures adj_close_price exists for strategies that expect it
|
|
79
|
+
sqa_df.data = sqa_df.data.with_column(
|
|
80
|
+
sqa_df.data["close_price"].alias("adj_close_price")
|
|
81
|
+
)
|
|
104
82
|
|
|
105
|
-
|
|
83
|
+
sqa_df
|
|
106
84
|
end
|
|
107
85
|
end
|
|
108
86
|
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# lib/sqa/data_frame/data.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
class SQA::DataFrame
|
|
7
|
+
# Data class to store stock metadata
|
|
8
|
+
#
|
|
9
|
+
# This class holds metadata about a stock including its ticker symbol,
|
|
10
|
+
# name, exchange, data source, technical indicators, and company overview.
|
|
11
|
+
#
|
|
12
|
+
# @example Creating from named parameters
|
|
13
|
+
# data = SQA::DataFrame::Data.new(
|
|
14
|
+
# ticker: 'AAPL',
|
|
15
|
+
# source: :alpha_vantage,
|
|
16
|
+
# indicators: { rsi: [30, 70], sma: [20, 50] }
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# @example Creating from hash (JSON)
|
|
20
|
+
# json_data = JSON.parse(File.read('stock_data.json'))
|
|
21
|
+
# data = SQA::DataFrame::Data.new(json_data)
|
|
22
|
+
#
|
|
23
|
+
class Data
|
|
24
|
+
attr_accessor :ticker, :name, :exchange, :source, :indicators, :overview
|
|
25
|
+
|
|
26
|
+
# Initialize stock metadata
|
|
27
|
+
#
|
|
28
|
+
# Can be called in two ways:
|
|
29
|
+
# 1. With a hash: SQA::DataFrame::Data.new(hash) - for JSON deserialization
|
|
30
|
+
# 2. With keyword args: SQA::DataFrame::Data.new(ticker: 'AAPL', source: :alpha_vantage, ...)
|
|
31
|
+
#
|
|
32
|
+
# @param data_hash [Hash, nil] Hash of all attributes (when passed as first positional arg)
|
|
33
|
+
# @param ticker [String, Symbol, nil] Ticker symbol
|
|
34
|
+
# @param name [String, nil] Stock name
|
|
35
|
+
# @param exchange [String, nil] Exchange symbol (e.g., 'NASDAQ', 'NYSE')
|
|
36
|
+
# @param source [Symbol] Data source (:alpha_vantage, :yahoo_finance)
|
|
37
|
+
# @param indicators [Hash] Technical indicators configuration
|
|
38
|
+
# @param overview [Hash] Company overview data
|
|
39
|
+
#
|
|
40
|
+
def initialize(data_hash = nil, ticker: nil, name: nil, exchange: nil, source: :alpha_vantage, indicators: {}, overview: {})
|
|
41
|
+
if data_hash.is_a?(Hash) && ticker.nil?
|
|
42
|
+
# Initialize from hash (JSON deserialization) - called as: new(hash)
|
|
43
|
+
@ticker = data_hash['ticker'] || data_hash[:ticker]
|
|
44
|
+
@name = data_hash['name'] || data_hash[:name]
|
|
45
|
+
@exchange = data_hash['exchange'] || data_hash[:exchange]
|
|
46
|
+
@source = data_hash['source'] || data_hash[:source] || source
|
|
47
|
+
@indicators = data_hash['indicators'] || data_hash[:indicators] || {}
|
|
48
|
+
@overview = data_hash['overview'] || data_hash[:overview] || {}
|
|
49
|
+
|
|
50
|
+
# Convert source to symbol if it's a string
|
|
51
|
+
@source = @source.to_sym if @source.is_a?(String)
|
|
52
|
+
else
|
|
53
|
+
# Initialize from named parameters - called as: new(ticker: 'AAPL', ...)
|
|
54
|
+
@ticker = ticker
|
|
55
|
+
@name = name
|
|
56
|
+
@exchange = exchange
|
|
57
|
+
@source = source
|
|
58
|
+
@indicators = indicators
|
|
59
|
+
@overview = overview
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Serialize to JSON string
|
|
64
|
+
#
|
|
65
|
+
# @return [String] JSON representation
|
|
66
|
+
def to_json(*args)
|
|
67
|
+
to_h.to_json(*args)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Convert to hash
|
|
71
|
+
#
|
|
72
|
+
# @return [Hash] Hash representation
|
|
73
|
+
def to_h
|
|
74
|
+
{
|
|
75
|
+
ticker: @ticker,
|
|
76
|
+
name: @name,
|
|
77
|
+
exchange: @exchange,
|
|
78
|
+
source: @source,
|
|
79
|
+
indicators: @indicators,
|
|
80
|
+
overview: @overview
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# String representation
|
|
85
|
+
#
|
|
86
|
+
# @return [String] Human-readable representation
|
|
87
|
+
def to_s
|
|
88
|
+
"#{@ticker || 'Unknown'} (#{@exchange || 'N/A'}) via #{@source}"
|
|
89
|
+
end
|
|
90
|
+
alias_method :inspect, :to_s
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# lib/sqa/data_frame/yahoo_finance.rb
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require 'polars'
|
|
5
|
+
|
|
4
6
|
=begin
|
|
5
7
|
The website financial.yahoo.com no longer supports an API.
|
|
6
8
|
To get recent stock historical price updates you have
|
|
7
9
|
to scrape the webpage.
|
|
8
10
|
=end
|
|
9
11
|
|
|
10
|
-
|
|
11
12
|
class SQA::DataFrame
|
|
12
13
|
class YahooFinance
|
|
13
14
|
CONNECTION = Faraday.new(url: 'https://finance.yahoo.com')
|
|
@@ -21,60 +22,51 @@ class SQA::DataFrame
|
|
|
21
22
|
:volume, # 6
|
|
22
23
|
]
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"Close" => HEADERS[4],
|
|
34
|
-
"Adj Close" => HEADERS[5],
|
|
35
|
-
"Volume" => HEADERS[6]
|
|
36
|
-
}
|
|
25
|
+
HEADER_MAPPING = {
|
|
26
|
+
"Date" => HEADERS[0],
|
|
27
|
+
"Open" => HEADERS[1],
|
|
28
|
+
"High" => HEADERS[2],
|
|
29
|
+
"Low" => HEADERS[3],
|
|
30
|
+
"Close" => HEADERS[4],
|
|
31
|
+
"Adj Close" => HEADERS[5],
|
|
32
|
+
"Volume" => HEADERS[6]
|
|
33
|
+
}
|
|
37
34
|
|
|
38
35
|
################################################################
|
|
39
36
|
|
|
40
|
-
|
|
41
37
|
# Scrape the Yahoo Finance website to get recent
|
|
42
38
|
# historical prices for a specific ticker
|
|
43
|
-
#
|
|
44
|
-
# ticker String the security to retrieve
|
|
45
|
-
# returns a DataFrame
|
|
46
|
-
#
|
|
39
|
+
# returns a Polars DataFrame
|
|
47
40
|
def self.recent(ticker)
|
|
48
|
-
response
|
|
49
|
-
doc
|
|
50
|
-
table
|
|
41
|
+
response = CONNECTION.get("/quote/#{ticker.upcase}/history")
|
|
42
|
+
doc = Nokogiri::HTML(response.body)
|
|
43
|
+
table = doc.css('table').first
|
|
51
44
|
|
|
52
45
|
raise "NoDataError" if table.nil?
|
|
53
46
|
|
|
54
|
-
rows
|
|
55
|
-
|
|
56
|
-
aofh = []
|
|
47
|
+
rows = table.css('tbody tr')
|
|
57
48
|
|
|
58
|
-
rows.
|
|
59
|
-
cols = row.css('td').map{|c| c.children[0].text}
|
|
49
|
+
data = rows.map do |row|
|
|
50
|
+
cols = row.css('td').map { |c| c.children[0].text }
|
|
60
51
|
|
|
61
|
-
next unless
|
|
52
|
+
next unless cols.size == 7
|
|
62
53
|
next if cols[1]&.include?("Dividend")
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
]
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
54
|
+
next if cols.any?(nil)
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
"Date" => Date.parse(cols[0]).to_s,
|
|
58
|
+
"Open" => cols[1].to_f,
|
|
59
|
+
"High" => cols[2].to_f,
|
|
60
|
+
"Low" => cols[3].to_f,
|
|
61
|
+
"Close" => cols[4].to_f,
|
|
62
|
+
"Adj Close" => cols[5].to_f,
|
|
63
|
+
"Volume" => cols[6].tr(',', '').to_i
|
|
64
|
+
}
|
|
65
|
+
end.compact
|
|
66
|
+
|
|
67
|
+
# Create Polars DataFrame then wrap in SQA::DataFrame
|
|
68
|
+
polars_df = Polars::DataFrame.new(data)
|
|
69
|
+
SQA::DataFrame.new(polars_df, mapping: HEADER_MAPPING)
|
|
78
70
|
end
|
|
79
71
|
end
|
|
80
72
|
end
|