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.
@@ -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