├── .gitignore
├── LICENSE.md
├── Procfile_dash
├── Procfile_restapi
├── README.md
├── art
└── homepage.png
├── assets
├── favicon.ico
├── s1.css
└── style.css
├── dash_app.py
├── dash_app_functions.py
├── docker
└── Dockerfile
├── requirements.txt
├── rest_api.py
├── rest_api_models.py
├── runtime.txt
├── start.sh
├── stock_pattern_analyzer
├── __init__.py
├── data.py
├── search_index.py
├── search_model.py
└── visualization.py
├── symbols.txt.example
└── tests
├── measurements.py
└── rest_api_stress_test.py
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | __pycache__/
3 | *.pk
4 | symbols.txt
5 |
6 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Stock Pattern Analyzer Tool License - Version 1.0
2 |
3 | Copyright 2023 Gabor Vecsei
4 |
5 | The following terms and conditions govern the use, modification, and distribution of the trading tool (the "Tool")
6 | developed by Gabor Vecsei (the "Author").
7 |
8 | 1. Grant of License
9 |
10 | Subject to the terms and conditions of this license, the Author hereby grants you a worldwide, non-exclusive,
11 | royalty-free, revocable license to use, modify, and distribute the Tool for non-commercial purposes.
12 |
13 | 2. Attribution
14 |
15 | When using, modifying, or distributing the Tool, you must give appropriate credit to the Author by clearly mentioning the following:
16 |
17 | - The name of the Author
18 | - The title of the Tool
19 | - The URL or link to the original repository
20 |
21 | 3. Non-Commercial Use
22 |
23 | You are not permitted to use the Tool, in whole or in part, for commercial purposes without obtaining a separate commercial license from the Author.
24 | Commercial purposes include, but are not limited to, selling, licensing, or distributing the Tool for financial gain.
25 |
26 | 4. No Derivative Works for Commercial Purposes
27 |
28 | You may not distribute modified versions of the Tool for commercial purposes without obtaining a separate commercial license from the Author.
29 | However, you are allowed to make modifications to the Tool for personal use or non-commercial research purposes.
30 |
31 | 5. No Warranty
32 |
33 | The Tool is provided "as is" without any warranties or guarantees of any kind, whether expressed or implied.
34 | The Author shall not be held liable for any damages or liabilities arising from the use, modification, or distribution of the Tool.
35 |
36 | 6. Entire Agreement
37 |
38 | This license constitutes the entire agreement between the parties regarding the use, modification,
39 | and distribution of the Tool and supersedes any prior agreements or understandings, whether written or oral.
40 |
41 | For any inquiries or requests regarding commercial use or obtaining a commercial license, please contact
42 | the Author at vecseigabor.x@gmail.com.
43 |
--------------------------------------------------------------------------------
/Procfile_dash:
--------------------------------------------------------------------------------
1 | web: gunicorn dash_app:server
--------------------------------------------------------------------------------
/Procfile_restapi:
--------------------------------------------------------------------------------
1 | web: uvicorn rest_api:app --host=0.0.0.0 --port=${PORT:-5000}
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Stocks Pattern Analyzer
2 |
3 |
4 |
5 | > *As I am not a a frontend guy, the client does not look good at all on mobile devices.
6 | This is the best I could do. Help is greatly appreciated.*
7 |
8 | ## Run it locally
9 |
10 | Include ticker symbols with the `symbols.txt` file - put each symbol here in a new line. Check `symbols.txt.example` for
11 | an example use case.
12 |
13 | There are 2 special symbols which you can use as a shortcut
14 | - `$SP500` to include all S&P500 symbols
15 | - `$CURRENCY_PAIRS` to include currency pairs where the base currency is EUR
16 |
17 | ### Build & Run with Docker
18 |
19 | (Execute these in the root folder of the project)
20 |
21 | ```shell script
22 | # Build the image
23 | $ docker build -t stock -f docker/Dockerfile .
24 | # Run it
25 | $ docker run --rm --name stock -v $(pwd):/code -p 8050:8050 stock start.sh
26 | ```
27 |
28 | After this you can access it at `localhost:8050`
29 |
30 | > *Disclaimer*: in a proper setup you would create 2 different images, on for the RestAPI and one for the Client App.
31 | Then with a `docker-compoase.yml` you could create the services. But just like with Heroku, this is a toy and local
32 | deployment, so I won't do fancy stuff here.
33 |
34 | ### Run directly
35 |
36 | - `python rest_api.py`
37 | - Wait until the data creation and search model creation is done (1-2 mins)
38 | - `python dash_app.py`
39 | - The environment variable `$REST_API_URL` controls the connection with the RestAPI. It should be the base URL
40 | - Enjoy :sunglasses:
41 |
42 | ## Deployment to Heroku (toy deployment)
43 |
44 | First of all, this is a mono-repo which is not ideal, but the deployment is just an example.
45 | This is why a multi-buildpack solution is used with `heroku-community/multi-procfile`.
46 |
47 | ```shell script
48 | $ heroku create stock-restapi --remote restapi
49 | $ heroku buildpacks:add -a stock-restapi heroku/python
50 | $ heroku buildpacks:add -a stock-restapi -i 1 heroku-community/multi-procfile
51 | $ heroku config:set -a stock-restapi PROCFILE=Procfile_restapi
52 | $ git push restapi master
53 | $
54 | $ heroku create stock-dash-client --remote dash
55 | $ heroku buildpacks:add -a stock-dash-client heroku/python
56 | $ heroku buildpacks:add -a stock-dash-client -i 1 heroku-community/multi-procfile
57 | $ heroku config:set -a stock-dash-client PROCFILE=Procfile_dash
58 | $ heroku config:set -a stock-dash-client REST_API_URL=https://stock-restapi.herokuapp.com --> this is the URL where we can reach the RestAPI
59 | $ git push dash master
60 | ```
61 |
62 | Heroku Files:
63 | - `runtime.txt` describes the Python version
64 | - `Procfile_restapi` Heroku Procfile for the RestAPI app
65 | - `Procfile_dash` Heroku Procfile for the Dash Client app
66 |
67 | ## TODOs
68 |
69 | - Backend
70 | - Proper logging and getting rid of `print`s
71 | - RAM and Speed measurements for the different Search Models
72 | - Frontend
73 | - React frontend instead of the dash app
74 |
--------------------------------------------------------------------------------
/art/homepage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gaborvecsei/Stocks-Pattern-Analyzer/72ebded1862419c8c65a95f4d6c8277b9b693143/art/homepage.png
--------------------------------------------------------------------------------
/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gaborvecsei/Stocks-Pattern-Analyzer/72ebded1862419c8c65a95f4d6c8277b9b693143/assets/favicon.ico
--------------------------------------------------------------------------------
/assets/s1.css:
--------------------------------------------------------------------------------
1 | /* Table of contents
2 | ––––––––––––––––––––––––––––––––––––––––––––––––
3 | - Plotly.js
4 | - Grid
5 | - Base Styles
6 | - Typography
7 | - Links
8 | - Buttons
9 | - Forms
10 | - Lists
11 | - Code
12 | - Tables
13 | - Spacing
14 | - Utilities
15 | - Clearing
16 | - Media Queries
17 |
18 | */
19 |
20 | /* PLotly.js
21 | –––––––––––––––––––––––––––––––––––––––––––––––– */
22 | /* plotly.js's modebar's z-index is 1001 by default
23 | * https://github.com/plotly/plotly.js/blob/7e4d8ab164258f6bd48be56589dacd9bdd7fded2/src/css/_modebar.scss#L5
24 | * In case a dropdown is above the graph, the dropdown's options
25 | * will be rendered below the modebar
26 | * Increase the select option's z-index
27 |
28 | */
29 |
30 | /* This was actually not quite right -
31 | dropdowns were overlapping each other (edited October 26)
32 |
33 | .Select {
34 | z-index: 1002;
35 | }*/
36 |
37 | /* Grid
38 | –––––––––––––––––––––––––––––––––––––––––––––––––– */
39 | .container {
40 | position: relative;
41 | width: 100%;
42 | max-width: 960px;
43 | margin: 0 auto;
44 | padding: 0 20px;
45 | box-sizing: border-box;
46 | }
47 | .column,
48 | .columns {
49 | width: 100%;
50 | float: left;
51 | box-sizing: border-box;
52 | }
53 |
54 | /* For devices larger than 400px */
55 | @media (min-width: 400px) {
56 | .container {
57 | width: 85%;
58 | padding: 0;
59 | }
60 | }
61 |
62 | /* For devices larger than 550px */
63 | @media (min-width: 550px) {
64 | .container {
65 | width: 80%;
66 | }
67 | .column,
68 | .columns {
69 | margin-left: 4%;
70 | }
71 |
72 | .one.column,
73 | .one.columns {
74 | width: 4.66666666667%;
75 | }
76 | .two.columns {
77 | width: 13.3333333333%;
78 | }
79 | .three.columns {
80 | width: 22%;
81 | }
82 | .four.columns {
83 | width: 30.6666666667%;
84 | }
85 | .five.columns {
86 | width: 39.3333333333%;
87 | }
88 | .six.columns {
89 | width: 48%;
90 | }
91 | .seven.columns {
92 | width: 56.6666666667%;
93 | }
94 | .eight.columns {
95 | width: 65.3333333333%;
96 | }
97 | .nine.columns {
98 | width: 74%;
99 | }
100 | .ten.columns {
101 | width: 82.6666666667%;
102 | }
103 | .eleven.columns {
104 | width: 91.3333333333%;
105 | }
106 | .twelve.columns {
107 | width: 100%;
108 | margin-left: 0;
109 | }
110 |
111 | .one-third.column {
112 | width: 30.6666666667%;
113 | }
114 | .two-thirds.column {
115 | width: 65.3333333333%;
116 | }
117 |
118 | .one-half.column {
119 | width: 48%;
120 | }
121 |
122 | /* Offsets */
123 | .offset-by-one.column,
124 | .offset-by-one.columns {
125 | margin-left: 8.66666666667%;
126 | }
127 | .offset-by-two.column,
128 | .offset-by-two.columns {
129 | margin-left: 17.3333333333%;
130 | }
131 | .offset-by-three.column,
132 | .offset-by-three.columns {
133 | margin-left: 26%;
134 | }
135 | .offset-by-four.column,
136 | .offset-by-four.columns {
137 | margin-left: 34.6666666667%;
138 | }
139 | .offset-by-five.column,
140 | .offset-by-five.columns {
141 | margin-left: 43.3333333333%;
142 | }
143 | .offset-by-six.column,
144 | .offset-by-six.columns {
145 | margin-left: 52%;
146 | }
147 | .offset-by-seven.column,
148 | .offset-by-seven.columns {
149 | margin-left: 60.6666666667%;
150 | }
151 | .offset-by-eight.column,
152 | .offset-by-eight.columns {
153 | margin-left: 69.3333333333%;
154 | }
155 | .offset-by-nine.column,
156 | .offset-by-nine.columns {
157 | margin-left: 78%;
158 | }
159 | .offset-by-ten.column,
160 | .offset-by-ten.columns {
161 | margin-left: 86.6666666667%;
162 | }
163 | .offset-by-eleven.column,
164 | .offset-by-eleven.columns {
165 | margin-left: 95.3333333333%;
166 | }
167 |
168 | .offset-by-one-third.column,
169 | .offset-by-one-third.columns {
170 | margin-left: 34.6666666667%;
171 | }
172 | .offset-by-two-thirds.column,
173 | .offset-by-two-thirds.columns {
174 | margin-left: 69.3333333333%;
175 | }
176 |
177 | .offset-by-one-half.column,
178 | .offset-by-one-half.columns {
179 | margin-left: 52%;
180 | }
181 | }
182 |
183 | /* Base Styles
184 | –––––––––––––––––––––––––––––––––––––––––––––––––– */
185 | /* NOTE
186 | html is set to 62.5% so that all the REM measurements throughout Skeleton
187 | are based on 10px sizing. So basically 1.5rem = 15px :) */
188 | html {
189 | font-size: 62.5%;
190 | }
191 | body {
192 | font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */
193 | line-height: 1.6;
194 | font-weight: 400;
195 | font-family: "Open Sans", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial,
196 | sans-serif;
197 | color: rgb(50, 50, 50);
198 | }
199 |
200 | /* Typography
201 | –––––––––––––––––––––––––––––––––––––––––––––––––– */
202 | h1,
203 | h2,
204 | h3,
205 | h4,
206 | h5,
207 | h6 {
208 | margin-top: 0;
209 | margin-bottom: 0;
210 | font-weight: 300;
211 | }
212 | h1 {
213 | font-size: 4.5rem;
214 | line-height: 1.2;
215 | letter-spacing: -0.1rem;
216 | margin-bottom: 2rem;
217 | }
218 | h2 {
219 | font-size: 3.6rem;
220 | line-height: 1.25;
221 | letter-spacing: -0.1rem;
222 | margin-bottom: 1.8rem;
223 | margin-top: 1.8rem;
224 | }
225 | h3 {
226 | font-size: 3rem;
227 | line-height: 1.3;
228 | letter-spacing: -0.1rem;
229 | margin-bottom: 1.5rem;
230 | margin-top: 1.5rem;
231 | }
232 | h4 {
233 | font-size: 2.6rem;
234 | line-height: 1.35;
235 | letter-spacing: -0.08rem;
236 | margin-bottom: 1.2rem;
237 | margin-top: 1.2rem;
238 | }
239 | h5 {
240 | font-size: 2.2rem;
241 | line-height: 1.5;
242 | letter-spacing: -0.05rem;
243 | margin-bottom: 0.6rem;
244 | margin-top: 0.6rem;
245 | }
246 | h6 {
247 | font-size: 2rem;
248 | line-height: 1.6;
249 | letter-spacing: 0;
250 | margin-bottom: 0.75rem;
251 | margin-top: 0.75rem;
252 | }
253 |
254 | p {
255 | margin-top: 0;
256 | }
257 |
258 | /* Blockquotes
259 | –––––––––––––––––––––––––––––––––––––––––––––––––– */
260 | blockquote {
261 | border-left: 4px lightgrey solid;
262 | padding-left: 1rem;
263 | margin-top: 2rem;
264 | margin-bottom: 2rem;
265 | margin-left: 0rem;
266 | }
267 |
268 | /* Links
269 | –––––––––––––––––––––––––––––––––––––––––––––––––– */
270 | a {
271 | color: #1eaedb;
272 | text-decoration: underline;
273 | cursor: pointer;
274 | }
275 | a:hover {
276 | color: #0fa0ce;
277 | }
278 |
279 | /* Buttons
280 | –––––––––––––––––––––––––––––––––––––––––––––––––– */
281 | .button,
282 | button,
283 | input[type="submit"],
284 | input[type="reset"],
285 | input[type="button"] {
286 | display: inline-block;
287 | height: 38px;
288 | padding: 0 30px;
289 | color: #555;
290 | text-align: center;
291 | font-size: 11px;
292 | font-weight: 600;
293 | line-height: 38px;
294 | letter-spacing: 0.1rem;
295 | text-transform: uppercase;
296 | text-decoration: none;
297 | white-space: nowrap;
298 | background-color: transparent;
299 | border-radius: 4px;
300 | border: 1px solid #bbb;
301 | cursor: pointer;
302 | box-sizing: border-box;
303 | }
304 | .button:hover,
305 | button:hover,
306 | input[type="submit"]:hover,
307 | input[type="reset"]:hover,
308 | input[type="button"]:hover,
309 | .button:focus,
310 | button:focus,
311 | input[type="submit"]:focus,
312 | input[type="reset"]:focus,
313 | input[type="button"]:focus {
314 | color: #333;
315 | border-color: #888;
316 | outline: 0;
317 | }
318 | .button.button-primary,
319 | button.button-primary,
320 | input[type="submit"].button-primary,
321 | input[type="reset"].button-primary,
322 | input[type="button"].button-primary {
323 | color: #fff;
324 | background-color: #33c3f0;
325 | border-color: #33c3f0;
326 | }
327 | .button.button-primary:hover,
328 | button.button-primary:hover,
329 | input[type="submit"].button-primary:hover,
330 | input[type="reset"].button-primary:hover,
331 | input[type="button"].button-primary:hover,
332 | .button.button-primary:focus,
333 | button.button-primary:focus,
334 | input[type="submit"].button-primary:focus,
335 | input[type="reset"].button-primary:focus,
336 | input[type="button"].button-primary:focus {
337 | color: #fff;
338 | background-color: #1eaedb;
339 | border-color: #1eaedb;
340 | }
341 |
342 | /* Forms
343 | –––––––––––––––––––––––––––––––––––––––––––––––––– */
344 | input[type="email"],
345 | input[type="number"],
346 | input[type="search"],
347 | input[type="text"],
348 | input[type="tel"],
349 | input[type="url"],
350 | input[type="password"],
351 | textarea,
352 | select {
353 | height: 38px;
354 | padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */
355 | background-color: #fff;
356 | border: 1px solid #d1d1d1;
357 | border-radius: 4px;
358 | box-shadow: none;
359 | box-sizing: border-box;
360 | font-family: inherit;
361 | font-size: inherit; /*https://stackoverflow.com/questions/6080413/why-doesnt-input-inherit-the-font-from-body*/
362 | }
363 | /* Removes awkward default styles on some inputs for iOS */
364 | input[type="email"],
365 | input[type="number"],
366 | input[type="search"],
367 | input[type="text"],
368 | input[type="tel"],
369 | input[type="url"],
370 | input[type="password"],
371 | textarea {
372 | -webkit-appearance: none;
373 | -moz-appearance: none;
374 | appearance: none;
375 | }
376 | textarea {
377 | min-height: 65px;
378 | padding-top: 6px;
379 | padding-bottom: 6px;
380 | }
381 | input[type="email"]:focus,
382 | input[type="number"]:focus,
383 | input[type="search"]:focus,
384 | input[type="text"]:focus,
385 | input[type="tel"]:focus,
386 | input[type="url"]:focus,
387 | input[type="password"]:focus,
388 | textarea:focus,
389 | select:focus {
390 | border: 1px solid #33c3f0;
391 | outline: 0;
392 | }
393 | label,
394 | legend {
395 | display: block;
396 | margin-bottom: 0px;
397 | }
398 | fieldset {
399 | padding: 0;
400 | border-width: 0;
401 | }
402 | input[type="checkbox"],
403 | input[type="radio"] {
404 | display: inline;
405 | }
406 | label > .label-body {
407 | display: inline-block;
408 | margin-left: 0.5rem;
409 | font-weight: normal;
410 | }
411 |
412 | /* Lists
413 | –––––––––––––––––––––––––––––––––––––––––––––––––– */
414 | ul {
415 | list-style: circle inside;
416 | }
417 | ol {
418 | list-style: decimal inside;
419 | }
420 | ol,
421 | ul {
422 | padding-left: 0;
423 | margin-top: 0;
424 | }
425 | ul ul,
426 | ul ol,
427 | ol ol,
428 | ol ul {
429 | margin: 1.5rem 0 1.5rem 3rem;
430 | font-size: 90%;
431 | }
432 | li {
433 | margin-bottom: 1rem;
434 | }
435 |
436 | /* Tables
437 | –––––––––––––––––––––––––––––––––––––––––––––––––– */
438 | table {
439 | }
440 | th,
441 | td {
442 | padding: 12px 15px;
443 | text-align: left;
444 | border-bottom: 1px solid #e1e1e1;
445 | }
446 | th:first-child,
447 | td:first-child {
448 | padding-left: 0;
449 | }
450 | th:last-child,
451 | td:last-child {
452 | padding-right: 0;
453 | }
454 |
455 | /* Spacing
456 | –––––––––––––––––––––––––––––––––––––––––––––––––– */
457 | button,
458 | .button {
459 | margin-bottom: 0rem;
460 | }
461 | input,
462 | textarea,
463 | select,
464 | fieldset {
465 | margin-bottom: 0rem;
466 | }
467 | pre,
468 | dl,
469 | figure,
470 | table,
471 | form {
472 | margin-bottom: 0rem;
473 | }
474 | p,
475 | ul,
476 | ol {
477 | margin-bottom: 0.75rem;
478 | }
479 |
480 | /* Utilities
481 | –––––––––––––––––––––––––––––––––––––––––––––––––– */
482 | .u-full-width {
483 | width: 100%;
484 | box-sizing: border-box;
485 | }
486 | .u-max-full-width {
487 | max-width: 100%;
488 | box-sizing: border-box;
489 | }
490 | .u-pull-right {
491 | float: right;
492 | }
493 | .u-pull-left {
494 | float: left;
495 | }
496 |
497 | /* Misc
498 | –––––––––––––––––––––––––––––––––––––––––––––––––– */
499 | hr {
500 | margin-top: 3rem;
501 | margin-bottom: 3.5rem;
502 | border-width: 0;
503 | border-top: 1px solid #e1e1e1;
504 | }
505 |
506 | /* Clearing
507 | –––––––––––––––––––––––––––––––––––––––––––––––––– */
508 |
509 | /* Self Clearing Goodness */
510 | .container:after,
511 | .row:after,
512 | .u-cf {
513 | content: "";
514 | display: table;
515 | clear: both;
516 | }
517 |
518 | /* Media Queries
519 | –––––––––––––––––––––––––––––––––––––––––––––––––– */
520 | /*
521 | Note: The best way to structure the use of media queries is to create the queries
522 | near the relevant code. For example, if you wanted to change the styles for buttons
523 | on small devices, paste the mobile query code up in the buttons section and style it
524 | there.
525 | */
526 |
527 | /* Larger than mobile, screen sizes larger than 400px */
528 | @media (min-width: 400px) {
529 | }
530 |
531 | /* Larger than phablet (also point when grid becomes active), screen larger than 550px */
532 | @media (min-width: 550px) {
533 | .one.column,
534 | .one.columns {
535 | width: 8%;
536 | }
537 | .two.columns {
538 | width: 16.25%;
539 | }
540 | .three.columns {
541 | width: 22%;
542 | }
543 | .four.columns {
544 | width: calc(100% / 3);
545 | }
546 | .five.columns {
547 | width: calc(100% * 5 / 12);
548 | }
549 | .six.columns {
550 | width: 49.75%;
551 | }
552 | .seven.columns {
553 | width: calc(100% * 7 / 12);
554 | }
555 | }
556 |
557 | /* Larger than tablet, for screens smaller than 768px */
558 | @media (max-width: 550px) {
559 | .flex-display {
560 | display: block !important;
561 | }
562 | .pretty_container {
563 | margin: 0 !important;
564 | margin-bottom: 25px !important;
565 | }
566 | #individual_graph,
567 | #count_graph,
568 | #aggregate_graph {
569 | position: static !important;
570 | }
571 | .container-display {
572 | display: flex;
573 | }
574 |
575 | .mini_container {
576 | margin-bottom: 25px !important;
577 | border-radius: 5px;
578 | background-color: #f9f9f9;
579 | padding: 15px;
580 | position: relative;
581 | box-shadow: 2px 2px 2px lightgrey;
582 | }
583 |
584 | h3 {
585 | font-size: 2.5rem;
586 | }
587 | h5 {
588 | font-size: 2rem;
589 | }
590 | h6 {
591 | font-size: 1.25rem;
592 | }
593 | p {
594 | font-size: 11px;
595 | }
596 | }
597 |
598 | /* Larger than desktop */
599 | @media (min-width: 1000px) {
600 | }
601 |
602 | /* Larger than Desktop HD */
603 | @media (min-width: 1200px) {
604 | }
--------------------------------------------------------------------------------
/assets/style.css:
--------------------------------------------------------------------------------
1 | .js-plotly-plot .plotly .modebar {
2 | padding-top: 5%;
3 | margin-right: 3.5%;
4 | }
5 |
6 | body {
7 | background-color: #f2f2f2;
8 | margin-top: 2%;
9 | margin-bottom: 2%;
10 | margin-left: 5%;
11 | margin-right: 5%;
12 | }
13 |
14 | .two.columns {
15 | width: 16.25%;
16 | }
17 |
18 | .column,
19 | .columns {
20 | margin-left: 0.5%;
21 | }
22 |
23 | .pretty_container {
24 | border-radius: 5px;
25 | background-color: #f9f9f9;
26 | margin: 10px;
27 | padding: 15px;
28 | position: relative;
29 | box-shadow: 2px 2px 2px lightgrey;
30 | }
31 |
32 | .bare_container {
33 | margin: 0 0 0 0;
34 | padding: 0 0 0 0;
35 | }
36 |
37 | .dcc_control {
38 | margin: 0;
39 | padding: 5px;
40 | width: calc(80%);
41 | }
42 |
43 | .control_label {
44 | margin: 0;
45 | padding: 10px;
46 | padding-bottom: 0px;
47 | margin-bottom: 0px;
48 | width: calc(100%-40px);
49 | }
50 |
51 | .rc-slider {
52 | margin-left: 0px;
53 | padding-left: 0px;
54 | }
55 |
56 | .flex-display {
57 | display: flex;
58 | }
59 |
60 | .container-display {
61 | display: flex;
62 | }
63 |
64 | #individual_graph,
65 | #aggregate_graph {
66 | width: calc(100% - 30px);
67 | position: absolute;
68 | }
69 |
70 | #count_graph {
71 | position: absolute;
72 | height: calc(100% - 30px);
73 | width: calc(100% - 30px);
74 | }
75 |
76 | #countGraphContainer {
77 | flex: 5;
78 | position: relative;
79 | }
80 |
81 | #header {
82 | align-items: center;
83 | }
84 |
85 | #learn-more-button {
86 | text-align: center;
87 | height: 100%;
88 | padding: 0 20px;
89 | text-transform: none;
90 | font-size: 15px;
91 | float: right;
92 | margin-right: 10px;
93 | margin-top: 30px;
94 | }
95 | #title {
96 | text-align: center;
97 | }
98 |
99 | .mini_container {
100 | border-radius: 5px;
101 | background-color: #f9f9f9;
102 | margin: 10px;
103 | padding: 15px;
104 | position: relative;
105 | box-shadow: 2px 2px 2px lightgrey;
106 | }
107 |
108 | #right-column {
109 | display: flex;
110 | flex-direction: column;
111 | }
112 |
113 | #wells {
114 | flex: 1;
115 | }
116 |
117 | #gas {
118 | flex: 1;
119 | }
120 |
121 | #aggregate_data {
122 | align-items: center;
123 | }
124 |
125 | #oil {
126 | flex: 1;
127 | }
128 |
129 | #water {
130 | flex: 1;
131 | }
132 |
133 | #tripleContainer {
134 | display: flex;
135 | flex: 3;
136 | }
137 |
138 | #mainContainer {
139 | display: flex;
140 | flex-direction: column;
141 | }
142 |
143 | #pie_graph > div > div > svg:nth-child(3) > g.infolayer > g.legend {
144 | pointer-events: all;
145 | transform: translate(30px, 349px);
146 | }
--------------------------------------------------------------------------------
/dash_app.py:
--------------------------------------------------------------------------------
1 | import dash_core_components as dcc
2 | import dash_html_components as html
3 | import dash_table
4 | from dash import Dash
5 | from dash.dependencies import Input, Output
6 |
7 | import stock_pattern_analyzer as spa
8 | from dash_app_functions import get_search_window_sizes, get_symbols, search_most_recent
9 |
10 | app = Dash(__name__, meta_tags=[{"name": "viewport", "content": "width=device-width"}])
11 | app.title = "Stock Patterns"
12 | server = app.server
13 |
14 | ##### Header #####
15 |
16 | header_div = html.Div([html.Div([html.H3("📈")], className="one-third column"),
17 | html.Div([html.Div([html.H3("Stock Patterns", style={"margin-bottom": "0px"}),
18 | html.H5("Find historical patterns and use for forecasting",
19 | style={"margin-top": "0px"})])],
20 | className="one-half column", id="title"),
21 | html.Div([html.A(html.Button("Gabor Vecsei"), href="https://www.gaborvecsei.com/")],
22 | className="one-third column",
23 | id="learn-more-button")],
24 | id="header", className="row flex-display", style={"margin-bottom": "25px"})
25 |
26 | ##### Explanation #####
27 |
28 | explanation_div = html.Div([dcc.Markdown("""Select a stock symbol and a time-frame. This tools finds similar patterns in
29 | historical data.
30 |
31 | The most similar patters are visualized with an extended *time-frame/'future data'*, which can be an
32 | indication of future price movement for the selected (anchor) stock.""")])
33 |
34 | ##### Settings container #####
35 |
36 | symbol_dropdown_id = "id-symbol-dropdown"
37 | available_symbols = get_symbols()
38 | default_symbol = "AAPL" if "AAPL" in available_symbols else available_symbols[0]
39 | symbol_dropdown = dcc.Dropdown(id=symbol_dropdown_id,
40 | options=[{"label": x, "value": x} for x in available_symbols],
41 | multi=False,
42 | value=default_symbol,
43 | className="dcc_control")
44 |
45 | window_size_dropdown_id = "id-window-size-dropdown"
46 | window_sizes = get_search_window_sizes()
47 | window_size_dropdown = dcc.Dropdown(id=window_size_dropdown_id,
48 | options=[{"label": f"{x} days", "value": x} for x in window_sizes],
49 | multi=False,
50 | value=window_sizes[2],
51 | className="dcc_control")
52 |
53 | future_size_input_id = "id-future-size-input"
54 | MAX_FUTURE_WINDOW_SIZE = 10
55 | future_size_input = dcc.Input(id=future_size_input_id, type="number", min=0, max=MAX_FUTURE_WINDOW_SIZE, value=5,
56 | className="dcc_control")
57 |
58 | top_k_input_id = "id-top-k-input"
59 | MAX_TOP_K_VALUE = 10
60 | top_k_input = dcc.Input(id=top_k_input_id, type="number", min=0, max=MAX_TOP_K_VALUE, value=5, className="dcc_control")
61 |
62 | offset_checkbox_id = "id-offset-checkbox"
63 | offset_checkbox = dcc.Checklist(id=offset_checkbox_id, options=[{"label": "Use Offset", "value": "offset"}],
64 | value=["offset"], className="dcc_control")
65 |
66 | settings_div = html.Div([html.P("Symbol (anchor)", className="control_label"),
67 | symbol_dropdown,
68 | html.P("Search window size", className="control_label"),
69 | window_size_dropdown,
70 | html.P(f"Future window size (max. {MAX_FUTURE_WINDOW_SIZE})", className="control_label"),
71 | future_size_input,
72 | html.P(f"Patterns to match (max. {MAX_TOP_K_VALUE})", className="control_label"),
73 | top_k_input,
74 | html.P("Offset the matched patterns for easy comparison (to the anchors last market close)",
75 | className="control_label"),
76 | offset_checkbox],
77 | className="pretty_container three columns",
78 | id="id-settings-div")
79 |
80 | ##### Stats & Graph #####
81 |
82 | graph_id = "id-graph"
83 | stats_and_graph_div = html.Div([html.Div(id="id-stats-container", className="row container-display"),
84 | html.Div([dcc.Graph(id=graph_id)], id="id-graph-div", className="pretty_container")],
85 | id="id-graph-container", className="nine columns")
86 |
87 | ##### Matched Stocks List #####
88 |
89 | matched_table_id = "id-matched-list"
90 | table_columns = ["Index",
91 | "Match distance",
92 | "Symbol",
93 | "Pattern Start Date",
94 | "Pattern End Date",
95 | "Pattern Start Close Value ($)",
96 | "Pattern End Close Value ($)",
97 | "Pattern Future Close Value ($)"]
98 | table = dash_table.DataTable(id=matched_table_id, columns=[{"id": c, "name": c} for c in table_columns], page_size=5)
99 | matched_div = html.Div([html.Div([html.H6("Matched (most similar) patterns"), table],
100 | className="pretty_container")],
101 | id="id-matched-list-container",
102 | className="eleven columns")
103 |
104 | ##### Reference Links #####
105 |
106 | css_link = html.A("[1] Style of the page (css)",
107 | href="https://github.com/plotly/dash-sample-apps/tree/master/apps/dash-oil-and-gas")
108 | yahoo_data_link = html.A("[2] Yahoo data", href="https://finance.yahoo.com")
109 | gabor_github_link = html.A("[3] Gabor Vecsei GitHub", href="https://github.com/gaborvecsei")
110 | reference_links_div = html.Div([html.Div([html.H6("References"),
111 | css_link,
112 | html.Br(),
113 | yahoo_data_link,
114 | html.Br(),
115 | gabor_github_link],
116 | className="pretty_container")],
117 | className="four columns")
118 |
119 | ##### Layout #####
120 |
121 | app.layout = html.Div([header_div,
122 | explanation_div,
123 | html.Div([settings_div,
124 | stats_and_graph_div],
125 | className="row flex-display"),
126 | html.Div([matched_div], className="row flex-display"),
127 | reference_links_div],
128 | id="mainContainer",
129 | style={"display": "flex", "flex-direction": "column"})
130 |
131 |
132 | ##### Callbacks #####
133 |
134 | @app.callback([Output(graph_id, "figure"),
135 | Output(matched_table_id, "data")],
136 | [Input(symbol_dropdown_id, "value"),
137 | Input(window_size_dropdown_id, "value"),
138 | Input(future_size_input_id, "value"),
139 | Input(top_k_input_id, "value"),
140 | Input(offset_checkbox_id, "value")])
141 | def update_plot_and_table(symbol_value, window_size_value, future_size_value, top_k_value, checkbox_value):
142 | # RetAPI search
143 | ret = search_most_recent(symbol=symbol_value,
144 | window_size=window_size_value,
145 | top_k=top_k_value,
146 | future_size=future_size_value)
147 |
148 | # Parse response and build the HTML table rows
149 | table_rows = []
150 | values = []
151 | symbols = []
152 | start_end_dates = []
153 | for i, match in enumerate(ret.matches):
154 | values.append(match.values)
155 | symbols.append(match.symbol)
156 | start_end_dates.append((match.start_date, match.end_date))
157 | row_values = [i + 1,
158 | match.distance,
159 | match.symbol,
160 | match.end_date,
161 | match.start_date,
162 | match.values[-1],
163 | match.values[window_size_value - 1],
164 | match.values[0]]
165 | row_dict = {c: v for c, v in zip(table_columns, row_values)}
166 | table_rows.append(row_dict)
167 |
168 | offset_traces = False if len(checkbox_value) == 0 else True
169 |
170 | # Visualize the data on a graph
171 | fig = spa.visualize_graph(match_values_list=values,
172 | match_symbols=symbols,
173 | match_str_dates=start_end_dates,
174 | window_size=window_size_value,
175 | future_size=future_size_value,
176 | anchor_symbol=ret.anchor_symbol,
177 | anchor_values=ret.anchor_values,
178 | show_legend=False,
179 | offset_traces=offset_traces)
180 |
181 | return fig, table_rows
182 |
183 |
184 | if __name__ == "__main__":
185 | app.run_server(debug=False, host="0.0.0.0")
186 |
--------------------------------------------------------------------------------
/dash_app_functions.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import requests
4 |
5 | from rest_api_models import (TopKSearchResponse, SearchWindowSizeResponse, DataRefreshResponse,
6 | AvailableSymbolsResponse)
7 |
8 | BASE_URL = os.environ.get("REST_API_URL", default="http://localhost:8001")
9 |
10 |
11 | def get_search_window_sizes() -> list:
12 | res = requests.get(f"{BASE_URL}/search/sizes")
13 | res = SearchWindowSizeResponse.parse_obj(res.json())
14 | return res.sizes
15 |
16 |
17 | def get_symbols() -> list:
18 | res = requests.get(f"{BASE_URL}/data/symbols")
19 | res = AvailableSymbolsResponse.parse_obj(res.json())
20 | return res.symbols
21 |
22 |
23 | def search_most_recent(symbol: str, window_size: int, top_k: int, future_size: int) -> TopKSearchResponse:
24 | url = f"{BASE_URL}/search/recent/?symbol={symbol.upper()}&window_size={window_size}&top_k={top_k}&future_size={future_size}"
25 | res = requests.get(url)
26 | res = TopKSearchResponse.parse_obj(res.json())
27 | return res
28 |
29 |
30 | def get_last_refresh_date() -> str:
31 | url = f"{BASE_URL}/refresh/when"
32 | res = requests.get(url)
33 | res = DataRefreshResponse.parse_obj(res)
34 | date_str = res.date.strftime("%Y/%m/%d, %H:%M:%S")
35 | return date_str
36 |
--------------------------------------------------------------------------------
/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.9.2-slim
2 |
3 | RUN apt-get update
4 |
5 | # Requirements copied first, not the whole project, so code change won't trigger a pip install always
6 | # It is only triggered when the requirements.txt changes
7 | COPY ./requirements.txt /requirements.txt
8 | RUN pip install -r /requirements.txt
9 |
10 | WORKDIR /code
11 |
12 | ENTRYPOINT ["/bin/bash"]
13 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | numpy
2 | yfinance
3 | pandas
4 | tqdm
5 | fastapi
6 | dash
7 | plotly
8 | matplotlib
9 | fastapi
10 | pydantic
11 | apscheduler
12 | uvicorn[standard]
13 | scikit-learn
14 | gunicorn
15 | scipy
16 | faiss-cpu
17 | psutil
18 |
--------------------------------------------------------------------------------
/rest_api.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | import itertools
3 | from pathlib import Path
4 | import threading
5 | from typing import Optional, Set, Tuple
6 |
7 | from apscheduler.schedulers.asyncio import AsyncIOScheduler
8 | from fastapi import FastAPI, HTTPException, Response
9 | import numpy as np
10 | import pandas as pd
11 |
12 | from rest_api_models import (
13 | AvailableSymbolsResponse,
14 | DataRefreshResponse,
15 | IsReadyResponse,
16 | MatchResponse,
17 | SearchWindowSizeResponse,
18 | SuccessResponse,
19 | TopKSearchResponse,
20 | )
21 | import stock_pattern_analyzer as spa
22 |
23 | app = FastAPI()
24 |
25 |
26 | def _get_sp500_ticker_list() -> set:
27 | table = pd.read_html('https://en.wikipedia.org/wiki/List_of_S%26P_500_companies')
28 | df = table[0]
29 | symbols = set(df["Symbol"].values)
30 | return symbols
31 |
32 |
33 | def _get_currency_pairs_symbol_list(base_currency: str) -> set:
34 | table = pd.read_html("https://en.wikipedia.org/wiki/Currency_pair")
35 | df = table[2]
36 | currency_symbols: Set[str] = set(df["ISO 4217 code"].values)
37 | currency_pairs: Set[Tuple[str, str]] = set(itertools.product([base_currency.upper()], currency_symbols))
38 | # This is a format what yahoo finance uses
39 | currency_pair_str_list: Set[str] = {f"{x}{y}=X" for x, y in currency_pairs}
40 | return currency_pair_str_list
41 |
42 |
43 | AVAILABLE_SEARCH_WINDOW_SIZES = list(range(6, 17, 2)) + [5, 20, 25, 30, 45]
44 | AVAILABLE_SEARCH_WINDOW_SIZES = sorted(AVAILABLE_SEARCH_WINDOW_SIZES)
45 |
46 | user_defined_tickers_file_path = Path("symbols.txt")
47 | user_defined_tickers: Set[str] = set()
48 | if user_defined_tickers_file_path.exists():
49 | user_defined_tickers = set(user_defined_tickers_file_path.read_text().split("\n"))
50 | else:
51 | raise FileNotFoundError("We need a symbols.txt - check readme")
52 |
53 | if "$SP500" in user_defined_tickers:
54 | sp500_symbols = _get_sp500_ticker_list()
55 | user_defined_tickers.remove("$SP500")
56 | user_defined_tickers = user_defined_tickers.union(sp500_symbols)
57 |
58 | if "$CURRENCY_PAIRS" in user_defined_tickers:
59 | currency_pair_symbols = _get_currency_pairs_symbol_list("EUR")
60 | user_defined_tickers.remove("$CURRENCY_PAIRS")
61 | user_defined_tickers = user_defined_tickers.union(currency_pair_symbols)
62 |
63 | SYMBOL_LIST = sorted(user_defined_tickers)
64 |
65 | PERIOD_YEARS = 20
66 |
67 |
68 | def _prepare_data(force_update: bool = False) -> spa.RawStockDataHolder:
69 | return spa.initialize_data_holder(tickers=SYMBOL_LIST, period_years=PERIOD_YEARS, force_update=force_update)
70 |
71 |
72 | data_holder: spa.RawStockDataHolder = _prepare_data()
73 | search_tree_dict: dict = {}
74 | refresh_scheduler: AsyncIOScheduler = AsyncIOScheduler()
75 | last_refreshed: Optional[datetime] = None
76 |
77 |
78 | def _date_to_str(date):
79 | return pd.to_datetime(date).strftime("%Y-%m-%d")
80 |
81 |
82 | def _find_and_remove_files(folder_path: str, file_pattern: str) -> list:
83 | paths = Path(folder_path).glob(file_pattern)
84 | for p in paths:
85 | p.unlink()
86 | return list(paths)
87 |
88 |
89 | @app.get("/")
90 | def root():
91 | return Response(content="Welcome to the stock pattern matcher RestAPI")
92 |
93 |
94 | @app.get("/is_ready", response_model=IsReadyResponse)
95 | def is_read():
96 | if (data_holder is None) or not data_holder.is_filled:
97 | return IsReadyResponse(is_ready=False)
98 |
99 | if len(search_tree_dict) == 0:
100 | return IsReadyResponse(is_ready=False)
101 |
102 | return IsReadyResponse(is_ready=True)
103 |
104 |
105 | @app.get("/data/symbols", response_model=AvailableSymbolsResponse, tags=["data"])
106 | def get_available_symbols():
107 | return AvailableSymbolsResponse(symbols=SYMBOL_LIST)
108 |
109 |
110 | @app.get("/data/refresh", response_model=SuccessResponse, include_in_schema=False)
111 | def refresh_data():
112 | # TODO: hardcoded file prefix and folder
113 | _find_and_remove_files(".", "data_holder_*.pk")
114 | global data_holder
115 | data_holder = _prepare_data()
116 | print("Data refreshed")
117 | return SuccessResponse(message="Existing data holder files removed, and a new one is created")
118 |
119 |
120 | @app.get("/refresh", response_model=SuccessResponse, include_in_schema=False)
121 | def refresh_everything():
122 | refresh_data()
123 | refresh_search()
124 | global last_refreshed
125 | last_refreshed = datetime.now()
126 | return SuccessResponse()
127 |
128 |
129 | @app.get("/refresh/when", response_model=DataRefreshResponse, tags=["refresh"])
130 | def when_was_data_refreshed():
131 | return DataRefreshResponse(date=last_refreshed)
132 |
133 |
134 | @app.get("/search/prepare/{window_size}", response_model=SuccessResponse, include_in_schema=False)
135 | def prepare_search_tree(window_size: int, force_update: bool = False):
136 | global search_tree_dict
137 | search_tree_dict[window_size] = spa.initialize_search_tree(data_holder=data_holder,
138 | window_size=window_size,
139 | force_update=force_update)
140 | return SuccessResponse()
141 |
142 |
143 | @app.get("/search/prepare", response_model=SuccessResponse, include_in_schema=False)
144 | def prepare_all_search_trees(force_update: bool = False):
145 | # TODO: The parallel creation of the search windows gives Memory error on Heroku free dynos
146 | # with concurrent.futures.ThreadPoolExecutor() as pool:
147 | # futures = {}
148 | # for w in AVAILABLE_SEARCH_WINDOW_SIZES:
149 | # f = pool.submit(prepare_search_tree, window_size=w, force_update=force_update)
150 | # futures[f] = w
151 | #
152 | # for f in concurrent.futures.as_completed(futures):
153 | # w = futures[f]
154 | # try:
155 | # f.result()
156 | # print(f"Search tree with size {w} prepared")
157 | # except Exception as e:
158 | # print(f"There was a problem with size {w}, could not create it")
159 |
160 | # TODO: Sequential creation is used because this way Heroku won't crash (because of RAM limit)
161 | for w in AVAILABLE_SEARCH_WINDOW_SIZES:
162 | prepare_search_tree(window_size=w, force_update=force_update)
163 | print(f"Search tree with size {w} prepared")
164 | return SuccessResponse()
165 |
166 |
167 | @app.get("/search/refresh", response_model=SuccessResponse, include_in_schema=False)
168 | def refresh_search():
169 | # TODO: hardcoded file prefix and folder
170 | _find_and_remove_files(".", "search_tree_*.pk")
171 | prepare_all_search_trees()
172 | print("Search trees are refreshed")
173 | return SuccessResponse()
174 |
175 |
176 | @app.get("/search/sizes", response_model=SearchWindowSizeResponse, tags=["search"])
177 | def get_available_search_window_sizes():
178 | return SearchWindowSizeResponse(sizes=AVAILABLE_SEARCH_WINDOW_SIZES)
179 |
180 |
181 | @app.get("/search/recent/", response_model=TopKSearchResponse, tags=["search"])
182 | async def search_most_recent(symbol: str, window_size: int = 5, top_k: int = 5, future_size: int = 5):
183 | symbol = symbol.upper()
184 | try:
185 | label = data_holder.symbol_to_label[symbol]
186 | except KeyError:
187 | raise HTTPException(status_code=400, detail=f"Ticker symbol {symbol} is not supported")
188 | most_recent_values = data_holder.values[label][:window_size]
189 |
190 | try:
191 | search_tree = search_tree_dict[window_size]
192 | except KeyError:
193 | raise HTTPException(status_code=400, detail=f"No prepared {window_size} day search window")
194 |
195 | top_k_indices, top_k_distances = search_tree.search(values=most_recent_values, k=top_k + 1)
196 | # We need to discard the first item, as that is our search sequence
197 | top_k_indices = top_k_indices[1:]
198 | top_k_distances = top_k_distances[1:]
199 |
200 | forecast_values = []
201 | matches = []
202 |
203 | for index, distance in zip(top_k_indices, top_k_distances):
204 | ticker = search_tree.get_window_symbol(index)
205 | start_date, end_date = search_tree.get_start_end_date(index)
206 |
207 | start_date_str = _date_to_str(start_date)
208 | end_date_str = _date_to_str(end_date)
209 |
210 | window_with_future_values = search_tree.get_window_values(index=index, future_length=future_size)
211 | todays_value = window_with_future_values[-window_size]
212 | future_value = window_with_future_values[0]
213 | diff_from_today = todays_value - future_value
214 |
215 | match = MatchResponse(symbol=ticker,
216 | distance=distance,
217 | start_date=start_date_str,
218 | end_date=end_date_str,
219 | todays_value=todays_value,
220 | future_value=future_value,
221 | change=diff_from_today,
222 | values=window_with_future_values.tolist())
223 |
224 | matches.append(match)
225 |
226 | forecast_values.append(diff_from_today)
227 |
228 | tmp = np.where(np.array(forecast_values) < 0, 0, 1)
229 | forecast_confidence = np.sum(tmp) / len(tmp)
230 | forecast_type = "gain"
231 | if forecast_confidence <= 0.5:
232 | forecast_type = "loss"
233 | forecast_confidence = 1 - forecast_confidence
234 |
235 | top_k_match = TopKSearchResponse(matches=matches,
236 | forecast_type=forecast_type,
237 | forecast_confidence=forecast_confidence,
238 | anchor_symbol=symbol,
239 | window_size=window_size,
240 | top_k=top_k,
241 | future_size=future_size,
242 | anchor_values=most_recent_values.tolist())
243 |
244 | return top_k_match
245 |
246 |
247 | @app.on_event("startup")
248 | def startup_event():
249 | # Download and prepare new data when app starts
250 | # This is started in the bg as app needs to start-up in less than 60secs (for Heroku)
251 | threading.Thread(target=refresh_everything).start()
252 |
253 | # Refresh data after every market close
254 | # TODO: set the timezones and add multiple refresh jobs for the multiple market closes
255 | refresh_scheduler.add_job(func=refresh_everything, trigger="cron", day="*", hour=8, minute=35)
256 | refresh_scheduler.add_job(func=refresh_everything, trigger="cron", day="*", hour=15, minute=35)
257 | refresh_scheduler.start()
258 |
--------------------------------------------------------------------------------
/rest_api_models.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import List, Optional
3 |
4 | from pydantic import BaseModel
5 |
6 |
7 | class SuccessResponse(BaseModel):
8 | message: str = "Successful"
9 |
10 |
11 | class SearchWindowSizeResponse(BaseModel):
12 | sizes: List[int]
13 |
14 |
15 | class AvailableSymbolsResponse(BaseModel):
16 | symbols: List[str]
17 |
18 |
19 | class MatchResponse(BaseModel):
20 | symbol: str
21 | distance: float
22 | start_date: str
23 | end_date: str
24 | todays_value: Optional[float]
25 | future_value: Optional[float]
26 | change: Optional[float]
27 | values: Optional[List[float]]
28 |
29 |
30 | class TopKSearchResponse(BaseModel):
31 | matches: List[MatchResponse] = []
32 | forecast_type: str
33 | forecast_confidence: float
34 | anchor_symbol: str
35 | anchor_values: Optional[List[float]]
36 | window_size: int
37 | top_k: int
38 | future_size: int
39 |
40 |
41 | class DataRefreshResponse(BaseModel):
42 | message: str = "Last (most recent) refresh"
43 | date: datetime
44 |
45 |
46 | class IsReadyResponse(BaseModel):
47 | is_ready: bool
48 |
--------------------------------------------------------------------------------
/runtime.txt:
--------------------------------------------------------------------------------
1 | python-3.8.7
--------------------------------------------------------------------------------
/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -m
4 |
5 | # Run the RestAPI ("debug" deployment)
6 | uvicorn rest_api:app --host 0.0.0.0 --port 8001 &
7 |
8 | # Wait a bit for the restapi to start-up
9 | sleep 3
10 |
11 | # Run the Dash client app ("debug" deployment)
12 | python /code/dash_app.py
13 |
14 | fg %1
15 |
--------------------------------------------------------------------------------
/stock_pattern_analyzer/__init__.py:
--------------------------------------------------------------------------------
1 | from .data import RawStockDataHolder, initialize_data_holder
2 | from .search_index import MemoryEfficientIndex, cKDTreeIndex, FastIndex
3 | from .search_model import SearchModel, initialize_search_tree
4 | from .visualization import visualize_graph
5 |
--------------------------------------------------------------------------------
/stock_pattern_analyzer/data.py:
--------------------------------------------------------------------------------
1 | import concurrent.futures
2 | import pickle
3 | from datetime import datetime
4 | from pathlib import Path
5 | from typing import Tuple
6 |
7 | import numpy as np
8 | import pandas as pd
9 | import yfinance
10 | from tqdm import tqdm
11 |
12 |
13 | class RawStockDataHolder:
14 | def __init__(self, ticker_symbols: list, period_years: int = 5, interval: int = 1):
15 | self.ticker_symbols = ticker_symbols
16 | self.period_years = period_years
17 | self.interval = interval
18 |
19 | max_values_per_stock = self.period_years * self.interval * 365
20 | nb_ticker_symbols = len(self.ticker_symbols)
21 |
22 | self.dates = np.zeros((nb_ticker_symbols, max_values_per_stock))
23 | self.values = np.zeros((nb_ticker_symbols, max_values_per_stock), dtype=np.float32)
24 | self.nb_of_valid_values = np.zeros(nb_ticker_symbols, dtype=np.int32)
25 |
26 | self.symbol_to_label = {symbol: label for label, symbol in enumerate(ticker_symbols)}
27 | self.label_to_symbol = {label: symbol for symbol, label in self.symbol_to_label.items()}
28 |
29 | self.is_filled = False
30 |
31 | def _download_stock_data(self, symbol: str) -> pd.DataFrame:
32 | ticker = yfinance.Ticker(symbol)
33 | period_str = f"{self.period_years}y"
34 | interval_str = f"{self.interval}d"
35 | ticker_df = ticker.history(period=period_str, interval=interval_str, rounding=True)[::-1]
36 | if ticker_df.empty or len(ticker_df) == 0:
37 | raise ValueError(f"{symbol} does not have enough data")
38 | return ticker_df
39 |
40 | def _get_stock_data_for_symbol(self, symbol: str) -> Tuple[np.ndarray, np.ndarray, int]:
41 | ticker_df = self._download_stock_data(symbol=symbol)
42 | close_values = ticker_df["Close"].values
43 | dates = ticker_df.index.values
44 | label = self.symbol_to_label[symbol]
45 | return close_values, dates, label
46 |
47 | def fill(self):
48 | """
49 | Fills the data holder with the defined stock data
50 | Returns:
51 | None
52 | """
53 |
54 | pbar = tqdm(desc="Symbol data download", total=len(self.ticker_symbols))
55 |
56 | with concurrent.futures.ThreadPoolExecutor() as pool:
57 | future_to_symbol = {}
58 | for symbol in self.ticker_symbols:
59 | future = pool.submit(self._get_stock_data_for_symbol, symbol=symbol)
60 | future_to_symbol[future] = symbol
61 |
62 | for future in concurrent.futures.as_completed(future_to_symbol):
63 | completed_symbol = future_to_symbol[future]
64 | try:
65 | close_values, dates, label = future.result()
66 | self.values[label, :len(close_values)] = close_values
67 | self.dates[label, :len(dates)] = dates
68 | self.nb_of_valid_values[label] = len(dates)
69 | except ValueError as e:
70 | print(f"ERROR with {completed_symbol}: {e}")
71 | continue
72 |
73 | pbar.update(1)
74 | self.is_filled = True
75 | pbar.close()
76 |
77 | def create_filename_for_today(self) -> str:
78 | current_date = datetime.now().strftime("%Y_%m_%d")
79 | file_name = f"data_holder_{self.period_years}y_{self.interval}d_{current_date}.pk"
80 | return file_name
81 |
82 | def serialize(self) -> str:
83 | if not self.is_filled:
84 | raise ValueError("You need to fill the class with data first")
85 |
86 | file_name = self.create_filename_for_today()
87 | with open(file_name, "wb") as f:
88 | pickle.dump(self, f)
89 |
90 | return file_name
91 |
92 | @staticmethod
93 | def load(file_name: str) -> "RawStockDataHolder":
94 | with open(file_name, "rb") as f:
95 | obj = pickle.load(f)
96 | return obj
97 |
98 |
99 | def initialize_data_holder(tickers: list, period_years: int, force_update: bool = False):
100 | data_holder = RawStockDataHolder(ticker_symbols=tickers,
101 | period_years=period_years,
102 | interval=1)
103 |
104 | file_path = Path(data_holder.create_filename_for_today())
105 |
106 | if (not file_path.exists()) or force_update:
107 | data_holder.fill()
108 | data_holder.serialize()
109 | else:
110 | data_holder = RawStockDataHolder.load(str(file_path))
111 | return data_holder
112 |
--------------------------------------------------------------------------------
/stock_pattern_analyzer/search_index.py:
--------------------------------------------------------------------------------
1 | import abc
2 | import pickle
3 | from typing import Tuple
4 |
5 | import faiss
6 | import numpy as np
7 | from scipy.spatial.ckdtree import cKDTree
8 |
9 |
10 | class _BaseIndex:
11 |
12 | def __init__(self):
13 | self.index = None
14 |
15 | @abc.abstractmethod
16 | def create(self, X: np.ndarray) -> None:
17 | """
18 | This method creates the self.index object (index/search-tree)
19 | Args:
20 | X: Data [n_rows, n_features]
21 |
22 | Returns:
23 | None
24 | """
25 | raise NotImplementedError()
26 |
27 | @abc.abstractmethod
28 | def query(self, q: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
29 | """
30 | This method allows us to query from the index
31 | Args:
32 | q: query vector
33 | k: number of matches to return
34 |
35 | Returns:
36 | Results as a tuple: distances, indices (from X)
37 | """
38 | raise NotImplementedError()
39 |
40 | @classmethod
41 | @abc.abstractmethod
42 | def load(cls, file_path: str) -> "_BaseIndex":
43 | raise NotImplementedError()
44 |
45 | @abc.abstractmethod
46 | def serialize(self, file_path: str) -> None:
47 | raise NotImplementedError()
48 |
49 |
50 | class FastIndex(_BaseIndex):
51 |
52 | def __init__(self):
53 | super().__init__()
54 |
55 | def create(self, X: np.ndarray):
56 | self.index = faiss.IndexFlatL2(X.shape[-1])
57 | self.index.add(X)
58 |
59 | def query(self, q: np.ndarray, k: int):
60 | distances, indices = self.index.search(q, k)
61 | return distances[0], indices[0]
62 |
63 | @classmethod
64 | def load(cls, file_path: str):
65 | faiss.read_index(str(file_path))
66 |
67 | def serialize(self, file_path: str):
68 | faiss.write_index(self.index, str(file_path))
69 |
70 |
71 | class MemoryEfficientIndex(_BaseIndex):
72 |
73 | def __init__(self):
74 | super().__init__()
75 |
76 | def create(self, X: np.ndarray):
77 | d = X.shape[-1]
78 | # TODO: refine this as this is just a dummy selection for "m"
79 | if d % 4 == 0:
80 | m = 4
81 | elif d % 5 == 0:
82 | m = 5
83 | elif d % 2 == 0:
84 | m = 2
85 | else:
86 | raise ValueError("This is not handled, can not find a good value for m")
87 | quantizer = faiss.IndexFlatL2(d)
88 | self.index = faiss.IndexIVFPQ(quantizer, d, 100, m, 8)
89 | self.index.train(X)
90 | self.index.add(X)
91 |
92 | def query(self, q: np.ndarray, k: int):
93 | distances, indices = self.index.search(q, k)
94 | return distances[0], indices[0]
95 |
96 | @classmethod
97 | def load(cls, file_path: str):
98 | obj = cls()
99 | obj.index = faiss.read_index(str(file_path))
100 | return obj
101 |
102 | def serialize(self, file_path: str):
103 | faiss.write_index(self.index, str(file_path))
104 |
105 |
106 | class cKDTreeIndex(_BaseIndex):
107 |
108 | def __init__(self):
109 | super().__init__()
110 |
111 | def create(self, X: np.ndarray):
112 | self.index = cKDTree(data=X)
113 |
114 | def query(self, q: np.ndarray, k: int):
115 | top_k_distances, top_k_indices = self.index.query(x=q, k=k)
116 | return top_k_distances, top_k_indices
117 |
118 | @classmethod
119 | def load(cls, file_path: str):
120 | obj = cls()
121 | with open(file_path, "rb") as f:
122 | obj.index = pickle.load(f)
123 | return obj
124 |
125 | def serialize(self, file_path: str):
126 | with open(file_path, "wb") as f:
127 | pickle.dump(self.index, f)
128 |
--------------------------------------------------------------------------------
/stock_pattern_analyzer/search_model.py:
--------------------------------------------------------------------------------
1 | import pickle
2 | from datetime import datetime
3 | from pathlib import Path
4 |
5 | import numpy as np
6 | from sklearn.preprocessing import minmax_scale
7 |
8 | from .data import RawStockDataHolder
9 | from .search_index import MemoryEfficientIndex
10 |
11 | MINIMUM_WINDOW_SIZE = 5
12 |
13 |
14 | class SearchModel:
15 | def __init__(self, data_holder: RawStockDataHolder, window_size: int):
16 | if window_size < MINIMUM_WINDOW_SIZE:
17 | raise ValueError(f"Window size is too small. Minimum is {MINIMUM_WINDOW_SIZE}")
18 |
19 | self.window_size = window_size
20 | self._data_holder = data_holder
21 |
22 | # This is the object we can use for querying
23 | self.index = None
24 | # TODO: solve this more efficiently without wasting memory
25 | # This stores the start and end indices in the original array of the windows
26 | self.start_end_indices_in_original_array = None
27 | # This stores the ticket symbol label associated with every window
28 | self.labels = None
29 | # This shows if the index is created or not
30 | self.is_built = False
31 |
32 | def _create_windows(self):
33 | """
34 | Create the sliding windows from the stock dara
35 | Returns:
36 | windows as a numpy array [n_samples, window_size]
37 | """
38 |
39 | if not self._data_holder.is_filled:
40 | raise ValueError("Data holder needs to be filled first")
41 |
42 | windows = []
43 | self.start_end_indices_in_original_array = []
44 | self.labels = []
45 |
46 | # TODO: this for-loop should be vectorized
47 | for symbol in self._data_holder.ticker_symbols:
48 | label = self._data_holder.symbol_to_label[symbol]
49 | nb_valid_values = self._data_holder.nb_of_valid_values[label]
50 |
51 | symbol_values = self._data_holder.values[label][:nb_valid_values]
52 | # Vectorized sliding window creation
53 | window_indices = np.arange(symbol_values.shape[0] - self.window_size + 1)[:, None] + np.arange(
54 | self.window_size)
55 | windows.extend(symbol_values[window_indices])
56 | self.start_end_indices_in_original_array.extend(window_indices[:, (0, -1)])
57 | self.labels.extend([label] * len(window_indices))
58 |
59 | self.start_end_indices_in_original_array = np.array(self.start_end_indices_in_original_array)
60 | self.labels = np.array(self.labels)
61 |
62 | windows = np.array(windows)
63 | # Separate windows should be normalized, so it is comparable within a given window size (time-frame)
64 | windows = minmax_scale(windows, feature_range=(0, 1), axis=1)
65 | windows = np.nan_to_num(windows)
66 |
67 | return windows
68 |
69 | def build_index(self):
70 | """
71 | Build the search index
72 |
73 | Returns:
74 | None
75 | """
76 |
77 | X = self._create_windows()
78 | self.index = MemoryEfficientIndex()
79 | self.index.create(X.astype(np.float32))
80 | self.is_built = True
81 |
82 | def search(self, values: np.ndarray, k: int = 5) -> tuple:
83 | """
84 | Search in the data
85 | Args:
86 | values: "query" data - not (min-max) scaled
87 | k: This is how many matches will be returned
88 |
89 | Returns:
90 | tuple: indices, distances
91 | """
92 |
93 | if not self.is_built:
94 | raise ValueError("You need to build thh search tree first")
95 |
96 | values = minmax_scale(values, feature_range=(0, 1))
97 | if len(values.shape) == 1:
98 | values = values.reshape(1, -1)
99 | values = values.astype(np.float32)
100 |
101 | top_k_distances, top_k_indices = self.index.query(q=values, k=k)
102 | top_k_distances = top_k_distances.ravel()
103 | top_k_indices = top_k_indices.ravel()
104 |
105 | return top_k_indices, top_k_distances
106 |
107 | def get_window_symbol_label(self, index: int):
108 | return self.labels[index]
109 |
110 | def get_window_symbol(self, index: int) -> str:
111 | label = self.get_window_symbol_label(index)
112 | return self._data_holder.label_to_symbol[label]
113 |
114 | def _get_label_and_start_end_indices(self, index: int, future_length: int):
115 | start_index, end_index = self.start_end_indices_in_original_array[index]
116 | label = self.get_window_symbol_label(index)
117 |
118 | if future_length > 0:
119 | start_index -= future_length
120 | if start_index < 0:
121 | start_index = 0
122 | return label, start_index, end_index
123 |
124 | def get_window_dates(self, index: int, future_length: int = 0) -> np.ndarray:
125 | label, start_index, end_index = self._get_label_and_start_end_indices(index, future_length)
126 | dates = self._data_holder.dates[label][start_index:end_index + 1]
127 | return dates
128 |
129 | def get_window_values(self, index: int, future_length: int = 0):
130 | label, start_index, end_index = self._get_label_and_start_end_indices(index, future_length)
131 | values = self._data_holder.values[label][start_index:end_index + 1]
132 | return values
133 |
134 | def get_start_end_date(self, index: int, future_length: int = 0) -> tuple:
135 | dates = self.get_window_dates(index, future_length)
136 | return dates[0], dates[-1]
137 |
138 | def create_filename_for_today(self) -> str:
139 | current_date = datetime.now().strftime("%Y_%m_%d")
140 | file_name = f"search_tree_{self.window_size}win_{current_date}.pk"
141 | return file_name
142 |
143 | def serialize(self) -> str:
144 | if not self.is_built:
145 | raise ValueError("You need to build the tree first")
146 |
147 | file_name = self.create_filename_for_today()
148 | with open(file_name, "wb") as f:
149 | pickle.dump(self, f)
150 |
151 | return file_name
152 |
153 | @staticmethod
154 | def load(file_name: str) -> "SearchModel":
155 | with open(file_name, "rb") as f:
156 | obj = pickle.load(f)
157 | return obj
158 |
159 |
160 | def initialize_search_tree(data_holder: RawStockDataHolder, window_size: int, force_update: bool = False):
161 | search_tree = SearchModel(data_holder=data_holder, window_size=window_size)
162 |
163 | file_path = Path(search_tree.create_filename_for_today())
164 |
165 | if (not file_path.exists()) or force_update:
166 | search_tree.build_index()
167 | # TODO: implement serialization
168 | # search_tree.serialize()
169 | else:
170 | search_tree = SearchModel.load(str(file_path))
171 | return search_tree
172 |
--------------------------------------------------------------------------------
/stock_pattern_analyzer/visualization.py:
--------------------------------------------------------------------------------
1 | from typing import List, Tuple
2 |
3 | import numpy as np
4 | from plotly import graph_objs
5 | from sklearn.preprocessing import minmax_scale
6 |
7 | FIG_BG_COLOR = "#F9F9F9"
8 | ANCHOR_COLOR = "#FF372D"
9 | VALUES_COLOR = "#89D4F5"
10 |
11 |
12 | def visualize_graph(match_values_list: List[np.ndarray],
13 | match_symbols: List[str],
14 | match_str_dates: List[Tuple[str, str]],
15 | window_size: int,
16 | future_size: int,
17 | anchor_symbol: str,
18 | anchor_values: np.ndarray,
19 | show_legend: bool = True,
20 | offset_traces: bool = False) -> graph_objs.Figure:
21 | nb_matches = len(match_symbols)
22 | opacity_values = np.linspace(0.2, 1.0, nb_matches)[::-1]
23 |
24 | anchor_original_values = anchor_values[::-1]
25 | minmax_anchor_values = minmax_scale(anchor_original_values)
26 |
27 | fig = graph_objs.Figure()
28 |
29 | assert len(match_values_list) == len(match_symbols), "Something is fishy"
30 |
31 | # Draw all matches
32 | for i in range(nb_matches):
33 | match_values = match_values_list[i]
34 | match_symbol = match_symbols[i]
35 | match_start_date, match_end_date = match_str_dates[i]
36 |
37 | x = list(range(1, len(match_values) + 1))
38 | original_values = match_values[::-1]
39 | minmax_matched_values = minmax_scale(original_values)
40 | if offset_traces:
41 | diff = minmax_anchor_values[window_size - 1] - minmax_matched_values[window_size - 1]
42 | minmax_matched_values = minmax_matched_values + diff
43 | trace_name = f"{i}) {match_symbol} ({match_start_date} - {match_end_date})"
44 | trace = graph_objs.Scatter(x=x,
45 | y=minmax_matched_values,
46 | name=trace_name,
47 | meta=trace_name,
48 | mode="lines",
49 | line=dict(color=VALUES_COLOR),
50 | opacity=opacity_values[i],
51 | customdata=original_values,
52 | hovertemplate="%{meta}
Norm. val.: %{y:.2f}
Value: %{customdata:.2f}$")
53 | fig.add_trace(trace)
54 |
55 | # Draw the anchor series
56 | x = list(range(1, len(anchor_values) + 1))
57 | trace_name = f"Anchor ({anchor_symbol})"
58 | trace = graph_objs.Scatter(x=x,
59 | y=minmax_anchor_values,
60 | name=trace_name,
61 | meta=trace_name,
62 | mode="lines+markers",
63 | line=dict(color=ANCHOR_COLOR),
64 | customdata=anchor_original_values,
65 | hovertemplate="%{meta}
Norm. val.: %{y:.2f}
Value: %{customdata:.2f}$")
66 | fig.add_trace(trace)
67 |
68 | # Add "last market close" line
69 | fig.add_vline(x=window_size, line_dash="dash", line_color="black",
70 | annotation_text="Last market close (for selected symbol)")
71 |
72 | # Style the figure
73 | fig.update_xaxes(showspikes=True, spikecolor="black", spikesnap="cursor", spikemode="across")
74 | # fig.update_yaxes(showspikes=True, spikecolor="black", spikethickness=1)
75 |
76 | x_axis_ticker_labels = list(range(-window_size, future_size + 1))
77 | fig.update_layout(title=f"Similar patters for {anchor_symbol} based on historical market close data",
78 | yaxis=dict(title="Normalized Value"),
79 | xaxis=dict(title="Days",
80 | tickmode="array",
81 | tickvals=list(range(len(x_axis_ticker_labels))),
82 | ticktext=x_axis_ticker_labels),
83 | autosize=True,
84 | plot_bgcolor=FIG_BG_COLOR,
85 | paper_bgcolor=FIG_BG_COLOR,
86 | legend=dict(font=dict(size=9), orientation="h", yanchor="bottom", y=-0.5),
87 | showlegend=show_legend,
88 | spikedistance=1000,
89 | hoverdistance=100)
90 |
91 | return fig
92 |
--------------------------------------------------------------------------------
/symbols.txt.example:
--------------------------------------------------------------------------------
1 | $SP500
2 | GME
3 | TSLA
4 |
5 |
--------------------------------------------------------------------------------
/tests/measurements.py:
--------------------------------------------------------------------------------
1 | import os
2 | import time
3 | import json
4 |
5 | import numpy as np
6 | from tqdm import tqdm
7 |
8 | import stock_pattern_analyzer as spa
9 |
10 | NB_STOCKS = 100
11 | NB_DAYS_PER_STOCK = 5 * 365
12 | WINDOW_SIZES = [5, 10, 20, 50, 100]
13 |
14 |
15 | def create_windows(X, window_size: int):
16 | window_indices = np.arange(X.shape[0] - window_size + 1)[:, None] + np.arange(window_size)
17 | return X[window_indices]
18 |
19 |
20 | def over_estimate_memory_footprint(model):
21 | """
22 | A good over-approximation for the memory footprint is the file size of the pickled object
23 | Based on https://stackoverflow.com/a/565382/5108062
24 | """
25 |
26 | tmp_filename = "test.pk"
27 | model.serialize(tmp_filename)
28 | size_of_the_file = os.path.getsize(tmp_filename)
29 | os.remove(tmp_filename)
30 | return size_of_the_file
31 |
32 |
33 | def measure_build_time(model_class, windowed_data):
34 | start_time = time.time()
35 | model = model_class()
36 | model.create(windowed_data)
37 | end_time = time.time()
38 | build_time = end_time - start_time
39 | return model, build_time
40 |
41 |
42 | def estimate_query_speed(model, windowed_data, N: int):
43 | query = windowed_data[0]
44 | start_time = time.time()
45 | for i in range(N):
46 | _ = model.query(query, k=10)
47 | end_time = time.time()
48 | query_time = (end_time - start_time) / N
49 | return query_time
50 |
51 |
52 | def perform_measurements():
53 | max_values = NB_STOCKS * NB_DAYS_PER_STOCK
54 | X = np.random.random(max_values)
55 |
56 | res_dict = {}
57 |
58 | for model_class in tqdm([spa.cKDTreeIndex, spa.FastIndex, spa.MemoryEfficientIndex]):
59 | res_dict[model_class.__name__] = {}
60 | for window_size in WINDOW_SIZES:
61 | res_dict[model_class.__name__][window_size] = {}
62 | data = create_windows(X, window_size)
63 |
64 | model, build_time = measure_build_time(model_class, data)
65 | res_dict[model_class.__name__][window_size]["build_time"] = build_time
66 |
67 | memory_footprint = over_estimate_memory_footprint(model)
68 | res_dict[model_class.__name__][window_size]["memory_footprint"] = memory_footprint
69 |
70 | query_speed = estimate_query_speed(model, data, 10)
71 | res_dict[model_class.__name__][window_size]["query_speed"] = query_speed
72 |
73 | with open("measurement_results.json", "w") as f:
74 | json.dump(res_dict, f)
75 |
76 |
77 | if __name__ == "__main__":
78 | perform_measurements()
79 |
--------------------------------------------------------------------------------
/tests/rest_api_stress_test.py:
--------------------------------------------------------------------------------
1 | import concurrent.futures
2 | import time
3 |
4 | import numpy as np
5 | import requests
6 |
7 | BASE_URL = "http://localhost:8001"
8 |
9 |
10 | def search_recent(symbol: str, window_size: int, future_size: int, top_k: int):
11 | s = time.time()
12 | url = f"{BASE_URL}/search/recent/?symbol={symbol.upper()}&window_size={window_size}&top_k={top_k}&future_size={future_size}"
13 | _ = requests.get(url)
14 | e = time.time()
15 | return e - s
16 |
17 |
18 | def print_stats(request_execution_times: list, start_time, end_time, N: int):
19 | request_execution_times = np.array(request_execution_times)
20 | print("Statistics:")
21 |
22 | execution_time = end_time - start_time
23 | print(f"Execution time: {execution_time:.4f}")
24 | print(f"FPS: {N / execution_time:.4f}")
25 |
26 | print(f"(single) Request execution time: {request_execution_times.mean()}+/-{request_execution_times.std()} ")
27 |
28 |
29 | def test_most_recent_search(N: int):
30 | print(f"Recent search test with {N} requests")
31 |
32 | # # Sequential requests
33 | # print("Sequential requests")
34 | #
35 | # start_time = time.time()
36 | # request_execution_times = []
37 | # for i in range(N):
38 | # exec_time = search_recent("AAPL", window_size=5, future_size=5, top_k=5)
39 | # request_execution_times.append(exec_time)
40 | # end_time = time.time()
41 | #
42 | # print_stats(request_execution_times, start_time, end_time, N)
43 | # print("-" * 30)
44 |
45 | # Concurrent requests
46 | print("Concurrent requests")
47 |
48 | start_time = time.time()
49 | request_execution_times = []
50 | with concurrent.futures.ThreadPoolExecutor(max_workers=None) as pool:
51 | futures = {}
52 | for i in range(N):
53 | # Previous manual tests showed there is no latency when using bigger sizes
54 | # (tested to the maximum allowed window length)
55 | f = pool.submit(search_recent, symbol="AAPL", window_size=5, future_size=5, top_k=5)
56 | futures[f] = i
57 |
58 | for future in concurrent.futures.as_completed(futures):
59 | try:
60 | exec_time = future.result()
61 | request_execution_times.append(exec_time)
62 | except Exception as e:
63 | print(e)
64 |
65 | end_time = time.time()
66 |
67 | print_stats(request_execution_times, start_time, end_time, N)
68 | print("-" * 30)
69 |
70 |
71 | if __name__ == "__main__":
72 | test_most_recent_search(100)
73 |
--------------------------------------------------------------------------------