From 16c8eccb04d3844fe9dbea52e6e15aeaa0e9285d Mon Sep 17 00:00:00 2001 From: "Marvin A. Ruder" Date: Tue, 14 May 2024 16:03:15 +0200 Subject: [PATCH] Reduce value set of Analyst Consensus (#1291) * Add `analystRatings` stock property * Add Analyst Rating Bar * Simplify error handling in fetchers * Add helpers for mathematical operations on Records * Remove Selenium-related code * Run more tests concurrently Signed-off-by: Marvin A. Ruder --- .devcontainer/docker-compose.yml | 1 - packages/backend/dev/docker-compose.yml | 15 - packages/backend/package.json | 5 +- .../migration.sql | 7 + packages/backend/prisma/schema.prisma | 23 +- .../src/controllers/FetchController.ts | 2 +- .../controllers/StatusController.live.test.ts | 1 - .../src/controllers/StatusController.ts | 2 - .../controllers/StocksController.live.test.ts | 17 +- .../src/controllers/StocksController.ts | 25 +- .../src/db/tables/stockTable.live.test.ts | 4 +- packages/backend/src/db/tables/stockTable.ts | 15 +- packages/backend/src/db/types.d.ts | 7 + packages/backend/src/fetchers/fetchHelper.ts | 247 ++++++--------- packages/backend/src/fetchers/lsegFetcher.ts | 51 +--- .../src/fetchers/marketScreenerFetcher.ts | 133 ++++---- .../src/fetchers/morningstarFetcher.ts | 79 ++--- packages/backend/src/fetchers/msciFetcher.ts | 43 +-- packages/backend/src/fetchers/spFetcher.ts | 45 +-- .../src/models/dynamicStockAttributes.test.ts | 31 +- .../src/models/dynamicStockAttributes.ts | 41 +-- packages/backend/src/openapi/components.ts | 22 +- .../backend/src/openapi/parameters/stock.ts | 6 +- .../backend/src/utils/DataProviderError.ts | 19 +- packages/backend/src/utils/startup.ts | 8 +- packages/backend/src/utils/webdriver.ts | 142 --------- packages/backend/test/env.ts | 1 - packages/backend/test/seeds/postgres.ts | 44 +-- packages/backend/vitest.config.ts | 1 - packages/commons/src/index.ts | 3 + packages/commons/src/lib/Currency.test.ts | 2 +- packages/commons/src/lib/DataProvider.test.ts | 27 +- packages/commons/src/lib/DataProvider.ts | 44 +-- packages/commons/src/lib/Fetch.test.ts | 4 +- packages/commons/src/lib/MessageType.test.ts | 2 +- packages/commons/src/lib/Service.test.ts | 2 +- packages/commons/src/lib/Service.ts | 2 +- .../commons/src/lib/SortableAttribute.test.ts | 2 +- packages/commons/src/lib/StockListColumn.ts | 2 +- .../commons/src/lib/gecs/Industry.test.ts | 2 +- .../src/lib/gecs/IndustryGroup.test.ts | 2 +- packages/commons/src/lib/gecs/Sector.test.ts | 2 +- .../commons/src/lib/gecs/SuperSector.test.ts | 2 +- packages/commons/src/lib/geo/Country.test.ts | 4 +- packages/commons/src/lib/geo/Region.test.ts | 2 +- .../commons/src/lib/geo/SuperRegion.test.ts | 4 +- packages/commons/src/lib/math/Record.test.ts | 47 +++ packages/commons/src/lib/math/Record.ts | 20 ++ .../commons/src/lib/models/portfolio.test.ts | 286 ++++++++++-------- packages/commons/src/lib/models/portfolio.ts | 50 ++- packages/commons/src/lib/models/stock.ts | 10 +- packages/commons/src/lib/models/user.test.ts | 6 +- .../src/lib/ratings/AnalystRating.test.ts | 13 + .../commons/src/lib/ratings/AnalystRating.ts | 18 ++ packages/commons/src/lib/ratings/MSCI.test.ts | 2 +- .../commons/src/lib/stylebox/Size.test.ts | 2 +- .../commons/src/lib/stylebox/Style.test.ts | 2 +- packages/commons/vitest.config.ts | 2 +- .../src/components/etc/Navigators.tsx | 4 +- .../components/stock/layouts/StockDetails.tsx | 53 +--- .../src/components/stock/layouts/StockRow.tsx | 50 +-- .../components/stock/layouts/StockTable.tsx | 8 +- .../stock/layouts/StockTableFilters.tsx | 31 +- .../stock/properties/AnalystRatingBar.tsx | 66 ++++ .../stock/properties/PropertyDescription.tsx | 23 +- .../stock/properties/Range52WSlider.tsx | 62 +++- .../content/modules/Portfolio/Portfolio.tsx | 23 +- .../PortfolioBuilder/PortfolioBuilder.tsx | 9 +- packages/frontend/src/theme/ThemeProvider.tsx | 34 +-- packages/frontend/src/theme/scheme.ts | 16 +- packages/frontend/src/types/StockFilter.ts | 6 +- .../frontend/src/utils/formatters.test.ts | 4 +- .../src/utils/portfolioComputation.test.ts | 7 +- packages/frontend/vite.config.mts | 1 + yarn.lock | 34 ++- 75 files changed, 990 insertions(+), 1044 deletions(-) create mode 100644 packages/backend/prisma/migrations/05-1234-reduce-value-set-of-analyst-consensus/migration.sql create mode 100644 packages/backend/src/db/types.d.ts delete mode 100644 packages/backend/src/utils/webdriver.ts create mode 100644 packages/commons/src/lib/math/Record.test.ts create mode 100644 packages/commons/src/lib/math/Record.ts create mode 100644 packages/commons/src/lib/ratings/AnalystRating.test.ts create mode 100644 packages/commons/src/lib/ratings/AnalystRating.ts create mode 100644 packages/frontend/src/components/stock/properties/AnalystRatingBar.tsx diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 4875b0fb0..6589c7b75 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -14,7 +14,6 @@ services: environment: DATABASE_URL: postgresql://rating-tracker:rating-tracker@postgres:5432/rating-tracker?schema=rating-tracker REDIS_URL: redis://redis:6379 - # SELENIUM_URL: http://selenium:4444 SIGNAL_URL: http://signal:8080 PORT: 3001 MAX_FETCH_CONCURRENCY: 4 diff --git a/packages/backend/dev/docker-compose.yml b/packages/backend/dev/docker-compose.yml index b70a9ed14..2023f197f 100644 --- a/packages/backend/dev/docker-compose.yml +++ b/packages/backend/dev/docker-compose.yml @@ -59,21 +59,6 @@ services: - ./postgresql/pgpass:/var/lib/postgresql/pgpass shm_size: "256mb" - # selenium: - # container_name: selenium - # hostname: selenium - # image: seleniarm/standalone-chromium - # restart: unless-stopped - # environment: - # - SE_NODE_MAX_SESSIONS=2 - # cap_drop: - # - all - # security_opt: - # - no-new-privileges - # ports: - # - "127.0.0.1:4444:4444" - # shm_size: "2gb" - signal: container_name: signal hostname: signal diff --git a/packages/backend/package.json b/packages/backend/package.json index 8d8612d4c..08efe7a9c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -46,12 +46,14 @@ "pino": "9.1.0", "pino-pretty": "11.0.0", "prisma": "5.14.0", + "prisma-json-types-generator": "3.0.4", "redis": "4.6.13", "redis-om": "0.4.3", "response-time": "2.3.2", "supertest": "7.0.0", "swagger-ui-dist": "5.17.9", "swagger-ui-express": "5.0.0", + "typescript": "5.4.5", "undici": "6.16.1", "vite": "5.2.11", "vitest": "1.6.0", @@ -75,7 +77,6 @@ "eslint-plugin-import": "2.29.1", "eslint-plugin-jsdoc": "48.2.4", "eslint-plugin-prettier": "5.1.3", - "prettier": "3.2.5", - "typescript": "5.4.5" + "prettier": "3.2.5" } } diff --git a/packages/backend/prisma/migrations/05-1234-reduce-value-set-of-analyst-consensus/migration.sql b/packages/backend/prisma/migrations/05-1234-reduce-value-set-of-analyst-consensus/migration.sql new file mode 100644 index 000000000..3dd2d3159 --- /dev/null +++ b/packages/backend/prisma/migrations/05-1234-reduce-value-set-of-analyst-consensus/migration.sql @@ -0,0 +1,7 @@ +-- CreateEnum +CREATE TYPE "AnalystRating" AS ENUM ('Sell', 'Underperform', 'Hold', 'Outperform', 'Buy'); + +-- AlterTable +ALTER TABLE "Stock" ADD COLUMN "analystRatings" JSONB, +DROP COLUMN "analystConsensus", +ADD COLUMN "analystConsensus" "AnalystRating"; diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index 5dc6387a9..3e646046f 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -1,10 +1,15 @@ generator client { - provider = "prisma-client-js" - output = "./client" - binaryTargets = ["native", "linux-musl-openssl-3.0.x", "linux-musl-arm64-openssl-3.0.x"] + provider = "prisma-client-js" + output = "./client" + binaryTargets = ["native", "linux-musl-openssl-3.0.x", "linux-musl-arm64-openssl-3.0.x"] previewFeatures = ["omitApi"] } +generator json { + provider = "prisma-json-types-generator" + namespace = "PrismaJSON" +} + datasource db { provider = "postgresql" url = env("DATABASE_URL") @@ -35,7 +40,9 @@ model Stock { positionIn52w Float? marketScreenerID String? @unique @db.VarChar(255) marketScreenerLastFetch DateTime? @db.Timestamp(6) - analystConsensus Float? + analystConsensus AnalystRating? + /// [AnalystRatings] + analystRatings Json? analystCount Int? @db.SmallInt analystTargetPrice Float? analystTargetPricePercentageToLastClose Float? @@ -689,3 +696,11 @@ enum MSCIESGRating { B CCC } + +enum AnalystRating { + Sell + Underperform + Hold + Outperform + Buy +} diff --git a/packages/backend/src/controllers/FetchController.ts b/packages/backend/src/controllers/FetchController.ts index ce58a351d..01fda6162 100644 --- a/packages/backend/src/controllers/FetchController.ts +++ b/packages/backend/src/controllers/FetchController.ts @@ -392,7 +392,7 @@ class FetchController extends Singleton { successfulCount += 1; } catch (e) { if (req.query.ticker) - // If this request was for a single stock, we shut down the driver and throw an error. + // If this request was for a single stock, we throw an error. throw new APIError( (e as APIError).status ?? 500, `Stock ${stock.ticker}: Unable to extract Sustainalytics ESG Risk`, diff --git a/packages/backend/src/controllers/StatusController.live.test.ts b/packages/backend/src/controllers/StatusController.live.test.ts index 1b68d1f2d..fda225f24 100644 --- a/packages/backend/src/controllers/StatusController.live.test.ts +++ b/packages/backend/src/controllers/StatusController.live.test.ts @@ -19,7 +19,6 @@ tests.push({ expect(res.body.services).not.toHaveProperty("Redis"); // Mocked to be not ready during live tests: - // expect(res.body.services).toHaveProperty("Selenium"); expect(res.body.services).toHaveProperty("Signal"); expect(res.body.services.Signal).toBe("Signal is not ready"); }, diff --git a/packages/backend/src/controllers/StatusController.ts b/packages/backend/src/controllers/StatusController.ts index fdf7416b5..5e9b97486 100644 --- a/packages/backend/src/controllers/StatusController.ts +++ b/packages/backend/src/controllers/StatusController.ts @@ -9,7 +9,6 @@ import { redisIsReady } from "../redis/redis"; import { signalIsReadyOrUnused } from "../signal/signalBase"; import Endpoint from "../utils/Endpoint"; import Singleton from "../utils/Singleton"; -// import { seleniumIsReady } from "../utils/webdriver"; /** * This class is responsible for providing a status report of the backend API and the services it depends on. @@ -40,7 +39,6 @@ class StatusController extends Singleton { // The order is important here and must match the order in `serviceArray`. prismaIsReady(), redisIsReady(), - // seleniumIsReady(), signalIsReadyOrUnused(), ]) ).forEach((result, index) => { diff --git a/packages/backend/src/controllers/StocksController.live.test.ts b/packages/backend/src/controllers/StocksController.live.test.ts index 38a422108..6065b52c3 100644 --- a/packages/backend/src/controllers/StocksController.live.test.ts +++ b/packages/backend/src/controllers/StocksController.live.test.ts @@ -10,6 +10,7 @@ import { styleArray, watchlistsEndpointPath, portfoliosEndpointPath, + analystRatingArray, } from "@rating-tracker/commons"; import type { Response } from "supertest"; @@ -185,11 +186,11 @@ tests.push({ expectStocksToBePresent( await supertest .get( - `${baseURL}${stocksEndpointPath}?analystConsensusMin=7&analystConsensusMax=8.5` + - "&analystCountMin=20&analystCountMax=40&sortBy=name", + `${baseURL}${stocksEndpointPath}?analystConsensusMin=Outperform&analystConsensusMax=Outperform` + + "&analystCountMin=25&analystCountMax=40&sortBy=name", ) .set("Cookie", ["authToken=exampleSessionID"]), - ["Iberdrola", "MercadoLibre", "Novo Nordisk", "Ørsted A/S"], + ["Novo Nordisk"], ); }, }); @@ -285,12 +286,13 @@ tests.push({ .get(`${baseURL}${stocksEndpointPath}?totalScoreMin=30&totalScoreMax=60&sortBy=name`) .set("Cookie", ["authToken=exampleSessionID"]); expect(res.status).toBe(200); - expect(res.body.count).toBe(4); - expect(res.body.stocks).toHaveLength(4); + expect(res.body.count).toBe(5); + expect(res.body.stocks).toHaveLength(5); expect(res.body.stocks[0].name).toMatch("Allianz"); expect(res.body.stocks[1].name).toMatch("Danone"); expect(res.body.stocks[2].name).toMatch("Kion"); - expect(res.body.stocks[3].name).toMatch("Ørsted"); + expect(res.body.stocks[3].name).toMatch("Newmont"); + expect(res.body.stocks[4].name).toMatch("Ørsted"); }, }); @@ -394,6 +396,9 @@ tests.push({ case "style": sortCriterionArray = styleArray; break; + case "analystConsensus": + sortCriterionArray = analystRatingArray; + break; case "msciESGRating": sortCriterionArray = msciESGRatingArray; break; diff --git a/packages/backend/src/controllers/StocksController.ts b/packages/backend/src/controllers/StocksController.ts index c3feea1ef..5aaa5bb2d 100644 --- a/packages/backend/src/controllers/StocksController.ts +++ b/packages/backend/src/controllers/StocksController.ts @@ -14,6 +14,8 @@ import { stockLogoEndpointSuffix, WRITE_STOCKS_ACCESS, DUMMY_SVG, + isAnalystRating, + analystRatingArray, } from "@rating-tracker/commons"; import type { Request, RequestHandler, Response } from "express"; @@ -250,14 +252,25 @@ class StocksController extends Singleton { filters.push({ morningstarFairValuePercentageToLastClose: { lte: morningstarFairValueDiffMax } }); } - if (req.query.analystConsensusMin !== undefined) { - const analystConsensusMin = Number(req.query.analystConsensusMin); - if (!Number.isNaN(analystConsensusMin)) filters.push({ analystConsensus: { gte: analystConsensusMin } }); + let analystConsensusArray = [...analystRatingArray]; + if (req.query.analystConsensusMin !== undefined && typeof req.query.analystConsensusMin === "string") { + const analystConsensusMin = req.query.analystConsensusMin; + if (isAnalystRating(analystConsensusMin)) + analystConsensusArray = analystConsensusArray.filter( + (analystRating) => + analystRatingArray.indexOf(analystRating) >= analystRatingArray.indexOf(analystConsensusMin), + ); } - if (req.query.analystConsensusMax !== undefined) { - const analystConsensusMax = Number(req.query.analystConsensusMax); - if (!Number.isNaN(analystConsensusMax)) filters.push({ analystConsensus: { lte: analystConsensusMax } }); + if (req.query.analystConsensusMax !== undefined && typeof req.query.analystConsensusMax === "string") { + const analystConsensusMax = req.query.analystConsensusMax; + if (isAnalystRating(analystConsensusMax)) + analystConsensusArray = analystConsensusArray.filter( + (analystRating) => + analystRatingArray.indexOf(analystRating) <= analystRatingArray.indexOf(analystConsensusMax), + ); } + if (analystRatingArray.some((msciESGRating) => !analystConsensusArray.includes(msciESGRating))) + filters.push({ analystConsensus: { in: analystConsensusArray } }); if (req.query.analystCountMin !== undefined) { const analystCountMin = Number(req.query.analystCountMin); diff --git a/packages/backend/src/db/tables/stockTable.live.test.ts b/packages/backend/src/db/tables/stockTable.live.test.ts index 9ef2c1847..ffb68b487 100644 --- a/packages/backend/src/db/tables/stockTable.live.test.ts +++ b/packages/backend/src/db/tables/stockTable.live.test.ts @@ -41,7 +41,7 @@ tests.push({ high52w: 145.67, marketScreenerID: "NEW-STOCK-238712974", marketScreenerLastFetch: new Date(), - analystConsensus: 2.5, + analystConsensus: "Hold", analystCount: 5, analystTargetPrice: 150, msciID: "IID000001238712974", @@ -66,7 +66,7 @@ tests.push({ const slightlyWorseValues: Partial> = { starRating: 3, morningstarFairValue: 150, - analystConsensus: 2.3, + analystConsensus: "Underperform", analystTargetPrice: 145, msciESGRating: "CCC", msciTemperature: 2.2, diff --git a/packages/backend/src/db/tables/stockTable.ts b/packages/backend/src/db/tables/stockTable.ts index 707789247..b9969c95c 100644 --- a/packages/backend/src/db/tables/stockTable.ts +++ b/packages/backend/src/db/tables/stockTable.ts @@ -1,5 +1,5 @@ -import type { Stock, MSCIESGRating, OmitDynamicAttributesStock } from "@rating-tracker/commons"; -import { msciESGRatingArray } from "@rating-tracker/commons"; +import type { Stock, MSCIESGRating, OmitDynamicAttributesStock, AnalystRating } from "@rating-tracker/commons"; +import { analystRatingArray, msciESGRatingArray } from "@rating-tracker/commons"; import type { Prisma } from "../../../prisma/client"; import { addDynamicAttributesToStockData, dynamicStockAttributes } from "../../models/dynamicStockAttributes"; @@ -145,6 +145,17 @@ export const updateStock = async (ticker: string, newValues: Partial, for case "sustainalyticsESGRisk": let signalPrefix = ""; switch (k) { + case "analystConsensus": + signalPrefix = + // larger index in array [Sell, ..., Buy] is better + (newValues.analystConsensus ? analystRatingArray.indexOf(newValues.analystConsensus) : -1) > + (stock.analystConsensus + ? analystRatingArray.indexOf(stock.analystConsensus as AnalystRating) + : /* c8 ignore next */ // This never occurs with our test dataset + -1) + ? SIGNAL_PREFIX_BETTER + : SIGNAL_PREFIX_WORSE; + break; case "msciESGRating": signalPrefix = // smaller index in array [AAA, ..., CCC] is better diff --git a/packages/backend/src/db/types.d.ts b/packages/backend/src/db/types.d.ts new file mode 100644 index 000000000..b079ef77e --- /dev/null +++ b/packages/backend/src/db/types.d.ts @@ -0,0 +1,7 @@ +import type { AnalystRating } from "@rating-tracker/commons"; + +declare global { + namespace PrismaJSON { + type AnalystRatings = Record; + } +} diff --git a/packages/backend/src/fetchers/fetchHelper.ts b/packages/backend/src/fetchers/fetchHelper.ts index 461f27bd4..683e9bc4d 100644 --- a/packages/backend/src/fetchers/fetchHelper.ts +++ b/packages/backend/src/fetchers/fetchHelper.ts @@ -1,23 +1,14 @@ -import type { - FetchRequestOptions, - HTMLDataProvider, - IndividualDataProvider, - JSONDataProvider, - Stock, -} from "@rating-tracker/commons"; +import type { FetchRequestOptions, IndividualDataProvider, Stock } from "@rating-tracker/commons"; import { FetchError, dataProviderID, dataProviderLastFetch, dataProviderName, dataProviderTTL, - isHTMLDataProvider, - isJSONDataProvider, resourcesEndpointPath, } from "@rating-tracker/commons"; import { DOMParser } from "@xmldom/xmldom"; import type { Request, Response } from "express"; -// import { WebDriver } from "selenium-webdriver"; import { DateTime } from "luxon"; import { Agent } from "undici"; @@ -29,7 +20,6 @@ import APIError from "../utils/APIError"; import DataProviderError from "../utils/DataProviderError"; import { performFetchRequest } from "../utils/fetchRequest"; import logger from "../utils/logger"; -// import { getDriver, quitDriver, takeScreenshot } from "../utils/webdriver"; import lsegFetcher from "./lsegFetcher"; import marketScreenerFetcher from "./marketScreenerFetcher"; @@ -47,121 +37,80 @@ export type FetcherWorkspace = { failed: T[]; }; -// type Fetcher = HTMLFetcher | JSONFetcher; // | SeleniumFetcher; - -export type JSONFetcher = (req: Request, stocks: FetcherWorkspace, stock: Stock, json: Object) => Promise; - -export type HTMLFetcher = ( - req: Request, - stocks: FetcherWorkspace, - stock: Stock, - document: ParseResult, -) => Promise; - -// export type SeleniumFetcher = ( -// req: Request, -// stocks: FetcherWorkspace, -// stock: Stock, -// driver: WebDriver, -// ) => Promise; - -/** - * An object holding the result of a `DOMParser` and/or the error that occurred during parsing. - */ -export type ParseResult = { - /** - * The parsed HTML document. - */ - document?: Document; - /** - * The error that occurred during parsing. - */ - error?: Error; -}; - /** - * An object holding the source of a fetcher. Only one of the properties is set. + * A function fetching information from a data provider for a single stock. + * @param req Request object + * @param stock The stock to fetch information for + * @returns a Promise that resolves after the fetch is completed successfully + * @throws a {@link DataProviderError} in case of a severe error */ -type FetcherSource = { - /** - * The JSON object fetched by a {@link JSONFetcher}. - */ - json?: Object; - /** - * The HTML document fetched by a {@link HTMLFetcher}. - */ - document?: Document; - // /** - // * The WebDriver instance used by a {@link SeleniumFetcher}. - // */ - // driver?: WebDriver; -}; +export type Fetcher = (req: Request, stock: Stock) => Promise; /** * Captures the fetched resource of a fetcher in case of an error and stores it in Redis. Based on the fetcher type, the * resource can either be a {@link Document} or a {@link Object}. * @param stock the affected stock * @param dataProvider the name of the data provider - * @param source the source of the fetcher + * @param e The error that occurred during fetching, holding fetched resources if available * @returns A string holding a general informational message and a URL to the screenshot */ export const captureDataProviderError = async ( stock: Stock, dataProvider: IndividualDataProvider, - source: FetcherSource, + e: DataProviderError, ): Promise => { - let resourceID: string = ""; - switch (true) { - case isJSONDataProvider(dataProvider): - resourceID = `error-${dataProvider}-${stock.ticker}-${new Date().getTime().toString()}.json`; - if ( - // Only create the resource if we actually have a JSON object - !source?.json || - // Create the JSON resource in Redis - !(await createResource( + const resourceIDs: string[] = []; + + for await (const dataSource of e.dataSources ?? []) { + if (!dataSource) continue; + switch (true) { + case "documentElement" in dataSource && dataSource.documentElement?.toString().length > 0: { + // HTML documents + const resourceID = `error-${dataProvider}-${stock.ticker}-${new Date().getTime().toString(16)}.html`; + const success = await createResource( { url: resourceID, fetchDate: new Date(), - content: JSON.stringify(source.json), + content: dataSource.documentElement.toString(), }, - 60 * 60 * 48, // We only store the resource for 48 hours. - )) - ) - resourceID = ""; // If unsuccessful, clear the resource ID - break; - case isHTMLDataProvider(dataProvider): - resourceID = `error-${dataProvider}-${stock.ticker}-${new Date().getTime().toString()}.html`; - if ( - // Only create the resource if we actually have a valid HTML document - !source?.document?.documentElement?.toString() || - // Create the HTML resource in Redis - !(await createResource( + 60 * 60 * 48, // We only store the screenshot for 48 hours. + ); + if (success) resourceIDs.push(resourceID); + break; + } + default: { + // All other objects (usually parsed from JSON) + const resourceID = `error-${dataProvider}-${stock.ticker}-${new Date().getTime().toString(16)}.json`; + const success = await createResource( { url: resourceID, fetchDate: new Date(), - content: source.document.documentElement.toString(), + content: JSON.stringify(dataSource), }, - 60 * 60 * 48, // We only store the screenshot for 48 hours. - )) - ) - resourceID = ""; // If unsuccessful, clear the resource ID - break; - // case isSeleniumDataProvider(dataProvider): - // resourceID = await takeScreenshot(source.driver, stock, dataProvider); - // break; + 60 * 60 * 48, // We only store the resource for 48 hours. + ); + if (success) resourceIDs.push(resourceID); + break; + } + } } - return resourceID - ? `For additional information, see https://${process.env.SUBDOMAIN ? process.env.SUBDOMAIN + "." : ""}${ - process.env.DOMAIN - // Ensure the user is logged in before accessing the resource API endpoint. - }/login?redirect=${encodeURIComponent(`/api${resourcesEndpointPath}/${resourceID}`)}.` + return resourceIDs.length + ? `For additional information, see ${resourceIDs + .map( + (resourceID) => + `https://${process.env.SUBDOMAIN ? process.env.SUBDOMAIN + "." : ""}${ + process.env.DOMAIN + // Ensure the user is logged in before accessing the resource API endpoint. + }/login?redirect=${encodeURIComponent(`/api${resourcesEndpointPath}/${resourceID}`)}`, + ) + .join(", ")}.` : "No additional information available."; }; /** * A record of functions that extract data from a data provider. */ -const dataProviderFetchers: Record & Record = { +const dataProviderFetchers: Record = { morningstar: morningstarFetcher, marketScreener: marketScreenerFetcher, msci: msciFetcher, @@ -258,21 +207,8 @@ export const fetchFromDataProvider = async ( const rejectedResult = ( await Promise.allSettled( [...Array(determineConcurrency(req))].map(async () => { - let parseResult: ParseResult; - let json: Object; - - // let driver: WebDriver; - // let sessionID: string; - // if (seleniumDataProviders.includes(dataProvider)) { - // // Acquire a new session - // driver = await getDriver(true); - // sessionID = (await driver.getSession()).getId(); - // } - // Work while stocks are in the queue while (stocks.queued.length) { - parseResult = { document: undefined, error: undefined }; - json = {}; // Get the first stock in the queue const stock = stocks.queued.shift(); // If the queue got empty in the meantime, we end. @@ -298,34 +234,26 @@ export const fetchFromDataProvider = async ( } try { - if (isHTMLDataProvider(dataProvider)) { - await dataProviderFetchers[dataProvider](req, stocks, stock, parseResult); - } else if (isJSONDataProvider(dataProvider)) { - await dataProviderFetchers[dataProvider](req, stocks, stock, json); - // } else if (isSeleniumDataProvider(dataProvider)) { - // if (!(await (dataProviderFetchers[dataProvider] as SeleniumFetcher)(req, stocks, stock, driver))) - // break; - } + await dataProviderFetchers[dataProvider](req, stock); + stocks.successful.push(await readStock(stock.ticker)); } catch (e) { - stocks.failed.push(stock); + stocks.failed.push(await readStock(stock.ticker)); if (req.query.ticker) { - // // If the request was for a single stock, we shut down the driver and throw an error. - // driver && (await quitDriver(driver, sessionID)); throw new APIError( 502, - `Stock ${stock.ticker}: Unable to fetch ${dataProviderName[dataProvider]} data`, + `Stock ${stock.ticker}: Error while fetching ${dataProviderName[dataProvider]} data`, e, ); } logger.error( { prefix: "fetch", err: e }, - `Stock ${stock.ticker}: Unable to fetch ${dataProviderName[dataProvider]} data`, + `Stock ${stock.ticker}: Error while fetching ${dataProviderName[dataProvider]} data`, ); await signal.sendMessage( SIGNAL_PREFIX_ERROR + - `Stock ${stock.ticker}: Unable to fetch ${dataProviderName[dataProvider]} data: ${ - String(e.message).split(/[\n:{]/)[0] - }\n${await captureDataProviderError(stock, dataProvider, { json, document: parseResult?.document })}`, + `Stock ${stock.ticker}: Error while fetching ${dataProviderName[dataProvider]} data: ` + + e.message + + (e instanceof DataProviderError ? "\n" + (await captureDataProviderError(stock, dataProvider, e)) : ""), "fetchError", ); } @@ -353,8 +281,6 @@ export const fetchFromDataProvider = async ( break; } } - // // The queue is now empty, we end the session. - // driver && (await quitDriver(driver, sessionID)); }), ) ).find((result) => result.status === "rejected") as PromiseRejectedResult | undefined; @@ -403,18 +329,37 @@ const patchHTML = (html: string): string => { * @param config The fetch request options * @param stock The affected stock * @param dataProvider The name of the data provider to fetch from - * @param parseResult The variable the result of the parsing will be assigned to. This may either be a - * `Document` or a {@link FetchError}. + * @returns The parsed HTML document */ export const getAndParseHTML = async ( url: string, config: FetchRequestOptions, stock: Stock, dataProvider: IndividualDataProvider, - parseResult: ParseResult, -): Promise => { - let error: FetchError = undefined; - parseResult.document = new DOMParser({ +): Promise => { + let error: Error; + // Fetch the response + const responseData = await performFetchRequest(url, { + ...config, + dispatcher: new Agent({ allowH2: true }), + headers: { + ...config?.headers, + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/124.0.0.0 Safari/537.36", + }, + }) + .then((res) => patchHTML(res.data)) + .catch((e) => { + // If the status code indicates a non-successful fetch, we try to parse the response nonetheless + if (e instanceof FetchError) { + error = e; + return patchHTML(e.response.data); + } + throw e; + }); + const parser = new DOMParser({ errorHandler: { warning: () => undefined, error: () => undefined, @@ -425,30 +370,12 @@ export const getAndParseHTML = async ( ); }, }, - }).parseFromString( - await performFetchRequest(url, { - ...config, - dispatcher: new Agent({ allowH2: true }), - headers: { - ...config?.headers, - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + - "AppleWebKit/537.36 (KHTML, like Gecko) " + - "Chrome/124.0.0.0 Safari/537.36", - }, - }) - .then((res) => patchHTML(res.data)) - .catch((e) => { - if (e instanceof FetchError) { - error = e; - return patchHTML(e.response.data); - } - throw e; - }), - "text/html", - ); - if (error) { - parseResult.error = error; - throw error; - } + }); + const document = parser.parseFromString(responseData, "text/html"); + if (error) + throw new DataProviderError(`Error while fetching HTML page: ${error.message}`, { + cause: error, + dataSources: [document], + }); + return document; }; diff --git a/packages/backend/src/fetchers/lsegFetcher.ts b/packages/backend/src/fetchers/lsegFetcher.ts index c95968f06..d5179a249 100644 --- a/packages/backend/src/fetchers/lsegFetcher.ts +++ b/packages/backend/src/fetchers/lsegFetcher.ts @@ -3,55 +3,40 @@ import assert from "node:assert"; import type { Stock } from "@rating-tracker/commons"; import type { Request } from "express"; -import { readStock, updateStock } from "../db/tables/stockTable"; -import * as signal from "../signal/signal"; -import { SIGNAL_PREFIX_ERROR } from "../signal/signal"; -import APIError from "../utils/APIError"; +import { updateStock } from "../db/tables/stockTable"; import DataProviderError from "../utils/DataProviderError"; import { performFetchRequest } from "../utils/fetchRequest"; import logger from "../utils/logger"; -import type { JSONFetcher, FetcherWorkspace } from "./fetchHelper"; -import { captureDataProviderError } from "./fetchHelper"; +import type { Fetcher } from "./fetchHelper"; /** * Fetches data from LSEG Data & Analytics. * @param req Request object - * @param stocks An object with the stocks to fetch and the stocks already fetched (successful or with errors) * @param stock The stock to extract data for - * @param json The fetched and parsed JSON * @returns A {@link Promise} that resolves when the fetch is complete - * @throws an {@link APIError} in case of a severe error + * @throws a {@link DataProviderError} in case of a severe error */ -const lsegFetcher: JSONFetcher = async ( - req: Request, - stocks: FetcherWorkspace, - stock: Stock, - json: Object, -): Promise => { +const lsegFetcher: Fetcher = async (req: Request, stock: Stock): Promise => { let lsegESGScore: number = req.query.clear ? null : undefined; let lsegEmissions: number = req.query.clear ? null : undefined; - const url = `https://www.lseg.com/bin/esg/esgsearchresult?ricCode=${stock.ric}`; + const json = (await performFetchRequest(`https://www.lseg.com/bin/esg/esgsearchresult?ricCode=${stock.ric}`)).data; - Object.assign(json, (await performFetchRequest(url)).data); - - if (Object.keys(json).length === 0 && json.constructor === Object) { - throw new APIError(502, "No LSEG information available."); - } + if (Object.keys(json).length === 0 && json.constructor === Object) + throw new DataProviderError("No LSEG information available.", { dataSources: [json] }); if ( "status" in json && typeof json.status === "object" && "limitExceeded" in json.status && json.status.limitExceeded === true - ) { + ) // If the limit has been exceeded, we stop fetching data and throw an error. - throw new APIError(429, "Limit exceeded."); - } + throw new DataProviderError("Limit exceeded.", { dataSources: [json] }); - // Prepare an error message header containing the stock name and ticker. - let errorMessage = `Error while fetching LSEG information for stock ${stock.ticker}:`; + // Prepare an error message. + let errorMessage = ""; try { assert( @@ -105,18 +90,8 @@ const lsegFetcher: JSONFetcher = async ( lsegESGScore, lsegEmissions, }); - if (errorMessage.includes("\n")) { - // An error occurred if and only if the error message contains a newline character. - errorMessage += `\n${await captureDataProviderError(stock, "lseg", { json })}`; - // If this request was for a single stock, we throw an error instead of sending a message, so that the error - // message will be part of the response. - if (req.query.ticker) throw new DataProviderError(errorMessage); - - await signal.sendMessage(SIGNAL_PREFIX_ERROR + errorMessage, "fetchError"); - stocks.failed.push(await readStock(stock.ticker)); - } else { - stocks.successful.push(await readStock(stock.ticker)); - } + // An error occurred if and only if the error message contains a newline character. + if (errorMessage.includes("\n")) throw new DataProviderError(errorMessage, { dataSources: [json] }); }; export default lsegFetcher; diff --git a/packages/backend/src/fetchers/marketScreenerFetcher.ts b/packages/backend/src/fetchers/marketScreenerFetcher.ts index 3b80ed9bb..7909a7d3a 100644 --- a/packages/backend/src/fetchers/marketScreenerFetcher.ts +++ b/packages/backend/src/fetchers/marketScreenerFetcher.ts @@ -1,17 +1,17 @@ import assert from "node:assert"; -import type { Stock } from "@rating-tracker/commons"; +import { RecordMath, analystRatingArray, type Stock } from "@rating-tracker/commons"; import type { Request } from "express"; import xpath from "xpath-ts2"; -import { readStock, updateStock } from "../db/tables/stockTable"; -import * as signal from "../signal/signal"; -import { SIGNAL_PREFIX_ERROR } from "../signal/signal"; +import type { AnalystRating } from "../../prisma/client"; +import { updateStock } from "../db/tables/stockTable"; import DataProviderError from "../utils/DataProviderError"; +import { performFetchRequest } from "../utils/fetchRequest"; import logger from "../utils/logger"; -import type { HTMLFetcher, FetcherWorkspace, ParseResult } from "./fetchHelper"; -import { captureDataProviderError, getAndParseHTML } from "./fetchHelper"; +import type { Fetcher } from "./fetchHelper"; +import { getAndParseHTML } from "./fetchHelper"; const XPATH_ANALYST_COUNT = xpath.parse( "//div[@class='card-content']/div/div/div[contains(text(), 'Number of Analysts')]/following-sibling::div", @@ -23,66 +23,39 @@ const XPATH_SPREAD_AVERAGE_TARGET = xpath.parse( /** * Fetches data from MarketScreener. * @param req Request object - * @param stocks An object with the stocks to fetch and the stocks already fetched (successful or with errors) * @param stock The stock to extract data for - * @param parseResult The fetched and parsed HTML document and/or the error that occurred during parsing * @returns A {@link Promise} that resolves when the fetch is complete - * @throws an {@link APIError} in case of a severe error + * @throws a {@link DataProviderError} in case of a severe error */ -const marketScreenerFetcher: HTMLFetcher = async ( - req: Request, - stocks: FetcherWorkspace, - stock: Stock, - parseResult: ParseResult, -): Promise => { - let analystConsensus: number = req.query.clear ? null : undefined; +const marketScreenerFetcher: Fetcher = async (req: Request, stock: Stock): Promise => { + let analystConsensus: AnalystRating = req.query.clear ? null : undefined; + let analystRatings: Record = req.query.clear ? null : undefined; let analystCount: number = req.query.clear ? null : undefined; let analystTargetPrice: number = req.query.clear ? null : undefined; - await getAndParseHTML( - `https://www.marketscreener.com/quote/stock/${stock.marketScreenerID}/`, + const codeZBMatches = stock.marketScreenerID.match(/-([0-9]+)$/); + assert(codeZBMatches && !Number.isNaN(+codeZBMatches[1]), "Unable to extract ZB code from MarketScreener ID."); + const codeZB = +codeZBMatches[1]; + + const document = await getAndParseHTML( + `https://www.marketscreener.com/quote/stock/${stock.marketScreenerID}/consensus`, undefined, stock, "marketScreener", - parseResult, ); + const json = (await performFetchRequest(`https://www.marketscreener.com/async/graph/af/cd?codeZB=${codeZB}&h=0`)) + .data; - const { document } = parseResult; - - // Prepare an error message header containing the stock name and ticker. - let errorMessage = `Error while fetching MarketScreener data for stock ${stock.ticker}:`; + // Prepare an error message. + let errorMessage = ""; try { - // Check for the presence of the div containing all relevant analyst-related information. - const consensusTableDiv = document.getElementById("consensusDetail"); + // Check for the presence of the div and JSON properties containing all relevant analyst-related information. + const consensusTableDiv = document.getElementById("consensusdetail"); assert(consensusTableDiv, "Unable to find Analyst Consensus div."); - - try { - const analystConsensusNode = document.getElementsByClassName("consensus-gauge")[0]; - assert(analystConsensusNode, "Unable to find Analyst Consensus node."); - const analystConsensusMatches = analystConsensusNode - .getAttribute("title") // Example: " Rate: 9.1 / 10" - .match(/(\d+(\.\d+)?)/g); // Extract the first decimal number from the title. - if ( - analystConsensusMatches === null || - analystConsensusMatches.length < 1 || - Number.isNaN(+analystConsensusMatches[0]) - ) - throw new TypeError("Extracted analyst consensus is no valid number."); - analystConsensus = +analystConsensusMatches[0]; - } catch (e) { - logger.warn({ prefix: "fetch" }, `Stock ${stock.ticker}: Unable to extract Analyst Consensus: ${e}`); - if (stock.analystConsensus !== null) { - // If an analyst consensus is already stored in the database, but we cannot extract it from the page, we - // log this as an error and send a message. - logger.error( - { prefix: "fetch", err: e }, - `Stock ${stock.ticker}: Extraction of analyst consensus failed unexpectedly. ` + - "This incident will be reported.", - ); - errorMessage += `\n\tUnable to extract Analyst Consensus: ${String(e.message).split(/[\n:{]/)[0]}`; - } - } + assert(json.constructor === Object, "Unable to find Analyst Ratings."); + assert(json.error === false, "The server reported an error when fetching Analyst Ratings."); + assert("data" in json && Array.isArray(json.data) && Array.isArray(json.data[0]), "No Analyst Ratings available."); try { const analystCountNode = XPATH_ANALYST_COUNT.select1({ node: consensusTableDiv, isHtml: true }); @@ -104,7 +77,10 @@ const marketScreenerFetcher: HTMLFetcher = async ( try { // We need the last close price to calculate the analyst target price. - if (!stock.lastClose) throw new DataProviderError("No Last Close price available to compare spread against."); + if (!stock.lastClose) + throw new DataProviderError("No Last Close price available to compare spread against.", { + dataSources: [document, json], + }); const analystTargetPriceNode = XPATH_SPREAD_AVERAGE_TARGET.select1({ node: consensusTableDiv, isHtml: true }); assert(analystTargetPriceNode, "Unable to find Analyst Target Price node."); @@ -139,9 +115,40 @@ const marketScreenerFetcher: HTMLFetcher = async ( errorMessage += `\n\tUnable to extract Analyst Target Price: ${String(e.message).split(/[\n:{]/)[0]}`; } } + + try { + analystRatings = Object.fromEntries( + analystRatingArray.map((analystRating) => { + const ratingObject = (json.data[0] as { name: string; y: number }[]).find( + (obj) => obj.name === analystRating.toUpperCase(), + ); + if (!ratingObject || !("y" in ratingObject)) + throw new TypeError(`Analyst Rating “${analystRating}” not found in Analyst Rating response.`); + return [analystRating, ratingObject.y]; + }), + ) as Record; + analystConsensus = RecordMath.mean(analystRatings); + } catch (e) { + logger.warn({ prefix: "fetch" }, `Stock ${stock.ticker}: Unable to extract Analyst Ratings: ${e}`); + if (stock.analystConsensus !== null || stock.analystRatings !== null) { + // If an analyst consensus or analyst ratings are already stored in the database, but we cannot extract them + // from the page, we log this as an error and send a message. + logger.error( + { prefix: "fetch", err: e }, + `Stock ${stock.ticker}: Extraction of analyst ratings failed unexpectedly. ` + + "This incident will be reported.", + ); + errorMessage += `\n\tUnable to extract Analyst Ratings: ${String(e.message).split(/[\n:{]/)[0]}`; + } + } } catch (e) { logger.warn({ prefix: "fetch" }, `Stock ${stock.ticker}: \n\tUnable to extract Analyst Information: ${e}`); - if (stock.analystConsensus !== null || stock.analystCount !== null || stock.analystTargetPrice !== null) { + if ( + stock.analystConsensus !== null || + stock.analystCount !== null || + stock.analystTargetPrice !== null || + stock.analystRatings !== null + ) { // If any of the analyst-related information is already stored in the database, but we cannot extract it // from the page, we log this as an error and send a message. logger.error( @@ -156,23 +163,13 @@ const marketScreenerFetcher: HTMLFetcher = async ( // Update the stock in the database. await updateStock(stock.ticker, { marketScreenerLastFetch: errorMessage.includes("\n") ? undefined : new Date(), - analystConsensus, analystCount, analystTargetPrice, + analystConsensus, + analystRatings, }); - if (errorMessage.includes("\n")) { - // An error occurred if and only if the error message contains a newline character. - // We capture the resource and send a message. - errorMessage += `\n${await captureDataProviderError(stock, "marketScreener", { document })}`; - // If this request was for a single stock, we throw an error instead of sending a message, so that the error - // message will be part of the response. - if (req.query.ticker) throw new DataProviderError(errorMessage); - - await signal.sendMessage(SIGNAL_PREFIX_ERROR + errorMessage, "fetchError"); - stocks.failed.push(await readStock(stock.ticker)); - } else { - stocks.successful.push(await readStock(stock.ticker)); - } + // An error occurred if and only if the error message contains a newline character. + if (errorMessage.includes("\n")) throw new DataProviderError(errorMessage, { dataSources: [document, json] }); }; export default marketScreenerFetcher; diff --git a/packages/backend/src/fetchers/morningstarFetcher.ts b/packages/backend/src/fetchers/morningstarFetcher.ts index b9fd29602..50649a88a 100644 --- a/packages/backend/src/fetchers/morningstarFetcher.ts +++ b/packages/backend/src/fetchers/morningstarFetcher.ts @@ -5,14 +5,12 @@ import { isIndustry, isSize, isStyle, isCurrency } from "@rating-tracker/commons import type { Request } from "express"; import xpath from "xpath-ts2"; -import { readStock, updateStock } from "../db/tables/stockTable"; -import * as signal from "../signal/signal"; -import { SIGNAL_PREFIX_ERROR } from "../signal/signal"; +import { updateStock } from "../db/tables/stockTable"; import DataProviderError from "../utils/DataProviderError"; import logger from "../utils/logger"; -import type { HTMLFetcher, FetcherWorkspace, ParseResult } from "./fetchHelper"; -import { captureDataProviderError, getAndParseHTML } from "./fetchHelper"; +import type { Fetcher } from "./fetchHelper"; +import { getAndParseHTML } from "./fetchHelper"; const XPATH_CONTENT = xpath.parse( "//*[@id='SnapshotBodyContent'][count(.//*[@id='IntradayPriceSummary']) > 0][count(.//*[@id='CompanyProfile']) > 0]", @@ -22,23 +20,14 @@ const XPATH_SIZE_STYLE = xpath.parse("//*/div[@id='CompanyProfile']/div/h3[conta const XPATH_DESCRIPTION = xpath.parse("//*/div[@id='CompanyProfile']/div[1][not(.//h3)]"); const XPATH_FAIR_VALUE = xpath.parse("//*/div[@id='FairValueEstimate']/span/datapoint"); -const MAX_RETRIES = 10; - /** * Fetches data from Morningstar Italy. * @param req Request object - * @param stocks An object with the stocks to fetch and the stocks already fetched (successful or with errors) * @param stock The stock to extract data for - * @param parseResult The fetched and parsed HTML document and/or the error that occurred during parsing * @returns A {@link Promise} that resolves when the fetch is complete - * @throws an {@link APIError} in case of a severe error + * @throws a {@link DataProviderError} in case of a severe error */ -const morningstarFetcher: HTMLFetcher = async ( - req: Request, - stocks: FetcherWorkspace, - stock: Stock, - parseResult: ParseResult, -): Promise => { +const morningstarFetcher: Fetcher = async (req: Request, stock: Stock): Promise => { let industry: Industry = req.query.clear ? null : undefined; let size: Size = req.query.clear ? null : undefined; let style: Style = req.query.clear ? null : undefined; @@ -53,39 +42,24 @@ const morningstarFetcher: HTMLFetcher = async ( let high52w: number = req.query.clear ? null : undefined; let description: string = req.query.clear ? null : undefined; - const url = + const document = await getAndParseHTML( `https://tools.morningstar.it/it/stockreport/default.aspx?Site=us&id=${stock.morningstarID}` + - `&LanguageId=en-US&SecurityToken=${stock.morningstarID}]3]0]E0WWE$$ALL`; - await getAndParseHTML(url, undefined, stock, "morningstar", parseResult); - - let attempts = 1; - while (attempts > 0) { - try { - // Check for the presence of the div containing all relevant information. - const contentDiv = XPATH_CONTENT.select1({ node: parseResult?.document, isHtml: true }); - assert(contentDiv, "Unable to find content div."); - attempts = 0; // Page load succeeded. - } catch (e) { - // We probably stumbled upon a temporary 502 Bad Gateway or 429 Too Many Requests error, which persists for a - // few minutes. We periodically retry to fetch the page. - - // Too many attempts failed, we throw the error occurred during parsing, or the last assertion error. - if (++attempts > MAX_RETRIES) throw parseResult?.error ?? e; + `&LanguageId=en-US&SecurityToken=${stock.morningstarID}]3]0]E0WWE$$ALL`, + undefined, + stock, + "morningstar", + ); - logger.warn( - { prefix: "fetch" }, - `Unable to load Morningstar page for ${stock.name} (${stock.ticker}). ` + - `Will retry (attempt ${attempts} of ${MAX_RETRIES})`, - ); - // Load the page once again - await getAndParseHTML(url, undefined, stock, "morningstar", parseResult); - } + try { + // Check for the presence of the div containing all relevant information. + const contentDiv = XPATH_CONTENT.select1({ node: document, isHtml: true }); + assert(contentDiv, "Unable to find content div."); + } catch (e) { + throw new DataProviderError("Unable to find content div.", { cause: e, dataSources: [document] }); } - const { document } = parseResult; - - // Prepare an error message header containing the stock name and ticker. - let errorMessage = `Error while fetching Morningstar data for ${stock.name} (${stock.ticker}):`; + // Prepare an error message. + let errorMessage = ""; try { const industryNode = XPATH_INDUSTRY.select1({ node: document, isHtml: true }); @@ -383,19 +357,8 @@ const morningstarFetcher: HTMLFetcher = async ( high52w, description, }); - if (errorMessage.includes("\n")) { - // An error occurred if and only if the error message contains a newline character. - // We capture the resource and send a message. - errorMessage += `\n${await captureDataProviderError(stock, "morningstar", { document })}`; - // If this request was for a single stock, we throw an error instead of sending a message, so that the error - // message will be part of the response. - if (req.query.ticker) throw new DataProviderError(errorMessage); - - await signal.sendMessage(SIGNAL_PREFIX_ERROR + errorMessage, "fetchError"); - stocks.failed.push(await readStock(stock.ticker)); - } else { - stocks.successful.push(await readStock(stock.ticker)); - } + // An error occurred if and only if the error message contains a newline character. + if (errorMessage.includes("\n")) throw new DataProviderError(errorMessage, { dataSources: [document] }); }; export default morningstarFetcher; diff --git a/packages/backend/src/fetchers/msciFetcher.ts b/packages/backend/src/fetchers/msciFetcher.ts index ff5ebe561..e0c4f8ad4 100644 --- a/packages/backend/src/fetchers/msciFetcher.ts +++ b/packages/backend/src/fetchers/msciFetcher.ts @@ -2,34 +2,25 @@ import type { MSCIESGRating, Stock } from "@rating-tracker/commons"; import { isMSCIESGRating } from "@rating-tracker/commons"; import type { Request } from "express"; -import { readStock, updateStock } from "../db/tables/stockTable"; -import * as signal from "../signal/signal"; -import { SIGNAL_PREFIX_ERROR } from "../signal/signal"; +import { updateStock } from "../db/tables/stockTable"; import DataProviderError from "../utils/DataProviderError"; import logger from "../utils/logger"; -import type { FetcherWorkspace, HTMLFetcher, ParseResult } from "./fetchHelper"; -import { captureDataProviderError, getAndParseHTML } from "./fetchHelper"; +import type { Fetcher } from "./fetchHelper"; +import { getAndParseHTML } from "./fetchHelper"; /** * Fetches data from MSCI. * @param req Request object - * @param stocks An object with the stocks to fetch and the stocks already fetched (successful or with errors) * @param stock The stock to extract data for - * @param parseResult The fetched and parsed HTML document and/or the error that occurred during parsing * @returns A {@link Promise} that resolves when the fetch is complete - * @throws an {@link APIError} in case of a severe error + * @throws a {@link DataProviderError} in case of a severe error */ -const msciFetcher: HTMLFetcher = async ( - req: Request, - stocks: FetcherWorkspace, - stock: Stock, - parseResult: ParseResult, -): Promise => { +const msciFetcher: Fetcher = async (req: Request, stock: Stock): Promise => { let msciESGRating: MSCIESGRating = req.query.clear ? null : undefined; let msciTemperature: number = req.query.clear ? null : undefined; - await getAndParseHTML( + const document = await getAndParseHTML( "https://www.msci.com/our-solutions/esg-investing/esg-ratings-climate-search-tool", { params: { @@ -45,13 +36,10 @@ const msciFetcher: HTMLFetcher = async ( }, stock, "msci", - parseResult, ); - const { document } = parseResult; - - // Prepare an error message header containing the stock name and ticker. - let errorMessage = `Error while fetching MSCI information for ${stock.name} (${stock.ticker}):`; + // Prepare an error message. + let errorMessage = ""; try { // Example: "esg-rating-circle-bbb" @@ -108,19 +96,8 @@ const msciFetcher: HTMLFetcher = async ( msciESGRating, msciTemperature, }); - if (errorMessage.includes("\n")) { - // An error occurred if and only if the error message contains a newline character. - // We capture the resource and send a message. - errorMessage += `\n${await captureDataProviderError(stock, "msci", { document })}`; - // If this request was for a single stock, we throw an error instead of sending a message, so that the error - // message will be part of the response. - if (req.query.ticker) throw new DataProviderError(errorMessage); - - await signal.sendMessage(SIGNAL_PREFIX_ERROR + errorMessage, "fetchError"); - stocks.failed.push(await readStock(stock.ticker)); - } else { - stocks.successful.push(await readStock(stock.ticker)); - } + // An error occurred if and only if the error message contains a newline character. + if (errorMessage.includes("\n")) throw new DataProviderError(errorMessage, { dataSources: [document] }); }; export default msciFetcher; diff --git a/packages/backend/src/fetchers/spFetcher.ts b/packages/backend/src/fetchers/spFetcher.ts index 231e75f1e..68438636d 100644 --- a/packages/backend/src/fetchers/spFetcher.ts +++ b/packages/backend/src/fetchers/spFetcher.ts @@ -2,44 +2,32 @@ import type { Stock } from "@rating-tracker/commons"; import { SP_PREMIUM_STOCK_ERROR_MESSAGE } from "@rating-tracker/commons"; import type { Request } from "express"; -import { readStock, updateStock } from "../db/tables/stockTable"; -import { SIGNAL_PREFIX_ERROR } from "../signal/signal"; -import * as signal from "../signal/signal"; +import { updateStock } from "../db/tables/stockTable"; import DataProviderError from "../utils/DataProviderError"; import logger from "../utils/logger"; -import type { FetcherWorkspace, HTMLFetcher, ParseResult } from "./fetchHelper"; -import { captureDataProviderError, getAndParseHTML } from "./fetchHelper"; +import type { Fetcher } from "./fetchHelper"; +import { getAndParseHTML } from "./fetchHelper"; /** * Fetches data from Standard & Poor’s. * @param req Request object - * @param stocks An object with the stocks to fetch and the stocks already fetched (successful or with errors) * @param stock The stock to extract data for - * @param parseResult The fetched and parsed HTML document and/or the error that occurred during parsing * @returns A {@link Promise} that resolves when the fetch is complete - * @throws an {@link APIError} in case of a severe error + * @throws a {@link DataProviderError} in case of a severe error */ -const spFetcher: HTMLFetcher = async ( - req: Request, - stocks: FetcherWorkspace, - stock: Stock, - parseResult: ParseResult, -): Promise => { +const spFetcher: Fetcher = async (req: Request, stock: Stock): Promise => { let spESGScore: number = req.query.clear ? null : undefined; - await getAndParseHTML( + const document = await getAndParseHTML( `https://www.spglobal.com/esg/scores/results?cid=${stock.spID}`, undefined, stock, "sp", - parseResult, ); - const { document } = parseResult; - - // Prepare an error message header containing the stock name and ticker. - let errorMessage = `Error while fetching S&P information for ${stock.name} (${stock.ticker}):`; + // Prepare an error message. + let errorMessage = ""; try { const lockedContent = document.getElementsByClassName("lock__content"); @@ -53,7 +41,7 @@ const spFetcher: HTMLFetcher = async ( // Sadly, we are not a premium subscriber :( // We will still count this as a successful fetch await updateStock(stock.ticker, { spLastFetch: new Date() }); - throw new DataProviderError(SP_PREMIUM_STOCK_ERROR_MESSAGE); + throw new DataProviderError(SP_PREMIUM_STOCK_ERROR_MESSAGE, { dataSources: [document] }); } spESGScore = +document.getElementsByClassName("scoreModule__score")[0].textContent; } catch (e) { @@ -73,19 +61,8 @@ const spFetcher: HTMLFetcher = async ( // Update the stock in the database. await updateStock(stock.ticker, { spLastFetch: errorMessage.includes("\n") ? undefined : new Date(), spESGScore }); - if (errorMessage.includes("\n")) { - // An error occurred if and only if the error message contains a newline character. - // We capture the resource and send a message. - errorMessage += `\n${await captureDataProviderError(stock, "sp", { document })}`; - // If this request was for a single stock, we throw an error instead of sending a message, so that the error - // message will be part of the response. - if (req.query.ticker) throw new DataProviderError(errorMessage); - - await signal.sendMessage(SIGNAL_PREFIX_ERROR + errorMessage, "fetchError"); - stocks.failed.push(await readStock(stock.ticker)); - } else { - stocks.successful.push(await readStock(stock.ticker)); - } + // An error occurred if and only if the error message contains a newline character. + if (errorMessage.includes("\n")) throw new DataProviderError(errorMessage, { dataSources: [document] }); }; export default spFetcher; diff --git a/packages/backend/src/models/dynamicStockAttributes.test.ts b/packages/backend/src/models/dynamicStockAttributes.test.ts index a7cc57a5b..de8642050 100644 --- a/packages/backend/src/models/dynamicStockAttributes.test.ts +++ b/packages/backend/src/models/dynamicStockAttributes.test.ts @@ -24,7 +24,7 @@ describe("Stock Scores", () => { lastClose: 100, starRating: 3, morningstarFairValue: 100, - analystConsensus: 5, + analystConsensus: "Hold", analystCount: 10, analystTargetPrice: 100, msciESGRating: "A", @@ -49,7 +49,7 @@ describe("Stock Scores", () => { lastClose: 100, starRating: 1, morningstarFairValue: 25, - analystConsensus: 0, + analystConsensus: "Sell", analystCount: 10, analystTargetPrice: 20, msciESGRating: "CCC", @@ -74,7 +74,7 @@ describe("Stock Scores", () => { lastClose: 100, starRating: 5, morningstarFairValue: 230, - analystConsensus: 10, + analystConsensus: "Buy", analystCount: 10, analystTargetPrice: 215, msciESGRating: "AAA", @@ -137,6 +137,29 @@ describe("Stock Scores", () => { expect(dynamicStockAttributes(stock).esgScore).toBe(-0.5); }); + it("has defined scores for every possible Analyst Consensus", () => { + const stock = addDynamicAttributesToStockData({ + ...optionalStockValuesNull, + ticker: "EXAMPLE", + name: "Example Inc.", + isin: "US0000000000", + country: "US", + }); + expect(stock.esgScore).toBe(0); + + stock.analystCount = 10; + stock.analystConsensus = "Buy"; + expect(dynamicStockAttributes(stock).financialScore).toBe(1 / 3); + stock.analystConsensus = "Outperform"; + expect(dynamicStockAttributes(stock).financialScore).toBe(0.5 / 3); + stock.analystConsensus = "Hold"; + expect(dynamicStockAttributes(stock).financialScore).toBe(0); + stock.analystConsensus = "Underperform"; + expect(dynamicStockAttributes(stock).financialScore).toBe(-0.5 / 3); + stock.analystConsensus = "Sell"; + expect(dynamicStockAttributes(stock).financialScore).toBe(-1 / 3); + }); + it("has financial score depending on analyst count", () => { const stock = addDynamicAttributesToStockData({ ...optionalStockValuesNull, @@ -148,7 +171,7 @@ describe("Stock Scores", () => { stock.lastClose = 100; stock.analystTargetPrice = 200; - stock.analystConsensus = 10; + stock.analystConsensus = "Buy"; stock.analystCount = 10; expect(dynamicStockAttributes(stock).financialScore).toBe((2 / 3) * 1); diff --git a/packages/backend/src/models/dynamicStockAttributes.ts b/packages/backend/src/models/dynamicStockAttributes.ts index 21b94903b..85c1ac726 100644 --- a/packages/backend/src/models/dynamicStockAttributes.ts +++ b/packages/backend/src/models/dynamicStockAttributes.ts @@ -1,4 +1,9 @@ -import type { OmitDynamicAttributesStock, Stock } from "@rating-tracker/commons"; +import { + analystRatingArray, + msciESGRatingArray, + type OmitDynamicAttributesStock, + type Stock, +} from "@rating-tracker/commons"; /** * Provides a score for the stock based on its Morningstar star rating. @@ -36,19 +41,16 @@ const getMorningstarFairValueScore = (stock: OmitDynamicAttributesStock): number /** * Provides a score for the stock based on its analyst consensus. * @param stock The stock. - * @returns The score, ranging from -1 (consensus of 0) to 1 (consensus of 10). `null`, if no analyst consensus exists, - * or if the analyst count is 0. + * @returns The score, ranging from -1 (consensus of Sell) to 1 (consensus of Buy). `null`, if no analyst consensus + * exists, or if the analyst count is 0. If less than 10 analysts have rated the stock, the score is adjusted + * based on the number of analysts. */ const getAnalystConsensusScore = (stock: OmitDynamicAttributesStock): number | null => { if (stock.analystCount && stock.analystConsensus !== null) { - if (stock.analystCount >= 10) { - return (stock.analystConsensus - 5) / 5; - } else { - return (stock.analystCount / 10) * ((stock.analystConsensus - 5) / 5); - } - } else { - return null; - } + const rawAnalystConsensusScore = 0.5 * analystRatingArray.indexOf(stock.analystConsensus) - 1; + if (stock.analystCount >= 10) return rawAnalystConsensusScore; + else return rawAnalystConsensusScore * (stock.analystCount / 10); + } else return null; }; /** @@ -113,22 +115,7 @@ const getMSCIESGRatingScore = (stock: OmitDynamicAttributesStock): number | null if (stock.msciESGRating === null) { return null; } - switch (stock.msciESGRating) { - case "AAA": - return 1; - case "AA": - return 0.5; - case "A": - return 0; - case "BBB": - return -0.5; - case "BB": - return -1; - case "B": - return -1.5; - case "CCC": - return -2; - } + return -0.5 * msciESGRatingArray.indexOf(stock.msciESGRating) + 1; }; /** diff --git a/packages/backend/src/openapi/components.ts b/packages/backend/src/openapi/components.ts index 7da55c20b..aff091918 100644 --- a/packages/backend/src/openapi/components.ts +++ b/packages/backend/src/openapi/components.ts @@ -9,6 +9,7 @@ import { REGEX_PHONE_NUMBER, serviceArray, DUMMY_SVG, + analystRatingArray, } from "@rating-tracker/commons"; import type { OpenAPIV3 } from "express-openapi-validator/dist/framework/types"; @@ -56,6 +57,13 @@ export const components: OpenAPIV3.ComponentsObject = { enum: [...Array.from(msciESGRatingArray), null], example: "AA", }, + AnalystConsensus: { + type: "string", + nullable: true, + description: "The consensus of analysts’ opinions on the stock, that is, the mean value of all analyst ratings.", + enum: [...Array.from(analystRatingArray), null], + example: "Outperform", + }, Stock: { type: "object", description: "A stock.", @@ -150,9 +158,19 @@ export const components: OpenAPIV3.ComponentsObject = { example: "2022-11-24T03:30:15.908Z", }, analystConsensus: { - type: "number", + $ref: "#/components/schemas/AnalystConsensus", + }, + analystRatings: { + type: "object", nullable: true, - example: 8.1, + properties: Object.fromEntries(analystRatingArray.map((rating) => [rating, { type: "number" }])), + example: { + Sell: 1, + Underperform: 1, + Hold: 12, + Outperform: 8, + Buy: 20, + }, }, analystCount: { type: "integer", diff --git a/packages/backend/src/openapi/parameters/stock.ts b/packages/backend/src/openapi/parameters/stock.ts index 4e8d3f75a..fc0f5c419 100644 --- a/packages/backend/src/openapi/parameters/stock.ts +++ b/packages/backend/src/openapi/parameters/stock.ts @@ -353,8 +353,7 @@ export const analystConsensusMin: OpenAPIV3.ParameterObject = { name: "analystConsensusMin", description: "The minimum analyst consensus of a stock.", schema: { - type: "number", - example: 5.5, + $ref: "#/components/schemas/AnalystConsensus", }, }; @@ -366,8 +365,7 @@ export const analystConsensusMax: OpenAPIV3.ParameterObject = { name: "analystConsensusMax", description: "The maximum analyst consensus of a stock.", schema: { - type: "number", - example: 9.5, + $ref: "#/components/schemas/AnalystConsensus", }, }; diff --git a/packages/backend/src/utils/DataProviderError.ts b/packages/backend/src/utils/DataProviderError.ts index 7a566a09e..86e328623 100644 --- a/packages/backend/src/utils/DataProviderError.ts +++ b/packages/backend/src/utils/DataProviderError.ts @@ -7,11 +7,26 @@ export default class DataProviderError extends Error { * @param message A descriptive message for the error. * @param options Additional options for the error. */ - constructor(message?: string, options?: ErrorOptions) { - super(message, options); + constructor( + message?: string, + options?: ErrorOptions & { + /** + * A data source from which fetching failed. Can be an HTML document or a JSON object. + */ + dataSources?: (Document | Object)[]; + }, + ) { + super(message, typeof options === "object" && "cause" in options ? { cause: options.cause } : undefined); + + this.dataSources = options?.dataSources; // Set the prototype explicitly. DataProviderError.prototype.name = "DataProviderError"; Object.setPrototypeOf(this, DataProviderError.prototype); } + + /** + * A data source from which fetching failed. Can be an HTML document or a JSON object. + */ + public dataSources?: (Document | Object)[]; } diff --git a/packages/backend/src/utils/startup.ts b/packages/backend/src/utils/startup.ts index dd21a4855..3c516383e 100644 --- a/packages/backend/src/utils/startup.ts +++ b/packages/backend/src/utils/startup.ts @@ -50,13 +50,7 @@ const logo = chalk.bold( /** * Mandatory environment variables. If not set, Rating Tracker cannot run. */ -const mandatoryEnvVars: readonly string[] = [ - "PORT", - "DOMAIN", - "DATABASE_URL", - /* "SELENIUM_URL", */ - "REDIS_URL", -] as const; +const mandatoryEnvVars: readonly string[] = ["PORT", "DOMAIN", "DATABASE_URL", "REDIS_URL"] as const; /** * The startup method prints a welcome message and checks whether all mandatory environment variables are set. If not, diff --git a/packages/backend/src/utils/webdriver.ts b/packages/backend/src/utils/webdriver.ts deleted file mode 100644 index 87d51ef4b..000000000 --- a/packages/backend/src/utils/webdriver.ts +++ /dev/null @@ -1,142 +0,0 @@ -/* eslint-disable max-len */ - -// // This class is not tested because it is not possible to use it without a running Selenium WebDriver. -// import { DataProvider, Stock } from "@rating-tracker/commons"; -// import axios, { AxiosError } from "axios"; -// import { Builder, Capabilities, WebDriver, until } from "selenium-webdriver"; -// import * as chrome from "selenium-webdriver/chrome"; - -// import { createResource } from "../redis/repositories/resourceRepository"; - -// import APIError from "./APIError"; -// import logger from "./logger"; - -// /** -// * A page load strategy to use by the WebDriver. -// */ -// type PageLoadStrategy = "normal" | "eager" | "none"; - -// /** -// * Checks if the Selenium instance is reachable. -// * -// * @returns {Promise} A promise that resolves when the Selenium instance is reachable, or rejects with an error if -// * it is not. -// */ -// export const seleniumIsReady = (): Promise => -// axios -// .get(`${process.env.SELENIUM_URL}/status`, { timeout: 1000 }) -// .then((res) => (res.data.value.ready ? Promise.resolve() : Promise.reject(new Error("Selenium is not ready")))) -// .catch((e) => -// e instanceof AxiosError -// ? Promise.reject(new Error("Selenium is not reachable: " + e.message)) -// : Promise.reject(e), -// ); - -// /** -// * Creates and returns a new WebDriver instance. -// * -// * @param {boolean} headless whether to run the browser in headless mode -// * @param {PageLoadStrategy} pageLoadStrategy whether to run the browser in headless mode -// * @returns {Promise} The WebDriver instance -// * @throws an {@link APIError} if the WebDriver cannot be created -// */ -// export const getDriver = async (headless?: boolean, pageLoadStrategy?: PageLoadStrategy): Promise => { -// // Wait up to 1 second randomly to avoid a not yet identified bottleneck -// await new Promise((resolve) => setTimeout(() => resolve(), Math.random() * 1000)); -// const url = process.env.SELENIUM_URL; -// const options = new chrome.Options().addArguments("--blink-settings=imagesEnabled=false"); // Do not load images -// headless && options.addArguments("--headless=new"); // In headless mode, the browser window is not shown. - -// return await new Builder() -// .usingServer(url) -// .withCapabilities( -// new Capabilities() -// // Use Chrome as the browser. -// .setBrowserName("chrome") -// // Do not wait for all resources to load. This speeds up the page load. -// .setPageLoadStrategy(pageLoadStrategy ?? "none") -// // Silently dismiss all unexpected prompts -// .setAlertBehavior("dismiss"), -// ) -// .setChromeOptions(options) -// .build() -// .then(async (driver) => { -// // Use `setSize(…)` after https://github.com/SeleniumHQ/selenium/issues/12243 is resolved. -// await driver.manage().window().setRect({ width: 1080, height: 7680 }); // convenient for screenshots of whole page -// return driver; -// }) -// .catch((e) => { -// throw new APIError(502, "Unable to connect to Selenium WebDriver", e); -// }); -// }; - -// /** -// * Let the WebDriver open a URL and wait until the URL is present and previous content is removed. -// * -// * @param {WebDriver} driver the WebDriver instance to shut down -// * @param {string } url the URL to open -// * @returns {Promise} Whether the operation succeeded -// */ -// export const openPageAndWait = async (driver: WebDriver, url: string): Promise => { -// try { -// await driver.get(url); -// await driver.wait(until.urlIs(url), 5000); -// return true; -// } catch (e) { -// logger.error({ prefix: "fetch", err: e }, `Unable to fetch from page ${url} (driver may be unhealthy)`); -// return false; -// } -// }; - -// /** -// * Shuts down the given WebDriver instance gracefully, deallocating all associated resources. -// * If a graceful shutdown fails, a DELETE request is sent to Selenium requesting to terminate the stale session -// * forcefully. -// * -// * @param {WebDriver} driver the WebDriver instance to shut down -// * @param {string} sessionID the ID of the WebDriver session -// * @throws an {@link APIError} if the WebDriver cannot be shut down gracefully -// */ -// export const quitDriver = async (driver: WebDriver, sessionID?: string): Promise => { -// try { -// await driver.quit(); -// } catch (e) { -// logger.error({ prefix: "fetch", err: e }, "Unable to shut down Selenium WebDriver gracefully"); -// if (sessionID) { -// logger.info({ prefix: "fetch" }, `Attempting forceful shutdown of stale session ${sessionID}.`); -// axios.delete(`${process.env.SELENIUM_URL}/session/${sessionID}`).catch((e) => { -// logger.error( -// { prefix: "fetch", err: e }, -// `An error occurred while forcefully terminating session ${sessionID}`, -// ); -// }); -// } -// } -// }; - -// /** -// * Creates a screenshot of the current page and stores it in Redis. -// * -// * @param {WebDriver} driver the WebDriver instance in use -// * @param {Stock} stock the affected stock -// * @param {DataProvider} dataProvider the name of the data provider -// * @returns {Promise} A string holding the ID of the screenshot resource. -// */ -// export const takeScreenshot = async (driver: WebDriver, stock: Stock, dataProvider: DataProvider): Promise => { -// const screenshotID = `error-${dataProvider}-${stock.ticker}-${new Date().getTime().toString()}.png`; -// try { -// const screenshot = await driver.takeScreenshot(); -// await createResource( -// { -// url: screenshotID, -// fetchDate: new Date(), -// content: screenshot, // base64-encoded PNG image -// }, -// 60 * 60 * 48, // We only store the screenshot for 48 hours. -// ); -// return screenshotID; -// } catch (e) { -// logger.warn({ prefix: "fetch", err: e }, `Unable to take screenshot “${screenshotID}”`); -// return ""; -// } -// }; diff --git a/packages/backend/test/env.ts b/packages/backend/test/env.ts index db44e1849..70024c37c 100644 --- a/packages/backend/test/env.ts +++ b/packages/backend/test/env.ts @@ -12,7 +12,6 @@ process.env = { REDIS_PASS: "", POSTGRES_USER: "rating-tracker-test", POSTGRES_PASS: "rating-tracker-test", - // SELENIUM_URL: "unused in tests, but needs to be set", SIGNAL_URL: "http://nonexisting.signal.api.host", SIGNAL_SENDER: "+493012345678", }; diff --git a/packages/backend/test/seeds/postgres.ts b/packages/backend/test/seeds/postgres.ts index 832ccecfc..0b8aea7cf 100644 --- a/packages/backend/test/seeds/postgres.ts +++ b/packages/backend/test/seeds/postgres.ts @@ -27,8 +27,9 @@ const stockData: Stock[] = [ high52w: 232.5, marketScreenerID: "ALLIANZ-SE-436843", marketScreenerLastFetch: new Date("2022-12-18T16:46:11.120Z"), - analystConsensus: 7.8, - analystCount: 18, + analystConsensus: "Buy", + analystRatings: { Buy: 9, Hold: 3, Sell: 0, Outperform: 3, Underperform: 1 }, + analystCount: 16, analystTargetPrice: 241.74, msciID: "IID000000002156841", msciLastFetch: new Date("2022-12-14T21:19:47.194Z"), @@ -67,8 +68,9 @@ const stockData: Stock[] = [ high52w: 182.94, marketScreenerID: "APPLE-INC-4849", marketScreenerLastFetch: new Date("2022-12-18T16:46:56.697Z"), - analystConsensus: 8.1, - analystCount: 45, + analystConsensus: "Outperform", + analystRatings: { Buy: 20, Hold: 12, Sell: 1, Outperform: 8, Underperform: 1 }, + analystCount: 42, analystTargetPrice: 175.12949999999998, msciID: "IID000000002157615", msciLastFetch: new Date("2022-12-14T21:29:55.242Z"), @@ -107,8 +109,9 @@ const stockData: Stock[] = [ high52w: 58.24, marketScreenerID: "DANONE-4634", marketScreenerLastFetch: new Date("2022-12-18T16:47:25.201Z"), - analystConsensus: 6, - analystCount: 24, + analystConsensus: "Outperform", + analystRatings: { Buy: 7, Hold: 8, Sell: 0, Outperform: 5, Underperform: 2 }, + analystCount: 22, analystTargetPrice: 57.63707, msciID: "IID000000002168368", msciLastFetch: new Date("2022-12-14T21:19:52.010Z"), @@ -147,7 +150,8 @@ const stockData: Stock[] = [ high52w: 11.49, marketScreenerID: "IBERDROLA-S-A-355153", marketScreenerLastFetch: new Date("2022-12-18T16:48:15.345Z"), - analystConsensus: 7, + analystConsensus: "Hold", + analystRatings: { Buy: 6, Hold: 11, Sell: 1, Outperform: 4, Underperform: 0 }, analystCount: 22, analystTargetPrice: 12.074202, msciID: "IID000000002126836", @@ -187,7 +191,8 @@ const stockData: Stock[] = [ high52w: 98.82, marketScreenerID: "KION-GROUP-AG-13495387", marketScreenerLastFetch: new Date("2022-12-18T16:48:49.606Z"), - analystConsensus: 8.2, + analystConsensus: "Buy", + analystRatings: { Buy: 10, Hold: 7, Sell: 0, Outperform: 1, Underperform: 0 }, analystCount: 18, analystTargetPrice: 39.49407, ric: "KGX.DE", @@ -223,8 +228,9 @@ const stockData: Stock[] = [ high52w: 1359.61, marketScreenerID: "MERCADOLIBRE-INC-58469", marketScreenerLastFetch: new Date("2022-12-18T16:49:27.283Z"), - analystConsensus: 8.4, - analystCount: 22, + analystConsensus: "Buy", + analystRatings: { Buy: 14, Hold: 3, Sell: 0, Outperform: 6, Underperform: 0 }, + analystCount: 23, analystTargetPrice: 1307.0211, msciID: "IID000000002153488", msciLastFetch: new Date("2022-12-14T21:19:55.885Z"), @@ -260,8 +266,9 @@ const stockData: Stock[] = [ high52w: 86.37, marketScreenerID: "NEWMONT-CORPORATION-13711", marketScreenerLastFetch: new Date("2022-12-18T16:57:18.564Z"), - analystConsensus: 6.6, - analystCount: 22, + analystConsensus: "Outperform", + analystRatings: { Buy: 7, Hold: 9, Sell: 0, Outperform: 5, Underperform: 2 }, + analystCount: 23, analystTargetPrice: 54.27072, msciID: "IID000000002177254", msciLastFetch: new Date("2022-12-14T21:19:27.773Z"), @@ -300,8 +307,9 @@ const stockData: Stock[] = [ high52w: 940, marketScreenerID: "NOVO-NORDISK-A-S-1412980", marketScreenerLastFetch: new Date("2022-12-18T16:57:43.959Z"), - analystConsensus: 7.4, - analystCount: 24, + analystConsensus: "Outperform", + analystRatings: { Buy: 12, Hold: 5, Sell: 1, Outperform: 5, Underperform: 2 }, + analystCount: 25, analystTargetPrice: 904.875, msciID: "IID000000002135404", msciLastFetch: new Date("2022-12-14T21:19:33.088Z"), @@ -347,8 +355,9 @@ const stockData: Stock[] = [ high52w: 918.4, marketScreenerID: "ORSTED-A-S-28607554", marketScreenerLastFetch: new Date("2022-12-18T16:58:13.295Z"), - analystConsensus: 7.1, - analystCount: 24, + analystConsensus: "Hold", + analystRatings: { Buy: 9, Hold: 14, Sell: 0, Outperform: 3, Underperform: 0 }, + analystCount: 26, analystTargetPrice: 838.1268, msciID: "IID000000002226401", msciLastFetch: new Date("2022-12-14T21:19:37.853Z"), @@ -387,7 +396,8 @@ const stockData: Stock[] = [ high52w: 128.66, marketScreenerID: "TAIWAN-SEMICONDUCTOR-MANU-40246786", marketScreenerLastFetch: new Date("2022-12-18T16:58:48.518Z"), - analystConsensus: 9, + analystConsensus: "Buy", + analystRatings: { Buy: 20, Hold: 1, Sell: 0, Outperform: 10, Underperform: 0 }, analystCount: 31, analystTargetPrice: 105.66895999999998, msciID: "IID000000002186091", diff --git a/packages/backend/vitest.config.ts b/packages/backend/vitest.config.ts index d11aa9a24..bcf1e40b7 100644 --- a/packages/backend/vitest.config.ts +++ b/packages/backend/vitest.config.ts @@ -31,7 +31,6 @@ export default defineConfig({ "src/utils/fetchRequest.*", "src/utils/logger.*", "src/utils/logFormatterConfig.*", - "src/utils/webdriver.*", ], }, exclude: ["**/*.live.test.ts"], diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts index 340e82151..1b6dc69a0 100644 --- a/packages/commons/src/index.ts +++ b/packages/commons/src/index.ts @@ -7,6 +7,8 @@ export * from "./lib/gecs/IndustryGroup"; export * from "./lib/gecs/Sector"; export * from "./lib/gecs/SuperSector"; +export * from "./lib/math/Record"; + export * from "./lib/models/portfolio"; export * from "./lib/models/resource"; export * from "./lib/models/session"; @@ -29,6 +31,7 @@ export * from "./lib/paths/account"; export * from "./lib/paths/users"; export * from "./lib/paths/watchlists"; +export * from "./lib/ratings/AnalystRating"; export * from "./lib/ratings/MSCI"; export * from "./lib/stylebox/Size"; diff --git a/packages/commons/src/lib/Currency.test.ts b/packages/commons/src/lib/Currency.test.ts index 40c54bbe4..032e07d4c 100644 --- a/packages/commons/src/lib/Currency.test.ts +++ b/packages/commons/src/lib/Currency.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { isCurrency } from "./Currency"; -describe("Currency", () => { +describe.concurrent("Currency", () => { it("is a currency", () => { expect(isCurrency("EUR")).toBe(true); }); diff --git a/packages/commons/src/lib/DataProvider.test.ts b/packages/commons/src/lib/DataProvider.test.ts index 8e8fd5cc1..b2caa9158 100644 --- a/packages/commons/src/lib/DataProvider.test.ts +++ b/packages/commons/src/lib/DataProvider.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { isDataProvider, isBulkDataProvider, isHTMLDataProvider, isJSONDataProvider } from "./DataProvider"; +import { isDataProvider, isBulkDataProvider, isIndividualDataProvider } from "./DataProvider"; -describe("Data Provider", () => { +describe.concurrent("Data Provider", () => { it("is a data provider", () => { expect(isDataProvider("morningstar")).toBe(true); expect(isDataProvider("marketScreener")).toBe(true); @@ -12,22 +12,13 @@ describe("Data Provider", () => { expect(isDataProvider("sustainalytics")).toBe(true); }); - it("is an HTML data provider", () => { - expect(isHTMLDataProvider("morningstar")).toBe(true); - expect(isHTMLDataProvider("marketScreener")).toBe(true); - expect(isHTMLDataProvider("msci")).toBe(true); - expect(isHTMLDataProvider("lseg")).toBe(false); - expect(isHTMLDataProvider("sp")).toBe(true); - expect(isHTMLDataProvider("sustainalytics")).toBe(false); - }); - - it("is a JSON data provider", () => { - expect(isJSONDataProvider("morningstar")).toBe(false); - expect(isJSONDataProvider("marketScreener")).toBe(false); - expect(isJSONDataProvider("msci")).toBe(false); - expect(isJSONDataProvider("lseg")).toBe(true); - expect(isJSONDataProvider("sp")).toBe(false); - expect(isJSONDataProvider("sustainalytics")).toBe(false); + it("is an individual data provider", () => { + expect(isIndividualDataProvider("morningstar")).toBe(true); + expect(isIndividualDataProvider("marketScreener")).toBe(true); + expect(isIndividualDataProvider("msci")).toBe(true); + expect(isIndividualDataProvider("lseg")).toBe(true); + expect(isIndividualDataProvider("sp")).toBe(true); + expect(isIndividualDataProvider("sustainalytics")).toBe(false); }); it("is a bulk data provider", () => { diff --git a/packages/commons/src/lib/DataProvider.ts b/packages/commons/src/lib/DataProvider.ts index 8cf41d3db..d6ee3cd69 100644 --- a/packages/commons/src/lib/DataProvider.ts +++ b/packages/commons/src/lib/DataProvider.ts @@ -19,34 +19,25 @@ export const dataProviderArray = ["morningstar", "marketScreener", "msci", "lseg export type DataProvider = (typeof dataProviderArray)[number]; /** - * An array of data providers which provide information in the form of HTML pages. + * An array of data providers from which stocks are fetched individually. */ -export const htmlDataProviderArray = ["morningstar", "marketScreener", "msci", "sp"] as const satisfies DataProvider[]; - -/** - * An array of data providers which provide information in the form of JSON objects. - */ -export const jsonDataProviderArray = ["lseg"] as const satisfies DataProvider[]; +export const individualDataProviderArray = [ + "morningstar", + "marketScreener", + "msci", + "lseg", + "sp", +] as const satisfies DataProvider[]; /** * An array of data providers which provide information for many stocks in one response. */ export const bulkDataProviderArray = ["sustainalytics"] as const satisfies DataProvider[]; -/** - * A data provider which provides information in the form of HTML pages. - */ -export type HTMLDataProvider = (typeof htmlDataProviderArray)[number]; - -/** - * A data provider which provides information in the form of JSON objects. - */ -export type JSONDataProvider = (typeof jsonDataProviderArray)[number]; - /** * A data provider from which stocks are fetched individually. */ -export type IndividualDataProvider = HTMLDataProvider | JSONDataProvider; +export type IndividualDataProvider = (typeof individualDataProviderArray)[number]; /** * A data provider from which stocks are fetched in bulk. @@ -63,21 +54,12 @@ export function isDataProvider(dataProvider: string): dataProvider is DataProvid } /** - * Checks if a data provider is a valid HTML data provider. - * @param dataProvider The data provider to check. - * @returns True if the data provider is a valid HTML data provider. - */ -export function isHTMLDataProvider(dataProvider: DataProvider): dataProvider is HTMLDataProvider { - return htmlDataProviderArray.includes(dataProvider as HTMLDataProvider); -} - -/** - * Checks if a data provider is a valid JSON data provider. + * Checks if a data provider is a valid individual data provider. * @param dataProvider The data provider to check. - * @returns True if the data provider is a valid JSON data provider. + * @returns True if the data provider is a valid individual data provider. */ -export function isJSONDataProvider(dataProvider: DataProvider): dataProvider is JSONDataProvider { - return jsonDataProviderArray.includes(dataProvider as JSONDataProvider); +export function isIndividualDataProvider(dataProvider: DataProvider): dataProvider is IndividualDataProvider { + return individualDataProviderArray.includes(dataProvider as IndividualDataProvider); } /** diff --git a/packages/commons/src/lib/Fetch.test.ts b/packages/commons/src/lib/Fetch.test.ts index a5b76a411..02446ff31 100644 --- a/packages/commons/src/lib/Fetch.test.ts +++ b/packages/commons/src/lib/Fetch.test.ts @@ -35,7 +35,7 @@ const mockedResponse: Pick< text: () => undefined, }; -describe("URL Search Parameters", () => { +describe.concurrent("URL Search Parameters", () => { it("creates a new URLSearchParams instance from the given parameter record", () => { const params = { string: "string with symbols?", @@ -49,7 +49,7 @@ describe("URL Search Parameters", () => { }); }); -describe("Response handler", () => { +describe.concurrent("Response handler", () => { it("parses JSON response", async () => { const response = { ...mockedResponse, diff --git a/packages/commons/src/lib/MessageType.test.ts b/packages/commons/src/lib/MessageType.test.ts index 39dd32b10..b458fa283 100644 --- a/packages/commons/src/lib/MessageType.test.ts +++ b/packages/commons/src/lib/MessageType.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { isMessageType } from "./MessageType"; -describe("Message Type", () => { +describe.concurrent("Message Type", () => { it("is a message type", () => { expect(isMessageType("fetchError")).toBe(true); }); diff --git a/packages/commons/src/lib/Service.test.ts b/packages/commons/src/lib/Service.test.ts index 2d32c9f6d..6decb444d 100644 --- a/packages/commons/src/lib/Service.test.ts +++ b/packages/commons/src/lib/Service.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { isService } from "./Service"; -describe("Sortable Attribute", () => { +describe.concurrent("Sortable Attribute", () => { it("is a service", () => { expect(isService("PostgreSQL")).toBe(true); }); diff --git a/packages/commons/src/lib/Service.ts b/packages/commons/src/lib/Service.ts index 3f6150f67..6ce2f5c5b 100644 --- a/packages/commons/src/lib/Service.ts +++ b/packages/commons/src/lib/Service.ts @@ -1,7 +1,7 @@ /** * An array of services Rating Tracker depends on. */ -export const serviceArray = ["PostgreSQL", "Redis", /* "Selenium", */ "Signal"] as const; +export const serviceArray = ["PostgreSQL", "Redis", "Signal"] as const; /** * A service Rating Tracker depends on. diff --git a/packages/commons/src/lib/SortableAttribute.test.ts b/packages/commons/src/lib/SortableAttribute.test.ts index f091b896a..49604ae3f 100644 --- a/packages/commons/src/lib/SortableAttribute.test.ts +++ b/packages/commons/src/lib/SortableAttribute.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { isSortableAttribute } from "./SortableAttribute"; -describe("Sortable Attribute", () => { +describe.concurrent("Sortable Attribute", () => { it("is a sortable attribute", () => { expect(isSortableAttribute("totalScore")).toBe(true); }); diff --git a/packages/commons/src/lib/StockListColumn.ts b/packages/commons/src/lib/StockListColumn.ts index 8122a7d40..4483f1397 100644 --- a/packages/commons/src/lib/StockListColumn.ts +++ b/packages/commons/src/lib/StockListColumn.ts @@ -11,7 +11,7 @@ export const stockListColumnArray = [ "ESG Score", "Star Rating", "Morningstar Fair Value", - "Analyst Consensus", + "Analyst Ratings", "Analyst Target Price", "MSCI ESG Rating", "MSCI Implied Temperature Rise", diff --git a/packages/commons/src/lib/gecs/Industry.test.ts b/packages/commons/src/lib/gecs/Industry.test.ts index 110116dfe..3db65d29b 100644 --- a/packages/commons/src/lib/gecs/Industry.test.ts +++ b/packages/commons/src/lib/gecs/Industry.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { isIndustry } from "./Industry"; -describe("Industry", () => { +describe.concurrent("Industry", () => { it("is an industry", () => { expect(isIndustry("SpecialtyChemicals")).toBe(true); }); diff --git a/packages/commons/src/lib/gecs/IndustryGroup.test.ts b/packages/commons/src/lib/gecs/IndustryGroup.test.ts index 18df6a6b0..7337ffb52 100644 --- a/packages/commons/src/lib/gecs/IndustryGroup.test.ts +++ b/packages/commons/src/lib/gecs/IndustryGroup.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { isIndustryGroup, getIndustriesInGroup } from "./IndustryGroup"; -describe("Industry Group", () => { +describe.concurrent("Industry Group", () => { it("is an industry group", () => { expect(isIndustryGroup("RetailCyclical")).toBe(true); }); diff --git a/packages/commons/src/lib/gecs/Sector.test.ts b/packages/commons/src/lib/gecs/Sector.test.ts index fc216baa1..46352251b 100644 --- a/packages/commons/src/lib/gecs/Sector.test.ts +++ b/packages/commons/src/lib/gecs/Sector.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { getIndustryGroupsInSector, isSector } from "./Sector"; -describe("Sector", () => { +describe.concurrent("Sector", () => { it("is a sector", () => { expect(isSector("RealEstate")).toBe(true); }); diff --git a/packages/commons/src/lib/gecs/SuperSector.test.ts b/packages/commons/src/lib/gecs/SuperSector.test.ts index ceccf8a02..aabf58ca9 100644 --- a/packages/commons/src/lib/gecs/SuperSector.test.ts +++ b/packages/commons/src/lib/gecs/SuperSector.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { getSectorsInSuperSector, isSuperSector } from "./SuperSector"; -describe("Super Sector", () => { +describe.concurrent("Super Sector", () => { it("is a super sector", () => { expect(isSuperSector("Defensive")).toBeTruthy(); }); diff --git a/packages/commons/src/lib/geo/Country.test.ts b/packages/commons/src/lib/geo/Country.test.ts index 9b1dd2298..a01117d1a 100644 --- a/packages/commons/src/lib/geo/Country.test.ts +++ b/packages/commons/src/lib/geo/Country.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { emojiFlag, isCountry } from "./Country"; -describe("Country Codes", () => { +describe.concurrent("Country Codes", () => { it("is an ISO 3166-1 alpha-2 country code", () => { expect(isCountry("US")).toBe(true); }); @@ -12,7 +12,7 @@ describe("Country Codes", () => { }); }); -describe("Emoji Flags", () => { +describe.concurrent("Emoji Flags", () => { it("is a flag emoji", () => { expect(emojiFlag("US")).toBe("🇺🇸"); }); diff --git a/packages/commons/src/lib/geo/Region.test.ts b/packages/commons/src/lib/geo/Region.test.ts index e9cb42734..526fe85fc 100644 --- a/packages/commons/src/lib/geo/Region.test.ts +++ b/packages/commons/src/lib/geo/Region.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { isRegion, getCountriesInRegion } from "./Region"; -describe("Region", () => { +describe.concurrent("Region", () => { it("is a region", () => { expect(isRegion("Eurozone")).toBe(true); }); diff --git a/packages/commons/src/lib/geo/SuperRegion.test.ts b/packages/commons/src/lib/geo/SuperRegion.test.ts index 6988b2a05..b5980f8bd 100644 --- a/packages/commons/src/lib/geo/SuperRegion.test.ts +++ b/packages/commons/src/lib/geo/SuperRegion.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { isSuperRegion, getRegionsInSuperRegion, emojiGlobe } from "./SuperRegion"; -describe("Super Region", () => { +describe.concurrent("Super Region", () => { it("is a super region", () => { expect(isSuperRegion("Americas")).toBe(true); }); @@ -16,7 +16,7 @@ describe("Super Region", () => { }); }); -describe("Emoji Globes", () => { +describe.concurrent("Emoji Globes", () => { it("is a globe emoji", () => { expect(emojiGlobe("EMEA")).toBe("🌍"); }); diff --git a/packages/commons/src/lib/math/Record.test.ts b/packages/commons/src/lib/math/Record.test.ts new file mode 100644 index 000000000..a997d6b8b --- /dev/null +++ b/packages/commons/src/lib/math/Record.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import type { AnalystRating } from "../ratings/AnalystRating"; + +import { RecordMath } from "./Record"; + +const analystRatingValues: Record = { + Sell: 1, + Underperform: 1, + Hold: 12, + Outperform: 8, + Buy: 20, +}; + +describe.concurrent("Record Math", () => { + it("computes the sum of a record", () => { + expect(RecordMath.sum(analystRatingValues)).toBe(42); + }); + + it("computes the sum of a small record", () => { + expect(RecordMath.sum({ a: 1 })).toBe(1); + }); + + it("computes the sum of a record with zeroes", () => { + expect(RecordMath.sum({ a: 0, b: 0, c: 0 })).toBe(0); + }); + + it("computes the sum of an empty record", () => { + expect(RecordMath.sum({})).toBe(0); + }); + + it("computes the mean of a record", () => { + expect(RecordMath.mean(analystRatingValues)).toBe("Outperform"); + }); + + it("computes the mean of a small record", () => { + expect(RecordMath.mean({ a: 1 })).toBe("a"); + }); + + it("computes the mean of a record with zeroes", () => { + expect(RecordMath.mean({ a: 0, b: 0, c: 0 })).toBeUndefined(); + }); + + it("computes the mean of an empty record", () => { + expect(RecordMath.mean({})).toBeUndefined(); + }); +}); diff --git a/packages/commons/src/lib/math/Record.ts b/packages/commons/src/lib/math/Record.ts new file mode 100644 index 000000000..8d5803cc4 --- /dev/null +++ b/packages/commons/src/lib/math/Record.ts @@ -0,0 +1,20 @@ +/** + * A collection of mathematical functions for working with Records. + */ +export class RecordMath { + /** + * Sums the values of a record. + * @param record The record to sum. + * @returns The sum of the values. + */ + static sum = (record: Record): number => + Object.values(record).reduce((a, b) => a + b, 0); + + /** + * Calculates the mean of a record. + * @param record The record to calculate the mean of. + * @returns The mean of the record. + */ + static mean = (record: Record): K => + Object.keys(record).flatMap((key) => Array(record[key]).fill(key))[Math.floor(RecordMath.sum(record) / 2)]; +} diff --git a/packages/commons/src/lib/models/portfolio.test.ts b/packages/commons/src/lib/models/portfolio.test.ts index 43cf19554..e55be1028 100644 --- a/packages/commons/src/lib/models/portfolio.test.ts +++ b/packages/commons/src/lib/models/portfolio.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { Portfolio } from "./portfolio"; import { + getAnalystRatingDistribution, getCountrySunburstData, getEstimateValue, getIndustrySunburstData, @@ -10,6 +11,7 @@ import { getTotalAmount, getWeightedAverage, getWeightedAverageMSCIESGRating, + getWeightedMeanAnalystConsensus, getWeightedStylebox, stripPrefixFromSunburstID, } from "./portfolio"; @@ -29,6 +31,8 @@ const portfolio: Portfolio = { morningstarFairValue: 320, morningstarFairValuePercentageToLastClose: 12.5, analystTargetPricePercentageToLastClose: null, + analystRatings: { Sell: 0, Underperform: 3, Hold: 5, Outperform: 12, Buy: 42 }, + analystCount: 62, positionIn52w: null, ticker: "META", name: "Meta Platforms Inc A", @@ -50,6 +54,8 @@ const portfolio: Portfolio = { morningstarFairValue: 140, morningstarFairValuePercentageToLastClose: -25, analystTargetPricePercentageToLastClose: null, + analystRatings: { Sell: 0, Underperform: 0, Hold: 1, Outperform: 10, Buy: 20 }, + analystCount: 31, positionIn52w: null, ticker: "TSM", name: "Taiwan Semiconductor Manufacturing Co Ltd", @@ -73,6 +79,8 @@ const portfolio: Portfolio = { morningstarFairValuePercentageToLastClose: -20, analystTargetPrice: 50, analystTargetPricePercentageToLastClose: -20, + analystRatings: { Sell: 0, Underperform: 2, Hold: 9, Outperform: 5, Buy: 7 }, + analystCount: 23, positionIn52w: null, ticker: "NEM", name: "Newmont Corp", @@ -109,7 +117,7 @@ const portfolioWithEmptyStocks: Portfolio = { ], }; -describe("Portfolio Statistics", () => { +describe.concurrent("Portfolio Statistics", () => { it("calculates the total amount", () => { expect(getTotalAmount(portfolio)).toBe(600); expect(getTotalAmount(portfolioWithEmptyStocks)).toBe(0); @@ -137,6 +145,20 @@ describe("Portfolio Statistics", () => { expect(getPercentageToTotalAmount(portfolioWithEmptyStocks, "morningstarFairValue")).toBe(null); }); + it("calculates the weighted Analyst Ratings", () => { + expect(getAnalystRatingDistribution(portfolio)).toEqual({ + Sell: 0, + Underperform: (3 * 300) / (600 * 62) + (2 * 100) / (600 * 23), + Hold: (5 * 300) / (600 * 62) + (1 * 200) / (600 * 31) + (9 * 100) / (600 * 23), + Outperform: (12 * 300) / (600 * 62) + (10 * 200) / (600 * 31) + (5 * 100) / (600 * 23), + Buy: (42 * 300) / (600 * 62) + (20 * 200) / (600 * 31) + (7 * 100) / (600 * 23), + }); + expect(getWeightedMeanAnalystConsensus(portfolio)).toBe("Buy"); + + expect(getAnalystRatingDistribution(portfolioWithEmptyStocks)).toEqual(null); + expect(getWeightedMeanAnalystConsensus(portfolioWithEmptyStocks)).toBe(null); + }); + it("calculates the weighted average of the MSCI ESG Rating", () => { expect(getWeightedAverageMSCIESGRating(portfolio)).toBe("BBB"); @@ -157,141 +179,143 @@ describe("Portfolio Statistics", () => { }); }); -it("calculates the region sunburst data", () => { - expect(getCountrySunburstData(portfolio)).toEqual({ - id: "root", - name: "All Countries", - children: [ - { - id: "Americas", - name: "The Americas", - children: [ - { - id: "NorthAmerica", - name: "North America", - children: [ - { - id: "US", - name: "United States", - value: 400, - }, - ], - }, - ], - }, - { - id: "Asia", - name: "Greater Asia", - children: [ - { - id: "AsiaDeveloped", - name: "Asia Developed", - children: [ - { - id: "TW", - name: "Republic of China (Taiwan)", - value: 200, - }, - ], - }, - ], - }, - ], +describe.concurrent("Portfolio Chart Data", () => { + it("calculates the region sunburst data", () => { + expect(getCountrySunburstData(portfolio)).toEqual({ + id: "root", + name: "All Countries", + children: [ + { + id: "Americas", + name: "The Americas", + children: [ + { + id: "NorthAmerica", + name: "North America", + children: [ + { + id: "US", + name: "United States", + value: 400, + }, + ], + }, + ], + }, + { + id: "Asia", + name: "Greater Asia", + children: [ + { + id: "AsiaDeveloped", + name: "Asia Developed", + children: [ + { + id: "TW", + name: "Republic of China (Taiwan)", + value: 200, + }, + ], + }, + ], + }, + ], + }); }); -}); -it("calculates the industry sunburst data", () => { - expect(getIndustrySunburstData(portfolio)).toEqual({ - id: "root", - name: "All Industries", - children: [ - { - id: "SuperSectorCyclical", - name: "Cyclical", - children: [ - { - id: "SectorBasicMaterials", - name: "Basic Materials", - children: [ - { - id: "IndustryGroupMetalsMining", - name: "Metals & Mining", - children: [ - { - id: "IndustryGold", - name: "Gold", - value: 100, - }, - ], - }, - ], - }, - ], - }, - { - id: "SuperSectorSensitive", - name: "Sensitive", - children: [ - { - id: "SectorCommunicationServices", - name: "Communication Services", - children: [ - { - id: "IndustryGroupInteractiveMedia", - name: "Interactive Media", - children: [ - { - id: "IndustryInternetContentInformation", - name: "Internet Content & Information", - value: 300, - }, - ], - }, - ], - }, - { - id: "SectorTechnology", - name: "Technology", - children: [ - { - id: "IndustryGroupSemiconductors", - name: "Semiconductors", - children: [ - { - id: "IndustrySemiconductors", - name: "Semiconductors", - value: 200, - }, - ], - }, - ], - }, - ], - }, - ], + it("calculates the industry sunburst data", () => { + expect(getIndustrySunburstData(portfolio)).toEqual({ + id: "root", + name: "All Industries", + children: [ + { + id: "SuperSectorCyclical", + name: "Cyclical", + children: [ + { + id: "SectorBasicMaterials", + name: "Basic Materials", + children: [ + { + id: "IndustryGroupMetalsMining", + name: "Metals & Mining", + children: [ + { + id: "IndustryGold", + name: "Gold", + value: 100, + }, + ], + }, + ], + }, + ], + }, + { + id: "SuperSectorSensitive", + name: "Sensitive", + children: [ + { + id: "SectorCommunicationServices", + name: "Communication Services", + children: [ + { + id: "IndustryGroupInteractiveMedia", + name: "Interactive Media", + children: [ + { + id: "IndustryInternetContentInformation", + name: "Internet Content & Information", + value: 300, + }, + ], + }, + ], + }, + { + id: "SectorTechnology", + name: "Technology", + children: [ + { + id: "IndustryGroupSemiconductors", + name: "Semiconductors", + children: [ + { + id: "IndustrySemiconductors", + name: "Semiconductors", + value: 200, + }, + ], + }, + ], + }, + ], + }, + ], + }); }); -}); -it("strips the prefix from a sunburst data ID", () => { - expect(stripPrefixFromSunburstID("SuperSectorSensitive")).toBe("Sensitive"); - expect(stripPrefixFromSunburstID("SectorTechnology")).toBe("Technology"); - expect(stripPrefixFromSunburstID("IndustryGroupHardware")).toBe("Hardware"); - expect(stripPrefixFromSunburstID("IndustryConsumerElectronics")).toBe("ConsumerElectronics"); - expect(stripPrefixFromSunburstID("root")).toBeUndefined(); -}); + it("strips the prefix from a sunburst data ID", () => { + expect(stripPrefixFromSunburstID("SuperSectorSensitive")).toBe("Sensitive"); + expect(stripPrefixFromSunburstID("SectorTechnology")).toBe("Technology"); + expect(stripPrefixFromSunburstID("IndustryGroupHardware")).toBe("Hardware"); + expect(stripPrefixFromSunburstID("IndustryConsumerElectronics")).toBe("ConsumerElectronics"); + expect(stripPrefixFromSunburstID("root")).toBeUndefined(); + }); -it("provides a name for a sunburst data ID", () => { - // Industry sectors - expect(getSunburstDatumName("SuperSectorSensitive")).toBe("Sensitive"); - expect(getSunburstDatumName("SectorTechnology")).toBe("Technology"); - expect(getSunburstDatumName("IndustryGroupHardware")).toBe("Hardware"); - expect(getSunburstDatumName("IndustryConsumerElectronics")).toBe("Consumer Electronics"); + it("provides a name for a sunburst data ID", () => { + // Industry sectors + expect(getSunburstDatumName("SuperSectorSensitive")).toBe("Sensitive"); + expect(getSunburstDatumName("SectorTechnology")).toBe("Technology"); + expect(getSunburstDatumName("IndustryGroupHardware")).toBe("Hardware"); + expect(getSunburstDatumName("IndustryConsumerElectronics")).toBe("Consumer Electronics"); - // Regions - expect(getSunburstDatumName("Americas")).toBe("The Americas"); - expect(getSunburstDatumName("NorthAmerica")).toBe("North America"); - expect(getSunburstDatumName("US")).toBe("United States"); + // Regions + expect(getSunburstDatumName("Americas")).toBe("The Americas"); + expect(getSunburstDatumName("NorthAmerica")).toBe("North America"); + expect(getSunburstDatumName("US")).toBe("United States"); - // Unknown IDs are passed through - expect(getSunburstDatumName("root")).toBe("root"); + // Unknown IDs are passed through + expect(getSunburstDatumName("root")).toBe("root"); + }); }); diff --git a/packages/commons/src/lib/models/portfolio.ts b/packages/commons/src/lib/models/portfolio.ts index b17c27a13..dccd969d9 100644 --- a/packages/commons/src/lib/models/portfolio.ts +++ b/packages/commons/src/lib/models/portfolio.ts @@ -10,6 +10,9 @@ import { isSuperSector, superSectorName, superSectorOfSector } from "../gecs/Sup import { countryName, isCountry } from "../geo/Country"; import { isRegion, regionName, regionOfCountry } from "../geo/Region"; import { isSuperRegion, superRegionName, superRegionOfRegion } from "../geo/SuperRegion"; +import { RecordMath } from "../math/Record"; +import type { AnalystRating } from "../ratings/AnalystRating"; +import { analystRatingArray } from "../ratings/AnalystRating"; import type { MSCIESGRating } from "../ratings/MSCI"; import { msciESGRatingArray } from "../ratings/MSCI"; import type { Size } from "../stylebox/Size"; @@ -143,6 +146,48 @@ export const getPercentageToTotalAmount = | null => { + const totalAmount = getTotalAmount(portfolio, "analystRatings"); + if (totalAmount === 0) return null; + + return portfolio.stocks + .filter((stock) => stock.analystRatings) + .reduce>( + (distribution, stock) => { + const sum = RecordMath.sum(stock.analystRatings); + Object.entries(stock.analystRatings).forEach( + ([rating, value]) => (distribution[rating] += (Number(value) * stock.amount) / (totalAmount * sum)), + ); + return distribution; + }, + { Sell: 0, Underperform: 0, Hold: 0, Outperform: 0, Buy: 0 }, + ); +}; + +/** + * Computes the weighted mean value of the analyst ratings of the stocks in a portfolio. Stocks with no analyst + * ratings are ignored. + * @param portfolio The portfolio. + * @returns The weighted mean of the analyst ratings of the stocks in the portfolio. + */ +export const getWeightedMeanAnalystConsensus = (portfolio: Portfolio): AnalystRating | null => { + const analystRatingDistribution = getAnalystRatingDistribution(portfolio); + if (analystRatingDistribution === null) return null; + const sum = RecordMath.sum(analystRatingDistribution); // should be 1, but we add it here just in case + + let cumulativeSum = 0; + for (const analystRating of analystRatingArray) { + cumulativeSum += analystRatingDistribution[analystRating]; + if (cumulativeSum >= 0.5 * sum) return analystRating; + } + /* c8 ignore next */ // Unreachable, since we compare against the distribution’s sum, so the loop will always return. +}; + /** * Computes the weighted average value of the MSCI ESG Rating of the stocks in a portfolio. Stocks with no MSCI ESG * Rating are ignored. @@ -151,9 +196,8 @@ export const getPercentageToTotalAmount = { const totalAmount = getTotalAmount(portfolio, "msciESGRating"); - if (totalAmount === 0) { - return null; - } + if (totalAmount === 0) return null; + return msciESGRatingArray[ Math.round( portfolio.stocks diff --git a/packages/commons/src/lib/models/stock.ts b/packages/commons/src/lib/models/stock.ts index d005ea89a..abc0765a3 100644 --- a/packages/commons/src/lib/models/stock.ts +++ b/packages/commons/src/lib/models/stock.ts @@ -2,6 +2,7 @@ import type { Currency } from "../Currency"; import type { Industry } from "../gecs/Industry"; import type { Country } from "../geo/Country"; import type { OmitFunctions } from "../OmitFunctions"; +import type { AnalystRating } from "../ratings/AnalystRating"; import type { MSCIESGRating } from "../ratings/MSCI"; import type { Size } from "../stylebox/Size"; import type { Style } from "../stylebox/Style"; @@ -117,9 +118,13 @@ export type Stock = { */ marketScreenerLastFetch: Date | null; /** - * The consensus of analysts’ opinions on the stock. + * The consensus of analysts’ opinions on the stock, that is, the mean value of all analyst ratings. */ - analystConsensus: number | null; + analystConsensus: AnalystRating | null; + /** + * The ratings of analysts for the stock. + */ + analystRatings: Record | null; /** * The number of analysts that cover the stock. */ @@ -248,6 +253,7 @@ export const optionalStockValuesNull: OmitFunctions< marketScreenerID: null, marketScreenerLastFetch: null, analystConsensus: null, + analystRatings: null, analystCount: null, analystTargetPrice: null, msciID: null, diff --git a/packages/commons/src/lib/models/user.test.ts b/packages/commons/src/lib/models/user.test.ts index 89aeaa396..13c9f4577 100644 --- a/packages/commons/src/lib/models/user.test.ts +++ b/packages/commons/src/lib/models/user.test.ts @@ -47,7 +47,7 @@ const userWhoDoesNotGiveAFuckAboutAnything = new User({ subscriptions: 0, }); -describe("User Constructor", () => { +describe.concurrent("User Constructor", () => { it("removes credentials", () => { const userWithCredentials: UserWithCredentials = new UserWithCredentials({ ...regularUser, @@ -66,7 +66,7 @@ describe("User Constructor", () => { }); }); -describe("User Access Rights", () => { +describe.concurrent("User Access Rights", () => { it("has all access rights", () => { expect(root.hasAccessRight(GENERAL_ACCESS)).toBe(true); expect(root.hasAccessRight(WRITE_STOCKS_ACCESS)).toBe(true); @@ -92,7 +92,7 @@ describe("User Access Rights", () => { }); }); -describe("Message Subscriptions", () => { +describe.concurrent("Message Subscriptions", () => { it("receives messages if subscribed and access rights suffice", () => { expect(root.isAllowedAndWishesToReceiveMessage("stockUpdate")).toBe(true); expect(root.isAllowedAndWishesToReceiveMessage("fetchError")).toBe(true); diff --git a/packages/commons/src/lib/ratings/AnalystRating.test.ts b/packages/commons/src/lib/ratings/AnalystRating.test.ts new file mode 100644 index 000000000..f6226ea0a --- /dev/null +++ b/packages/commons/src/lib/ratings/AnalystRating.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { isAnalystRating } from "./AnalystRating"; + +describe.concurrent("Analyst Rating", () => { + it("is an Analyst Rating value", () => { + expect(isAnalystRating("Underperform")).toBe(true); + }); + + it("is not an Analyst Rating value", () => { + expect(isAnalystRating("Overperform")).toBe(false); + }); +}); diff --git a/packages/commons/src/lib/ratings/AnalystRating.ts b/packages/commons/src/lib/ratings/AnalystRating.ts new file mode 100644 index 000000000..1dd3a1d07 --- /dev/null +++ b/packages/commons/src/lib/ratings/AnalystRating.ts @@ -0,0 +1,18 @@ +/** + * An array of all Analyst Rating values. + */ +export const analystRatingArray = ["Sell", "Underperform", "Hold", "Outperform", "Buy"] as const; + +/** + * An Analyst Rating value. + */ +export type AnalystRating = (typeof analystRatingArray)[number]; + +/** + * Checks if a string is a valid Analyst Rating value. + * @param value The string to check. + * @returns True if the string is a valid Analyst Rating value. + */ +export function isAnalystRating(value: string): value is AnalystRating { + return analystRatingArray.includes(value as AnalystRating); +} diff --git a/packages/commons/src/lib/ratings/MSCI.test.ts b/packages/commons/src/lib/ratings/MSCI.test.ts index e8f7a4449..23e8f6b9f 100644 --- a/packages/commons/src/lib/ratings/MSCI.test.ts +++ b/packages/commons/src/lib/ratings/MSCI.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { isMSCIESGRating } from "./MSCI"; -describe("MSCI ESG Rating", () => { +describe.concurrent("MSCI ESG Rating", () => { it("is a rating", () => { expect(isMSCIESGRating("AAA")).toBe(true); }); diff --git a/packages/commons/src/lib/stylebox/Size.test.ts b/packages/commons/src/lib/stylebox/Size.test.ts index c78da2859..36f348ba8 100644 --- a/packages/commons/src/lib/stylebox/Size.test.ts +++ b/packages/commons/src/lib/stylebox/Size.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { isSize } from "./Size"; -describe("Size", () => { +describe.concurrent("Size", () => { it("is a size", () => { expect(isSize("Small")).toBe(true); }); diff --git a/packages/commons/src/lib/stylebox/Style.test.ts b/packages/commons/src/lib/stylebox/Style.test.ts index f16309cd5..cda762613 100644 --- a/packages/commons/src/lib/stylebox/Style.test.ts +++ b/packages/commons/src/lib/stylebox/Style.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { isStyle } from "./Style"; -describe("Style", () => { +describe.concurrent("Style", () => { it("is a style", () => { expect(isStyle("Blend")).toBe(true); }); diff --git a/packages/commons/vitest.config.ts b/packages/commons/vitest.config.ts index c61c89bc7..4c236064a 100644 --- a/packages/commons/vitest.config.ts +++ b/packages/commons/vitest.config.ts @@ -12,5 +12,5 @@ customLogger.error = (msg, options) => { export default defineConfig({ customLogger, cacheDir: ".vite", - test: { coverage: { all: false, enabled: true, provider: "v8" } }, + test: { coverage: { all: false, enabled: true, provider: "v8" }, poolOptions: { threads: { useAtomics: true } } }, }); diff --git a/packages/frontend/src/components/etc/Navigators.tsx b/packages/frontend/src/components/etc/Navigators.tsx index 62a6d25fa..8c0892081 100644 --- a/packages/frontend/src/components/etc/Navigators.tsx +++ b/packages/frontend/src/components/etc/Navigators.tsx @@ -52,7 +52,9 @@ export const MorningstarNavigator = (props: React.PropsWithChildren): JSX.Element => ( { {props.stock ? ( <> {props.stock?.lastClose !== null && props.stock?.low52w !== null && props.stock?.high52w !== null && ( - value.toFixed(currencyMinorUnits[props.stock.currency])} - disabled - /> + )} ) : ( - + )} @@ -475,39 +452,27 @@ export const StockDetails = (props: StockDetailsProps): JSX.Element => { - {/* Analyst Consensus */} + {/* Analyst Ratings */} - } arrow placement={tooltipPlacement}> + } arrow placement={tooltipPlacement}> Analyst
- Consensus + Ratings
{props.stock ? ( <> - {props.stock?.analystConsensus !== null && ( + {props.stock?.analystConsensus !== null && props.stock?.analystRatings !== null && ( - - {props.stock.analystConsensus}} - style={{ cursor: "inherit" }} - sx={{ - backgroundColor: theme.colors.consensus[Math.round(Math.round(props.stock.analystConsensus))], - opacity: props.stock.analystCount < 10 ? props.stock.analystCount / 10 : 1, - width: 60, - mt: "4px", - }} - size="small" - /> - + )} ) : ( - + )} {/* Analyst Target Price */} diff --git a/packages/frontend/src/components/stock/layouts/StockRow.tsx b/packages/frontend/src/components/stock/layouts/StockRow.tsx index eaa035a93..521570f19 100644 --- a/packages/frontend/src/components/stock/layouts/StockRow.tsx +++ b/packages/frontend/src/components/stock/layouts/StockRow.tsx @@ -101,6 +101,7 @@ import { SPNavigator, SustainalyticsNavigator, } from "../../etc/Navigators"; +import { AnalystRatingBar } from "../properties/AnalystRatingBar"; import { Range52WSlider } from "../properties/Range52WSlider"; import { SectorIcon } from "../properties/SectorIcon"; import { StarRating } from "../properties/StarRating"; @@ -616,20 +617,11 @@ export const StockRow = (props: StockRowProps): JSX.Element => { - {/* Analyst Consensus */} - - {props.stock.analystConsensus !== null && ( + {/* Analyst Ratings */} + + {props.stock.analystConsensus !== null && props.stock.analystRatings && ( - {props.stock.analystConsensus}} - style={{ cursor: "inherit" }} - sx={{ - backgroundColor: theme.colors.consensus[Math.round(props.stock.analystConsensus)], - opacity: props.stock.analystCount < 10 ? props.stock.analystCount / 10 : 1, - width: 60, - }} - size="small" - /> + )} @@ -783,31 +775,7 @@ export const StockRow = (props: StockRowProps): JSX.Element => { {/* 52 Week Range */} {props.stock.lastClose !== null && props.stock.low52w !== null && props.stock.high52w !== null && ( - value.toFixed(currencyMinorUnits[props.stock.currency])} - disabled - /> + )} {/* Dividend Yield */} @@ -1046,9 +1014,9 @@ export const StockRow = (props: StockRowProps): JSX.Element => { - {/* Analyst Consensus */} - - + {/* Analyst Ratings */} + + {/* Analyst Target */} diff --git a/packages/frontend/src/components/stock/layouts/StockTable.tsx b/packages/frontend/src/components/stock/layouts/StockTable.tsx index 74ba2f1e0..91bf904cd 100644 --- a/packages/frontend/src/components/stock/layouts/StockTable.tsx +++ b/packages/frontend/src/components/stock/layouts/StockTable.tsx @@ -299,15 +299,15 @@ export const StockTable: FC = (props: StockTableProps): JSX.Ele - {/* Analyst Consensus */} - + {/* Analyst Ratings */} + - } arrow> - Anlst Consns + } arrow> + Analyst Ratings diff --git a/packages/frontend/src/components/stock/layouts/StockTableFilters.tsx b/packages/frontend/src/components/stock/layouts/StockTableFilters.tsx index 0d3595345..bb43a58ca 100644 --- a/packages/frontend/src/components/stock/layouts/StockTableFilters.tsx +++ b/packages/frontend/src/components/stock/layouts/StockTableFilters.tsx @@ -29,6 +29,7 @@ import { FormControlLabel, } from "@mui/material"; import type { + AnalystRating, Country, Industry, IndustryGroup, @@ -42,6 +43,7 @@ import type { SuperSector, } from "@rating-tracker/commons"; import { + analystRatingArray, countryNameWithFlag, getCountriesInRegion, getIndustriesInGroup, @@ -82,7 +84,7 @@ export const StockTableFilters: FC = (props: StockTableF const [starRatingInput, setStarRatingInput] = useState([0, 5]); const [morningstarFairValueDiffInput, setMorningstarFairValueDiffInput] = useState([-50, 50]); - const [analystConsensusInput, setAnalystConsensusInput] = useState([0, 10]); + const [analystConsensusInput, setAnalystConsensusInput] = useState<(AnalystRating | "None")[]>(["None", "Buy"]); const [analystCountInput, setAnalystCountInput] = useState([0, 60]); const [analystTargetDiffInput, setAnalystTargetDiffInput] = useState([-50, 50]); @@ -146,8 +148,11 @@ export const StockTableFilters: FC = (props: StockTableF morningstarFairValueDiffInput[0] !== -50 ? morningstarFairValueDiffInput[0] : undefined, morningstarFairValueDiffMax: morningstarFairValueDiffInput[1] !== 50 ? morningstarFairValueDiffInput[1] : undefined, - analystConsensusMin: analystConsensusInput[0] !== 0 ? analystConsensusInput[0] : undefined, - analystConsensusMax: analystConsensusInput[1] !== 10 ? analystConsensusInput[1] : undefined, + analystConsensusMin: analystConsensusInput[0] !== "None" ? analystConsensusInput[0] : undefined, + analystConsensusMax: + analystConsensusInput[1] !== "Buy" && analystConsensusInput[1] !== "None" + ? analystConsensusInput[1] + : undefined, analystCountMin: analystCountInput[0] !== 0 ? analystCountInput[0] : undefined, analystCountMax: analystCountInput[1] !== 60 ? analystCountInput[1] : undefined, analystTargetDiffMin: analystTargetDiffInput[0] !== -50 ? analystTargetDiffInput[0] : undefined, @@ -205,7 +210,7 @@ export const StockTableFilters: FC = (props: StockTableF setPriceEarningRatioInput([0, 100]); setStarRatingInput([0, 5]); setMorningstarFairValueDiffInput([-50, 50]); - setAnalystConsensusInput([0, 10]); + setAnalystConsensusInput(["None", "Buy"]); setAnalystCountInput([0, 60]); setAnalystTargetDiffInput([-50, 50]); setMSCIESGRatingInput(["AAA", "None"]); @@ -360,12 +365,20 @@ export const StockTableFilters: FC = (props: StockTableF setAnalystConsensusInput(newValue)} + value={analystConsensusInput.map((value) => + value === "None" ? -1 : analystRatingArray.findIndex((element) => element === value), + )} + min={-1} + max={4} + step={1} + marks + onChange={(_, newValue: number[]) => + setAnalystConsensusInput( + newValue.map((value) => (value === -1 ? "None" : analystRatingArray[value])), + ) + } valueLabelDisplay="auto" + valueLabelFormat={(value) => (value === -1 ? "None" : analystRatingArray[value])} /> {/* Analyst Count */} diff --git a/packages/frontend/src/components/stock/properties/AnalystRatingBar.tsx b/packages/frontend/src/components/stock/properties/AnalystRatingBar.tsx new file mode 100644 index 000000000..118267c62 --- /dev/null +++ b/packages/frontend/src/components/stock/properties/AnalystRatingBar.tsx @@ -0,0 +1,66 @@ +import { Box, Tooltip, useTheme } from "@mui/material"; +import type { Stock } from "@rating-tracker/commons"; +import { RecordMath, analystRatingArray } from "@rating-tracker/commons"; + +/** + * A colored bar with a tooltip that is used to visualize the Analyst Ratings. + * @param props The properties of the component. + * @param props.stock The stock to display the analyst rating for. + * @returns The component. + */ +export const AnalystRatingBar = ({ stock, ...props }: AnalystRatingBarProps): JSX.Element => { + const theme = useTheme(); + const sum = RecordMath.sum(stock.analystRatings); + + let processedCount = 0; + const gradient = `linear-gradient(to right, ${analystRatingArray + .map((rating) => { + const colorStart = `${theme.colors.consensus[rating]} ${(processedCount / sum) * 100}%`; + const count = stock.analystRatings[rating]; + const colorEnd = `${theme.colors.consensus[rating]} ${((processedCount + count) / sum) * 100}%`; + processedCount += count; + return `${colorStart}, ${colorEnd}`; + }) + .join(", ")})`; + + return ( + + + + ); +}; + +interface AnalystRatingBarProps { + /** + * The stock to display the analyst rating for. + */ + stock: Pick; + /** + * The width of the slider. + */ + width?: number; +} diff --git a/packages/frontend/src/components/stock/properties/PropertyDescription.tsx b/packages/frontend/src/components/stock/properties/PropertyDescription.tsx index 4102ecea6..4c1786583 100644 --- a/packages/frontend/src/components/stock/properties/PropertyDescription.tsx +++ b/packages/frontend/src/components/stock/properties/PropertyDescription.tsx @@ -15,7 +15,7 @@ type DescribedProperty = | "esgScore" | "starRating" | "morningstarFairValue" - | "analystConsensus" + | "analystRatings" | "analystTargetPrice" | "analystCount" | "msciESGRating" @@ -111,8 +111,8 @@ export const PropertyDescription = (props: { property: DescribedProperty }): JSX rating. - Four- and 5-star ratings mean the stock is undervalued, while a 3-star rating means it’s fairly valued, and - 1- and 2-star stocks are overvalued. When looking for investments, a 5-star stock is generally a better + 4- and 5-star ratings mean the stock is undervalued, while a 3-star rating means it’s fairly valued, and 1- + and 2-star stocks are overvalued. When looking for investments, a 5-star stock is generally a better opportunity than a 1-star stock. @@ -133,18 +133,13 @@ export const PropertyDescription = (props: { property: DescribedProperty }): JSX ); - case "analystConsensus": + case "analystRatings": return ( - <> - - The consensus of analyst recommendations for a stock is calculated by aggregating the recommendations of - analysts who cover the stock and then normalizing the data to a scale of 0 to 10. - - - A score of 0 indicates a strong sell recommendation, while a score of 10 indicates a strong buy - recommendation. - - + + The consensus of analyst recommendations for a stock is defined as the mean analyst rating, i.e., the + consensus value is chosen so that half of the analysts have a more optimistic opinion on the stock and half + have a more pessimistic opinion. + ); case "analystTargetPrice": return ( diff --git a/packages/frontend/src/components/stock/properties/Range52WSlider.tsx b/packages/frontend/src/components/stock/properties/Range52WSlider.tsx index fc55e62c6..0e95906b2 100644 --- a/packages/frontend/src/components/stock/properties/Range52WSlider.tsx +++ b/packages/frontend/src/components/stock/properties/Range52WSlider.tsx @@ -1,39 +1,71 @@ -import type { SliderProps } from "@mui/material"; import { Slider, SliderMarkLabel, useTheme } from "@mui/material"; -import type { FC } from "react"; +import type { Stock } from "@rating-tracker/commons"; +import { currencyMinorUnits } from "@rating-tracker/commons"; +import React from "react"; /** * A slider that is used to display a 52-week range. * @param props The properties of the component. * @returns The component. */ -export const Range52WSlider: FC = (props: SliderProps): JSX.Element => { +export const Range52WSlider = (props: Range52WSliderProps): JSX.Element => { const theme = useTheme(); return ( value.toFixed(currencyMinorUnits[props.stock.currency])} + disabled slots={{ mark: () => undefined, // no marks - markLabel: (props) => { + markLabel: (props: { style: React.CSSProperties }) => { const style = props.style ?? {}; style.top = 18; // Align the labels based directly on the value - const position = Number(props.style.left.replace("%", "")); + const position = Number(props.style.left.toString().replace("%", "")); if (position <= Number.EPSILON) style.transform = "translateX(0%)"; if (position >= 100 * (1 - Number.EPSILON)) style.transform = "translateX(-100%)"; return ; }, }} - sx={{ - ...props.sx, - "@media (pointer: coarse)": { padding: "13px 0" }, - "& .MuiSlider-valueLabel": { - fontSize: theme.typography.body2.fontSize, - top: 0, - backgroundColor: "unset", - color: theme.palette.text.primary, - }, - }} /> ); }; + +interface Range52WSliderProps { + /** + * The stock to display the 52-week range for. + */ + stock: Stock; + /** + * The width of the slider. + */ + width?: number; +} diff --git a/packages/frontend/src/content/modules/Portfolio/Portfolio.tsx b/packages/frontend/src/content/modules/Portfolio/Portfolio.tsx index 552b8e9df..329910619 100644 --- a/packages/frontend/src/content/modules/Portfolio/Portfolio.tsx +++ b/packages/frontend/src/content/modules/Portfolio/Portfolio.tsx @@ -47,6 +47,8 @@ import { superSectorOfSector, sectorOfIndustryGroup, stripPrefixFromSunburstID, + getAnalystRatingDistribution, + getWeightedMeanAnalystConsensus, } from "@rating-tracker/commons"; import { animated } from "@react-spring/web"; import { useEffect, useState } from "react"; @@ -59,6 +61,7 @@ import { YellowIconChip } from "../../../components/chips/YellowIconChip"; import { Footer } from "../../../components/etc/Footer"; import { HeaderWrapper } from "../../../components/etc/HeaderWrapper"; import { StockTable } from "../../../components/stock/layouts/StockTable"; +import { AnalystRatingBar } from "../../../components/stock/properties/AnalystRatingBar"; import { getSectorIconPaths } from "../../../components/stock/properties/SectorIcon"; import { StarRating } from "../../../components/stock/properties/StarRating"; import { useNotificationContextUpdater } from "../../../contexts/NotificationContext"; @@ -121,7 +124,8 @@ const PortfolioModule = (): JSX.Element => { const percentageToMorningstarFairValue = portfolio ? getPercentageToTotalAmount(portfolio, "morningstarFairValue") : null; - const analystConsensus = portfolio ? getWeightedAverage(portfolio, "analystConsensus") : null; + const analystConsensus = portfolio ? getWeightedMeanAnalystConsensus(portfolio) : null; + const analystRatings = portfolio ? getAnalystRatingDistribution(portfolio) : null; const analystTargetPrice = portfolio ? getEstimateValue(portfolio, "analystTargetPrice") : null; const percentageToAnalystTargetPrice = portfolio ? getPercentageToTotalAmount(portfolio, "analystTargetPrice") : null; @@ -415,23 +419,16 @@ const PortfolioModule = (): JSX.Element => { {portfolio ? ( <> - {analystConsensus !== null && ( + {analystConsensus !== null && analystRatings !== null && ( - {analystConsensus?.toFixed(1)}} - style={{ cursor: "inherit" }} - sx={{ - backgroundColor: theme.colors.consensus[Math.round(analystConsensus)], - width: 60, - mt: "4px", - }} - size="small" - /> + + + )} ) : ( - + )} {/* Analyst Target Price */} diff --git a/packages/frontend/src/content/modules/PortfolioBuilder/PortfolioBuilder.tsx b/packages/frontend/src/content/modules/PortfolioBuilder/PortfolioBuilder.tsx index 055005a07..fee57e2f2 100644 --- a/packages/frontend/src/content/modules/PortfolioBuilder/PortfolioBuilder.tsx +++ b/packages/frontend/src/content/modules/PortfolioBuilder/PortfolioBuilder.tsx @@ -61,6 +61,7 @@ import type { } from "@rating-tracker/commons"; import { FAVORITES_NAME, + RecordMath, currencyMinorUnits, groupOfIndustry, isCurrency, @@ -179,25 +180,25 @@ const PortfolioBuilderModule = (): JSX.Element => { /** * The sum of all region constraints. Must be 1 in order to be able to proceed to the next step. */ - let regionConstraintsSum = Object.values(regionConstraints).reduce((a, b) => a + b); + let regionConstraintsSum = RecordMath.sum(regionConstraints); if (Math.abs(regionConstraintsSum - 1) < regionArray.length * Number.EPSILON) regionConstraintsSum = 1; /** * The sum of all sector constraints. Must be 1 in order to be able to proceed to the next step. */ - let sectorConstraintsSum = Object.values(sectorConstraints).reduce((a, b) => a + b); + let sectorConstraintsSum = RecordMath.sum(sectorConstraints); if (Math.abs(sectorConstraintsSum - 1) < sectorArray.length * Number.EPSILON) sectorConstraintsSum = 1; /** * The sum of all size constraints. Must be 1 in order to be able to proceed to the next step. */ - let sizeConstraintsSum = Object.values(sizeConstraints).reduce((a, b) => a + b); + let sizeConstraintsSum = RecordMath.sum(sizeConstraints); if (Math.abs(sizeConstraintsSum - 1) < sizeArray.length * Number.EPSILON) sizeConstraintsSum = 1; /** * The sum of all style constraints. Must be 1 in order to be able to proceed to the next step. */ - let styleConstraintsSum = Object.values(styleConstraints).reduce((a, b) => a + b); + let styleConstraintsSum = RecordMath.sum(styleConstraints); if (Math.abs(styleConstraintsSum - 1) < styleArray.length * Number.EPSILON) styleConstraintsSum = 1; // Recompute the disabled controls when the stocks change diff --git a/packages/frontend/src/theme/ThemeProvider.tsx b/packages/frontend/src/theme/ThemeProvider.tsx index 99c883c08..c945359a5 100644 --- a/packages/frontend/src/theme/ThemeProvider.tsx +++ b/packages/frontend/src/theme/ThemeProvider.tsx @@ -1,6 +1,6 @@ import type { Theme } from "@mui/material"; import { ThemeProvider } from "@mui/material"; -import type { SuperRegion, SuperSector } from "@rating-tracker/commons"; +import type { AnalystRating, SuperRegion, SuperSector } from "@rating-tracker/commons"; import type { FC } from "react"; import React, { createContext, useEffect, useState } from "react"; @@ -105,21 +105,9 @@ declare module "@mui/material/styles" { */ sector: Record; /** - * A color spectrum from red (0) to green (10) used for the analyst consensus. + * A color spectrum from red (Sell) to green (Buy) used for the analyst ratings. */ - consensus: { - 0: string; - 1: string; - 2: string; - 3: string; - 4: string; - 5: string; - 6: string; - 7: string; - 8: string; - 9: string; - 10: string; - }; + consensus: Record; /** * The colors MSCI uses for ratings and implied temperature rises. */ @@ -295,21 +283,9 @@ declare module "@mui/material/styles" { */ sector: Record; /** - * A color spectrum from red (0) to green (10) used for the analyst consensus. + * A color spectrum from red (Sell) to green (Buy) used for the analyst ratings. */ - consensus: { - 0: string; - 1: string; - 2: string; - 3: string; - 4: string; - 5: string; - 6: string; - 7: string; - 8: string; - 9: string; - 10: string; - }; + consensus: Record; /** * The colors MSCI uses for ratings and implied temperature rises. */ diff --git a/packages/frontend/src/theme/scheme.ts b/packages/frontend/src/theme/scheme.ts index 29c8513fa..ab9157ddd 100644 --- a/packages/frontend/src/theme/scheme.ts +++ b/packages/frontend/src/theme/scheme.ts @@ -263,17 +263,11 @@ const generateScheme = (light: boolean, themeColors, colors) => ({ Sensitive: light ? "#1F55A5" : "#4F88DE", }, consensus: { - 0: light ? "#ff1943" : darken("#ff1943", 0.4), - 1: light ? "#ff353b" : darken("#ff353b", 0.4), - 2: light ? "#ff5032" : darken("#ff5032", 0.4), - 3: light ? "#ff6c2a" : darken("#ff6c2a", 0.4), - 4: light ? "#ff8721" : darken("#ff8721", 0.4), - 5: light ? "#ffa319" : darken("#ffa319", 0.4), - 6: light ? "#ddab1b" : darken("#dda51d", 0.4), - 7: light ? "#bcb31d" : darken("#bba620", 0.4), - 8: light ? "#9aba1e" : darken("#98a824", 0.4), - 9: light ? "#79c220" : darken("#76a927", 0.4), - 10: light ? "#57ca22" : darken("#54ab2b", 0.4), + Sell: "#D60A22", + Underperform: "#EA7034", + Hold: "#FFD747", + Outperform: "#81A949", + Buy: "#037B66", }, msci: { Leader: "#007567", diff --git a/packages/frontend/src/types/StockFilter.ts b/packages/frontend/src/types/StockFilter.ts index 5a71a2cca..588fc69c2 100644 --- a/packages/frontend/src/types/StockFilter.ts +++ b/packages/frontend/src/types/StockFilter.ts @@ -1,4 +1,4 @@ -import type { Country, Industry, MSCIESGRating, Size, Style } from "@rating-tracker/commons"; +import type { AnalystRating, Country, Industry, MSCIESGRating, Size, Style } from "@rating-tracker/commons"; /** * An object containing all possible values for filtering stocks. @@ -18,8 +18,8 @@ export type StockFilter = { starRatingMax?: number; morningstarFairValueDiffMin?: number; morningstarFairValueDiffMax?: number; - analystConsensusMin?: number; - analystConsensusMax?: number; + analystConsensusMin?: AnalystRating; + analystConsensusMax?: AnalystRating; analystCountMin?: number; analystCountMax?: number; analystTargetDiffMin?: number; diff --git a/packages/frontend/src/utils/formatters.test.ts b/packages/frontend/src/utils/formatters.test.ts index 9be86c7b3..5b92464e4 100644 --- a/packages/frontend/src/utils/formatters.test.ts +++ b/packages/frontend/src/utils/formatters.test.ts @@ -12,7 +12,7 @@ const stock: OmitDynamicAttributesStock = { country: "US", }; -describe("Market Capitalization Formatter", () => { +describe.concurrent("Market Capitalization Formatter", () => { it("formats trillions", () => { stock.marketCap = 1234000000000; expect(formatMarketCap(stock)).toBe("1.23 T"); @@ -39,7 +39,7 @@ describe("Market Capitalization Formatter", () => { }); }); -describe("Percentage Formatter", () => { +describe.concurrent("Percentage Formatter", () => { it("formats a simple percentage", () => { expect(formatPercentage(0.1234)).toBe("12.3 %"); }); diff --git a/packages/frontend/src/utils/portfolioComputation.test.ts b/packages/frontend/src/utils/portfolioComputation.test.ts index 104d072f0..9b1962ed3 100644 --- a/packages/frontend/src/utils/portfolioComputation.test.ts +++ b/packages/frontend/src/utils/portfolioComputation.test.ts @@ -74,7 +74,7 @@ const validateResults = ( }, ) => { // The sum of all amounts should be equal to the total amount - expect(result.weightedStocks.map((stock) => stock.amount).reduce((a, b) => a + b)).toBe(options.totalAmount); + expect(result.weightedStocks.reduce((sum, stock) => sum + stock.amount, 0)).toBe(options.totalAmount); // The constraints should be fulfilled Object.entries(constraints).forEach(([key, value]) => { @@ -88,8 +88,7 @@ const validateResults = ( return stock.size === key; } }) - .map((stock) => stock.amount) - .reduce((a, b) => a + b, 0), + .reduce((sum, stock) => sum + stock.amount, 0), ).toBe(options.totalAmount * value); }); @@ -104,7 +103,7 @@ const validateResults = ( expect(result.rse).toBe(0); }; -describe("Portfolio Computation", () => { +describe.concurrent("Portfolio Computation", () => { it("computes weights for stocks fulfilling given constraints with Sainte-Laguë/Schepers algorithm", () => { const options = { ...defaultOptions, proportionalRepresentationAlgorithm: "sainteLague" as const }; const result = computePortfolio(stocks as Stock[], constraints, options); diff --git a/packages/frontend/vite.config.mts b/packages/frontend/vite.config.mts index a3cc14ad8..1c5519059 100644 --- a/packages/frontend/vite.config.mts +++ b/packages/frontend/vite.config.mts @@ -55,6 +55,7 @@ export default mergeConfig( customLogger, test: { coverage: { all: false, enabled: true, provider: "v8" }, + poolOptions: { threads: { useAtomics: true } }, }, }), ); diff --git a/yarn.lock b/yarn.lock index 032f2123d..aa22994bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1318,6 +1318,13 @@ __metadata: languageName: node linkType: hard +"@prisma/debug@npm:5.9.1": + version: 5.9.1 + resolution: "@prisma/debug@npm:5.9.1" + checksum: 10c0/0e116019f5e8df7ec30503bcfd033e54c94a20e2a6d6abeed525eac3e0be8f40f8cd3b0cf43abad91d1ec80c3dbca8d827f753b2ccbf9eea20a05ff1cd1d12f5 + languageName: node + linkType: hard + "@prisma/engines-version@npm:5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48": version: 5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48 resolution: "@prisma/engines-version@npm:5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48" @@ -1357,6 +1364,15 @@ __metadata: languageName: node linkType: hard +"@prisma/generator-helper@npm:5.9.1": + version: 5.9.1 + resolution: "@prisma/generator-helper@npm:5.9.1" + dependencies: + "@prisma/debug": "npm:5.9.1" + checksum: 10c0/34179bc1aded0fec379393f9849046cf3ec3792ca9c052a5486720127af0a234aa0ce3f2b31b04246ac481549a028ddbd9c08259ddcbdce81e8f88db77d5d1f4 + languageName: node + linkType: hard + "@prisma/get-platform@npm:5.14.0": version: 5.14.0 resolution: "@prisma/get-platform@npm:5.14.0" @@ -1466,6 +1482,7 @@ __metadata: pino-pretty: "npm:11.0.0" prettier: "npm:3.2.5" prisma: "npm:5.14.0" + prisma-json-types-generator: "npm:3.0.4" redis: "npm:4.6.13" redis-om: "npm:0.4.3" response-time: "npm:2.3.2" @@ -7417,6 +7434,21 @@ __metadata: languageName: node linkType: hard +"prisma-json-types-generator@npm:3.0.4": + version: 3.0.4 + resolution: "prisma-json-types-generator@npm:3.0.4" + dependencies: + "@prisma/generator-helper": "npm:5.9.1" + tslib: "npm:2.6.2" + peerDependencies: + prisma: ^5.1 + typescript: ^5.1 + bin: + prisma-json-types-generator: index.js + checksum: 10c0/526c21ab3acd0c440151a42612eca086bbd2d98cbd92a0557655f630d0c99aff4078a0220881828e8509d3134ffc3f3d79743020ef9716d7ff4ca3c057b78e1d + languageName: node + linkType: hard + "prisma@npm:5.14.0": version: 5.14.0 resolution: "prisma@npm:5.14.0" @@ -8799,7 +8831,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.1, tslib@npm:^2.6.2": +"tslib@npm:2.6.2, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.1, tslib@npm:^2.6.2": version: 2.6.2 resolution: "tslib@npm:2.6.2" checksum: 10c0/e03a8a4271152c8b26604ed45535954c0a45296e32445b4b87f8a5abdb2421f40b59b4ca437c4346af0f28179780d604094eb64546bee2019d903d01c6c19bdb