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.
@@ -31,19 +31,24 @@ body {
31
31
  line-height: 1.6;
32
32
  }
33
33
 
34
- /* Navigation */
35
- .navbar {
36
- background: linear-gradient(135deg, var(--dark-bg) 0%, #16213e 100%);
37
- color: white;
38
- padding: 1rem 0;
39
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
34
+ /* Sticky Header Container */
35
+ .site-header {
40
36
  position: sticky;
41
37
  top: 0;
42
38
  z-index: 1000;
39
+ background: var(--dark-bg);
40
+ }
41
+
42
+ /* Primary Navigation */
43
+ .navbar {
44
+ background: linear-gradient(135deg, var(--dark-bg) 0%, #16213e 100%);
45
+ color: white;
46
+ padding: 0.75rem 0;
47
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
43
48
  }
44
49
 
45
50
  .nav-container {
46
- max-width: 1200px;
51
+ max-width: 1400px;
47
52
  margin: 0 auto;
48
53
  padding: 0 2rem;
49
54
  display: flex;
@@ -52,11 +57,17 @@ body {
52
57
  }
53
58
 
54
59
  .nav-brand {
55
- font-size: 1.5rem;
60
+ font-size: 1.4rem;
56
61
  font-weight: 700;
57
62
  display: flex;
58
63
  align-items: center;
59
64
  gap: 0.5rem;
65
+ text-decoration: none;
66
+ color: white;
67
+ }
68
+
69
+ .nav-brand:hover {
70
+ color: white;
60
71
  }
61
72
 
62
73
  .nav-brand i {
@@ -66,21 +77,195 @@ body {
66
77
  .nav-menu {
67
78
  display: flex;
68
79
  list-style: none;
69
- gap: 2rem;
80
+ gap: 0.5rem;
81
+ align-items: center;
70
82
  }
71
83
 
72
84
  .nav-link {
73
- color: white;
85
+ color: var(--text-secondary);
74
86
  text-decoration: none;
75
87
  font-weight: 500;
76
- transition: color 0.3s ease;
88
+ transition: all 0.2s ease;
77
89
  display: flex;
78
90
  align-items: center;
79
91
  gap: 0.5rem;
92
+ padding: 0.5rem 1rem;
93
+ border-radius: 6px;
80
94
  }
81
95
 
82
96
  .nav-link:hover {
83
97
  color: var(--primary-color);
98
+ background: rgba(0, 212, 255, 0.1);
99
+ }
100
+
101
+ .nav-link i.fa-caret-down {
102
+ font-size: 0.75rem;
103
+ margin-left: 0.25rem;
104
+ }
105
+
106
+ /* Navigation Dropdowns */
107
+ .nav-dropdown {
108
+ position: relative;
109
+ }
110
+
111
+ .nav-dropdown-menu {
112
+ display: none;
113
+ position: absolute;
114
+ top: 100%;
115
+ left: 0;
116
+ background: var(--card-bg);
117
+ border: 1px solid var(--border-color);
118
+ border-radius: 8px;
119
+ min-width: 180px;
120
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
121
+ padding: 0.5rem 0;
122
+ margin-top: 0.25rem;
123
+ z-index: 1001;
124
+ }
125
+
126
+ .nav-dropdown:hover .nav-dropdown-menu {
127
+ display: block;
128
+ }
129
+
130
+ .nav-dropdown-menu a {
131
+ display: block;
132
+ padding: 0.6rem 1rem;
133
+ color: var(--text-primary);
134
+ text-decoration: none;
135
+ font-size: 0.9rem;
136
+ transition: background 0.2s ease;
137
+ }
138
+
139
+ .nav-dropdown-menu a:hover {
140
+ background: var(--card-bg-lighter);
141
+ color: var(--primary-color);
142
+ }
143
+
144
+ .nav-dropdown-divider {
145
+ height: 1px;
146
+ background: var(--border-color);
147
+ margin: 0.5rem 0;
148
+ }
149
+
150
+ .nav-dropdown-label {
151
+ display: block;
152
+ padding: 0.4rem 1rem;
153
+ font-size: 0.7rem;
154
+ font-weight: 600;
155
+ text-transform: uppercase;
156
+ letter-spacing: 0.05em;
157
+ color: var(--text-secondary);
158
+ }
159
+
160
+ /* Search Button in Nav */
161
+ .nav-search-btn {
162
+ background: var(--card-bg-lighter);
163
+ border: 1px solid var(--border-color);
164
+ color: var(--text-secondary);
165
+ width: 40px;
166
+ height: 40px;
167
+ border-radius: 8px;
168
+ cursor: pointer;
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: center;
172
+ transition: all 0.2s ease;
173
+ }
174
+
175
+ .nav-search-btn:hover {
176
+ background: var(--card-bg);
177
+ border-color: var(--primary-color);
178
+ color: var(--primary-color);
179
+ }
180
+
181
+ /* Context Bar - Stock-specific navigation */
182
+ .context-bar {
183
+ background: var(--card-bg);
184
+ border-top: 2px solid var(--primary-color);
185
+ padding: 0.5rem 0;
186
+ }
187
+
188
+ .context-container {
189
+ max-width: 1400px;
190
+ margin: 0 auto;
191
+ padding: 0 2rem;
192
+ display: flex;
193
+ align-items: center;
194
+ gap: 2rem;
195
+ }
196
+
197
+ .context-stock {
198
+ display: flex;
199
+ align-items: center;
200
+ gap: 0.75rem;
201
+ }
202
+
203
+ .context-ticker {
204
+ font-size: 1.1rem;
205
+ font-weight: 700;
206
+ color: var(--success-color);
207
+ }
208
+
209
+ .context-name {
210
+ font-size: 0.9rem;
211
+ color: var(--text-secondary);
212
+ }
213
+
214
+ .context-nav {
215
+ display: flex;
216
+ gap: 0.25rem;
217
+ flex: 1;
218
+ }
219
+
220
+ .context-link {
221
+ display: flex;
222
+ align-items: center;
223
+ gap: 0.4rem;
224
+ padding: 0.4rem 0.9rem;
225
+ border-radius: 20px;
226
+ font-size: 0.85rem;
227
+ font-weight: 500;
228
+ color: var(--text-secondary);
229
+ text-decoration: none;
230
+ border: 1px solid transparent;
231
+ transition: all 0.2s ease;
232
+ }
233
+
234
+ .context-link:hover {
235
+ color: var(--text-primary);
236
+ background: var(--card-bg-lighter);
237
+ border-color: var(--border-color);
238
+ }
239
+
240
+ .context-link.active {
241
+ background: var(--primary-color);
242
+ color: var(--dark-bg);
243
+ font-weight: 600;
244
+ }
245
+
246
+ .context-link i {
247
+ font-size: 0.8rem;
248
+ }
249
+
250
+ /* Period Selector in Context Bar */
251
+ .context-period {
252
+ margin-left: auto;
253
+ }
254
+
255
+ .context-period select {
256
+ background: var(--card-bg-lighter);
257
+ color: var(--text-primary);
258
+ border: 1px solid var(--border-color);
259
+ padding: 0.4rem 0.75rem;
260
+ border-radius: 6px;
261
+ font-size: 0.85rem;
262
+ cursor: pointer;
263
+ outline: none;
264
+ }
265
+
266
+ .context-period select:hover,
267
+ .context-period select:focus {
268
+ border-color: var(--primary-color);
84
269
  }
85
270
 
86
271
  /* Main Content */
@@ -906,6 +1091,22 @@ body {
906
1091
  color: var(--text-primary);
907
1092
  }
908
1093
 
1094
+ .modal-hint {
1095
+ color: var(--text-secondary);
1096
+ font-size: 0.85rem;
1097
+ margin-top: 0.5rem;
1098
+ text-align: center;
1099
+ }
1100
+
1101
+ /* Ticker Search Hint */
1102
+ .ticker-hint {
1103
+ color: var(--text-secondary);
1104
+ font-size: 0.9rem;
1105
+ margin-top: 1rem;
1106
+ text-align: center;
1107
+ opacity: 0.8;
1108
+ }
1109
+
909
1110
  /* Footer */
910
1111
  .footer {
911
1112
  background: var(--dark-bg);
@@ -932,6 +1133,26 @@ body {
932
1133
  }
933
1134
 
934
1135
  /* Responsive */
1136
+ @media (max-width: 1024px) {
1137
+ .nav-menu {
1138
+ gap: 0.25rem;
1139
+ }
1140
+
1141
+ .nav-link {
1142
+ padding: 0.4rem 0.6rem;
1143
+ font-size: 0.85rem;
1144
+ }
1145
+
1146
+ .context-container {
1147
+ gap: 1rem;
1148
+ }
1149
+
1150
+ .context-link {
1151
+ padding: 0.35rem 0.6rem;
1152
+ font-size: 0.8rem;
1153
+ }
1154
+ }
1155
+
935
1156
  @media (max-width: 768px) {
936
1157
  .hero-content h1 {
937
1158
  font-size: 2rem;
@@ -955,8 +1176,64 @@ body {
955
1176
  grid-template-columns: 1fr;
956
1177
  }
957
1178
 
1179
+ /* Mobile Navigation */
1180
+ .nav-container {
1181
+ padding: 0 1rem;
1182
+ }
1183
+
1184
+ .nav-brand span {
1185
+ display: none;
1186
+ }
1187
+
958
1188
  .nav-menu {
959
- gap: 1rem;
960
- font-size: 0.875rem;
1189
+ gap: 0.25rem;
1190
+ }
1191
+
1192
+ .nav-link {
1193
+ padding: 0.4rem 0.5rem;
1194
+ font-size: 0.8rem;
1195
+ }
1196
+
1197
+ .nav-link i:first-child {
1198
+ margin-right: 0;
1199
+ }
1200
+
1201
+ .nav-link span,
1202
+ .nav-link i.fa-caret-down {
1203
+ display: none;
1204
+ }
1205
+
1206
+ /* Context Bar Mobile */
1207
+ .context-container {
1208
+ flex-wrap: wrap;
1209
+ gap: 0.5rem;
1210
+ padding: 0 1rem;
1211
+ }
1212
+
1213
+ .context-stock {
1214
+ width: 100%;
1215
+ padding-bottom: 0.5rem;
1216
+ border-bottom: 1px solid var(--border-color);
1217
+ }
1218
+
1219
+ .context-nav {
1220
+ width: 100%;
1221
+ flex-wrap: wrap;
1222
+ gap: 0.25rem;
1223
+ }
1224
+
1225
+ .context-link {
1226
+ padding: 0.3rem 0.5rem;
1227
+ font-size: 0.75rem;
1228
+ }
1229
+
1230
+ .context-period {
1231
+ width: 100%;
1232
+ margin-left: 0;
1233
+ margin-top: 0.5rem;
1234
+ }
1235
+
1236
+ .context-period select {
1237
+ width: 100%;
961
1238
  }
962
1239
  }
@@ -1,6 +1,10 @@
1
1
  // Global functions for modal and navigation
2
2
 
3
- function showTickerModal() {
3
+ // Track the target route for navigation
4
+ let navTargetRoute = 'dashboard';
5
+
6
+ function showTickerModal(targetRoute = 'dashboard') {
7
+ navTargetRoute = targetRoute;
4
8
  document.getElementById('tickerModal').style.display = 'block';
5
9
  document.getElementById('tickerInput').focus();
6
10
  }
@@ -9,28 +13,71 @@ function closeTickerModal() {
9
13
  document.getElementById('tickerModal').style.display = 'none';
10
14
  }
11
15
 
16
+ function navigateToStock(route) {
17
+ showTickerModal(route);
18
+ }
19
+
20
+ function showCompareModal() {
21
+ navTargetRoute = 'compare';
22
+ document.getElementById('tickerModal').style.display = 'block';
23
+ const input = document.getElementById('tickerInput');
24
+ input.placeholder = 'Enter 2-5 tickers separated by spaces (e.g., AAPL MSFT GOOGL)';
25
+ input.focus();
26
+ }
27
+
12
28
  function searchTicker(event) {
13
29
  event.preventDefault();
14
30
 
15
31
  const input = event.target.querySelector('input[type="text"]');
16
- const ticker = input.value.trim().toUpperCase();
32
+ const inputValue = input.value.trim().toUpperCase();
17
33
 
18
- if (!ticker) {
34
+ if (!inputValue) {
19
35
  alert('Please enter a stock ticker symbol');
20
36
  return false;
21
37
  }
22
38
 
23
- // Validate ticker format (letters and optional dot)
24
- if (!/^[A-Z]{1,5}(\.[A-Z]{1,2})?$/.test(ticker)) {
25
- alert('Please enter a valid ticker symbol (e.g., AAPL, BRK.A)');
39
+ // Split by whitespace to check for multiple tickers
40
+ const tickers = inputValue.split(/\s+/).filter(t => t.length > 0);
41
+
42
+ // Validate each ticker format (letters and optional dot)
43
+ const tickerPattern = /^[A-Z]{1,5}(\.[A-Z]{1,2})?$/;
44
+ const invalidTickers = tickers.filter(t => !tickerPattern.test(t));
45
+
46
+ if (invalidTickers.length > 0) {
47
+ alert(`Invalid ticker symbol(s): ${invalidTickers.join(', ')}\nPlease use valid symbols (e.g., AAPL, BRK.A)`);
48
+ return false;
49
+ }
50
+
51
+ // Check maximum of 5 tickers for comparison
52
+ if (tickers.length > 5) {
53
+ alert('Maximum of 5 tickers allowed for comparison. Please reduce your selection.');
54
+ return false;
55
+ }
56
+
57
+ // If multiple tickers, go to comparison page
58
+ if (tickers.length > 1) {
59
+ window.location.href = `/compare?tickers=${tickers.join('+')}`;
26
60
  return false;
27
61
  }
28
62
 
29
- // Navigate to dashboard
30
- window.location.href = `/dashboard/${ticker}`;
63
+ // Single ticker - navigate to the target route (dashboard, analyze, or backtest)
64
+ window.location.href = `/${navTargetRoute}/${tickers[0]}`;
31
65
  return false;
32
66
  }
33
67
 
68
+ // Change period from header selector
69
+ function changePeriod(period) {
70
+ // Get current URL path
71
+ const path = window.location.pathname;
72
+
73
+ // Construct new URL with period parameter
74
+ const url = new URL(window.location.href);
75
+ url.searchParams.set('period', period);
76
+
77
+ // Reload page with new period
78
+ window.location.href = url.toString();
79
+ }
80
+
34
81
  // Close modal when clicking outside
35
82
  window.onclick = function(event) {
36
83
  const modal = document.getElementById('tickerModal');
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module SqaDemo
6
+ module Sinatra
7
+ module Routes
8
+ module Api
9
+ def self.registered(app)
10
+ # Get stock data
11
+ app.get '/api/stock/:ticker' do
12
+ content_type :json
13
+
14
+ ticker = params[:ticker].upcase
15
+ period = params[:period] || 'all'
16
+
17
+ begin
18
+ stock = SQA::Stock.new(ticker: ticker)
19
+ ohlcv = extract_ohlcv(stock)
20
+
21
+ # Filter by period
22
+ filtered_dates, filtered_opens, filtered_highs, filtered_lows, filtered_closes, filtered_volumes =
23
+ filter_by_period(ohlcv[:dates], ohlcv[:opens], ohlcv[:highs], ohlcv[:lows], ohlcv[:closes], ohlcv[:volumes], period: period)
24
+
25
+ # Calculate basic stats
26
+ current_price = filtered_closes.last
27
+ prev_price = filtered_closes[-2]
28
+ change = current_price - prev_price
29
+ change_pct = (change / prev_price) * 100
30
+
31
+ # 52-week high/low uses full data for reference
32
+ high_52w = ohlcv[:closes].last(252).max rescue ohlcv[:closes].max
33
+ low_52w = ohlcv[:closes].last(252).min rescue ohlcv[:closes].min
34
+
35
+ {
36
+ ticker: ticker,
37
+ period: period,
38
+ current_price: current_price,
39
+ change: change,
40
+ change_percent: change_pct,
41
+ high_52w: high_52w,
42
+ low_52w: low_52w,
43
+ dates: filtered_dates,
44
+ open: filtered_opens,
45
+ high: filtered_highs,
46
+ low: filtered_lows,
47
+ close: filtered_closes,
48
+ volume: filtered_volumes
49
+ }.to_json
50
+ rescue => e
51
+ status 500
52
+ { error: e.message }.to_json
53
+ end
54
+ end
55
+
56
+ # Get technical indicators
57
+ app.get '/api/indicators/:ticker' do
58
+ content_type :json
59
+
60
+ ticker = params[:ticker].upcase
61
+ period = params[:period] || 'all'
62
+
63
+ begin
64
+ stock = SQA::Stock.new(ticker: ticker)
65
+ ohlcv = extract_ohlcv(stock)
66
+
67
+ prices = ohlcv[:closes]
68
+ opens = ohlcv[:opens]
69
+ highs = ohlcv[:highs]
70
+ lows = ohlcv[:lows]
71
+ volumes = ohlcv[:volumes]
72
+ dates = ohlcv[:dates]
73
+ n = prices.length
74
+
75
+ # Calculate indicators on full dataset (they need historical context)
76
+ indicators = calculate_all_indicators(opens, highs, lows, prices, volumes, n)
77
+
78
+ # Detect candlestick patterns
79
+ detected_patterns = detect_candlestick_patterns(opens, highs, lows, prices, dates, n)
80
+
81
+ # Filter results by period
82
+ filtered_data = filter_indicators_by_period(dates, indicators, period)
83
+
84
+ filtered_data.merge(
85
+ period: period,
86
+ patterns: detected_patterns
87
+ ).to_json
88
+ rescue => e
89
+ status 500
90
+ { error: e.message }.to_json
91
+ end
92
+ end
93
+
94
+ # Run backtest
95
+ app.post '/api/backtest/:ticker' do
96
+ content_type :json
97
+
98
+ ticker = params[:ticker].upcase
99
+ strategy_name = params[:strategy] || 'RSI'
100
+
101
+ begin
102
+ stock = SQA::Stock.new(ticker: ticker)
103
+
104
+ # Resolve strategy
105
+ strategy = resolve_strategy(strategy_name)
106
+
107
+ # Run backtest
108
+ backtest = SQA::Backtest.new(
109
+ stock: stock,
110
+ strategy: strategy,
111
+ initial_capital: 10_000.0,
112
+ commission: 1.0
113
+ )
114
+
115
+ results = backtest.run
116
+
117
+ {
118
+ total_return: results.total_return,
119
+ annualized_return: results.annualized_return,
120
+ sharpe_ratio: results.sharpe_ratio,
121
+ max_drawdown: results.max_drawdown,
122
+ win_rate: results.win_rate,
123
+ total_trades: results.total_trades,
124
+ profit_factor: results.profit_factor,
125
+ avg_win: results.avg_win,
126
+ avg_loss: results.avg_loss
127
+ }.to_json
128
+ rescue => e
129
+ status 500
130
+ { error: e.message }.to_json
131
+ end
132
+ end
133
+
134
+ # Run market analysis
135
+ app.get '/api/analyze/:ticker' do
136
+ content_type :json
137
+
138
+ ticker = params[:ticker].upcase
139
+
140
+ begin
141
+ stock = SQA::Stock.new(ticker: ticker)
142
+ prices = stock.df["adj_close_price"].to_a
143
+
144
+ # Market regime
145
+ regime = SQA::MarketRegime.detect(stock)
146
+
147
+ # Seasonal analysis
148
+ seasonal = SQA::SeasonalAnalyzer.analyze(stock)
149
+
150
+ # FPOP analysis
151
+ fpop_results = analyze_fpop(stock, prices)
152
+
153
+ # Risk metrics
154
+ returns = prices.each_cons(2).map { |a, b| (b - a) / a }
155
+ var_95 = SQA::RiskManager.var(returns, confidence: 0.95)
156
+ sharpe = SQA::RiskManager.sharpe_ratio(returns)
157
+ max_dd = SQA::RiskManager.max_drawdown(prices)
158
+
159
+ {
160
+ regime: {
161
+ type: regime[:type],
162
+ volatility: regime[:volatility],
163
+ strength: regime[:strength_score],
164
+ trend: regime[:trend_score]
165
+ },
166
+ seasonal: {
167
+ best_months: seasonal[:best_months],
168
+ worst_months: seasonal[:worst_months],
169
+ best_quarters: seasonal[:best_quarters],
170
+ has_pattern: seasonal[:has_seasonal_pattern]
171
+ },
172
+ fpop: fpop_results,
173
+ risk: {
174
+ var_95: var_95,
175
+ sharpe_ratio: sharpe,
176
+ max_drawdown: max_dd[:max_drawdown]
177
+ }
178
+ }.to_json
179
+ rescue => e
180
+ status 500
181
+ { error: e.message }.to_json
182
+ end
183
+ end
184
+
185
+ # Compare strategies
186
+ app.post '/api/compare/:ticker' do
187
+ content_type :json
188
+
189
+ ticker = params[:ticker].upcase
190
+
191
+ begin
192
+ stock = SQA::Stock.new(ticker: ticker)
193
+
194
+ strategies = {
195
+ 'RSI' => SQA::Strategy::RSI,
196
+ 'SMA' => SQA::Strategy::SMA,
197
+ 'EMA' => SQA::Strategy::EMA,
198
+ 'MACD' => SQA::Strategy::MACD,
199
+ 'BollingerBands' => SQA::Strategy::BollingerBands
200
+ }
201
+
202
+ results = strategies.map do |name, strategy_class|
203
+ backtest = SQA::Backtest.new(
204
+ stock: stock,
205
+ strategy: strategy_class,
206
+ initial_capital: 10_000.0,
207
+ commission: 1.0
208
+ )
209
+
210
+ result = backtest.run
211
+
212
+ {
213
+ strategy: name,
214
+ return: result.total_return,
215
+ sharpe: result.sharpe_ratio,
216
+ drawdown: result.max_drawdown,
217
+ win_rate: result.win_rate,
218
+ trades: result.total_trades
219
+ }
220
+ rescue => e
221
+ nil
222
+ end.compact
223
+
224
+ results.sort_by! { |r| -r[:return] }
225
+ results.to_json
226
+ rescue => e
227
+ status 500
228
+ { error: e.message }.to_json
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end