Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sparklines #1312

Merged
merged 1 commit into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ Stocks and their information are presented in a paginated table which offers com

#### Automatic and scheduled data fetching from several providers

By providing identifiers for stocks from [Morningstar](https://www.morningstar.it/it/), [MarketScreener](https://www.marketscreener.com), [MSCI](https://www.msci.com/our-solutions/esg-investing/esg-ratings-climate-search-tool), [LSEG Data & Analytics](https://www.lseg.com/en/data-analytics/sustainable-finance/esg-scores), [Standard & Poor’s](https://www.spglobal.com/esg/solutions/data-intelligence-esg-scores) and [Sustainalytics](https://www.sustainalytics.com/esg-ratings) in the “Add Stock” dialog, Rating Tracker can automatically fetch financial data as well as financial and ESG ratings. The identifiers to use can be found in the provider’s URL for the stock as shown in the following examples:
By providing identifiers for stocks from [Yahoo Finance](https://finance.yahoo.com), [Morningstar](https://www.morningstar.it/it/), [MarketScreener](https://www.marketscreener.com), [MSCI](https://www.msci.com/our-solutions/esg-investing/esg-ratings-climate-search-tool), [LSEG Data & Analytics](https://www.lseg.com/en/data-analytics/sustainable-finance/esg-scores), [Standard & Poor’s](https://www.spglobal.com/esg/solutions/data-intelligence-esg-scores) and [Sustainalytics](https://www.sustainalytics.com/esg-ratings) in the “Add Stock” dialog, Rating Tracker can automatically fetch financial data as well as financial and ESG ratings. The identifiers to use can be found in the provider’s URL for the stock as shown in the following examples:

- Yahoo: `https://finance.yahoo.com/quote/`**`AAPL`** (This ticker symbol is also the primary identifier for stocks in URLs, database tables etc. If a Yahoo Finance ticker is not available for a stock, an arbitrary ticker can be prefixed with an underscore `_` to indicate that no prices must be fetched for this stock.)
- Morningstar: `https://tools.morningstar.it/it/stockreport/default.aspx?Site=it&id=`**`0P000000GY`**`&LanguageId=it-IT&SecurityToken=`**`0P000000GY`**`]3]0]E0WWE$$ALL`
- MarketScreener: `https://www.marketscreener.com/quote/stock/`**`APPLE-INC-4849`**
- MSCI: `https://www.msci.com/our-solutions/esg-investing/esg-ratings-climate-search-tool/issuer/apple-inc/`**`IID000000002157615`**
Expand Down
10 changes: 5 additions & 5 deletions packages/backend/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@
"env": {
"node": true
},
"extends": ["plugin:jsdoc/recommended-typescript", "plugin:i/recommended", "plugin:i/typescript", "google", "plugin:prettier/recommended"],
"extends": ["plugin:jsdoc/recommended-typescript", "plugin:import/recommended", "plugin:import/typescript", "google", "plugin:prettier/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"project": "./tsconfig.json"
},
"ignorePatterns": ["dist"],
"plugins": ["@typescript-eslint", "jsdoc", "i", "prettier"],
"plugins": ["@typescript-eslint", "jsdoc", "import", "prettier"],
"rules": {
"require-await": ["warn"],
"no-unused-vars": "off",
"@typescript-eslint/consistent-type-imports": ["warn"],
"@typescript-eslint/no-unused-vars": ["warn"],
"@typescript-eslint/no-floating-promises": ["warn"],
"@typescript-eslint/consistent-type-imports": ["warn", {}],
"@typescript-eslint/no-unused-vars": ["warn", {}],
"@typescript-eslint/no-floating-promises": ["warn", {}],
"@typescript-eslint/await-thenable": ["warn"],
"require-jsdoc": "off",
"valid-jsdoc": "off",
Expand Down
1 change: 0 additions & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@
"eslint": "8.57.0",
"eslint-config-google": "0.14.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-i": "2.29.1",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jsdoc": "48.2.5",
"eslint-plugin-prettier": "5.1.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Stock" ADD COLUMN "prices1mo" DOUBLE PRECISION[] DEFAULT ARRAY[]::DOUBLE PRECISION[],
ADD COLUMN "prices1y" DOUBLE PRECISION[] DEFAULT ARRAY[]::DOUBLE PRECISION[],
ADD COLUMN "yahooLastFetch" TIMESTAMP(6);

11 changes: 7 additions & 4 deletions packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,20 @@ model Stock {
financialScore Float @default(0)
esgScore Float @default(0)
totalScore Float @default(0)
yahooLastFetch DateTime? @db.Timestamp(6)
currency Currency?
lastClose Float?
low52w Float?
high52w Float?
prices1y Float[] @default([])
prices1mo Float[] @default([])
morningstarID String? @unique @db.VarChar(255)
morningstarLastFetch DateTime? @db.Timestamp(6)
starRating Int? @db.SmallInt
dividendYieldPercent Float?
priceEarningRatio Float?
currency Currency?
lastClose Float?
morningstarFairValue Float?
morningstarFairValuePercentageToLastClose Float?
low52w Float?
high52w Float?
positionIn52w Float?
marketScreenerID String? @unique @db.VarChar(255)
marketScreenerLastFetch DateTime? @db.Timestamp(6)
Expand Down
56 changes: 54 additions & 2 deletions packages/backend/src/controllers/FetchController.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Resource, Stock } from "@rating-tracker/commons";
import {
dataProviderProperties,
dataProviderTTL,
fetchAPIPath,
fetchLSEGEndpointSuffix,
Expand All @@ -8,6 +9,7 @@ import {
fetchMSCIEndpointSuffix,
fetchSPEndpointSuffix,
fetchSustainalyticsEndpointSuffix,
fetchYahooEndpointSuffix,
GENERAL_ACCESS,
WRITE_STOCKS_ACCESS,
} from "@rating-tracker/commons";
Expand Down Expand Up @@ -38,6 +40,46 @@ class FetchController extends SingletonController {
path = fetchAPIPath;
tags = ["Fetch API"];

/**
* Fetches information from Yahoo Finance API.
* @param req Request object
* @param res Response object
* @throws an {@link APIError} in case of a severe error
*/
@Endpoint({
spec: {
summary: "Fetch data from Yahoo Finance",
description: "Fetches information from Yahoo Finance API.",
parameters: [
{
...stock.ticker,
description:
"The ticker of a stock for which information is to be fetched. " +
"If not present, all stocks known to the system will be used",
},
fetch.detach,
fetch.noSkip,
fetch.clear,
fetch.concurrency,
],
responses: {
"200": okStockList,
"202": accepted,
"204": noContent,
"401": unauthorized,
"403": forbidden,
"404": notFound,
"502": badGateway,
},
},
method: "post",
path: fetchYahooEndpointSuffix,
accessRights: GENERAL_ACCESS + WRITE_STOCKS_ACCESS,
})
fetchYahooData: RequestHandler = async (req: Request, res: Response) => {
await fetchFromDataProvider(req, res, "yahoo");
};

/**
* Fetches information from Morningstar Italy web page.
* @param req Request object
Expand Down Expand Up @@ -350,8 +392,18 @@ class FetchController extends SingletonController {

const sustainalyticsXMLLines = sustainalyticsXMLResource.content.split("\n");

for await (const stock of stockList) {
let sustainalyticsESGRisk: number = req.query.clear ? null : undefined;
for await (let stock of stockList) {
if (req.query.clear) {
await updateStock(
stock.ticker,
dataProviderProperties["sustainalytics"].reduce((obj, key) => ({ ...obj, [key]: null }), {}),
undefined,
true,
);
stock = await readStock(stock.ticker);
}

let sustainalyticsESGRisk: number = undefined;

try {
// Look for the Sustainalytics ID in the XML lines.
Expand Down
150 changes: 142 additions & 8 deletions packages/backend/src/controllers/StocksController.live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,8 +638,18 @@ tests.push({
expect((res.body as Stock).name).toEqual("Apple Inc");
expect((res.body as Stock).morningstarID).toEqual("0P012345678");

// We can also update a stock’s ticker:
// attempting to update a non-existent stock results in an error
res = await supertest
.patch(`${baseURL}${stocksAPIPath}/doesNotExist?morningstarID=0P123456789`)
.set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(404);
},
});

tests.push({
testName: "[unsafe] updates a stock’s ticker",
testFunction: async () => {
let res = await supertest
.patch(`${baseURL}${stocksAPIPath}/exampleALV?ticker=exampleALV.DE`)
.set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(204);
Expand All @@ -648,31 +658,155 @@ tests.push({
expect((res.body as Stock).ticker).toEqual("exampleALV.DE");
expect((res.body as Stock).name).toEqual("Allianz SE");

// attempting to update a non-existent stock results in an error
// Now we indicate with an underscore prefix that no price information is available:
res = await supertest
.patch(`${baseURL}${stocksAPIPath}/doesNotExist?morningstarID=0P123456789`)
.patch(`${baseURL}${stocksAPIPath}/exampleALV.DE?ticker=_exampleALV.DE`)
.set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(404);

// updating a unique ID to an empty string results in the ID being null
expect(res.status).toBe(204);
res = await supertest
.patch(`${baseURL}${stocksAPIPath}/exampleAAPL?morningstarID=&spID=`)
.get(`${baseURL}${stocksAPIPath}/_exampleALV.DE`)
.set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(200);
expect((res.body as Stock).ticker).toEqual("_exampleALV.DE");
expect((res.body as Stock).name).toEqual("Allianz SE");
expect((res.body as Stock).currency).toBeNull();
expect((res.body as Stock).lastClose).toBeNull();
expect((res.body as Stock).low52w).toBeNull();
expect((res.body as Stock).high52w).toBeNull();
expect((res.body as Stock).prices1y).toHaveLength(0);
expect((res.body as Stock).prices1mo).toHaveLength(0);
},
});

tests.push({
testName: "[unsafe] updates a stock’s Morningstar ID to an empty string",
testFunction: async () => {
let res = await supertest
.patch(`${baseURL}${stocksAPIPath}/exampleAAPL?morningstarID=`)
.set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(204);
res = await supertest.get(`${baseURL}${stocksAPIPath}/exampleAAPL`).set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(200);
expect((res.body as Stock).name).toEqual("Apple Inc");
expect((res.body as Stock).morningstarID).toBeNull();
expect((res.body as Stock).spID).toBeNull();
// Removing a data provider’s ID removes attribute values related to it as well
expect((res.body as Stock).industry).toBeNull();
expect((res.body as Stock).size).toBeNull();
expect((res.body as Stock).style).toBeNull();
expect((res.body as Stock).starRating).toBeNull();
expect((res.body as Stock).dividendYieldPercent).toBeNull();
expect((res.body as Stock).priceEarningRatio).toBeNull();
expect((res.body as Stock).morningstarFairValue).toBeNull();
expect((res.body as Stock).morningstarFairValuePercentageToLastClose).toBeNull();
expect((res.body as Stock).marketCap).toBeNull();
expect((res.body as Stock).description).toBeNull();

// Losing information is always bad:
expect(sentMessages[0].message).toMatch("🔴");
expect(sentMessages[0].message).not.toMatch("🟢");
},
});

tests.push({
testName: "[unsafe] updates a stock’s Market Screener ID to an empty string",
testFunction: async () => {
let res = await supertest
.patch(`${baseURL}${stocksAPIPath}/exampleAAPL?marketScreenerID=`)
.set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(204);
res = await supertest.get(`${baseURL}${stocksAPIPath}/exampleAAPL`).set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(200);
expect((res.body as Stock).name).toEqual("Apple Inc");
expect((res.body as Stock).marketScreenerID).toBeNull();
// Removing a data provider’s ID removes attribute values related to it as well
expect((res.body as Stock).analystConsensus).toBeNull();
expect((res.body as Stock).analystRatings).toBeNull();
expect((res.body as Stock).analystCount).toBeNull();
expect((res.body as Stock).analystTargetPrice).toBeNull();

// Losing information is always bad:
expect(sentMessages[0].message).toMatch("🔴");
expect(sentMessages[0].message).not.toMatch("🟢");
},
});

tests.push({
testName: "[unsafe] updates a stock’s MSCI ID to an empty string",
testFunction: async () => {
let res = await supertest
.patch(`${baseURL}${stocksAPIPath}/exampleAAPL?msciID=`)
.set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(204);
res = await supertest.get(`${baseURL}${stocksAPIPath}/exampleAAPL`).set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(200);
expect((res.body as Stock).name).toEqual("Apple Inc");
expect((res.body as Stock).msciID).toBeNull();
// Removing a data provider’s ID removes attribute values related to it as well
expect((res.body as Stock).msciESGRating).toBeNull();
expect((res.body as Stock).msciTemperature).toBeNull();

// Losing information is always bad:
expect(sentMessages[0].message).toMatch("🔴");
expect(sentMessages[0].message).not.toMatch("🟢");
},
});

tests.push({
testName: "[unsafe] updates a stock’s RIC to an empty string",
testFunction: async () => {
let res = await supertest
.patch(`${baseURL}${stocksAPIPath}/exampleAAPL?ric=`)
.set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(204);
res = await supertest.get(`${baseURL}${stocksAPIPath}/exampleAAPL`).set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(200);
expect((res.body as Stock).name).toEqual("Apple Inc");
expect((res.body as Stock).ric).toBeNull();
// Removing a data provider’s ID removes attribute values related to it as well
expect((res.body as Stock).lsegESGScore).toBeNull();
expect((res.body as Stock).lsegEmissions).toBeNull();

// Losing information is always bad:
expect(sentMessages[0].message).toMatch("🔴");
expect(sentMessages[0].message).not.toMatch("🟢");
},
});

tests.push({
testName: "[unsafe] updates a stock’s S&P ID to an empty string",
testFunction: async () => {
let res = await supertest
.patch(`${baseURL}${stocksAPIPath}/exampleAAPL?spID=`)
.set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(204);
res = await supertest.get(`${baseURL}${stocksAPIPath}/exampleAAPL`).set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(200);
expect((res.body as Stock).name).toEqual("Apple Inc");
expect((res.body as Stock).spID).toBeNull();
// Removing a data provider’s ID removes attribute values related to it as well
expect((res.body as Stock).spESGScore).toBeNull();

// Losing information is always bad:
expect(sentMessages[0].message).toMatch("🔴");
expect(sentMessages[0].message).not.toMatch("🟢");
},
});

tests.push({
testName: "[unsafe] updates a stock’s Sustainalytics ID to an empty string",
testFunction: async () => {
let res = await supertest
.patch(`${baseURL}${stocksAPIPath}/exampleAAPL?sustainalyticsID=`)
.set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(204);
res = await supertest.get(`${baseURL}${stocksAPIPath}/exampleAAPL`).set("Cookie", ["authToken=exampleSessionID"]);
expect(res.status).toBe(200);
expect((res.body as Stock).name).toEqual("Apple Inc");
expect((res.body as Stock).sustainalyticsID).toBeNull();
// Removing a data provider’s ID removes attribute values related to it as well
expect((res.body as Stock).sustainalyticsESGRisk).toBeNull();

// Losing information is always bad:
expect(sentMessages[0].message).toMatch("🔴");
expect(sentMessages[0].message).not.toMatch("🟢");
},
Expand Down
Loading