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,