Feature: Yahoo Finance provider for stock market widget
This commit is contained in:
parent
794ec127cd
commit
0f72e3b44a
BIN
docs/assets/widget_stocks_demo_yahoo.png
Normal file
BIN
docs/assets/widget_stocks_demo_yahoo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@ -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:
|
||||
|
||||

|
||||
|
||||
#### 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:
|
||||
|
||||

|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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")}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@ -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" });
|
||||
}
|
||||
|
||||
@ -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" });
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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">
|
||||
|
||||
77
src/widgets/stocks/proxy.js
Normal file
77
src/widgets/stocks/proxy.js
Normal 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" });
|
||||
}
|
||||
@ -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: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user