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.
@@ -5,6 +5,16 @@ require 'sinatra/json'
5
5
  require 'json'
6
6
  require 'sqa'
7
7
 
8
+ # Load helpers
9
+ require_relative 'helpers/formatting'
10
+ require_relative 'helpers/filters'
11
+ require_relative 'helpers/stock_loader'
12
+ require_relative 'helpers/api_helpers'
13
+
14
+ # Load routes
15
+ require_relative 'routes/pages'
16
+ require_relative 'routes/api'
17
+
8
18
  module SqaDemo
9
19
  module Sinatra
10
20
  class App < ::Sinatra::Base
@@ -26,556 +36,15 @@ module SqaDemo
26
36
  SQA.init
27
37
  end
28
38
 
29
- # Helpers
30
- helpers do
31
- def format_percent(value)
32
- sprintf("%.2f%%", value)
33
- end
34
-
35
- def format_currency(value)
36
- sprintf("$%.2f", value)
37
- end
38
-
39
- def format_number(value)
40
- value.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
41
- end
42
-
43
- # Filter data arrays by time period
44
- # period can be: "30d", "60d", "90d", "1q", "2q", "3q", "4q", "all"
45
- def filter_by_period(dates, *data_arrays, period: 'all')
46
- return [dates, *data_arrays] if period == 'all' || dates.empty?
47
-
48
- require 'date'
49
-
50
- # Parse dates (they're strings in YYYY-MM-DD format)
51
- parsed_dates = dates.map { |d| Date.parse(d) }
52
- latest_date = parsed_dates.max
53
-
54
- # Calculate cutoff date based on period
55
- cutoff_date = case period
56
- when '30d'
57
- latest_date - 30
58
- when '60d'
59
- latest_date - 60
60
- when '90d'
61
- latest_date - 90
62
- when '1q'
63
- latest_date - 63 # ~3 months = 1 quarter (63 trading days)
64
- when '2q'
65
- latest_date - 126 # ~6 months = 2 quarters
66
- when '3q'
67
- latest_date - 189 # ~9 months = 3 quarters
68
- when '4q'
69
- latest_date - 252 # ~12 months = 4 quarters
70
- else
71
- parsed_dates.min # "all" - keep everything
72
- end
73
-
74
- # Find indices where date >= cutoff_date
75
- indices = parsed_dates.each_with_index.select { |d, _i| d >= cutoff_date }.map(&:last)
76
-
77
- # Filter all arrays by the same indices
78
- filtered_dates = indices.map { |i| dates[i] }
79
- filtered_data = data_arrays.map { |arr| indices.map { |i| arr[i] } }
80
-
81
- [filtered_dates, *filtered_data]
82
- end
83
- end
84
-
85
- # Routes
86
-
87
- # Home / Dashboard
88
- get '/' do
89
- erb :index
90
- end
91
-
92
- # Dashboard for specific ticker
93
- get '/dashboard/:ticker' do
94
- ticker = params[:ticker].upcase
95
-
96
- begin
97
- @stock = SQA::Stock.new(ticker: ticker)
98
- @ticker = ticker
99
- erb :dashboard
100
- rescue => e
101
- @error = "Failed to load data for #{ticker}: #{e.message}"
102
- erb :error
103
- end
104
- end
105
-
106
- # Analysis page
107
- get '/analyze/:ticker' do
108
- ticker = params[:ticker].upcase
109
-
110
- begin
111
- @stock = SQA::Stock.new(ticker: ticker)
112
- @ticker = ticker
113
- erb :analyze
114
- rescue => e
115
- @error = "Failed to load data for #{ticker}: #{e.message}"
116
- erb :error
117
- end
118
- end
119
-
120
- # Backtest page
121
- get '/backtest/:ticker' do
122
- ticker = params[:ticker].upcase
123
-
124
- begin
125
- @stock = SQA::Stock.new(ticker: ticker)
126
- @ticker = ticker
127
- erb :backtest
128
- rescue => e
129
- @error = "Failed to load data for #{ticker}: #{e.message}"
130
- erb :error
131
- end
132
- end
133
-
134
- # Portfolio optimizer
135
- get '/portfolio' do
136
- erb :portfolio
137
- end
138
-
139
- # API Endpoints
140
-
141
- # Get stock data
142
- get '/api/stock/:ticker' do
143
- content_type :json
144
-
145
- ticker = params[:ticker].upcase
146
- period = params[:period] || 'all'
147
-
148
- begin
149
- stock = SQA::Stock.new(ticker: ticker)
150
- df = stock.df
151
-
152
- # Get price data (all data first)
153
- dates = df["timestamp"].to_a.map(&:to_s)
154
- opens = df["open_price"].to_a
155
- highs = df["high_price"].to_a
156
- lows = df["low_price"].to_a
157
- closes = df["adj_close_price"].to_a
158
- volumes = df["volume"].to_a
159
-
160
- # Filter by period
161
- filtered_dates, filtered_opens, filtered_highs, filtered_lows, filtered_closes, filtered_volumes =
162
- filter_by_period(dates, opens, highs, lows, closes, volumes, period: period)
163
-
164
- # Calculate basic stats
165
- current_price = filtered_closes.last
166
- prev_price = filtered_closes[-2]
167
- change = current_price - prev_price
168
- change_pct = (change / prev_price) * 100
169
-
170
- # 52-week high/low uses full data for reference
171
- high_52w = closes.last(252).max rescue closes.max
172
- low_52w = closes.last(252).min rescue closes.min
173
-
174
- {
175
- ticker: ticker,
176
- period: period,
177
- current_price: current_price,
178
- change: change,
179
- change_percent: change_pct,
180
- high_52w: high_52w,
181
- low_52w: low_52w,
182
- dates: filtered_dates,
183
- open: filtered_opens,
184
- high: filtered_highs,
185
- low: filtered_lows,
186
- close: filtered_closes,
187
- volume: filtered_volumes
188
- }.to_json
189
- rescue => e
190
- status 500
191
- { error: e.message }.to_json
192
- end
193
- end
194
-
195
- # Get technical indicators
196
- get '/api/indicators/:ticker' do
197
- content_type :json
198
-
199
- ticker = params[:ticker].upcase
200
- period = params[:period] || 'all'
201
-
202
- begin
203
- stock = SQA::Stock.new(ticker: ticker)
204
- df = stock.df
205
-
206
- prices = df["adj_close_price"].to_a
207
- opens = df["open_price"].to_a
208
- highs = df["high_price"].to_a
209
- lows = df["low_price"].to_a
210
- volumes = df["volume"].to_a
211
- dates = df["timestamp"].to_a.map(&:to_s)
212
- n = prices.length
213
-
214
- # Calculate price indicators on full dataset (they need historical context)
215
- rsi = SQAI.rsi(prices, period: 14)
216
- macd_result = SQAI.macd(prices)
217
- bb_result = SQAI.bbands(prices)
218
- sma_12 = SQAI.sma(prices, period: 12)
219
- sma_20 = SQAI.sma(prices, period: 20)
220
- sma_50 = SQAI.sma(prices, period: 50)
221
- ema_20 = SQAI.ema(prices, period: 20)
222
-
223
- # Additional moving averages for price overlay
224
- wma_20 = SQAI.wma(prices, period: 20)
225
- dema_20 = SQAI.dema(prices, period: 20)
226
- tema_20 = SQAI.tema(prices, period: 20)
227
- kama_30 = SQAI.kama(prices, period: 30)
228
-
229
- # Momentum indicators (require high/low/close)
230
- stoch_result = SQAI.stoch(highs, lows, prices)
231
- mom_10 = SQAI.mom(prices, period: 10)
232
- cci_14 = SQAI.cci(highs, lows, prices, period: 14)
233
- willr_14 = SQAI.willr(highs, lows, prices, period: 14)
234
- roc_10 = SQAI.roc(prices, period: 10)
235
- adx_14 = SQAI.adx(highs, lows, prices, period: 14)
236
-
237
- # Volatility indicators
238
- atr_14 = SQAI.atr(highs, lows, prices, period: 14)
239
-
240
- # Volume indicators
241
- obv = SQAI.obv(prices, volumes)
242
- ad = SQAI.ad(highs, lows, prices, volumes)
243
-
244
- # Calculate volume moving averages
245
- vol_sma_12 = SQAI.sma(volumes, period: 12)
246
- vol_sma_20 = SQAI.sma(volumes, period: 20)
247
- vol_sma_50 = SQAI.sma(volumes, period: 50)
248
- vol_ema_12 = SQAI.ema(volumes, period: 12)
249
- vol_ema_20 = SQAI.ema(volumes, period: 20)
250
-
251
- # Candlestick pattern recognition (high-priority patterns)
252
- cdl_doji = SQAI.cdl_doji(opens, highs, lows, prices)
253
- cdl_hammer = SQAI.cdl_hammer(opens, highs, lows, prices)
254
- cdl_shootingstar = SQAI.cdl_shootingstar(opens, highs, lows, prices)
255
- cdl_engulfing = SQAI.cdl_engulfing(opens, highs, lows, prices)
256
- cdl_morningstar = SQAI.cdl_morningstar(opens, highs, lows, prices)
257
- cdl_eveningstar = SQAI.cdl_eveningstar(opens, highs, lows, prices)
258
- cdl_harami = SQAI.cdl_harami(opens, highs, lows, prices)
259
- cdl_3whitesoldiers = SQAI.cdl_3whitesoldiers(opens, highs, lows, prices)
260
- cdl_3blackcrows = SQAI.cdl_3blackcrows(opens, highs, lows, prices)
261
- cdl_piercing = SQAI.cdl_piercing(opens, highs, lows, prices)
262
- cdl_darkcloudcover = SQAI.cdl_darkcloudcover(opens, highs, lows, prices)
263
- cdl_marubozu = SQAI.cdl_marubozu(opens, highs, lows, prices)
264
-
265
- # Pad indicator arrays with nil at the beginning to align with dates
266
- # Indicators return shorter arrays due to warmup periods
267
- pad_array = ->(arr) { Array.new(n - arr.length, nil) + arr }
268
-
269
- rsi = pad_array.call(rsi)
270
- macd_line = pad_array.call(macd_result[0])
271
- macd_signal = pad_array.call(macd_result[1])
272
- macd_hist = pad_array.call(macd_result[2])
273
- bb_upper = pad_array.call(bb_result[0])
274
- bb_middle = pad_array.call(bb_result[1])
275
- bb_lower = pad_array.call(bb_result[2])
276
- sma_12 = pad_array.call(sma_12)
277
- sma_20 = pad_array.call(sma_20)
278
- sma_50 = pad_array.call(sma_50)
279
- ema_20 = pad_array.call(ema_20)
280
-
281
- # Pad additional moving averages
282
- wma_20 = pad_array.call(wma_20)
283
- dema_20 = pad_array.call(dema_20)
284
- tema_20 = pad_array.call(tema_20)
285
- kama_30 = pad_array.call(kama_30)
286
-
287
- # Pad momentum indicators
288
- stoch_slowk = pad_array.call(stoch_result[0])
289
- stoch_slowd = pad_array.call(stoch_result[1])
290
- mom_10 = pad_array.call(mom_10)
291
- cci_14 = pad_array.call(cci_14)
292
- willr_14 = pad_array.call(willr_14)
293
- roc_10 = pad_array.call(roc_10)
294
- adx_14 = pad_array.call(adx_14)
295
-
296
- # Pad volatility indicators
297
- atr_14 = pad_array.call(atr_14)
298
-
299
- # Pad volume indicators
300
- obv = pad_array.call(obv)
301
- ad = pad_array.call(ad)
302
- vol_sma_12 = pad_array.call(vol_sma_12)
303
- vol_sma_20 = pad_array.call(vol_sma_20)
304
- vol_sma_50 = pad_array.call(vol_sma_50)
305
- vol_ema_12 = pad_array.call(vol_ema_12)
306
- vol_ema_20 = pad_array.call(vol_ema_20)
307
-
308
- # Pad pattern arrays
309
- cdl_doji = pad_array.call(cdl_doji)
310
- cdl_hammer = pad_array.call(cdl_hammer)
311
- cdl_shootingstar = pad_array.call(cdl_shootingstar)
312
- cdl_engulfing = pad_array.call(cdl_engulfing)
313
- cdl_morningstar = pad_array.call(cdl_morningstar)
314
- cdl_eveningstar = pad_array.call(cdl_eveningstar)
315
- cdl_harami = pad_array.call(cdl_harami)
316
- cdl_3whitesoldiers = pad_array.call(cdl_3whitesoldiers)
317
- cdl_3blackcrows = pad_array.call(cdl_3blackcrows)
318
- cdl_piercing = pad_array.call(cdl_piercing)
319
- cdl_darkcloudcover = pad_array.call(cdl_darkcloudcover)
320
- cdl_marubozu = pad_array.call(cdl_marubozu)
321
-
322
- # Detect patterns from the data
323
- # Pattern types:
324
- # :neutral - always neutral signal (Doji)
325
- # :fixed - predetermined signal regardless of value sign
326
- # :directional - sign of value determines bullish (+) or bearish (-)
327
- pattern_defs = {
328
- doji: { data: cdl_doji, name: 'Doji', type: :neutral },
329
- hammer: { data: cdl_hammer, name: 'Hammer', type: :fixed, signal: 'bullish' },
330
- shootingstar: { data: cdl_shootingstar, name: 'Shooting Star', type: :fixed, signal: 'bearish' },
331
- engulfing: { data: cdl_engulfing, name: 'Engulfing', type: :directional },
332
- morningstar: { data: cdl_morningstar, name: 'Morning Star', type: :fixed, signal: 'bullish' },
333
- eveningstar: { data: cdl_eveningstar, name: 'Evening Star', type: :fixed, signal: 'bearish' },
334
- harami: { data: cdl_harami, name: 'Harami', type: :directional },
335
- whitesoldiers: { data: cdl_3whitesoldiers, name: 'Three White Soldiers', type: :fixed, signal: 'bullish' },
336
- blackcrows: { data: cdl_3blackcrows, name: 'Three Black Crows', type: :fixed, signal: 'bearish' },
337
- piercing: { data: cdl_piercing, name: 'Piercing', type: :fixed, signal: 'bullish' },
338
- darkcloudcover: { data: cdl_darkcloudcover, name: 'Dark Cloud Cover', type: :fixed, signal: 'bearish' },
339
- marubozu: { data: cdl_marubozu, name: 'Marubozu', type: :directional }
340
- }
341
-
342
- detected_patterns = []
343
- pattern_defs.each do |_key, pdef|
344
- pdef[:data].each_with_index do |val, i|
345
- next if val.nil? || val == 0
346
-
347
- signal = case pdef[:type]
348
- when :neutral then 'neutral'
349
- when :fixed then pdef[:signal]
350
- when :directional then val > 0 ? 'bullish' : 'bearish'
351
- end
352
-
353
- detected_patterns << {
354
- date: dates[i],
355
- pattern: pdef[:name],
356
- signal: signal,
357
- strength: val.abs
358
- }
359
- end
360
- end
361
- # Sort by date descending and keep only 20 most recent
362
- detected_patterns.sort_by! { |p| p[:date] }.reverse!
363
- detected_patterns = detected_patterns.first(20)
364
-
365
- # Filter results by period (keep indicators aligned with dates)
366
- filtered_dates, filtered_rsi, filtered_macd, filtered_macd_signal, filtered_macd_hist,
367
- filtered_bb_upper, filtered_bb_middle, filtered_bb_lower,
368
- filtered_sma_12, filtered_sma_20, filtered_sma_50, filtered_ema_20,
369
- filtered_wma_20, filtered_dema_20, filtered_tema_20, filtered_kama_30,
370
- filtered_stoch_slowk, filtered_stoch_slowd, filtered_mom_10,
371
- filtered_cci_14, filtered_willr_14, filtered_roc_10, filtered_adx_14,
372
- filtered_atr_14, filtered_obv, filtered_ad,
373
- filtered_vol_sma_12, filtered_vol_sma_20, filtered_vol_sma_50,
374
- filtered_vol_ema_12, filtered_vol_ema_20 =
375
- filter_by_period(dates, rsi, macd_line, macd_signal, macd_hist,
376
- bb_upper, bb_middle, bb_lower,
377
- sma_12, sma_20, sma_50, ema_20,
378
- wma_20, dema_20, tema_20, kama_30,
379
- stoch_slowk, stoch_slowd, mom_10,
380
- cci_14, willr_14, roc_10, adx_14,
381
- atr_14, obv, ad,
382
- vol_sma_12, vol_sma_20, vol_sma_50,
383
- vol_ema_12, vol_ema_20, period: period)
384
-
385
- {
386
- period: period,
387
- dates: filtered_dates,
388
- rsi: filtered_rsi,
389
- macd: filtered_macd,
390
- macd_signal: filtered_macd_signal,
391
- macd_hist: filtered_macd_hist,
392
- bb_upper: filtered_bb_upper,
393
- bb_middle: filtered_bb_middle,
394
- bb_lower: filtered_bb_lower,
395
- sma_12: filtered_sma_12,
396
- sma_20: filtered_sma_20,
397
- sma_50: filtered_sma_50,
398
- ema_20: filtered_ema_20,
399
- wma_20: filtered_wma_20,
400
- dema_20: filtered_dema_20,
401
- tema_20: filtered_tema_20,
402
- kama_30: filtered_kama_30,
403
- stoch_slowk: filtered_stoch_slowk,
404
- stoch_slowd: filtered_stoch_slowd,
405
- mom_10: filtered_mom_10,
406
- cci_14: filtered_cci_14,
407
- willr_14: filtered_willr_14,
408
- roc_10: filtered_roc_10,
409
- adx_14: filtered_adx_14,
410
- atr_14: filtered_atr_14,
411
- obv: filtered_obv,
412
- ad: filtered_ad,
413
- vol_sma_12: filtered_vol_sma_12,
414
- vol_sma_20: filtered_vol_sma_20,
415
- vol_sma_50: filtered_vol_sma_50,
416
- vol_ema_12: filtered_vol_ema_12,
417
- vol_ema_20: filtered_vol_ema_20,
418
- patterns: detected_patterns || []
419
- }.to_json
420
- rescue => e
421
- status 500
422
- { error: e.message }.to_json
423
- end
424
- end
425
-
426
- # Run backtest
427
- post '/api/backtest/:ticker' do
428
- content_type :json
429
-
430
- ticker = params[:ticker].upcase
431
- strategy_name = params[:strategy] || 'RSI'
432
-
433
- begin
434
- stock = SQA::Stock.new(ticker: ticker)
435
-
436
- # Resolve strategy
437
- strategy = case strategy_name.upcase
438
- when 'RSI' then SQA::Strategy::RSI
439
- when 'SMA' then SQA::Strategy::SMA
440
- when 'EMA' then SQA::Strategy::EMA
441
- when 'MACD' then SQA::Strategy::MACD
442
- when 'BOLLINGERBANDS' then SQA::Strategy::BollingerBands
443
- when 'KBS' then SQA::Strategy::KBS
444
- else SQA::Strategy::RSI
445
- end
39
+ # Register helpers
40
+ helpers Helpers::Formatting
41
+ helpers Helpers::Filters
42
+ helpers Helpers::StockLoader
43
+ helpers Helpers::ApiHelpers
446
44
 
447
- # Run backtest
448
- backtest = SQA::Backtest.new(
449
- stock: stock,
450
- strategy: strategy,
451
- initial_capital: 10_000.0,
452
- commission: 1.0
453
- )
454
-
455
- results = backtest.run
456
-
457
- {
458
- total_return: results.total_return,
459
- annualized_return: results.annualized_return,
460
- sharpe_ratio: results.sharpe_ratio,
461
- max_drawdown: results.max_drawdown,
462
- win_rate: results.win_rate,
463
- total_trades: results.total_trades,
464
- profit_factor: results.profit_factor,
465
- avg_win: results.avg_win,
466
- avg_loss: results.avg_loss
467
- }.to_json
468
- rescue => e
469
- status 500
470
- { error: e.message }.to_json
471
- end
472
- end
473
-
474
- # Run market analysis
475
- get '/api/analyze/:ticker' do
476
- content_type :json
477
-
478
- ticker = params[:ticker].upcase
479
-
480
- begin
481
- stock = SQA::Stock.new(ticker: ticker)
482
- prices = stock.df["adj_close_price"].to_a
483
-
484
- # Market regime
485
- regime = SQA::MarketRegime.detect(stock)
486
-
487
- # Seasonal analysis
488
- seasonal = SQA::SeasonalAnalyzer.analyze(stock)
489
-
490
- # FPOP analysis
491
- fpop_data = SQA::FPOP.fpl_analysis(prices, fpop: 10)
492
- recent_fpop = fpop_data.last(10).map do |f|
493
- {
494
- direction: f[:direction],
495
- magnitude: f[:magnitude],
496
- risk: f[:risk],
497
- interpretation: f[:interpretation]
498
- }
499
- end
500
-
501
- # Risk metrics
502
- returns = prices.each_cons(2).map { |a, b| (b - a) / a }
503
- var_95 = SQA::RiskManager.var(returns, confidence: 0.95)
504
- sharpe = SQA::RiskManager.sharpe_ratio(returns)
505
- max_dd = SQA::RiskManager.max_drawdown(prices)
506
-
507
- {
508
- regime: {
509
- type: regime[:type],
510
- volatility: regime[:volatility],
511
- strength: regime[:strength],
512
- trend: regime[:trend]
513
- },
514
- seasonal: {
515
- best_months: seasonal[:best_months],
516
- worst_months: seasonal[:worst_months],
517
- best_quarters: seasonal[:best_quarters],
518
- has_pattern: seasonal[:has_seasonal_pattern]
519
- },
520
- fpop: recent_fpop,
521
- risk: {
522
- var_95: var_95,
523
- sharpe_ratio: sharpe,
524
- max_drawdown: max_dd[:max_drawdown]
525
- }
526
- }.to_json
527
- rescue => e
528
- status 500
529
- { error: e.message }.to_json
530
- end
531
- end
532
-
533
- # Compare strategies
534
- post '/api/compare/:ticker' do
535
- content_type :json
536
-
537
- ticker = params[:ticker].upcase
538
-
539
- begin
540
- stock = SQA::Stock.new(ticker: ticker)
541
-
542
- strategies = {
543
- 'RSI' => SQA::Strategy::RSI,
544
- 'SMA' => SQA::Strategy::SMA,
545
- 'EMA' => SQA::Strategy::EMA,
546
- 'MACD' => SQA::Strategy::MACD,
547
- 'BollingerBands' => SQA::Strategy::BollingerBands
548
- }
549
-
550
- results = strategies.map do |name, strategy_class|
551
- backtest = SQA::Backtest.new(
552
- stock: stock,
553
- strategy: strategy_class,
554
- initial_capital: 10_000.0,
555
- commission: 1.0
556
- )
557
-
558
- result = backtest.run
559
-
560
- {
561
- strategy: name,
562
- return: result.total_return,
563
- sharpe: result.sharpe_ratio,
564
- drawdown: result.max_drawdown,
565
- win_rate: result.win_rate,
566
- trades: result.total_trades
567
- }
568
- rescue => e
569
- nil
570
- end.compact
571
-
572
- results.sort_by! { |r| -r[:return] }
573
- results.to_json
574
- rescue => e
575
- status 500
576
- { error: e.message }.to_json
577
- end
578
- end
45
+ # Register routes
46
+ register Routes::Pages
47
+ register Routes::Api
579
48
  end
580
49
  end
581
50
  end