diff --git a/docs/assets/widget_stocks_demo.png b/docs/assets/widget_stocks_demo.png new file mode 100644 index 000000000..0d2cde530 Binary files /dev/null and b/docs/assets/widget_stocks_demo.png differ diff --git a/docs/widgets/info/stocks.md b/docs/widgets/info/stocks.md new file mode 100644 index 000000000..548bedb43 --- /dev/null +++ b/docs/widgets/info/stocks.md @@ -0,0 +1,48 @@ +--- +title: Stocks +description: Stocks Information Widget Configuration +--- + +_(Find the Stocks service widget [here](../services/stocks.md))_ + +The Stocks Information Widget allows you to include basic stock market data in +your Homepage header. The widget includes the current price of a stock, and the +change in price for the day. + +Finnhub.io is currently the only supported provider for the stocks widget. +You can sign up for a free api key at [finnhub.io](https://finnhub.io). +You are encouraged to read finnhub.io's +[terms of service/privacy policy](https://finnhub.io/terms-of-service) before +signing up. The documentation for the endpoint that is utilized can be viewed +[here](https://finnhub.io/docs/api/quote). + +You must set `finnhub` as a provider in your `settings.yaml` like below: + +```yaml +providers: + finnhub: yourfinnhubapikeyhere +``` + +Next, configure the stocks widget in your `widgets.yaml`: + +The information widget allows for up to 8 items in the watchlist. + +```yaml +- stocks: + provider: finnhub + color: true # optional, defaults to true + cache: 1 # optional, default caches results for 1 minute + watchlist: + - GME + - AMC + - NVDA + - AMD + - TSM + - MSFT + - AAPL + - BRK.A +``` + +The above configuration would result in something like this: + +![Example of Stocks Widget](../../assets/widget_stocks_demo.png) diff --git a/docs/widgets/services/stocks.md b/docs/widgets/services/stocks.md new file mode 100644 index 000000000..5d64c9aca --- /dev/null +++ b/docs/widgets/services/stocks.md @@ -0,0 +1,50 @@ +--- +title: Stocks +description: Stocks Service Widget Configuration +--- + +_(Find the Stocks information widget [here](../info/stocks.md))_ + +The widget includes: + +- US stock market status +- Current price of provided stock symbol +- Change in price of stock symbol for the day. + +Finnhub.io is currently the only supported provider for the stocks widget. +You can sign up for a free api key at [finnhub.io](https://finnhub.io). +You are encouraged to read finnhub.io's +[terms of service/privacy policy](https://finnhub.io/terms-of-service) before +signing up. + +Allowed fields: no configurable fields for this widget. + +You must set `finnhub` as a provider in your `settings.yaml`: + +```yaml +providers: + finnhub: yourfinnhubapikeyhere +``` + +Next, configure the stocks widget in your `services.yaml`: + +The service widget allows for up to 28 items in the watchlist. You may get rate +limited if using the information and service widgets together. + +```yaml +widget: + type: stocks + provider: finnhub + showUSMarketStatus: true # optional, defaults to true + watchlist: + - GME + - AMC + - NVDA + - TSM + - BRK.A + - TSLA + - AAPL + - MSFT + - AMZN + - BRK.B +``` diff --git a/mkdocs.yml b/mkdocs.yml index a6a1f3d56..26f5fe0a2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -130,6 +130,7 @@ nav: - widgets/services/sonarr.md - widgets/services/speedtest-tracker.md - widgets/services/stash.md + - widgets/services/stocks.md - widgets/services/swagdashboard.md - widgets/services/syncthing-relay-server.md - widgets/services/tailscale.md @@ -160,6 +161,7 @@ nav: - widgets/info/openweathermap.md - widgets/info/resources.md - widgets/info/search.md + - widgets/info/stocks.md - widgets/info/unifi_controller.md - widgets/info/weather.md - "Learn": diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 469b98bf4..c0314d4e5 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -893,5 +893,12 @@ "ping": "Ping", "download": "Download", "upload": "Upload" + }, + "stocks": { + "stocks": "Stocks", + "loading": "Loading", + "open": "Open - US Market", + "closed": "Closed - US Market", + "invalidConfiguration": "Invalid Configuration" } } diff --git a/src/components/widgets/stocks/stocks.jsx b/src/components/widgets/stocks/stocks.jsx new file mode 100644 index 000000000..8c2c03fd0 --- /dev/null +++ b/src/components/widgets/stocks/stocks.jsx @@ -0,0 +1,91 @@ +import useSWR from "swr"; +import { useState } from "react"; +import { useTranslation } from "next-i18next"; +import { FaChartLine } from "react-icons/fa6"; + +import Error from "../widget/error"; +import Container from "../widget/container"; +import PrimaryText from "../widget/primary_text"; +import WidgetIcon from "../widget/widget_icon"; +import Raw from "../widget/raw"; + +export default function Widget({ options }) { + const { t, i18n } = useTranslation(); + + const [viewingPercentChange, setViewingPercentChange] = useState(false); + + const { color } = options; + + const { data, error } = useSWR( + `/api/widgets/stocks?${new URLSearchParams({ lang: i18n.language, ...options }).toString()}`, + ); + + if (error || data?.error) { + return ; + } + + 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,