From 0f72e3b44af76b488dbf624a0d37e248b56e37bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Mont=C3=A9r=C3=A9mal?= Date: Mon, 11 Nov 2024 14:05:28 +0100 Subject: [PATCH] Feature: Yahoo Finance provider for stock market widget --- docs/assets/widget_stocks_demo_yahoo.png | Bin 0 -> 11193 bytes docs/widgets/info/stocks.md | 39 +++++++++++- docs/widgets/services/stocks.md | 51 +++++++++++++-- src/components/widgets/stocks/stocks.jsx | 8 +-- src/pages/api/services/proxy.js | 11 +++- src/pages/api/widgets/stocks.js | 45 +++++++++++-- src/utils/config/service-helpers.js | 2 + src/widgets/stocks/component.jsx | 36 ++++++++--- src/widgets/stocks/proxy.js | 77 +++++++++++++++++++++++ src/widgets/stocks/widget.js | 7 ++- 10 files changed, 249 insertions(+), 27 deletions(-) create mode 100644 docs/assets/widget_stocks_demo_yahoo.png create mode 100644 src/widgets/stocks/proxy.js diff --git a/docs/assets/widget_stocks_demo_yahoo.png b/docs/assets/widget_stocks_demo_yahoo.png new file mode 100644 index 0000000000000000000000000000000000000000..0bf47c8642a78471ff6aa9bb97ffe04d53ce31a2 GIT binary patch literal 11193 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 literal 0 HcmV?d00001 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: {}, }, };