Refined comparison page
This commit is contained in:
parent
189c8cd2f7
commit
388dbd90b2
7 changed files with 265 additions and 188 deletions
|
@ -2,22 +2,24 @@ import os
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from scraper.top100_extractor import programming_crime_list
|
from scraper.top100_extractor import programming_crime_list
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
COMPANIES_CSV_PATH: str = 'scraper/companies.csv'
|
ROOT_PATH: str = os.path.join(os.path.dirname(__file__), '..', '..')
|
||||||
COMPANY_DATA_CSV_PATH: str = 'Elaborated_Data/normalized_data.csv'
|
COMPANIES_CSV_PATH: str = os.path.join('scraper', 'companies.csv')
|
||||||
|
COMPANY_DATA_CSV_PATH: str = os.path.join('Elaborated_Data', 'normalized_data.csv')
|
||||||
|
|
||||||
|
|
||||||
def non_nan(a: list[any]) -> list[any]:
|
def non_nan(a: list[any]) -> list[any]:
|
||||||
return list(filter(lambda a: type(a) == str or not np.isnan(a), a))
|
return list(filter(lambda a: type(a) == str or not np.isnan(a), a))
|
||||||
|
|
||||||
|
|
||||||
def get_companies(root_dir: str) -> list[dict]:
|
def get_companies(tickers: Optional[list[str]] = None) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
reads the companies.csv file and returns it as a JSON-ifiable object
|
reads the companies.csv file and returns it as a JSON-ifiable object
|
||||||
to return to the frontend.
|
to return to the frontend.
|
||||||
"""
|
"""
|
||||||
df = pd.read_csv(os.path.join(root_dir, COMPANIES_CSV_PATH), index_col='ticker')
|
df = pd.read_csv(os.path.join(ROOT_PATH, COMPANIES_CSV_PATH), index_col='ticker')
|
||||||
tickers = pd.Series(programming_crime_list)
|
tickers = pd.Series(programming_crime_list if tickers is None else tickers)
|
||||||
df = df.loc[df.index.isin(tickers), :]
|
df = df.loc[df.index.isin(tickers), :]
|
||||||
df['tags'] = df[['tag 1', 'tag 2', 'tag 3']].values.tolist()
|
df['tags'] = df[['tag 1', 'tag 2', 'tag 3']].values.tolist()
|
||||||
df['tags'] = df['tags'].apply(non_nan)
|
df['tags'] = df['tags'].apply(non_nan)
|
||||||
|
@ -26,7 +28,7 @@ def get_companies(root_dir: str) -> list[dict]:
|
||||||
del df['tag 3']
|
del df['tag 3']
|
||||||
|
|
||||||
# Include company metrics
|
# Include company metrics
|
||||||
df_data = pd.read_csv(os.path.join(root_dir, COMPANY_DATA_CSV_PATH), index_col='Ticker') \
|
df_data = pd.read_csv(os.path.join(ROOT_PATH, COMPANY_DATA_CSV_PATH), index_col='Ticker') \
|
||||||
.loc[:, ['Valuation', 'Financial Health', 'Estimated Growth', 'Past Performance']]
|
.loc[:, ['Valuation', 'Financial Health', 'Estimated Growth', 'Past Performance']]
|
||||||
|
|
||||||
# Compute limits of metrics
|
# Compute limits of metrics
|
||||||
|
|
|
@ -25,11 +25,13 @@ export interface PriceHistory {
|
||||||
[ticker: string]: string | number; // really just number
|
[ticker: string]: string | number; // really just number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCompanies = (): Promise<Company[]> =>
|
export const getCompanies = (tickers?: string[]): Promise<Company[]> =>
|
||||||
fetch(BACKEND_URL + '/companies').then(r => r.json()).then(list => list.map((e: Company) => ({
|
fetch(BACKEND_URL + '/companies' + (tickers ? ('/' + tickers.join('/')) : ''))
|
||||||
|
.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[]> =>
|
export const getPriceHistory = (tickers: string[]): Promise<PriceHistory[]> =>
|
||||||
fetch(BACKEND_URL + '/price_history/' + tickers.join(',').toUpperCase()).then(r => r.json())
|
fetch(BACKEND_URL + '/price_history/' + tickers.join('/').toUpperCase()).then(r => r.json())
|
17
stockingly-frontend/src/api/loader.ts
Normal file
17
stockingly-frontend/src/api/loader.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { reactive } from "vue";
|
||||||
|
|
||||||
|
export const defineLoader = <ApiResult>(apiCall: () => Promise<ApiResult>) => reactive({
|
||||||
|
loading: true,
|
||||||
|
data: undefined as ApiResult | undefined,
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const result: ApiResult = await apiCall();
|
||||||
|
this.data = result;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Error loading data'); // don't do this for the final product
|
||||||
|
}
|
||||||
|
this.loading = false;
|
||||||
|
console.log(this.data, this.loading);
|
||||||
|
}
|
||||||
|
})
|
170
stockingly-frontend/src/components/CompanyCard.vue
Normal file
170
stockingly-frontend/src/components/CompanyCard.vue
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
<template>
|
||||||
|
<v-card :class="{
|
||||||
|
'ma-1': true,
|
||||||
|
'fill-height': true,
|
||||||
|
'd-flex': true,
|
||||||
|
'flex-column': true,
|
||||||
|
'text-white': props.markerColor
|
||||||
|
}" :color="props.markerColor ?? void (0)">
|
||||||
|
<div class="pa-4 bg-white">
|
||||||
|
<v-img :src="company.logoSrc" :aspect-ratio="4.5" class="bg-white" />
|
||||||
|
</div>
|
||||||
|
<v-card-item>
|
||||||
|
<v-card-title>{{ company['short name'] }}</v-card-title>
|
||||||
|
<v-card-subtitle>{{ company['company name'] }}</v-card-subtitle>
|
||||||
|
</v-card-item>
|
||||||
|
|
||||||
|
<v-card-text class="flex-0" v-if="!props.minimal">
|
||||||
|
<p class="text--primary">
|
||||||
|
{{ company.description }}
|
||||||
|
</p>
|
||||||
|
<div class="pt-2 pb-2" v-for="m in metrics" :key="m.title">
|
||||||
|
<div class="d-inline-flex justify-space-between" style="width: 100%">
|
||||||
|
<strong>{{ m.title }}</strong>
|
||||||
|
<span class="text-right">{{ m.value(company) }}{{ m.symbol ?? '' }}</span>
|
||||||
|
</div>
|
||||||
|
<v-progress-linear :color="m.color(company)" :model-value="m.percentage(company)"></v-progress-linear>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<div class="px-4 flex-0">
|
||||||
|
<v-chip label :color="props.markerColor ? void(0) : 'pink'" class="ma-1">
|
||||||
|
<v-icon start icon="mdi-chart-timeline-variant"></v-icon>
|
||||||
|
{{ company.ticker }}
|
||||||
|
</v-chip>
|
||||||
|
<template v-if="!props.minimal">
|
||||||
|
<v-chip label color="green" class="ma-1">
|
||||||
|
<v-icon start icon="mdi-currency-usd"></v-icon>
|
||||||
|
{{ formatCurrency(company['market cap']) }}
|
||||||
|
</v-chip>
|
||||||
|
<template v-for="tag in company.tags" :key="tag">
|
||||||
|
<v-chip label class="ma-1" :color="props.highlightTag === tag ? 'teal' : void (0)"
|
||||||
|
@click="emit('tagClicked', tag)">
|
||||||
|
{{ tag }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<slot></slot>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Company } from '@/api';
|
||||||
|
import { roundTo } from 'round-to';
|
||||||
|
import { reactive, computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
company: Company,
|
||||||
|
highlightTag?: string,
|
||||||
|
minimal?: boolean,
|
||||||
|
markerColor?: string
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'tagClicked', tag: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const company = computed(() => props.company);
|
||||||
|
|
||||||
|
type ColorScale = { gt?: number, color: string }[];
|
||||||
|
|
||||||
|
interface Metric {
|
||||||
|
title: string,
|
||||||
|
minValue: number,
|
||||||
|
maxValue: number,
|
||||||
|
value: (c: Company) => number // in [0, 100],
|
||||||
|
symbol?: string,
|
||||||
|
decimals?: number
|
||||||
|
scale: ColorScale
|
||||||
|
}
|
||||||
|
|
||||||
|
// COLORS from Pietro:
|
||||||
|
// Valuation: < 1 ( GREEN); > 1 (RED); = 1 (ORANGE)
|
||||||
|
// Financial Health: < 1 (GREEN); > 1 (RED); = 1 (ORANGE)
|
||||||
|
// Growth: < 0 (RED); 0 < x < 8% (ORANGE); < 8 % (GREEN)
|
||||||
|
// Past performance: -100 < x < 0 (RED); = 0 (ORANGE); 0 < x < 100 (GREEN)
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
good: 'success',
|
||||||
|
warning: 'warning',
|
||||||
|
bad: 'error'
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricsData = reactive<Metric[]>([
|
||||||
|
{
|
||||||
|
title: 'Valuation',
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 150,
|
||||||
|
value: c => c['Valuation'],
|
||||||
|
scale: [
|
||||||
|
{ gt: 1.1, color: COLORS.bad },
|
||||||
|
{ gt: 0.9, color: COLORS.warning },
|
||||||
|
{ color: COLORS.good }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Financial Health',
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 500,
|
||||||
|
value: c => c['Financial Health'],
|
||||||
|
scale: [
|
||||||
|
{ gt: 1.1, color: COLORS.bad },
|
||||||
|
{ gt: 0.9, color: COLORS.warning },
|
||||||
|
{ color: COLORS.good }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Estimated Growth',
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 200,
|
||||||
|
value: c => c['Estimated Growth'],
|
||||||
|
symbol: ' %',
|
||||||
|
scale: [
|
||||||
|
{ gt: 8, color: COLORS.good },
|
||||||
|
{ gt: 0, color: COLORS.warning },
|
||||||
|
{ color: COLORS.bad }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Past Performance',
|
||||||
|
minValue: -100,
|
||||||
|
maxValue: 200,
|
||||||
|
value: c => c['Past Performance'],
|
||||||
|
symbol: ' %',
|
||||||
|
scale: [
|
||||||
|
{ gt: 5, color: COLORS.good },
|
||||||
|
{ gt: -5, color: COLORS.warning },
|
||||||
|
{ gt: -Infinity, color: COLORS.bad }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
type ExtendedMetric = Metric & {
|
||||||
|
percentage: (c: Company) => number,
|
||||||
|
color: (c: Company) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = computed<ExtendedMetric[]>(() => metricsData.map(e => ({
|
||||||
|
...e,
|
||||||
|
percentage: (c: Company) => (e.value(c) - e.minValue) * 100 / (e.maxValue - e.minValue),
|
||||||
|
value: (c: Company) => roundTo(e.value(c), e.decimals ?? 2),
|
||||||
|
color: (c: Company) => {
|
||||||
|
const value = e.value(c);
|
||||||
|
for (const s of e.scale) {
|
||||||
|
if (typeof s.gt !== 'number' || value > s.gt) {
|
||||||
|
return s.color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'blue-grey' // default value, should never be displayed
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
|
||||||
|
const formatCurrency = (d: number) => {
|
||||||
|
if (d < 1000) return `${d}`;
|
||||||
|
if (d < 1_000_000) return `${Math.round(d / 1000)} K`;
|
||||||
|
if (d < 1_000_000_000) return `${Math.round(d / 1_000_000)} M`
|
||||||
|
return `${Math.round(d / 1_000_000_000)} B`
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -8,6 +8,21 @@
|
||||||
</v-app-bar-title>
|
</v-app-bar-title>
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
<v-container class="fill-height">
|
<v-container class="fill-height">
|
||||||
|
<v-row style="width: 100%">
|
||||||
|
<h1 class="ma-4">Company comparison</h1>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<template v-if="companies.loading" v-for="i in tickers" :key="i">
|
||||||
|
<v-col cols="12" md="6" lg="4">
|
||||||
|
<v-skeleton-loader class="mx-auto" type="card"></v-skeleton-loader>
|
||||||
|
</v-col>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<v-col cols="12" md="6" lg="4" v-for="company in companies.data" :key="company.ticker">
|
||||||
|
<company-card :company="company" minimal :marker-color="getTickerColor(company.ticker)" />
|
||||||
|
</v-col>
|
||||||
|
</template>
|
||||||
|
</v-row>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12">
|
<v-col cols="12">
|
||||||
<v-card>
|
<v-card>
|
||||||
|
@ -23,7 +38,7 @@
|
||||||
<v-btn value="5Y">5Y</v-btn>
|
<v-btn value="5Y">5Y</v-btn>
|
||||||
<v-btn value="MAX">Max</v-btn>
|
<v-btn value="MAX">Max</v-btn>
|
||||||
</v-btn-toggle>
|
</v-btn-toggle>
|
||||||
<v-skeleton-loader class="chart-loader" v-if="loading" />
|
<v-skeleton-loader class="chart-loader" v-if="stockPrice.loading" />
|
||||||
<ag-charts-vue class="chart" v-else :options="options" />
|
<ag-charts-vue class="chart" v-else :options="options" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
@ -45,14 +60,16 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AgAxisLabelFormatterParams, AgSeriesTooltipRendererParams, time } from 'ag-charts-community';
|
import { AgAxisLabelFormatterParams, time } from 'ag-charts-community';
|
||||||
import { AgChartsVue } from 'ag-charts-vue3';
|
import { AgChartsVue } from 'ag-charts-vue3';
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { PriceHistory, getPriceHistory } from '@/api';
|
import { Company, PriceHistory, getCompanies, getPriceHistory } from '@/api';
|
||||||
import { DateTime, DurationLike } from 'luxon';
|
import { DateTime, DurationLike } from 'luxon';
|
||||||
import { TimeInterval } from 'ag-charts-community/dist/cjs/es5/util/time/interval';
|
import { TimeInterval } from 'ag-charts-community/dist/cjs/es5/util/time/interval';
|
||||||
import { roundTo } from 'round-to';
|
import { roundTo } from 'round-to';
|
||||||
|
import { defineLoader } from '@/api/loader';
|
||||||
|
import CompanyCard from '@/components/CompanyCard.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -62,6 +79,8 @@ const goHome = () => router.push('/');
|
||||||
type TimeOptions = '1M' | '3M' | '6M' | '1Y' | '5Y' | 'MAX';
|
type TimeOptions = '1M' | '3M' | '6M' | '1Y' | '5Y' | 'MAX';
|
||||||
|
|
||||||
const timeToggle = ref<TimeOptions>('1M');
|
const timeToggle = ref<TimeOptions>('1M');
|
||||||
|
const companies = defineLoader<Company[]>(() => getCompanies(tickers.value));
|
||||||
|
const stockPrice = defineLoader<PriceHistory[]>(() => getPriceHistory(tickers.value));
|
||||||
|
|
||||||
const optToDuration: Record<TimeOptions, [DurationLike | undefined, TimeInterval, string]> = {
|
const optToDuration: Record<TimeOptions, [DurationLike | undefined, TimeInterval, string]> = {
|
||||||
'1M': [{ months: 1 }, time.day, '%d'],
|
'1M': [{ months: 1 }, time.day, '%d'],
|
||||||
|
@ -69,19 +88,19 @@ const optToDuration: Record<TimeOptions, [DurationLike | undefined, TimeInterval
|
||||||
'6M': [{ months: 6 }, time.friday.every(2), '%m-%d'],
|
'6M': [{ months: 6 }, time.friday.every(2), '%m-%d'],
|
||||||
'1Y': [{ years: 1 }, time.month, '%Y-%m'],
|
'1Y': [{ years: 1 }, time.month, '%Y-%m'],
|
||||||
'5Y': [{ years: 5 }, time.month.every(3, {
|
'5Y': [{ years: 5 }, time.month.every(3, {
|
||||||
snapTo: DateTime.utc(2000, 1, 1).toJSDate()
|
snapTo: DateTime.utc(2000, 1, 1).toJSDate() // snap to solar year quarters
|
||||||
}), '%Y-%m'],
|
}), '%Y-%m'],
|
||||||
'MAX': [undefined, time.year.every(2), '%Y']
|
'MAX': [undefined, time.year.every(2), '%Y']
|
||||||
};
|
};
|
||||||
|
|
||||||
const loading = ref<boolean>(true);
|
|
||||||
const priceHistoryData = ref<PriceHistory[]>([]);
|
|
||||||
const tickers = ref<string[]>([]);
|
const tickers = ref<string[]>([]);
|
||||||
|
|
||||||
const colors = ['#E91E63', '#FF9800', '#3F51B5'];
|
const colors = ['#9C27B0', '#D32F2F', '#3F51B5'];
|
||||||
|
|
||||||
|
const getTickerColor = (ticker: string) => colors[tickers.value.indexOf(ticker)];
|
||||||
|
|
||||||
const options = computed(() => {
|
const options = computed(() => {
|
||||||
if (loading.value) return null;
|
if (stockPrice.loading) return null;
|
||||||
|
|
||||||
const maxDate = DateTime.now();
|
const maxDate = DateTime.now();
|
||||||
const opts = optToDuration[timeToggle.value];
|
const opts = optToDuration[timeToggle.value];
|
||||||
|
@ -94,16 +113,16 @@ const options = computed(() => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme: 'ag-material',
|
theme: 'ag-material',
|
||||||
data: priceHistoryData.value.map(e => ({ ...e, date: Date.parse(e.date) })),
|
data: stockPrice.data?.map(e => ({ ...e, date: Date.parse(e.date) })),
|
||||||
series: tickers.value.map((t: string, i: number) => ({
|
series: tickers.value.map((t: string) => ({
|
||||||
xKey: 'date',
|
xKey: 'date',
|
||||||
yKey: t,
|
yKey: t,
|
||||||
yName: t,
|
yName: t,
|
||||||
stroke: colors[i],
|
stroke: getTickerColor(t),
|
||||||
tooltip: { renderer: renderer },
|
tooltip: { renderer: renderer },
|
||||||
marker: {
|
marker: {
|
||||||
fill: colors[i],
|
fill: getTickerColor(t),
|
||||||
stroke: colors[i]
|
stroke: getTickerColor(t)
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
axes: [
|
axes: [
|
||||||
|
@ -127,7 +146,9 @@ const options = computed(() => {
|
||||||
label: {
|
label: {
|
||||||
format: '$~s',
|
format: '$~s',
|
||||||
formatter: (params: AgAxisLabelFormatterParams) =>
|
formatter: (params: AgAxisLabelFormatterParams) =>
|
||||||
params?.formatter?.(params.value).replace('k', 'K').replace('G', 'B') ?? ''
|
params?.formatter?.(params.value)
|
||||||
|
.replace('k', 'K')
|
||||||
|
.replace('G', 'B') ?? ''
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -140,11 +161,7 @@ const options = computed(() => {
|
||||||
// load chart data
|
// load chart data
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
tickers.value = route.params.tickers.toString().split(',');
|
tickers.value = route.params.tickers.toString().split(',');
|
||||||
getPriceHistory(tickers.value).then((p: PriceHistory[]) => {
|
stockPrice.load();
|
||||||
loading.value = false;
|
companies.load();
|
||||||
priceHistoryData.value = p;
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
|
@ -17,47 +17,9 @@
|
||||||
<v-skeleton-loader class="mx-auto" type="card"></v-skeleton-loader>
|
<v-skeleton-loader class="mx-auto" type="card"></v-skeleton-loader>
|
||||||
</v-col>
|
</v-col>
|
||||||
</template>
|
</template>
|
||||||
<template v-else v-for="company in filteredCompanies" :key="company.ticker">
|
<template v-else>
|
||||||
<v-col cols="12" md="6" lg="4">
|
<v-col cols="12" md="6" lg="4" v-for="company in filteredCompanies" :key="company.ticker">
|
||||||
<v-card class="ma-1 fill-height d-flex flex-column">
|
<company-card :company="company" :highlight-tag="searchText" @tag-clicked="setSearch($event)">
|
||||||
<div class="d-flex stretch align-center flex-0">
|
|
||||||
<div class="flex-0 pa-3">
|
|
||||||
<img style="max-height: 36px; min-height: 24px;" :src="company.logoSrc" />
|
|
||||||
</div>
|
|
||||||
<v-card-item style="flex: 1 !important">
|
|
||||||
<v-card-title>{{ company['short name'] }}</v-card-title>
|
|
||||||
<v-card-subtitle>{{ company['company name'] }}</v-card-subtitle>
|
|
||||||
</v-card-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-card-text class="flex-0">
|
|
||||||
<p class="text--primary">
|
|
||||||
{{ company.description }}
|
|
||||||
</p>
|
|
||||||
<div class="pt-2 pb-2" v-for="m in metrics" :key="m.title">
|
|
||||||
<div class="d-inline-flex justify-space-between" style="width: 100%">
|
|
||||||
<strong>{{ m.title }}</strong>
|
|
||||||
<span class="text-right">{{ m.value(company) }}{{ m.symbol ?? '' }}</span>
|
|
||||||
</div>
|
|
||||||
<v-progress-linear :color="m.color(company)" :model-value="m.percentage(company)"></v-progress-linear>
|
|
||||||
</div>
|
|
||||||
</v-card-text>
|
|
||||||
|
|
||||||
<div class="px-4 flex-0">
|
|
||||||
<v-chip label color="pink" class="ma-1">
|
|
||||||
<v-icon start icon="mdi-chart-timeline-variant"></v-icon>
|
|
||||||
{{ company.ticker }}
|
|
||||||
</v-chip>
|
|
||||||
<v-chip label color="green" class="ma-1">
|
|
||||||
<v-icon start icon="mdi-currency-usd"></v-icon>
|
|
||||||
{{ formatCurrency(company['market cap']) }}
|
|
||||||
</v-chip>
|
|
||||||
<template v-for="tag in company.tags" :key="tag">
|
|
||||||
<v-chip label class="ma-1" :color="searchText == tag ? 'teal' : void (0)" @click="setSearch(tag)">{{ tag
|
|
||||||
}}</v-chip>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-card-actions class="flex-1">
|
<v-card-actions class="flex-1">
|
||||||
<v-btn :variant="isSelected(company.ticker) ? 'tonal' : 'text'"
|
<v-btn :variant="isSelected(company.ticker) ? 'tonal' : 'text'"
|
||||||
@click="(isSelected(company.ticker) ? unselect : select)(company.ticker)"
|
@click="(isSelected(company.ticker) ? unselect : select)(company.ticker)"
|
||||||
|
@ -65,6 +27,13 @@
|
||||||
{{ isSelected(company.ticker) ? 'Unselect' : 'Select' }}
|
{{ isSelected(company.ticker) ? 'Unselect' : 'Select' }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
|
</company-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6" lg="4" v-if="filteredCompanies.length === 0">
|
||||||
|
<v-card>
|
||||||
|
<v-card-text>
|
||||||
|
No companies found.
|
||||||
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
</template>
|
</template>
|
||||||
|
@ -88,11 +57,11 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { getCompanies, Company } from '@/api';
|
import { getCompanies, Company } from '@/api';
|
||||||
import { ref, reactive, computed, watch } from 'vue';
|
import { ref, reactive, watch } from 'vue';
|
||||||
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';
|
import { useRouter } from 'vue-router';
|
||||||
|
import CompanyCard from '@/components/CompanyCard.vue';
|
||||||
|
|
||||||
const MAX_SELECT = 3;
|
const MAX_SELECT = 3;
|
||||||
|
|
||||||
|
@ -139,110 +108,10 @@ const goToComparison = (tickers: string[]) => router.push('/comparison/' + ticke
|
||||||
|
|
||||||
watch(filteredCompanies, () => loading.value = false);
|
watch(filteredCompanies, () => loading.value = false);
|
||||||
|
|
||||||
type ColorScale = { gt?: number, color: string }[];
|
|
||||||
|
|
||||||
interface Metric {
|
|
||||||
title: string,
|
|
||||||
minValue: number,
|
|
||||||
maxValue: number,
|
|
||||||
value: (c: Company) => number // in [0, 100],
|
|
||||||
symbol?: string,
|
|
||||||
decimals?: number
|
|
||||||
scale: ColorScale
|
|
||||||
}
|
|
||||||
|
|
||||||
// COLORS from Pietro:
|
|
||||||
// Valuation: < 1 ( GREEN); > 1 (RED); = 1 (ORANGE)
|
|
||||||
// Financial Health: < 1 (GREEN); > 1 (RED); = 1 (ORANGE)
|
|
||||||
// Growth: < 0 (RED); 0 < x < 8% (ORANGE); < 8 % (GREEN)
|
|
||||||
// Past performance: -100 < x < 0 (RED); = 0 (ORANGE); 0 < x < 100 (GREEN)
|
|
||||||
|
|
||||||
const COLORS = {
|
|
||||||
good: 'success',
|
|
||||||
warning: 'warning',
|
|
||||||
bad: 'error'
|
|
||||||
};
|
|
||||||
|
|
||||||
const metricsData = reactive<Metric[]>([
|
|
||||||
{
|
|
||||||
title: 'Valuation',
|
|
||||||
minValue: 0,
|
|
||||||
maxValue: 150,
|
|
||||||
value: c => c['Valuation'],
|
|
||||||
scale: [
|
|
||||||
{ gt: 1.1, color: COLORS.bad },
|
|
||||||
{ gt: 0.9, color: COLORS.warning },
|
|
||||||
{ color: COLORS.good }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Financial Health',
|
|
||||||
minValue: 0,
|
|
||||||
maxValue: 500,
|
|
||||||
value: c => c['Financial Health'],
|
|
||||||
scale: [
|
|
||||||
{ gt: 1.1, color: COLORS.bad },
|
|
||||||
{ gt: 0.9, color: COLORS.warning },
|
|
||||||
{ color: COLORS.good }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Estimated Growth',
|
|
||||||
minValue: 0,
|
|
||||||
maxValue: 200,
|
|
||||||
value: c => c['Estimated Growth'],
|
|
||||||
symbol: ' %',
|
|
||||||
scale: [
|
|
||||||
{ gt: 8, color: COLORS.good },
|
|
||||||
{ gt: 0, color: COLORS.warning },
|
|
||||||
{ color: COLORS.bad }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Past Performance',
|
|
||||||
minValue: -100,
|
|
||||||
maxValue: 200,
|
|
||||||
value: c => c['Past Performance'],
|
|
||||||
symbol: ' %',
|
|
||||||
scale: [
|
|
||||||
{ gt: 5, color: COLORS.good },
|
|
||||||
{ gt: -5, color: COLORS.warning },
|
|
||||||
{ gt: -Infinity, color: COLORS.bad }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
type ExtendedMetric = Metric & {
|
|
||||||
percentage: (c: Company) => number,
|
|
||||||
color: (c: Company) => string
|
|
||||||
}
|
|
||||||
|
|
||||||
const metrics = computed<ExtendedMetric[]>(() => metricsData.map(e => ({
|
|
||||||
...e,
|
|
||||||
percentage: (c: Company) => (e.value(c) - e.minValue) * 100 / (e.maxValue - e.minValue),
|
|
||||||
value: (c: Company) => roundTo(e.value(c), e.decimals ?? 2),
|
|
||||||
color: (c: Company) => {
|
|
||||||
const value = e.value(c);
|
|
||||||
for (const s of e.scale) {
|
|
||||||
if (typeof s.gt !== 'number' || value > s.gt) {
|
|
||||||
return s.color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'blue-grey' // default value, should never be displayed
|
|
||||||
}
|
|
||||||
})));
|
|
||||||
|
|
||||||
getCompanies().then(cs => {
|
getCompanies().then(cs => {
|
||||||
companies.value = cs;
|
companies.value = cs;
|
||||||
const myIndex = Fuse.createIndex(['ceo', 'company name', 'description', 'sector', 'tags', 'ticker', 'website'], cs);
|
const myIndex = Fuse.createIndex(['ceo', 'company name', 'description', 'sector', 'tags', 'ticker', 'website'], cs);
|
||||||
fuse = new Fuse(cs, { threshold: 0.5, ignoreLocation: true }, myIndex);
|
fuse = new Fuse(cs, { threshold: 0.5, ignoreLocation: true }, myIndex);
|
||||||
filteredCompanies.value = cs;
|
filteredCompanies.value = cs;
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatCurrency = (d: number) => {
|
|
||||||
if (d < 1000) return `${d}`;
|
|
||||||
if (d < 1_000_000) return `${Math.round(d / 1000)} K`;
|
|
||||||
if (d < 1_000_000_000) return `${Math.round(d / 1_000_000)} M`
|
|
||||||
return `${Math.round(d / 1_000_000_000)} B`
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -6,7 +6,7 @@ from backend.api.closing_price import get_closing_price_hist
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
ROOT_DIR: str = os.path.dirname(__file__)
|
ROOT_DIR: str = os.path.dirname(__file__)
|
||||||
|
|
||||||
|
@ -24,14 +24,14 @@ def index():
|
||||||
|
|
||||||
|
|
||||||
@app.route('/companies', methods=['GET'])
|
@app.route('/companies', methods=['GET'])
|
||||||
def companies() -> object:
|
@app.route('/companies/<path:tickers>', methods=['GET'])
|
||||||
return jsonify(get_companies(ROOT_DIR))
|
def companies(tickers: Optional[str] = None) -> object:
|
||||||
|
return jsonify(get_companies(None if tickers is None else tickers.split('/')))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/price_history/<tickers>', methods=['GET'])
|
@app.route('/price_history/<path:tickers>', methods=['GET'])
|
||||||
def price_history(tickers) -> object:
|
def price_history(tickers: Optional[str]) -> object:
|
||||||
tickers: list[str] = str(tickers).split(',')
|
return jsonify(get_closing_price_hist(tickers.split('/')))
|
||||||
return jsonify(get_closing_price_hist(tickers))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/companies/logos/<ticker>')
|
@app.route('/companies/logos/<ticker>')
|
||||||
|
|
Reference in a new issue