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
|
@@ -31,19 +31,24 @@ body {
|
|
|
31
31
|
line-height: 1.6;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/*
|
|
35
|
-
.
|
|
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:
|
|
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.
|
|
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:
|
|
80
|
+
gap: 0.5rem;
|
|
81
|
+
align-items: center;
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
.nav-link {
|
|
73
|
-
color:
|
|
85
|
+
color: var(--text-secondary);
|
|
74
86
|
text-decoration: none;
|
|
75
87
|
font-weight: 500;
|
|
76
|
-
transition:
|
|
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:
|
|
960
|
-
|
|
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
|
-
|
|
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
|
|
32
|
+
const inputValue = input.value.trim().toUpperCase();
|
|
17
33
|
|
|
18
|
-
if (!
|
|
34
|
+
if (!inputValue) {
|
|
19
35
|
alert('Please enter a stock ticker symbol');
|
|
20
36
|
return false;
|
|
21
37
|
}
|
|
22
38
|
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
//
|
|
30
|
-
window.location.href =
|
|
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
|