diff --git a/docs/assets/widget_stocks_demo_yahoo.png b/docs/assets/widget_stocks_demo_yahoo.png new file mode 100644 index 00000000..0bf47c86 Binary files /dev/null and b/docs/assets/widget_stocks_demo_yahoo.png differ diff --git a/docs/widgets/info/stocks.md b/docs/widgets/info/stocks.md index 548bedb4..9b04fcdd 100644 --- a/docs/widgets/info/stocks.md +++ b/docs/widgets/info/stocks.md @@ -9,7 +9,10 @@ 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. + +#### Finnhub.io + +Finnhub.io free API only supports US stocks. 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 @@ -46,3 +49,37 @@ The information widget allows for up to 8 items in the watchlist. The above configuration would result in something like this: ![Example of Stocks Widget](../../assets/widget_stocks_demo.png) + +#### Yahoo Finance +Yahoo Finance is a free provider and doesn't require any API key or authentication. +The API is not officially supported by Yahoo, so it may be subject to change or may be unreliable (e.g. rate limited). +Some data may be delayed for up to 15 minutes depending on the stock exchange. + +You may use the quote lookup tool on the [Yahoo Finance website](https://finance.yahoo.com/) to find the symbol you are interested in. + +Generally the following rules apply: + - US stocks: `TICKER` + - International stocks: `TICKER.STOCKEXCHANGE` + - Indices: `^INDEX` + - Forex: `TICKER=X` + - Cryptocurrencies: `TICKER-CURRENCY` + - Commodities/futures: `TICKER=F` + - Options: `TICKERDATESTRIKE` **(not recommended due to the excessive length)** + +```yaml +- stocks: + provider: yahoofinance + color: true # optional, defaults to true + cache: 1 # optional, default caches results for 1 minute + watchlist: + - AAPL + - LLOY.L + - ^STOXX + - JPY=X + - BTC-EUR + - GC=F + - NQ=F +``` +The above configuration would result in something like this: + +![Example of Stocks Widget](../../assets/widget_stocks_demo_yahoo.png) \ No newline at end of file diff --git a/docs/widgets/services/stocks.md b/docs/widgets/services/stocks.md index 5d64c9ac..2b7bbf92 100644 --- a/docs/widgets/services/stocks.md +++ b/docs/widgets/services/stocks.md @@ -5,13 +5,25 @@ description: Stocks Service Widget Configuration _(Find the Stocks information widget [here](../info/stocks.md))_ -The widget includes: +The widget supports: -- US stock market status +- US stock market status (finnhub only) - Current price of provided stock symbol -- Change in price of stock symbol for the day. +- Change in price of stock symbol for the day + +Additionally, using Yahoo Finance as a provider, the widget additionally supports: + +- International stocks +- Indices +- Forex +- Cryptocurrencies +- Commodities +- Futures +- Options + + +#### Finnhub.io -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 @@ -48,3 +60,34 @@ widget: - AMZN - BRK.B ``` + +#### Yahoo Finance +Yahoo Finance is a free provider and doesn't require any API key or authentication. +The API is not officially supported by Yahoo, so it may be subject to change or may be unreliable (e.g. rate limited). +Some data may be delayed for up to 15 minutes depending on the stock exchange. + +You may use the quote lookup tool on the [Yahoo Finance website](https://finance.yahoo.com/) to find the symbol you are interested in. + +Generally the following rules apply: + - US stocks: `TICKER` + - International stocks: `TICKER.STOCKEXCHANGE` + - Indices: `^INDEX` + - Forex: `TICKER=X` + - Cryptocurrencies: `TICKER-CURRENCY` + - Commodities/futures: `TICKER=F` + - Options: `TICKERDATESTRIKE` + +```yaml +widget: + type: stocks + provider: yahoofinance + watchlist: + - AAPL + - LLOY.L + - ^STOXX + - JPY=X + - BTC-EUR + - GC=F + - NQ=F + - NVDA270115C00250000 +``` diff --git a/src/components/widgets/stocks/stocks.jsx b/src/components/widgets/stocks/stocks.jsx index 8c2c03fd..d4ca7d23 100644 --- a/src/components/widgets/stocks/stocks.jsx +++ b/src/components/widgets/stocks/stocks.jsx @@ -62,10 +62,10 @@ export default function Widget({ options }) { > {stock.currentPrice !== null ? t("common.number", { - value: stock.currentPrice, - style: "currency", - currency: "USD", - }) + value: stock.currentPrice, + style: "currency", + currency: stock.currency || "USD", + }) : t("widget.api_error")} ) : ( diff --git a/src/pages/api/services/proxy.js b/src/pages/api/services/proxy.js index 90280c3d..08f70335 100644 --- a/src/pages/api/services/proxy.js +++ b/src/pages/api/services/proxy.js @@ -34,14 +34,21 @@ export default async function handler(req, res) { // map opaque endpoints to their actual endpoint if (widget?.mappings) { - const mapping = widget?.mappings?.[req.query.endpoint]; + let mapping = widget?.mappings?.[req.query.endpoint]; + // The none mapping is used to bypass the mapping - the endpoint is mapped to itself + if (!mapping && widget?.mappings?.none) { + mapping = { + endpoint: req.query.endpoint, + }; + } + const mappingParams = mapping?.params; const optionalParams = mapping?.optionalParams; const map = mapping?.map; const endpoint = mapping?.endpoint; const endpointProxy = mapping?.proxyHandler || serviceProxyHandler; - if (mapping.method && mapping.method !== req.method) { + if (mapping?.method && mapping.method !== req.method) { logger.debug("Unsupported method: %s", req.method); return res.status(403).json({ error: "Unsupported method" }); } diff --git a/src/pages/api/widgets/stocks.js b/src/pages/api/widgets/stocks.js index d80842e1..ee23d95c 100644 --- a/src/pages/api/widgets/stocks.js +++ b/src/pages/api/widgets/stocks.js @@ -28,19 +28,22 @@ export default async function handler(req, res) { return res.status(400).json({ error: "Missing provider" }); } - if (provider !== "finnhub") { + if (provider !== "finnhub" && provider !== "yahoofinance") { return res.status(400).json({ error: "Invalid provider" }); } const providersInConfig = getSettings()?.providers; + // Not all providers require an API key let apiKey; - Object.entries(providersInConfig).forEach(([key, val]) => { - if (key === provider) apiKey = val; - }); + if (provider === "finnhub") { + 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 (typeof apiKey === "undefined") { + return res.status(400).json({ error: "Missing or invalid API Key for provider" }); + } } if (provider === "finnhub") { @@ -72,5 +75,35 @@ export default async function handler(req, res) { }); } + if (provider === "yahoofinance") { + const results = await Promise.all( + watchlistArr.map(async (ticker) => { + if (!ticker) { + return { ticker: null, currentPrice: null, percentChange: null }; + } + + // Use the chart endpoint with minimal data points + const apiUrl = `https://query1.finance.yahoo.com/v8/finance/chart/${ticker}?period=1d&interval=1d`; + const { chart } = await cachedFetch(apiUrl, cache || 1); + + // Symbol not found + if (chart.result === null) { + return { ticker, currentPrice: null, percentChange: null }; + } + + const price = chart.result[0].meta.regularMarketPrice + const previousClose = chart.result[0].meta.chartPreviousClose + const change = previousClose ? ((price - previousClose) / previousClose) * 100 : 0.0 + + // Rounding percentage, but we want it back to a number for comparison + return { ticker, currentPrice: price.toFixed(2), percentChange: change.toFixed(2), currency: chart.result[0].meta.currency }; + }), + ); + + 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 63dfb608..43ad62c6 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -473,6 +473,7 @@ export function cleanServiceGroups(groups) { // stocks watchlist, showUSMarketStatus, + provider, // truenas enablePools, @@ -630,6 +631,7 @@ export function cleanServiceGroups(groups) { if (type === "stocks") { if (watchlist) cleanedService.widget.watchlist = watchlist; if (showUSMarketStatus) cleanedService.widget.showUSMarketStatus = showUSMarketStatus; + if (provider) cleanedService.widget.provider = provider; } if (type === "wgeasy") { if (threshold !== undefined) cleanedService.widget.threshold = parseInt(threshold, 10); diff --git a/src/widgets/stocks/component.jsx b/src/widgets/stocks/component.jsx index 844365cb..d1d2790a 100644 --- a/src/widgets/stocks/component.jsx +++ b/src/widgets/stocks/component.jsx @@ -46,7 +46,15 @@ function StockItem({ service, ticker }) { const { t } = useTranslation(); const { widget } = service; - const { data, error } = useWidgetAPI(widget, "quote", { symbol: ticker }); + let endpoint; + let queryParams; + if (widget.provider === "finnhub") { + endpoint = "quote"; + queryParams = { symbol: ticker }; + } else if (widget.provider === "yahoofinance") { + endpoint = ticker; + } + const { data, error } = useWidgetAPI(widget, endpoint, queryParams); if (error || data?.error) { return ; @@ -60,19 +68,33 @@ function StockItem({ service, ticker }) { ); } + let price; + let change; + let currency; + if (widget.provider === "finnhub") { + price = data.c; + change = data.dp; + currency = "USD"; // Finnhub free API only supports US based stocks + } else if (widget.provider === "yahoofinance") { + price = data.chart?.result[0]?.meta?.regularMarketPrice; + const previousClose = data.chart?.result[0]?.meta?.chartPreviousClose; + change = previousClose ? ((price - previousClose) / previousClose) * 100 : 0.0; + currency = data.chart?.result[0]?.meta?.currency; + } + return (
{ticker}
- 0 ? "text-emerald-300" : "text-rose-300"}`}> - {data.dp?.toFixed(2) ? `${data.dp?.toFixed(2)}%` : t("widget.api_error")} + 0 ? "text-emerald-300" : "text-rose-300"}`}> + {change != null ? `${change.toFixed(2)}%` : t("widget.api_error")} - {data.c + {price != null ? t("common.number", { - value: data?.c, + value: price, style: "currency", - currency: "USD", + currency, }) : t("widget.api_error")} @@ -97,7 +119,7 @@ export default function Component({ service }) { return (
- {showUSMarketStatus === true && } + {showUSMarketStatus === true && widget.provider === "finnhub" && }
diff --git a/src/widgets/stocks/proxy.js b/src/widgets/stocks/proxy.js new file mode 100644 index 00000000..807d17d9 --- /dev/null +++ b/src/widgets/stocks/proxy.js @@ -0,0 +1,77 @@ +import { getSettings } from "utils/config/config"; +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; +import { formatApiCall, sanitizeErrorURL } from "utils/proxy/api-helpers"; +import { httpProxy } from "utils/proxy/http"; +import validateWidgetData from "utils/proxy/validate-widget-data"; +import widgets from "widgets/widgets"; + +const logger = createLogger("stocksProxyHandler"); + +export default async function stocksProxyHandler(req, res, map) { + const { group, service, endpoint } = req.query; + + if (group && service) { + const widget = await getServiceWidget(group, service); + + if (!widgets?.[widget.type]?.api) { + return res.status(403).json({ error: "Service does not support API calls" }); + } + + if (widget) { + const { providers } = getSettings(); + + const headers = { + "Content-Type": "application/json", + }; + let baseUrl = ""; + + if (widget.provider === "finnhub" && providers?.finnhub) { + baseUrl = `https://finnhub.io/api/{endpoint}`; + headers["X-Finnhub-Token"] = `${providers?.finnhub}`; + } else if (widget.provider === "yahoofinance") { + baseUrl = `https://query1.finance.yahoo.com/v8/finance/chart/${endpoint}?period=1d&interval=1d`; + // Yahoo Finance API tends to block requests without a User-Agent header with a 429 Too Many Requests error + headers["User-Agent"] = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"; + } + + const url = new URL(formatApiCall(baseUrl, { endpoint, ...widget }).replace(/(?<=\?.*)\?/g, "&")); + const [status, contentType, data] = await httpProxy(url, { + method: req.method, + withCredentials: true, + credentials: "include", + headers, + }); + + let resultData = data; + + if (resultData.error?.url) { + resultData.error.url = sanitizeErrorURL(url); + } + + if (status === 204 || status === 304) { + return res.status(status).end(); + } + + if (status >= 400) { + logger.error("HTTP Error %d calling %s", status, url.toString()); + } + + if (status === 200) { + if (!validateWidgetData(widget, endpoint, resultData)) { + return res + .status(500) + .json({ error: { message: "Invalid data", url: sanitizeErrorURL(url), data: resultData } }); + } + if (map) resultData = map(resultData); + } + + if (contentType) res.setHeader("Content-Type", contentType); + return res.status(status).send(resultData); + } + } + + logger.debug("Invalid or missing proxy service type '%s' in group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); +} diff --git a/src/widgets/stocks/widget.js b/src/widgets/stocks/widget.js index c26274ed..0978504c 100644 --- a/src/widgets/stocks/widget.js +++ b/src/widgets/stocks/widget.js @@ -1,8 +1,8 @@ -import credentialedProxyHandler from "utils/proxy/handlers/credentialed"; +import stocksProxyHandler from "./proxy"; const widget = { - api: `https://finnhub.io/api/{endpoint}`, - proxyHandler: credentialedProxyHandler, + api: `{url}`, + proxyHandler: stocksProxyHandler, mappings: { quote: { @@ -15,6 +15,7 @@ const widget = { endpoint: "v1/stock/market-status", params: ["exchange"], }, + none: {}, }, };