Feature: Yahoo Finance provider for stock market widget

This commit is contained in:
Robin Montérémal 2024-11-11 14:05:28 +01:00
parent 794ec127cd
commit 0f72e3b44a
10 changed files with 249 additions and 27 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -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)

View File

@ -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
```

View File

@ -64,7 +64,7 @@ export default function Widget({ options }) {
? t("common.number", {
value: stock.currentPrice,
style: "currency",
currency: "USD",
currency: stock.currency || "USD",
})
: t("widget.api_error")}
</span>

View File

@ -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" });
}

View File

@ -28,13 +28,15 @@ 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;
if (provider === "finnhub") {
Object.entries(providersInConfig).forEach(([key, val]) => {
if (key === provider) apiKey = val;
});
@ -42,6 +44,7 @@ export default async function handler(req, res) {
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
@ -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" });
}

View File

@ -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);

View File

@ -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 <Container service={service} error={error} />;
@ -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 (
<div className="bg-theme-200/50 dark:bg-theme-900/20 rounded flex flex-1 items-center justify-between m-1 p-1 text-xs">
<span className="font-thin ml-2 flex-none">{ticker}</span>
<div className="flex items-center flex-row-reverse mr-2 text-right">
<span className={`font-bold ml-2 w-10 ${data.dp > 0 ? "text-emerald-300" : "text-rose-300"}`}>
{data.dp?.toFixed(2) ? `${data.dp?.toFixed(2)}%` : t("widget.api_error")}
<span className={`font-bold ml-2 w-10 ${change > 0 ? "text-emerald-300" : "text-rose-300"}`}>
{change != null ? `${change.toFixed(2)}%` : t("widget.api_error")}
</span>
<span className="font-bold">
{data.c
{price != null
? t("common.number", {
value: data?.c,
value: price,
style: "currency",
currency: "USD",
currency,
})
: t("widget.api_error")}
</span>
@ -97,7 +119,7 @@ export default function Component({ service }) {
return (
<Container service={service}>
<div className={classNames(service.description ? "-top-10" : "-top-8", "absolute right-1 z-20")}>
{showUSMarketStatus === true && <MarketStatus service={service} />}
{showUSMarketStatus === true && widget.provider === "finnhub" && <MarketStatus service={service} />}
</div>
<div className="flex flex-col w-full">

View File

@ -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" });
}

View File

@ -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: {},
},
};