Comparison page implemented with stock price chart all time
This commit is contained in:
parent
c010100c45
commit
1b9e6c4396
8 changed files with 135 additions and 12 deletions
24
backend/api/closing_price.py
Normal file
24
backend/api/closing_price.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import os
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from scraper.top100_extractor import programming_crime_list
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
DF_HIST_PATH: str = os.path.join(os.path.dirname(__file__), '..', '..', 'Elaborated_Data', 'price_history_data.csv')
|
||||||
|
DF_HIST: Optional[pd.DataFrame] = None
|
||||||
|
|
||||||
|
|
||||||
|
def lazy_load_history():
|
||||||
|
global DF_HIST
|
||||||
|
if DF_HIST is None:
|
||||||
|
DF_HIST = pd.read_csv(DF_HIST_PATH, index_col=0)
|
||||||
|
|
||||||
|
|
||||||
|
def get_closing_price_hist(tickers: list[str]) -> list[dict]:
|
||||||
|
lazy_load_history()
|
||||||
|
ticker_series = pd.Series(tickers)
|
||||||
|
df = DF_HIST.loc[DF_HIST.symbol.isin(ticker_series), :] \
|
||||||
|
.rename(columns={"Closing Price": "price", "symbol": "ticker"}) \
|
||||||
|
.reset_index(drop=True)
|
||||||
|
df = df.pivot(index='date', columns='ticker', values='price').reset_index(drop=False)
|
||||||
|
return df.replace({ np.nan: None }).to_dict('records')
|
|
@ -10,12 +10,16 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/font": "^7.2.96",
|
"@mdi/font": "^7.2.96",
|
||||||
|
"ag-charts-community": "^7.3.0",
|
||||||
|
"ag-charts-vue3": "^7.3.0",
|
||||||
"core-js": "^3.29.0",
|
"core-js": "^3.29.0",
|
||||||
"debounce": "^1.2.1",
|
"debounce": "^1.2.1",
|
||||||
"fuse.js": "^6.6.2",
|
"fuse.js": "^6.6.2",
|
||||||
"roboto-fontface": "*",
|
"roboto-fontface": "*",
|
||||||
"round-to": "^6.0.0",
|
"round-to": "^6.0.0",
|
||||||
"vue": "^3.2.0",
|
"vue": "^3.2.0",
|
||||||
|
"vue-class-component": "^8.0.0-beta.3",
|
||||||
|
"vue-property-decorator": "^9.1.2",
|
||||||
"vue-router": "^4.0.0",
|
"vue-router": "^4.0.0",
|
||||||
"vuetify": "^3.2.3",
|
"vuetify": "^3.2.3",
|
||||||
"webfontloader": "^1.0.0"
|
"webfontloader": "^1.0.0"
|
||||||
|
@ -25,7 +29,7 @@
|
||||||
"@types/debounce": "^1.2.1",
|
"@types/debounce": "^1.2.1",
|
||||||
"@types/node": "^18.15.0",
|
"@types/node": "^18.15.0",
|
||||||
"@types/webfontloader": "^1.6.35",
|
"@types/webfontloader": "^1.6.35",
|
||||||
"@vitejs/plugin-vue": "^3.0.3",
|
"@vitejs/plugin-vue": "^4.0.0",
|
||||||
"@vue/eslint-config-typescript": "^11.0.0",
|
"@vue/eslint-config-typescript": "^11.0.0",
|
||||||
"eslint": "^8.22.0",
|
"eslint": "^8.22.0",
|
||||||
"eslint-plugin-vue": "^9.3.0",
|
"eslint-plugin-vue": "^9.3.0",
|
||||||
|
|
|
@ -20,8 +20,16 @@ export interface Company {
|
||||||
'Past Performance': number;
|
'Past Performance': number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PriceHistory {
|
||||||
|
date: string;
|
||||||
|
[ticker: string]: string | number; // really just number
|
||||||
|
}
|
||||||
|
|
||||||
export const getCompanies = (): Promise<Company[]> =>
|
export const getCompanies = (): Promise<Company[]> =>
|
||||||
fetch(BACKEND_URL + '/companies').then(r => r.json()).then(list => list.map((e: Company) => ({
|
fetch(BACKEND_URL + '/companies').then(r => r.json()).then(list => list.map((e: Company) => ({
|
||||||
...e,
|
...e,
|
||||||
logoSrc: `${BACKEND_URL}/companies/logos/${e.ticker}`
|
logoSrc: `${BACKEND_URL}/companies/logos/${e.ticker}`
|
||||||
})));
|
})));
|
||||||
|
|
||||||
|
export const getPriceHistory = (tickers: string[]): Promise<PriceHistory[]> =>
|
||||||
|
fetch(BACKEND_URL + '/price_history/' + tickers.join(',').toUpperCase()).then(r => r.json())
|
|
@ -14,6 +14,11 @@ const routes = [
|
||||||
// which is lazy-loaded when the route is visited.
|
// which is lazy-loaded when the route is visited.
|
||||||
component: () => import(/* webpackChunkName: "home" */ '@/views/Home.vue'),
|
component: () => import(/* webpackChunkName: "home" */ '@/views/Home.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/comparison/:tickers([A-Z]+)+',
|
||||||
|
name: 'Comparison',
|
||||||
|
component: () => import(/* webpackChunkName: "comparison" */ '@/views/Comparison.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
52
stockingly-frontend/src/views/Comparison.vue
Normal file
52
stockingly-frontend/src/views/Comparison.vue
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
<template>
|
||||||
|
<v-app-bar color="primary">
|
||||||
|
<v-btn icon @click="goHome">
|
||||||
|
<v-icon>mdi-home</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-app-bar-title>
|
||||||
|
Stockingly
|
||||||
|
</v-app-bar-title>
|
||||||
|
</v-app-bar>
|
||||||
|
<v-container class="fill-height">
|
||||||
|
<h1>Stock price over time</h1>
|
||||||
|
<ag-charts-vue v-if="options !== null" :options="options" style="width: 100%; height: 30em" />
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { AgChartsVue } from 'ag-charts-vue3';
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { PriceHistory, getPriceHistory } from '@/api';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const goHome = () => router.push('/');
|
||||||
|
|
||||||
|
const options = ref<object | null>(null);
|
||||||
|
|
||||||
|
// load chart data
|
||||||
|
onMounted(() => {
|
||||||
|
const tickers = route.params.tickers.toString().split(',');
|
||||||
|
console.log(tickers);
|
||||||
|
getPriceHistory(tickers).then((p: PriceHistory[]) => {
|
||||||
|
options.value = {
|
||||||
|
data: p.map(e => ({ ...e, date: Date.parse(e.date) })),
|
||||||
|
series: tickers.map((t: string) => ({ xKey: 'date', yKey: t })),
|
||||||
|
axes: [
|
||||||
|
{
|
||||||
|
type: 'time',
|
||||||
|
position: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
position: 'left',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
|
@ -75,7 +75,8 @@
|
||||||
<small class="ma-2">{{ selected.length }} / {{ MAX_SELECT }} companies selected:</small>
|
<small class="ma-2">{{ selected.length }} / {{ MAX_SELECT }} companies selected:</small>
|
||||||
<v-chip class="ma-2" color="secondary" closable style="flex: initial !important" v-for="s in selected" :key="s"
|
<v-chip class="ma-2" color="secondary" closable style="flex: initial !important" v-for="s in selected" :key="s"
|
||||||
@click:close="unselect(s)">{{ s }}</v-chip>
|
@click:close="unselect(s)">{{ s }}</v-chip>
|
||||||
<v-btn class="ma-2" variant="flat" size="small" color="primary" prepend-icon="mdi-chart-areaspline-variant">
|
<v-btn class="ma-2" variant="flat" size="small" color="primary" prepend-icon="mdi-chart-areaspline-variant"
|
||||||
|
@click="goToComparison(selected)">
|
||||||
Compare
|
Compare
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn class="ma-2" size="small" variant="flat" color="error" @click="selected.splice(0, selected.length)"
|
<v-btn class="ma-2" size="small" variant="flat" color="error" @click="selected.splice(0, selected.length)"
|
||||||
|
@ -91,6 +92,7 @@ import { ref, reactive, computed, watch } from 'vue';
|
||||||
import { roundTo } from 'round-to';
|
import { roundTo } from 'round-to';
|
||||||
import debounce from 'debounce';
|
import debounce from 'debounce';
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const MAX_SELECT = 3;
|
const MAX_SELECT = 3;
|
||||||
|
|
||||||
|
@ -99,6 +101,7 @@ const companies = ref<Company[]>([]);
|
||||||
const filteredCompanies = ref<(Company & { score?: number })[]>([]);
|
const filteredCompanies = ref<(Company & { score?: number })[]>([]);
|
||||||
const selected = reactive<string[]>([]);
|
const selected = reactive<string[]>([]);
|
||||||
const searchText = ref("");
|
const searchText = ref("");
|
||||||
|
const router = useRouter();
|
||||||
let fuse: Fuse<Company> | undefined = undefined;
|
let fuse: Fuse<Company> | undefined = undefined;
|
||||||
|
|
||||||
const search = debounce(() => setTimeout(() => {
|
const search = debounce(() => setTimeout(() => {
|
||||||
|
@ -132,6 +135,8 @@ const clearSearch = () => searchText.value = '';
|
||||||
|
|
||||||
const setSearch = (text: string) => searchText.value = text;
|
const setSearch = (text: string) => searchText.value = text;
|
||||||
|
|
||||||
|
const goToComparison = (tickers: string[]) => router.push('/comparison/' + tickers.join('/').toUpperCase());
|
||||||
|
|
||||||
watch(filteredCompanies, () => loading.value = false);
|
watch(filteredCompanies, () => loading.value = false);
|
||||||
|
|
||||||
type ColorScale = { gt?: number, color: string }[];
|
type ColorScale = { gt?: number, color: string }[];
|
||||||
|
@ -217,13 +222,9 @@ const metrics = computed<ExtendedMetric[]>(() => metricsData.map(e => ({
|
||||||
percentage: (c: Company) => (e.value(c) - e.minValue) * 100 / (e.maxValue - e.minValue),
|
percentage: (c: Company) => (e.value(c) - e.minValue) * 100 / (e.maxValue - e.minValue),
|
||||||
value: (c: Company) => roundTo(e.value(c), e.decimals ?? 2),
|
value: (c: Company) => roundTo(e.value(c), e.decimals ?? 2),
|
||||||
color: (c: Company) => {
|
color: (c: Company) => {
|
||||||
console.log(JSON.parse(JSON.stringify(e.scale)));
|
|
||||||
const value = e.value(c);
|
const value = e.value(c);
|
||||||
console.log(c.ticker, value);
|
|
||||||
for (const s of e.scale) {
|
for (const s of e.scale) {
|
||||||
console.log('checking', s.gt, s.color);
|
|
||||||
if (typeof s.gt !== 'number' || value > s.gt) {
|
if (typeof s.gt !== 'number' || value > s.gt) {
|
||||||
console.log(s.gt, s.color);
|
|
||||||
return s.color;
|
return s.color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -327,10 +327,10 @@
|
||||||
"@typescript-eslint/types" "5.59.2"
|
"@typescript-eslint/types" "5.59.2"
|
||||||
eslint-visitor-keys "^3.3.0"
|
eslint-visitor-keys "^3.3.0"
|
||||||
|
|
||||||
"@vitejs/plugin-vue@^3.0.3":
|
"@vitejs/plugin-vue@^4.0.0":
|
||||||
version "3.2.0"
|
version "4.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-3.2.0.tgz#a1484089dd85d6528f435743f84cdd0d215bbb54"
|
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-4.2.3.tgz#ee0b6dfcc62fe65364e6395bf38fa2ba10bb44b6"
|
||||||
integrity sha512-E0tnaL4fr+qkdCNxJ+Xd0yM31UwMkQje76fsDVBBUCoGOUPexu2VDUYHL8P4CwV+zMvWw6nlRw19OnRKmYAJpw==
|
integrity sha512-R6JDUfiZbJA9cMiguQ7jxALsgiprjBeHL5ikpXfJCH62pPHtI+JdJ5xWj6Ev73yXSlYl86+blXn1kZHQ7uElxw==
|
||||||
|
|
||||||
"@volar/language-core@1.4.1":
|
"@volar/language-core@1.4.1":
|
||||||
version "1.4.1"
|
version "1.4.1"
|
||||||
|
@ -563,6 +563,16 @@ acorn@^8.8.0:
|
||||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
|
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
|
||||||
integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
|
integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
|
||||||
|
|
||||||
|
ag-charts-community@^7.3.0:
|
||||||
|
version "7.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/ag-charts-community/-/ag-charts-community-7.3.0.tgz#575284d2ed248fc725fc6b3029c7eae9872cec39"
|
||||||
|
integrity sha512-118U6YsCMia6iZHaN06zT19rr2SYa92WB73pMVCKQlp2H3c19uKQ6Y6DfKG/nIfNUzFXZLHBwKIdZXsMWJdZww==
|
||||||
|
|
||||||
|
ag-charts-vue3@^7.3.0:
|
||||||
|
version "7.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/ag-charts-vue3/-/ag-charts-vue3-7.3.0.tgz#afb4e978fecb80c9066856dc4741bad5a811d742"
|
||||||
|
integrity sha512-ftGrgH+xfI7PzXWW0N48KZkd7qhWqaS58VsSKeWNTJRqAzUIEI103EDGdi8VcD2D91JmLCkChGR2RyP3EVN75Q==
|
||||||
|
|
||||||
ajv@^6.10.0, ajv@^6.12.4:
|
ajv@^6.10.0, ajv@^6.12.4:
|
||||||
version "6.12.6"
|
version "6.12.6"
|
||||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
|
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
|
||||||
|
@ -1613,6 +1623,11 @@ vite@^4.2.0:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "~2.3.2"
|
fsevents "~2.3.2"
|
||||||
|
|
||||||
|
vue-class-component@^8.0.0-beta.3:
|
||||||
|
version "8.0.0-rc.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/vue-class-component/-/vue-class-component-8.0.0-rc.1.tgz#db692cd97656eb9a08206c03d0b7398cdb1d9420"
|
||||||
|
integrity sha512-w1nMzsT/UdbDAXKqhwTmSoyuJzUXKrxLE77PCFVuC6syr8acdFDAq116xgvZh9UCuV0h+rlCtxXolr3Hi3HyPQ==
|
||||||
|
|
||||||
vue-eslint-parser@^9.0.1, vue-eslint-parser@^9.1.1:
|
vue-eslint-parser@^9.0.1, vue-eslint-parser@^9.1.1:
|
||||||
version "9.2.0"
|
version "9.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.2.0.tgz#b397a7afae29961e5c59d80e895ab54a2e7f2f41"
|
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.2.0.tgz#b397a7afae29961e5c59d80e895ab54a2e7f2f41"
|
||||||
|
@ -1626,6 +1641,11 @@ vue-eslint-parser@^9.0.1, vue-eslint-parser@^9.1.1:
|
||||||
lodash "^4.17.21"
|
lodash "^4.17.21"
|
||||||
semver "^7.3.6"
|
semver "^7.3.6"
|
||||||
|
|
||||||
|
vue-property-decorator@^9.1.2:
|
||||||
|
version "9.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/vue-property-decorator/-/vue-property-decorator-9.1.2.tgz#266a2eac61ba6527e2e68a6933cfb98fddab5457"
|
||||||
|
integrity sha512-xYA8MkZynPBGd/w5QFJ2d/NM0z/YeegMqYTphy7NJQXbZcuU6FC6AOdUAcy4SXP+YnkerC6AfH+ldg7PDk9ESQ==
|
||||||
|
|
||||||
vue-router@^4.0.0:
|
vue-router@^4.0.0:
|
||||||
version "4.1.6"
|
version "4.1.6"
|
||||||
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.1.6.tgz#b70303737e12b4814578d21d68d21618469375a1"
|
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.1.6.tgz#b70303737e12b4814578d21d68d21618469375a1"
|
||||||
|
|
|
@ -2,7 +2,9 @@ from flask import Flask, jsonify, redirect, url_for, send_from_directory
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from backend.utils.build_frontend import build_frontend
|
from backend.utils.build_frontend import build_frontend
|
||||||
from backend.api.companies import get_companies
|
from backend.api.companies import get_companies
|
||||||
|
from backend.api.closing_price import get_closing_price_hist
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
@ -26,6 +28,12 @@ def companies() -> object:
|
||||||
return jsonify(get_companies(ROOT_DIR))
|
return jsonify(get_companies(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/price_history/<tickers>', methods=['GET'])
|
||||||
|
def price_history(tickers) -> object:
|
||||||
|
tickers: list[str] = str(tickers).split(',')
|
||||||
|
return jsonify(get_closing_price_hist(tickers))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/companies/logos/<ticker>')
|
@app.route('/companies/logos/<ticker>')
|
||||||
def get_company_logo(ticker: str):
|
def get_company_logo(ticker: str):
|
||||||
logo_dir: str = os.path.join(ROOT_DIR, 'scraper', 'logos', 'logos')
|
logo_dir: str = os.path.join(ROOT_DIR, 'scraper', 'logos', 'logos')
|
||||||
|
@ -38,5 +46,6 @@ def get_company_logo(ticker: str):
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) < 2 or sys.argv[1] != 'no-frontend':
|
||||||
build_frontend()
|
build_frontend()
|
||||||
app.run()
|
app.run()
|
||||||
|
|
Reference in a new issue