From 231e2408c30b377c3a7feae2be2c3f0696c5b28b Mon Sep 17 00:00:00 2001 From: Dylan Ullrich Date: Mon, 1 Jul 2024 17:16:10 -0700 Subject: [PATCH] Feature: stock market service and info widget (#3617) --------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/assets/widget_stocks_demo.png | Bin 0 -> 9468 bytes docs/widgets/info/stocks.md | 48 ++++++++++ docs/widgets/services/stocks.md | 50 +++++++++++ mkdocs.yml | 2 + public/locales/en/common.json | 7 ++ src/components/widgets/stocks/stocks.jsx | 91 +++++++++++++++++++ src/components/widgets/widget.jsx | 1 + src/pages/api/widgets/stocks.js | 76 ++++++++++++++++ src/utils/config/service-helpers.js | 8 ++ src/utils/proxy/handlers/credentialed.js | 8 +- src/widgets/components.js | 1 + src/widgets/stocks/component.jsx | 110 +++++++++++++++++++++++ src/widgets/stocks/widget.js | 21 +++++ src/widgets/widgets.js | 2 + 14 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 docs/assets/widget_stocks_demo.png create mode 100644 docs/widgets/info/stocks.md create mode 100644 docs/widgets/services/stocks.md create mode 100644 src/components/widgets/stocks/stocks.jsx create mode 100644 src/pages/api/widgets/stocks.js create mode 100644 src/widgets/stocks/component.jsx create mode 100644 src/widgets/stocks/widget.js diff --git a/docs/assets/widget_stocks_demo.png b/docs/assets/widget_stocks_demo.png new file mode 100644 index 0000000000000000000000000000000000000000..0d2cde530dd1d2be36055cc98fb8cd06a37a8aac GIT binary patch literal 9468 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 + } + + if (!data) { + return ( + + + {t("stocks.loading")}... + + ); + } + + if (data) { + return ( + + + + + + ); + } +} diff --git a/src/components/widgets/widget.jsx b/src/components/widgets/widget.jsx index b4fdb1434..c7f0bf4df 100644 --- a/src/components/widgets/widget.jsx +++ b/src/components/widgets/widget.jsx @@ -15,6 +15,7 @@ const widgetMappings = { openmeteo: dynamic(() => import("components/widgets/openmeteo/openmeteo")), longhorn: dynamic(() => import("components/widgets/longhorn/longhorn")), kubernetes: dynamic(() => import("components/widgets/kubernetes/kubernetes")), + stocks: dynamic(() => import("components/widgets/stocks/stocks")), }; export default function Widget({ widget, style }) { diff --git a/src/pages/api/widgets/stocks.js b/src/pages/api/widgets/stocks.js new file mode 100644 index 000000000..d80842e1b --- /dev/null +++ b/src/pages/api/widgets/stocks.js @@ -0,0 +1,76 @@ +import cachedFetch from "utils/proxy/cached-fetch"; +import { getSettings } from "utils/config/config"; + +export default async function handler(req, res) { + const { watchlist, provider, cache } = req.query; + + if (!watchlist) { + return res.status(400).json({ error: "Missing watchlist" }); + } + + const watchlistArr = watchlist.split(",") || [watchlist]; + + if (!watchlistArr.length || watchlistArr[0] === "null" || !watchlistArr[0]) { + return res.status(400).json({ error: "Missing watchlist" }); + } + + if (watchlistArr.length > 8) { + return res.status(400).json({ error: "Max items in watchlist is 8" }); + } + + const hasDuplicates = new Set(watchlistArr).size !== watchlistArr.length; + + if (hasDuplicates) { + return res.status(400).json({ error: "Watchlist contains duplicates" }); + } + + if (!provider) { + return res.status(400).json({ error: "Missing provider" }); + } + + if (provider !== "finnhub") { + return res.status(400).json({ error: "Invalid provider" }); + } + + const providersInConfig = getSettings()?.providers; + + let apiKey; + Object.entries(providersInConfig).forEach(([key, val]) => { + if (key === provider) apiKey = val; + }); + + if (typeof apiKey === "undefined") { + return res.status(400).json({ error: "Missing or invalid API Key for provider" }); + } + + if (provider === "finnhub") { + // Finnhub allows up to 30 calls/second + // https://finnhub.io/docs/api/rate-limit + const results = await Promise.all( + watchlistArr.map(async (ticker) => { + if (!ticker) { + return { ticker: null, currentPrice: null, percentChange: null }; + } + // https://finnhub.io/docs/api/quote + const apiUrl = `https://finnhub.io/api/v1/quote?symbol=${ticker}&token=${apiKey}`; + // Finnhub free accounts allow up to 60 calls/minute + // https://finnhub.io/pricing + const { c, dp } = await cachedFetch(apiUrl, cache || 1); + + // API sometimes returns 200, but values returned are `null` + if (c === null || dp === null) { + return { ticker, currentPrice: null, percentChange: null }; + } + + // Rounding percentage, but we want it back to a number for comparison + return { ticker, currentPrice: c.toFixed(2), percentChange: parseFloat(dp.toFixed(2)) }; + }), + ); + + return res.send({ + stocks: results, + }); + } + + return res.status(400).json({ error: "Invalid configuration" }); +} diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index 7de077bff..93b5b1b6f 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -457,6 +457,10 @@ export function cleanServiceGroups(groups) { // sonarr, radarr enableQueue, + // stocks + watchlist, + showUSMarketStatus, + // truenas enablePools, nasType, @@ -600,6 +604,10 @@ export function cleanServiceGroups(groups) { cleanedService.widget.bitratePrecision = parseInt(bitratePrecision, 10); } } + if (type === "stocks") { + if (watchlist) cleanedService.widget.watchlist = watchlist; + if (showUSMarketStatus) cleanedService.widget.showUSMarketStatus = showUSMarketStatus; + } if (type === "wgeasy") { if (threshold !== undefined) cleanedService.widget.threshold = parseInt(threshold, 10); } diff --git a/src/utils/proxy/handlers/credentialed.js b/src/utils/proxy/handlers/credentialed.js index 82e79550a..425228023 100644 --- a/src/utils/proxy/handlers/credentialed.js +++ b/src/utils/proxy/handlers/credentialed.js @@ -3,6 +3,7 @@ import { formatApiCall, sanitizeErrorURL } from "utils/proxy/api-helpers"; import validateWidgetData from "utils/proxy/validate-widget-data"; import { httpProxy } from "utils/proxy/http"; import createLogger from "utils/logger"; +import { getSettings } from "utils/config/config"; import widgets from "widgets/widgets"; const logger = createLogger("credentialedProxyHandler"); @@ -24,7 +25,12 @@ export default async function credentialedProxyHandler(req, res, map) { "Content-Type": "application/json", }; - if (widget.type === "coinmarketcap") { + if (widget.type === "stocks") { + const { providers } = getSettings(); + if (widget.provider === "finnhub" && providers?.finnhub) { + headers["X-Finnhub-Token"] = `${providers?.finnhub}`; + } + } else if (widget.type === "coinmarketcap") { headers["X-CMC_PRO_API_KEY"] = `${widget.key}`; } else if (widget.type === "gotify") { headers["X-gotify-Key"] = `${widget.key}`; diff --git a/src/widgets/components.js b/src/widgets/components.js index 6b31b0778..215de0421 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -103,6 +103,7 @@ const components = { sonarr: dynamic(() => import("./sonarr/component")), speedtest: dynamic(() => import("./speedtest/component")), stash: dynamic(() => import("./stash/component")), + stocks: dynamic(() => import("./stocks/component")), strelaysrv: dynamic(() => import("./strelaysrv/component")), swagdashboard: dynamic(() => import("./swagdashboard/component")), tailscale: dynamic(() => import("./tailscale/component")), diff --git a/src/widgets/stocks/component.jsx b/src/widgets/stocks/component.jsx new file mode 100644 index 000000000..844365cb2 --- /dev/null +++ b/src/widgets/stocks/component.jsx @@ -0,0 +1,110 @@ +import { useTranslation } from "next-i18next"; +import classNames from "classnames"; + +import Container from "components/services/widget/container"; +import Block from "components/services/widget/block"; +import useWidgetAPI from "utils/proxy/use-widget-api"; + +function MarketStatus({ service }) { + const { t } = useTranslation(); + const { widget } = service; + + const { data, error } = useWidgetAPI(widget, "status", { + exchange: "US", + }); + + if (error || data?.error) { + return ; + } + + if (!data) { + return ( + + + + ); + } + + const { isOpen } = data; + + if (isOpen) { + return ( + + {t("stocks.open")} + + ); + } + + return ( + + {t("stocks.closed")} + + ); +} + +function StockItem({ service, ticker }) { + const { t } = useTranslation(); + const { widget } = service; + + const { data, error } = useWidgetAPI(widget, "quote", { symbol: ticker }); + + if (error || data?.error) { + return ; + } + + if (!data) { + return ( + + + + ); + } + + return ( +
+ {ticker} +
+ 0 ? "text-emerald-300" : "text-rose-300"}`}> + {data.dp?.toFixed(2) ? `${data.dp?.toFixed(2)}%` : t("widget.api_error")} + + + {data.c + ? t("common.number", { + value: data?.c, + style: "currency", + currency: "USD", + }) + : t("widget.api_error")} + +
+
+ ); +} + +export default function Component({ service }) { + const { t } = useTranslation(); + const { widget } = service; + const { watchlist, showUSMarketStatus } = widget; + + if (!watchlist || !watchlist.length || watchlist.length > 28 || new Set(watchlist).size !== watchlist.length) { + return ( + + + + ); + } + + return ( + +
+ {showUSMarketStatus === true && } +
+ +
+ {watchlist.map((ticker) => ( + + ))} +
+
+ ); +} diff --git a/src/widgets/stocks/widget.js b/src/widgets/stocks/widget.js new file mode 100644 index 000000000..c26274ed9 --- /dev/null +++ b/src/widgets/stocks/widget.js @@ -0,0 +1,21 @@ +import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; + +const widget = { + api: `https://finnhub.io/api/{endpoint}`, + proxyHandler: credentialedProxyHandler, + + mappings: { + quote: { + // https://finnhub.io/docs/api/quote + endpoint: "v1/quote", + params: ["symbol"], + }, + status: { + // https://finnhub.io/docs/api/market-status + endpoint: "v1/stock/market-status", + params: ["exchange"], + }, + }, +}; + +export default widget; diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index da29dcc9e..c053e6c1b 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -94,6 +94,7 @@ import scrutiny from "./scrutiny/widget"; import sonarr from "./sonarr/widget"; import speedtest from "./speedtest/widget"; import stash from "./stash/widget"; +import stocks from "./stocks/widget"; import strelaysrv from "./strelaysrv/widget"; import swagdashboard from "./swagdashboard/widget"; import tailscale from "./tailscale/widget"; @@ -215,6 +216,7 @@ const widgets = { sonarr, speedtest, stash, + stocks, strelaysrv, swagdashboard, tailscale,