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.
Files changed (180) hide show
  1. checksums.yaml +4 -4
  2. data/.goose/memory/development.txt +3 -0
  3. data/.semver +6 -0
  4. data/ARCHITECTURE.md +648 -0
  5. data/CHANGELOG.md +82 -0
  6. data/CLAUDE.md +653 -0
  7. data/COMMITS.md +196 -0
  8. data/DATAFRAME_ARCHITECTURE_REVIEW.md +421 -0
  9. data/NEXT-STEPS.md +154 -0
  10. data/README.md +812 -262
  11. data/TASKS.md +358 -0
  12. data/TEST_RESULTS.md +140 -0
  13. data/TODO.md +42 -0
  14. data/_notes.txt +25 -0
  15. data/bin/sqa-console +11 -0
  16. data/data/talk_talk.json +103284 -0
  17. data/develop_summary.md +313 -0
  18. data/docs/advanced/backtesting.md +206 -0
  19. data/docs/advanced/ensemble.md +68 -0
  20. data/docs/advanced/fpop.md +153 -0
  21. data/docs/advanced/index.md +112 -0
  22. data/docs/advanced/multi-timeframe.md +67 -0
  23. data/docs/advanced/pattern-matcher.md +75 -0
  24. data/docs/advanced/portfolio-optimizer.md +79 -0
  25. data/docs/advanced/portfolio.md +166 -0
  26. data/docs/advanced/risk-management.md +210 -0
  27. data/docs/advanced/strategy-generator.md +158 -0
  28. data/docs/advanced/streaming.md +209 -0
  29. data/docs/ai_and_ml.md +80 -0
  30. data/docs/api/dataframe.md +1115 -0
  31. data/docs/api/index.md +126 -0
  32. data/docs/assets/css/custom.css +88 -0
  33. data/docs/assets/js/mathjax.js +18 -0
  34. data/docs/concepts/index.md +68 -0
  35. data/docs/contributing/index.md +60 -0
  36. data/docs/data-sources/index.md +66 -0
  37. data/docs/data_frame.md +317 -97
  38. data/docs/factors_that_impact_price.md +26 -0
  39. data/docs/finviz.md +11 -0
  40. data/docs/fx_pro_bit.md +25 -0
  41. data/docs/genetic_programming.md +104 -0
  42. data/docs/getting-started/index.md +123 -0
  43. data/docs/getting-started/installation.md +229 -0
  44. data/docs/getting-started/quick-start.md +244 -0
  45. data/docs/i_gotta_an_idea.md +22 -0
  46. data/docs/index.md +163 -0
  47. data/docs/indicators/index.md +97 -0
  48. data/docs/indicators.md +110 -24
  49. data/docs/options.md +8 -0
  50. data/docs/strategies/bollinger-bands.md +146 -0
  51. data/docs/strategies/consensus.md +64 -0
  52. data/docs/strategies/custom.md +310 -0
  53. data/docs/strategies/ema.md +53 -0
  54. data/docs/strategies/index.md +92 -0
  55. data/docs/strategies/kbs.md +164 -0
  56. data/docs/strategies/macd.md +96 -0
  57. data/docs/strategies/market-profile.md +54 -0
  58. data/docs/strategies/mean-reversion.md +58 -0
  59. data/docs/strategies/rsi.md +95 -0
  60. data/docs/strategies/sma.md +55 -0
  61. data/docs/strategies/stochastic.md +63 -0
  62. data/docs/strategies/volume-breakout.md +54 -0
  63. data/docs/tags.md +7 -0
  64. data/docs/true_strength_index.md +46 -0
  65. data/docs/weighted_moving_average.md +48 -0
  66. data/examples/README.md +354 -0
  67. data/examples/advanced_features_example.rb +350 -0
  68. data/examples/fpop_analysis_example.rb +191 -0
  69. data/examples/genetic_programming_example.rb +148 -0
  70. data/examples/kbs_strategy_example.rb +208 -0
  71. data/examples/pattern_context_example.rb +300 -0
  72. data/examples/rails_app/Gemfile +34 -0
  73. data/examples/rails_app/README.md +416 -0
  74. data/examples/rails_app/app/assets/javascripts/application.js +107 -0
  75. data/examples/rails_app/app/assets/stylesheets/application.css +659 -0
  76. data/examples/rails_app/app/controllers/analysis_controller.rb +11 -0
  77. data/examples/rails_app/app/controllers/api/v1/stocks_controller.rb +227 -0
  78. data/examples/rails_app/app/controllers/application_controller.rb +22 -0
  79. data/examples/rails_app/app/controllers/backtest_controller.rb +11 -0
  80. data/examples/rails_app/app/controllers/dashboard_controller.rb +21 -0
  81. data/examples/rails_app/app/controllers/portfolio_controller.rb +7 -0
  82. data/examples/rails_app/app/views/analysis/show.html.erb +209 -0
  83. data/examples/rails_app/app/views/backtest/show.html.erb +171 -0
  84. data/examples/rails_app/app/views/dashboard/index.html.erb +118 -0
  85. data/examples/rails_app/app/views/dashboard/show.html.erb +408 -0
  86. data/examples/rails_app/app/views/errors/show.html.erb +17 -0
  87. data/examples/rails_app/app/views/layouts/application.html.erb +60 -0
  88. data/examples/rails_app/app/views/portfolio/index.html.erb +33 -0
  89. data/examples/rails_app/bin/rails +6 -0
  90. data/examples/rails_app/config/application.rb +45 -0
  91. data/examples/rails_app/config/boot.rb +5 -0
  92. data/examples/rails_app/config/database.yml +18 -0
  93. data/examples/rails_app/config/environment.rb +11 -0
  94. data/examples/rails_app/config/routes.rb +26 -0
  95. data/examples/rails_app/config.ru +8 -0
  96. data/examples/realtime_stream_example.rb +274 -0
  97. data/examples/sinatra_app/Gemfile +22 -0
  98. data/examples/sinatra_app/QUICKSTART.md +159 -0
  99. data/examples/sinatra_app/README.md +461 -0
  100. data/examples/sinatra_app/app.rb +344 -0
  101. data/examples/sinatra_app/config.ru +5 -0
  102. data/examples/sinatra_app/public/css/style.css +659 -0
  103. data/examples/sinatra_app/public/js/app.js +107 -0
  104. data/examples/sinatra_app/views/analyze.erb +306 -0
  105. data/examples/sinatra_app/views/backtest.erb +325 -0
  106. data/examples/sinatra_app/views/dashboard.erb +419 -0
  107. data/examples/sinatra_app/views/error.erb +58 -0
  108. data/examples/sinatra_app/views/index.erb +118 -0
  109. data/examples/sinatra_app/views/layout.erb +61 -0
  110. data/examples/sinatra_app/views/portfolio.erb +43 -0
  111. data/examples/strategy_generator_example.rb +346 -0
  112. data/hsa_portfolio.csv +11 -0
  113. data/justfile +0 -0
  114. data/lib/api/alpha_vantage_api.rb +462 -0
  115. data/lib/sqa/backtest.rb +329 -0
  116. data/lib/sqa/data_frame/alpha_vantage.rb +43 -65
  117. data/lib/sqa/data_frame/data.rb +92 -0
  118. data/lib/sqa/data_frame/yahoo_finance.rb +35 -43
  119. data/lib/sqa/data_frame.rb +148 -243
  120. data/lib/sqa/ensemble.rb +359 -0
  121. data/lib/sqa/fpop.rb +199 -0
  122. data/lib/sqa/gp.rb +259 -0
  123. data/lib/sqa/indicator.rb +5 -8
  124. data/lib/sqa/init.rb +15 -8
  125. data/lib/sqa/market_regime.rb +240 -0
  126. data/lib/sqa/multi_timeframe.rb +379 -0
  127. data/lib/sqa/pattern_matcher.rb +497 -0
  128. data/lib/sqa/portfolio.rb +260 -6
  129. data/lib/sqa/portfolio_optimizer.rb +377 -0
  130. data/lib/sqa/risk_manager.rb +442 -0
  131. data/lib/sqa/seasonal_analyzer.rb +209 -0
  132. data/lib/sqa/sector_analyzer.rb +300 -0
  133. data/lib/sqa/stock.rb +67 -125
  134. data/lib/sqa/strategy/bollinger_bands.rb +42 -0
  135. data/lib/sqa/strategy/consensus.rb +5 -2
  136. data/lib/sqa/strategy/kbs_strategy.rb +470 -0
  137. data/lib/sqa/strategy/macd.rb +46 -0
  138. data/lib/sqa/strategy/mp.rb +1 -1
  139. data/lib/sqa/strategy/stochastic.rb +60 -0
  140. data/lib/sqa/strategy/volume_breakout.rb +57 -0
  141. data/lib/sqa/strategy.rb +5 -0
  142. data/lib/sqa/strategy_generator.rb +947 -0
  143. data/lib/sqa/stream.rb +361 -0
  144. data/lib/sqa/version.rb +1 -7
  145. data/lib/sqa.rb +23 -16
  146. data/main.just +81 -0
  147. data/mkdocs.yml +288 -0
  148. data/trace.log +0 -0
  149. metadata +261 -51
  150. data/bin/sqa +0 -6
  151. data/lib/patches/dry-cli.rb +0 -228
  152. data/lib/sqa/activity.rb +0 -10
  153. data/lib/sqa/cli.rb +0 -62
  154. data/lib/sqa/commands/analysis.rb +0 -309
  155. data/lib/sqa/commands/base.rb +0 -139
  156. data/lib/sqa/commands/web.rb +0 -199
  157. data/lib/sqa/commands.rb +0 -22
  158. data/lib/sqa/constants.rb +0 -23
  159. data/lib/sqa/indicator/average_true_range.rb +0 -33
  160. data/lib/sqa/indicator/bollinger_bands.rb +0 -28
  161. data/lib/sqa/indicator/candlestick_pattern_recognizer.rb +0 -60
  162. data/lib/sqa/indicator/donchian_channel.rb +0 -29
  163. data/lib/sqa/indicator/double_top_bottom_pattern.rb +0 -34
  164. data/lib/sqa/indicator/elliott_wave_theory.rb +0 -57
  165. data/lib/sqa/indicator/exponential_moving_average.rb +0 -25
  166. data/lib/sqa/indicator/exponential_moving_average_trend.rb +0 -36
  167. data/lib/sqa/indicator/fibonacci_retracement.rb +0 -23
  168. data/lib/sqa/indicator/head_and_shoulders_pattern.rb +0 -26
  169. data/lib/sqa/indicator/market_profile.rb +0 -32
  170. data/lib/sqa/indicator/mean_reversion.rb +0 -37
  171. data/lib/sqa/indicator/momentum.rb +0 -28
  172. data/lib/sqa/indicator/moving_average_convergence_divergence.rb +0 -29
  173. data/lib/sqa/indicator/peaks_and_valleys.rb +0 -29
  174. data/lib/sqa/indicator/predict_next_value.rb +0 -202
  175. data/lib/sqa/indicator/relative_strength_index.rb +0 -47
  176. data/lib/sqa/indicator/simple_moving_average.rb +0 -24
  177. data/lib/sqa/indicator/simple_moving_average_trend.rb +0 -32
  178. data/lib/sqa/indicator/stochastic_oscillator.rb +0 -68
  179. data/lib/sqa/indicator/true_range.rb +0 -39
  180. data/lib/sqa/trade.rb +0 -26
@@ -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 headers are being remapped so that
14
- # they match those of the Yahoo Finance CSV file.
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
- "date" => HEADERS[0],
18
- "open" => HEADERS[1],
19
- "high" => HEADERS[2],
20
- "low" => HEADERS[3],
21
- "close" => HEADERS[4],
22
- "adjusted_close" => HEADERS[5],
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] => -> (v) { v.to_f.round(3) },
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
- raw = response[:body].split
72
- headers = raw.shift.split(',')
73
-
74
- headers[0] = 'date' # website returns "timestamp" but that
75
- # has an unintended side-effect when
76
- # the names are normalized.
77
- # SMELL: IS THIS STILL TRUE?
78
-
79
- close_inx = headers.size - 2
80
- adj_close_inx = close_inx + 1
81
-
82
- headers.insert(adj_close_inx, 'adjusted_close')
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
- aofh.reject!{|e| Date.parse(e['date']) < from_date}
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
- return nil if aofh.empty?
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
- # ensure tha the data frame is
99
- # always sorted oldest to newest.
100
-
101
- if aofh.first['date'] > aofh.last['date']
102
- aofh.reverse!
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
- SQA::DataFrame.from_aofh(aofh, mapping: HEADER_MAPPING, transformers: TRANSFORMERS)
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
- # The Yahoo Finance Headers are being remapped so that
25
- # the header can be used as a method name to access the
26
- # vector.
27
- #
28
- HEADER_MAPPING = {
29
- "Date" => HEADERS[0],
30
- "Open" => HEADERS[1],
31
- "High" => HEADERS[2],
32
- "Low" => HEADERS[3],
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 = CONNECTION.get("/quote/#{ticker.upcase}/history")
49
- doc = Nokogiri::HTML(response.body)
50
- table = doc.css('table').first
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 = table.css('tbody tr')
55
-
56
- aofh = []
47
+ rows = table.css('tbody tr')
57
48
 
58
- rows.each do |row|
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 7 == cols.size
52
+ next unless cols.size == 7
62
53
  next if cols[1]&.include?("Dividend")
63
-
64
- if cols.any?(nil)
65
- debug_me('== ERROR =='){[
66
- :cols
67
- ]}
68
- next
69
- end
70
-
71
- cols[0] = Date.parse(cols[0]).to_s
72
- cols[6] = cols[6].tr(',','').to_i
73
- (1..5).each {|x| cols[x] = cols[x].to_f}
74
- aofh << HEADERS.zip(cols).to_h
75
- end
76
-
77
- aofh
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