sqa_demo-sinatra 0.1.0 → 0.2.2
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/CHANGELOG.md +141 -0
- data/README.md +18 -0
- data/lib/sqa_demo/sinatra/app.rb +18 -549
- data/lib/sqa_demo/sinatra/helpers/api_helpers.rb +221 -0
- data/lib/sqa_demo/sinatra/helpers/filters.rb +50 -0
- data/lib/sqa_demo/sinatra/helpers/formatting.rb +63 -0
- data/lib/sqa_demo/sinatra/helpers/stock_loader.rb +215 -0
- data/lib/sqa_demo/sinatra/public/css/style.css +290 -13
- data/lib/sqa_demo/sinatra/public/js/app.js +55 -8
- data/lib/sqa_demo/sinatra/routes/api.rb +235 -0
- data/lib/sqa_demo/sinatra/routes/pages.rb +137 -0
- data/lib/sqa_demo/sinatra/version.rb +1 -1
- data/lib/sqa_demo/sinatra/views/analyze.erb +83 -8
- data/lib/sqa_demo/sinatra/views/company.erb +682 -0
- data/lib/sqa_demo/sinatra/views/compare.erb +500 -0
- data/lib/sqa_demo/sinatra/views/dashboard.erb +11 -1
- data/lib/sqa_demo/sinatra/views/index.erb +2 -1
- data/lib/sqa_demo/sinatra/views/layout.erb +106 -14
- metadata +10 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module SqaDemo
|
|
6
|
+
module Sinatra
|
|
7
|
+
module Helpers
|
|
8
|
+
module ApiHelpers
|
|
9
|
+
# Resolve strategy name to class
|
|
10
|
+
def resolve_strategy(strategy_name)
|
|
11
|
+
case strategy_name.upcase
|
|
12
|
+
when 'RSI' then SQA::Strategy::RSI
|
|
13
|
+
when 'SMA' then SQA::Strategy::SMA
|
|
14
|
+
when 'EMA' then SQA::Strategy::EMA
|
|
15
|
+
when 'MACD' then SQA::Strategy::MACD
|
|
16
|
+
when 'BOLLINGERBANDS' then SQA::Strategy::BollingerBands
|
|
17
|
+
when 'KBS' then SQA::Strategy::KBS
|
|
18
|
+
else SQA::Strategy::RSI
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Calculate all technical indicators
|
|
23
|
+
def calculate_all_indicators(opens, highs, lows, prices, volumes, n)
|
|
24
|
+
pad_array = ->(arr) { Array.new(n - arr.length, nil) + arr }
|
|
25
|
+
|
|
26
|
+
# Price indicators
|
|
27
|
+
rsi = pad_array.call(SQAI.rsi(prices, period: 14))
|
|
28
|
+
macd_result = SQAI.macd(prices)
|
|
29
|
+
macd_line = pad_array.call(macd_result[0])
|
|
30
|
+
macd_signal = pad_array.call(macd_result[1])
|
|
31
|
+
macd_hist = pad_array.call(macd_result[2])
|
|
32
|
+
|
|
33
|
+
bb_result = SQAI.bbands(prices)
|
|
34
|
+
bb_upper = pad_array.call(bb_result[0])
|
|
35
|
+
bb_middle = pad_array.call(bb_result[1])
|
|
36
|
+
bb_lower = pad_array.call(bb_result[2])
|
|
37
|
+
|
|
38
|
+
# Moving averages
|
|
39
|
+
sma_12 = pad_array.call(SQAI.sma(prices, period: 12))
|
|
40
|
+
sma_20 = pad_array.call(SQAI.sma(prices, period: 20))
|
|
41
|
+
sma_50 = pad_array.call(SQAI.sma(prices, period: 50))
|
|
42
|
+
ema_20 = pad_array.call(SQAI.ema(prices, period: 20))
|
|
43
|
+
wma_20 = pad_array.call(SQAI.wma(prices, period: 20))
|
|
44
|
+
dema_20 = pad_array.call(SQAI.dema(prices, period: 20))
|
|
45
|
+
tema_20 = pad_array.call(SQAI.tema(prices, period: 20))
|
|
46
|
+
kama_30 = pad_array.call(SQAI.kama(prices, period: 30))
|
|
47
|
+
|
|
48
|
+
# Momentum indicators
|
|
49
|
+
stoch_result = SQAI.stoch(highs, lows, prices)
|
|
50
|
+
stoch_slowk = pad_array.call(stoch_result[0])
|
|
51
|
+
stoch_slowd = pad_array.call(stoch_result[1])
|
|
52
|
+
mom_10 = pad_array.call(SQAI.mom(prices, period: 10))
|
|
53
|
+
cci_14 = pad_array.call(SQAI.cci(highs, lows, prices, period: 14))
|
|
54
|
+
willr_14 = pad_array.call(SQAI.willr(highs, lows, prices, period: 14))
|
|
55
|
+
roc_10 = pad_array.call(SQAI.roc(prices, period: 10))
|
|
56
|
+
adx_14 = pad_array.call(SQAI.adx(highs, lows, prices, period: 14))
|
|
57
|
+
|
|
58
|
+
# Volatility indicators
|
|
59
|
+
atr_14 = pad_array.call(SQAI.atr(highs, lows, prices, period: 14))
|
|
60
|
+
|
|
61
|
+
# Volume indicators
|
|
62
|
+
obv = pad_array.call(SQAI.obv(prices, volumes))
|
|
63
|
+
ad = pad_array.call(SQAI.ad(highs, lows, prices, volumes))
|
|
64
|
+
vol_sma_12 = pad_array.call(SQAI.sma(volumes, period: 12))
|
|
65
|
+
vol_sma_20 = pad_array.call(SQAI.sma(volumes, period: 20))
|
|
66
|
+
vol_sma_50 = pad_array.call(SQAI.sma(volumes, period: 50))
|
|
67
|
+
vol_ema_12 = pad_array.call(SQAI.ema(volumes, period: 12))
|
|
68
|
+
vol_ema_20 = pad_array.call(SQAI.ema(volumes, period: 20))
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
rsi: rsi, macd: macd_line, macd_signal: macd_signal, macd_hist: macd_hist,
|
|
72
|
+
bb_upper: bb_upper, bb_middle: bb_middle, bb_lower: bb_lower,
|
|
73
|
+
sma_12: sma_12, sma_20: sma_20, sma_50: sma_50, ema_20: ema_20,
|
|
74
|
+
wma_20: wma_20, dema_20: dema_20, tema_20: tema_20, kama_30: kama_30,
|
|
75
|
+
stoch_slowk: stoch_slowk, stoch_slowd: stoch_slowd, mom_10: mom_10,
|
|
76
|
+
cci_14: cci_14, willr_14: willr_14, roc_10: roc_10, adx_14: adx_14,
|
|
77
|
+
atr_14: atr_14, obv: obv, ad: ad,
|
|
78
|
+
vol_sma_12: vol_sma_12, vol_sma_20: vol_sma_20, vol_sma_50: vol_sma_50,
|
|
79
|
+
vol_ema_12: vol_ema_12, vol_ema_20: vol_ema_20
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Detect candlestick patterns
|
|
84
|
+
def detect_candlestick_patterns(opens, highs, lows, prices, dates, n)
|
|
85
|
+
pad_array = ->(arr) { Array.new(n - arr.length, nil) + arr }
|
|
86
|
+
|
|
87
|
+
cdl_doji = pad_array.call(SQAI.cdl_doji(opens, highs, lows, prices))
|
|
88
|
+
cdl_hammer = pad_array.call(SQAI.cdl_hammer(opens, highs, lows, prices))
|
|
89
|
+
cdl_shootingstar = pad_array.call(SQAI.cdl_shootingstar(opens, highs, lows, prices))
|
|
90
|
+
cdl_engulfing = pad_array.call(SQAI.cdl_engulfing(opens, highs, lows, prices))
|
|
91
|
+
cdl_morningstar = pad_array.call(SQAI.cdl_morningstar(opens, highs, lows, prices))
|
|
92
|
+
cdl_eveningstar = pad_array.call(SQAI.cdl_eveningstar(opens, highs, lows, prices))
|
|
93
|
+
cdl_harami = pad_array.call(SQAI.cdl_harami(opens, highs, lows, prices))
|
|
94
|
+
cdl_3whitesoldiers = pad_array.call(SQAI.cdl_3whitesoldiers(opens, highs, lows, prices))
|
|
95
|
+
cdl_3blackcrows = pad_array.call(SQAI.cdl_3blackcrows(opens, highs, lows, prices))
|
|
96
|
+
cdl_piercing = pad_array.call(SQAI.cdl_piercing(opens, highs, lows, prices))
|
|
97
|
+
cdl_darkcloudcover = pad_array.call(SQAI.cdl_darkcloudcover(opens, highs, lows, prices))
|
|
98
|
+
cdl_marubozu = pad_array.call(SQAI.cdl_marubozu(opens, highs, lows, prices))
|
|
99
|
+
|
|
100
|
+
pattern_defs = {
|
|
101
|
+
doji: { data: cdl_doji, name: 'Doji', type: :neutral },
|
|
102
|
+
hammer: { data: cdl_hammer, name: 'Hammer', type: :fixed, signal: 'bullish' },
|
|
103
|
+
shootingstar: { data: cdl_shootingstar, name: 'Shooting Star', type: :fixed, signal: 'bearish' },
|
|
104
|
+
engulfing: { data: cdl_engulfing, name: 'Engulfing', type: :directional },
|
|
105
|
+
morningstar: { data: cdl_morningstar, name: 'Morning Star', type: :fixed, signal: 'bullish' },
|
|
106
|
+
eveningstar: { data: cdl_eveningstar, name: 'Evening Star', type: :fixed, signal: 'bearish' },
|
|
107
|
+
harami: { data: cdl_harami, name: 'Harami', type: :directional },
|
|
108
|
+
whitesoldiers: { data: cdl_3whitesoldiers, name: 'Three White Soldiers', type: :fixed, signal: 'bullish' },
|
|
109
|
+
blackcrows: { data: cdl_3blackcrows, name: 'Three Black Crows', type: :fixed, signal: 'bearish' },
|
|
110
|
+
piercing: { data: cdl_piercing, name: 'Piercing', type: :fixed, signal: 'bullish' },
|
|
111
|
+
darkcloudcover: { data: cdl_darkcloudcover, name: 'Dark Cloud Cover', type: :fixed, signal: 'bearish' },
|
|
112
|
+
marubozu: { data: cdl_marubozu, name: 'Marubozu', type: :directional }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
detected_patterns = []
|
|
116
|
+
pattern_defs.each do |_key, pdef|
|
|
117
|
+
pdef[:data].each_with_index do |val, i|
|
|
118
|
+
next if val.nil? || val == 0
|
|
119
|
+
|
|
120
|
+
signal = case pdef[:type]
|
|
121
|
+
when :neutral then 'neutral'
|
|
122
|
+
when :fixed then pdef[:signal]
|
|
123
|
+
when :directional then val > 0 ? 'bullish' : 'bearish'
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
detected_patterns << {
|
|
127
|
+
date: dates[i],
|
|
128
|
+
pattern: pdef[:name],
|
|
129
|
+
signal: signal,
|
|
130
|
+
strength: val.abs
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
detected_patterns.sort_by! { |p| p[:date] }.reverse!
|
|
136
|
+
detected_patterns.first(20)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Filter indicators by period
|
|
140
|
+
def filter_indicators_by_period(dates, indicators, period)
|
|
141
|
+
all_arrays = [dates] + indicators.values
|
|
142
|
+
filtered = filter_by_period(*all_arrays, period: period)
|
|
143
|
+
|
|
144
|
+
result = { dates: filtered[0] }
|
|
145
|
+
indicators.keys.each_with_index do |key, i|
|
|
146
|
+
result[key] = filtered[i + 1]
|
|
147
|
+
end
|
|
148
|
+
result
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Analyze FPOP (Future Period Loss/Profit)
|
|
152
|
+
def analyze_fpop(stock, prices)
|
|
153
|
+
dates = stock.df["timestamp"].to_a.map(&:to_s)
|
|
154
|
+
last_date = Date.parse(dates.last)
|
|
155
|
+
fpop_period = 10
|
|
156
|
+
fpop_data = SQA::FPOP.fpl_analysis(prices, fpop: fpop_period)
|
|
157
|
+
|
|
158
|
+
# Generate future trading dates (skip weekends)
|
|
159
|
+
future_dates = []
|
|
160
|
+
current_date = last_date + 1
|
|
161
|
+
while future_dates.length < fpop_period
|
|
162
|
+
unless current_date.saturday? || current_date.sunday?
|
|
163
|
+
future_dates << current_date.to_s
|
|
164
|
+
end
|
|
165
|
+
current_date += 1
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Show last 5 historical predictions + 10 future predictions
|
|
169
|
+
num_historical = [5, dates.length - 1].min
|
|
170
|
+
recent_fpop = []
|
|
171
|
+
|
|
172
|
+
# Historical predictions with verification
|
|
173
|
+
hist_start = dates.length - num_historical - 1
|
|
174
|
+
(0...num_historical).each do |i|
|
|
175
|
+
idx = hist_start + i
|
|
176
|
+
target_idx = idx + 1
|
|
177
|
+
next if idx < 0 || idx >= fpop_data.length || target_idx >= prices.length
|
|
178
|
+
|
|
179
|
+
prev_price = prices[idx]
|
|
180
|
+
actual_price = prices[target_idx]
|
|
181
|
+
actual_change = ((actual_price - prev_price) / prev_price) * 100
|
|
182
|
+
actual_direction = actual_change > 0.1 ? 'UP' : (actual_change < -0.1 ? 'DOWN' : 'FLAT')
|
|
183
|
+
|
|
184
|
+
predicted_magnitude = fpop_data[idx][:magnitude]
|
|
185
|
+
difference = (predicted_magnitude - actual_change).abs
|
|
186
|
+
correct = difference <= 1.0
|
|
187
|
+
|
|
188
|
+
recent_fpop << {
|
|
189
|
+
date: dates[target_idx],
|
|
190
|
+
direction: fpop_data[idx][:direction],
|
|
191
|
+
magnitude: fpop_data[idx][:magnitude],
|
|
192
|
+
risk: fpop_data[idx][:risk],
|
|
193
|
+
interpretation: fpop_data[idx][:interpretation],
|
|
194
|
+
actual_change: actual_change.round(2),
|
|
195
|
+
actual_direction: actual_direction,
|
|
196
|
+
correct: correct,
|
|
197
|
+
is_future: false
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Future predictions
|
|
202
|
+
fpop_data.last(fpop_period).each_with_index do |f, i|
|
|
203
|
+
recent_fpop << {
|
|
204
|
+
date: future_dates[i],
|
|
205
|
+
direction: f[:direction],
|
|
206
|
+
magnitude: f[:magnitude],
|
|
207
|
+
risk: f[:risk],
|
|
208
|
+
interpretation: f[:interpretation],
|
|
209
|
+
actual_change: nil,
|
|
210
|
+
actual_direction: nil,
|
|
211
|
+
correct: nil,
|
|
212
|
+
is_future: true
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
recent_fpop
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module SqaDemo
|
|
6
|
+
module Sinatra
|
|
7
|
+
module Helpers
|
|
8
|
+
module Filters
|
|
9
|
+
# Filter data arrays by time period
|
|
10
|
+
# period can be: "30d", "60d", "90d", "1q", "2q", "3q", "4q", "all"
|
|
11
|
+
def filter_by_period(dates, *data_arrays, period: 'all')
|
|
12
|
+
return [dates, *data_arrays] if period == 'all' || dates.empty?
|
|
13
|
+
|
|
14
|
+
# Parse dates (they're strings in YYYY-MM-DD format)
|
|
15
|
+
parsed_dates = dates.map { |d| Date.parse(d) }
|
|
16
|
+
latest_date = parsed_dates.max
|
|
17
|
+
|
|
18
|
+
# Calculate cutoff date based on period
|
|
19
|
+
cutoff_date = case period
|
|
20
|
+
when '30d'
|
|
21
|
+
latest_date - 30
|
|
22
|
+
when '60d'
|
|
23
|
+
latest_date - 60
|
|
24
|
+
when '90d'
|
|
25
|
+
latest_date - 90
|
|
26
|
+
when '1q'
|
|
27
|
+
latest_date - 63 # ~3 months = 1 quarter (63 trading days)
|
|
28
|
+
when '2q'
|
|
29
|
+
latest_date - 126 # ~6 months = 2 quarters
|
|
30
|
+
when '3q'
|
|
31
|
+
latest_date - 189 # ~9 months = 3 quarters
|
|
32
|
+
when '4q'
|
|
33
|
+
latest_date - 252 # ~12 months = 4 quarters
|
|
34
|
+
else
|
|
35
|
+
parsed_dates.min # "all" - keep everything
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Find indices where date >= cutoff_date
|
|
39
|
+
indices = parsed_dates.each_with_index.select { |d, _i| d >= cutoff_date }.map(&:last)
|
|
40
|
+
|
|
41
|
+
# Filter all arrays by the same indices
|
|
42
|
+
filtered_dates = indices.map { |i| dates[i] }
|
|
43
|
+
filtered_data = data_arrays.map { |arr| indices.map { |i| arr[i] } }
|
|
44
|
+
|
|
45
|
+
[filtered_dates, *filtered_data]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqaDemo
|
|
4
|
+
module Sinatra
|
|
5
|
+
module Helpers
|
|
6
|
+
module Formatting
|
|
7
|
+
def format_percent(value)
|
|
8
|
+
sprintf("%.2f%%", value)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def format_currency(value)
|
|
12
|
+
sprintf("$%.2f", value)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def format_number(value)
|
|
16
|
+
value.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Format comparison value based on type
|
|
20
|
+
def format_compare_value(value, format_type)
|
|
21
|
+
return '-' if value.nil?
|
|
22
|
+
|
|
23
|
+
case format_type
|
|
24
|
+
when :currency
|
|
25
|
+
sprintf('$%.2f', value)
|
|
26
|
+
when :currency_billions
|
|
27
|
+
sprintf('$%.2fB', value / 1_000_000_000.0)
|
|
28
|
+
when :percent
|
|
29
|
+
sprintf('%.2f%%', value)
|
|
30
|
+
when :percent_sign
|
|
31
|
+
prefix = value >= 0 ? '+' : ''
|
|
32
|
+
"#{prefix}#{sprintf('%.2f', value)}%"
|
|
33
|
+
when :percent_from_decimal
|
|
34
|
+
sprintf('%.1f%%', value * 100)
|
|
35
|
+
when :decimal2
|
|
36
|
+
sprintf('%.2f', value)
|
|
37
|
+
when :decimal3
|
|
38
|
+
sprintf('%.3f', value)
|
|
39
|
+
when :number
|
|
40
|
+
value.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
41
|
+
else
|
|
42
|
+
value.to_s
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Find best/worst tickers for a given metric
|
|
47
|
+
def find_extremes(stocks_data, key, higher_is_better)
|
|
48
|
+
return [nil, nil] if higher_is_better.nil?
|
|
49
|
+
|
|
50
|
+
values = stocks_data.map { |t, d| [t, d[key]] }.reject { |_, v| v.nil? }
|
|
51
|
+
return [nil, nil] if values.empty?
|
|
52
|
+
|
|
53
|
+
sorted = values.sort_by { |_, v| v }
|
|
54
|
+
if higher_is_better
|
|
55
|
+
[sorted.last[0], sorted.first[0]] # best is highest, worst is lowest
|
|
56
|
+
else
|
|
57
|
+
[sorted.first[0], sorted.last[0]] # best is lowest, worst is highest
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sqa'
|
|
4
|
+
require 'date'
|
|
5
|
+
|
|
6
|
+
module SqaDemo
|
|
7
|
+
module Sinatra
|
|
8
|
+
module Helpers
|
|
9
|
+
module StockLoader
|
|
10
|
+
# Central method to load stock data and company info
|
|
11
|
+
# Returns a hash with :stock, :ticker, :company_name
|
|
12
|
+
# Raises an error if loading fails
|
|
13
|
+
def load_stock(ticker)
|
|
14
|
+
ticker = ticker.upcase
|
|
15
|
+
stock = SQA::Stock.new(ticker: ticker)
|
|
16
|
+
ticker_info = SQA::Ticker.lookup(ticker)
|
|
17
|
+
company_name = ticker_info[:name] if ticker_info
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
stock: stock,
|
|
21
|
+
ticker: ticker,
|
|
22
|
+
company_name: company_name
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Load stock with company overview (for company page and comparison)
|
|
27
|
+
# Returns extended hash with :overview, :exchange
|
|
28
|
+
def load_stock_with_overview(ticker)
|
|
29
|
+
result = load_stock(ticker)
|
|
30
|
+
stock = result[:stock]
|
|
31
|
+
|
|
32
|
+
# Get comprehensive company overview (convert string keys to symbols)
|
|
33
|
+
raw_overview = stock.overview || {}
|
|
34
|
+
overview = raw_overview.transform_keys(&:to_sym)
|
|
35
|
+
|
|
36
|
+
# Fallback to ticker lookup if overview is empty
|
|
37
|
+
if overview.empty?
|
|
38
|
+
ticker_info = SQA::Ticker.lookup(result[:ticker])
|
|
39
|
+
company_name = ticker_info[:name]&.strip if ticker_info
|
|
40
|
+
exchange = ticker_info[:exchange] if ticker_info
|
|
41
|
+
else
|
|
42
|
+
company_name = overview[:name]&.strip
|
|
43
|
+
exchange = overview[:exchange]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
result.merge(
|
|
47
|
+
company_name: company_name,
|
|
48
|
+
overview: overview,
|
|
49
|
+
exchange: exchange
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Extract common price/volume data from stock dataframe
|
|
54
|
+
def extract_ohlcv(stock)
|
|
55
|
+
df = stock.df
|
|
56
|
+
{
|
|
57
|
+
dates: df["timestamp"].to_a.map(&:to_s),
|
|
58
|
+
opens: df["open_price"].to_a,
|
|
59
|
+
highs: df["high_price"].to_a,
|
|
60
|
+
lows: df["low_price"].to_a,
|
|
61
|
+
closes: df["adj_close_price"].to_a,
|
|
62
|
+
volumes: df["volume"].to_a
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Calculate basic price metrics
|
|
67
|
+
def calculate_price_metrics(prices)
|
|
68
|
+
current_price = prices.last
|
|
69
|
+
prev_price = prices[-2] || prices.last
|
|
70
|
+
change = current_price - prev_price
|
|
71
|
+
change_pct = prev_price > 0 ? (change / prev_price * 100) : 0
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
current_price: current_price,
|
|
75
|
+
prev_price: prev_price,
|
|
76
|
+
change: change,
|
|
77
|
+
change_pct: change_pct,
|
|
78
|
+
high_52w: prices.last(252).max,
|
|
79
|
+
low_52w: prices.last(252).min
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Calculate YTD return
|
|
84
|
+
def calculate_ytd_return(dates, prices)
|
|
85
|
+
current_year = Date.today.year
|
|
86
|
+
ytd_prices = dates.each_with_index
|
|
87
|
+
.select { |d, _| Date.parse(d).year == current_year }
|
|
88
|
+
.map { |_, i| prices[i] }
|
|
89
|
+
|
|
90
|
+
if ytd_prices.length > 1
|
|
91
|
+
((ytd_prices.last - ytd_prices.first) / ytd_prices.first * 100).round(2)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Calculate technical indicators for a stock
|
|
96
|
+
# Returns a hash of indicator values (last value for each)
|
|
97
|
+
def calculate_indicators(highs, lows, prices)
|
|
98
|
+
{
|
|
99
|
+
rsi: safe_indicator { SQAI.rsi(prices, period: 14).last },
|
|
100
|
+
macd: safe_indicator { SQAI.macd(prices)[0].last },
|
|
101
|
+
macd_signal: safe_indicator { SQAI.macd(prices)[1].last },
|
|
102
|
+
macd_hist: safe_indicator { SQAI.macd(prices)[2].last },
|
|
103
|
+
stoch_k: safe_indicator { SQAI.stoch(highs, lows, prices)[0].last },
|
|
104
|
+
stoch_d: safe_indicator { SQAI.stoch(highs, lows, prices)[1].last },
|
|
105
|
+
sma_50: safe_indicator { SQAI.sma(prices, period: 50).last },
|
|
106
|
+
sma_200: safe_indicator { SQAI.sma(prices, period: 200).last },
|
|
107
|
+
ema_20: safe_indicator { SQAI.ema(prices, period: 20).last },
|
|
108
|
+
bb_upper: safe_indicator { SQAI.bbands(prices)[0].last },
|
|
109
|
+
bb_middle: safe_indicator { SQAI.bbands(prices)[1].last },
|
|
110
|
+
bb_lower: safe_indicator { SQAI.bbands(prices)[2].last },
|
|
111
|
+
adx: safe_indicator { SQAI.adx(highs, lows, prices, period: 14).last },
|
|
112
|
+
atr: safe_indicator { SQAI.atr(highs, lows, prices, period: 14).last },
|
|
113
|
+
cci: safe_indicator { SQAI.cci(highs, lows, prices, period: 14).last },
|
|
114
|
+
willr: safe_indicator { SQAI.willr(highs, lows, prices, period: 14).last },
|
|
115
|
+
mom: safe_indicator { SQAI.mom(prices, period: 10).last },
|
|
116
|
+
roc: safe_indicator { SQAI.roc(prices, period: 10).last }
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Calculate risk metrics
|
|
121
|
+
def calculate_risk_metrics(prices)
|
|
122
|
+
returns = prices.each_cons(2).map { |a, b| (b - a) / a }
|
|
123
|
+
sharpe = safe_indicator { SQA::RiskManager.sharpe_ratio(returns) }
|
|
124
|
+
max_dd = safe_indicator { SQA::RiskManager.max_drawdown(prices) }
|
|
125
|
+
max_drawdown = max_dd ? max_dd[:max_drawdown] : nil
|
|
126
|
+
|
|
127
|
+
{
|
|
128
|
+
sharpe_ratio: sharpe,
|
|
129
|
+
max_drawdown: max_drawdown
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Fetch all comparison data for a single ticker
|
|
134
|
+
# This is used by the compare route for parallel fetching
|
|
135
|
+
def fetch_comparison_data(ticker)
|
|
136
|
+
data = load_stock_with_overview(ticker)
|
|
137
|
+
stock = data[:stock]
|
|
138
|
+
overview = data[:overview]
|
|
139
|
+
|
|
140
|
+
ohlcv = extract_ohlcv(stock)
|
|
141
|
+
prices = ohlcv[:closes]
|
|
142
|
+
volumes = ohlcv[:volumes]
|
|
143
|
+
dates = ohlcv[:dates]
|
|
144
|
+
highs = ohlcv[:highs]
|
|
145
|
+
lows = ohlcv[:lows]
|
|
146
|
+
|
|
147
|
+
# Calculate metrics
|
|
148
|
+
price_metrics = calculate_price_metrics(prices)
|
|
149
|
+
indicators = calculate_indicators(highs, lows, prices)
|
|
150
|
+
risk_metrics = calculate_risk_metrics(prices)
|
|
151
|
+
|
|
152
|
+
# Average volume
|
|
153
|
+
avg_volume = (volumes.sum.to_f / volumes.length).round
|
|
154
|
+
|
|
155
|
+
[ticker, {
|
|
156
|
+
ticker: ticker,
|
|
157
|
+
company_name: data[:company_name],
|
|
158
|
+
current_price: price_metrics[:current_price],
|
|
159
|
+
change: price_metrics[:change],
|
|
160
|
+
change_pct: price_metrics[:change_pct],
|
|
161
|
+
high_52w: price_metrics[:high_52w],
|
|
162
|
+
low_52w: price_metrics[:low_52w],
|
|
163
|
+
ytd_return: calculate_ytd_return(dates, prices),
|
|
164
|
+
avg_volume: avg_volume,
|
|
165
|
+
# Technical indicators
|
|
166
|
+
rsi: indicators[:rsi],
|
|
167
|
+
macd: indicators[:macd],
|
|
168
|
+
macd_signal: indicators[:macd_signal],
|
|
169
|
+
macd_hist: indicators[:macd_hist],
|
|
170
|
+
stoch_k: indicators[:stoch_k],
|
|
171
|
+
stoch_d: indicators[:stoch_d],
|
|
172
|
+
sma_50: indicators[:sma_50],
|
|
173
|
+
sma_200: indicators[:sma_200],
|
|
174
|
+
ema_20: indicators[:ema_20],
|
|
175
|
+
bb_upper: indicators[:bb_upper],
|
|
176
|
+
bb_middle: indicators[:bb_middle],
|
|
177
|
+
bb_lower: indicators[:bb_lower],
|
|
178
|
+
adx: indicators[:adx],
|
|
179
|
+
atr: indicators[:atr],
|
|
180
|
+
cci: indicators[:cci],
|
|
181
|
+
willr: indicators[:willr],
|
|
182
|
+
mom: indicators[:mom],
|
|
183
|
+
roc: indicators[:roc],
|
|
184
|
+
# Fundamental data from overview
|
|
185
|
+
pe_ratio: overview[:pe_ratio],
|
|
186
|
+
forward_pe: overview[:forward_pe],
|
|
187
|
+
peg_ratio: overview[:peg_ratio],
|
|
188
|
+
price_to_book: overview[:price_to_book_ratio],
|
|
189
|
+
eps: overview[:eps],
|
|
190
|
+
dividend_yield: overview[:dividend_yield],
|
|
191
|
+
profit_margin: overview[:profit_margin],
|
|
192
|
+
operating_margin: overview[:operating_margin_ttm],
|
|
193
|
+
roe: overview[:return_on_equity_ttm],
|
|
194
|
+
roa: overview[:return_on_assets_ttm],
|
|
195
|
+
market_cap: overview[:market_capitalization],
|
|
196
|
+
beta: overview[:beta],
|
|
197
|
+
analyst_target: overview[:analyst_target_price],
|
|
198
|
+
# Risk metrics
|
|
199
|
+
sharpe_ratio: risk_metrics[:sharpe_ratio],
|
|
200
|
+
max_drawdown: risk_metrics[:max_drawdown]
|
|
201
|
+
}]
|
|
202
|
+
rescue => e
|
|
203
|
+
[ticker, { error: e.message }]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Safely execute indicator calculation, returning nil on error
|
|
207
|
+
def safe_indicator
|
|
208
|
+
yield
|
|
209
|
+
rescue StandardError
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|