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
|
your Homepage header. The widget includes the current price of a stock, and the
|
||||||
change in price for the day.
|
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 can sign up for a free api key at [finnhub.io](https://finnhub.io).
|
||||||
You are encouraged to read finnhub.io's
|
You are encouraged to read finnhub.io's
|
||||||
[terms of service/privacy policy](https://finnhub.io/terms-of-service) before
|
[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:
|
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))_
|
_(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
|
- 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 can sign up for a free api key at [finnhub.io](https://finnhub.io).
|
||||||
You are encouraged to read finnhub.io's
|
You are encouraged to read finnhub.io's
|
||||||
[terms of service/privacy policy](https://finnhub.io/terms-of-service) before
|
[terms of service/privacy policy](https://finnhub.io/terms-of-service) before
|
||||||
@ -48,3 +60,34 @@ widget:
|
|||||||
- AMZN
|
- AMZN
|
||||||
- BRK.B
|
- 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
|
{stock.currentPrice !== null
|
||||||
? t("common.number", {
|
? t("common.number", {
|
||||||
value: stock.currentPrice,
|
value: stock.currentPrice,
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: stock.currency || "USD",
|
||||||
})
|
})
|
||||||
: t("widget.api_error")}
|
: t("widget.api_error")}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -34,14 +34,21 @@ export default async function handler(req, res) {
|
|||||||
|
|
||||||
// map opaque endpoints to their actual endpoint
|
// map opaque endpoints to their actual endpoint
|
||||||
if (widget?.mappings) {
|
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 mappingParams = mapping?.params;
|
||||||
const optionalParams = mapping?.optionalParams;
|
const optionalParams = mapping?.optionalParams;
|
||||||
const map = mapping?.map;
|
const map = mapping?.map;
|
||||||
const endpoint = mapping?.endpoint;
|
const endpoint = mapping?.endpoint;
|
||||||
const endpointProxy = mapping?.proxyHandler || serviceProxyHandler;
|
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);
|
logger.debug("Unsupported method: %s", req.method);
|
||||||
return res.status(403).json({ error: "Unsupported 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" });
|
return res.status(400).json({ error: "Missing provider" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider !== "finnhub") {
|
if (provider !== "finnhub" && provider !== "yahoofinance") {
|
||||||
return res.status(400).json({ error: "Invalid provider" });
|
return res.status(400).json({ error: "Invalid provider" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const providersInConfig = getSettings()?.providers;
|
const providersInConfig = getSettings()?.providers;
|
||||||
|
|
||||||
|
// Not all providers require an API key
|
||||||
let apiKey;
|
let apiKey;
|
||||||
Object.entries(providersInConfig).forEach(([key, val]) => {
|
if (provider === "finnhub") {
|
||||||
if (key === provider) apiKey = val;
|
Object.entries(providersInConfig).forEach(([key, val]) => {
|
||||||
});
|
if (key === provider) apiKey = val;
|
||||||
|
});
|
||||||
|
|
||||||
if (typeof apiKey === "undefined") {
|
if (typeof apiKey === "undefined") {
|
||||||
return res.status(400).json({ error: "Missing or invalid API Key for provider" });
|
return res.status(400).json({ error: "Missing or invalid API Key for provider" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === "finnhub") {
|
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" });
|
return res.status(400).json({ error: "Invalid configuration" });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -473,6 +473,7 @@ export function cleanServiceGroups(groups) {
|
|||||||
// stocks
|
// stocks
|
||||||
watchlist,
|
watchlist,
|
||||||
showUSMarketStatus,
|
showUSMarketStatus,
|
||||||
|
provider,
|
||||||
|
|
||||||
// truenas
|
// truenas
|
||||||
enablePools,
|
enablePools,
|
||||||
@ -630,6 +631,7 @@ export function cleanServiceGroups(groups) {
|
|||||||
if (type === "stocks") {
|
if (type === "stocks") {
|
||||||
if (watchlist) cleanedService.widget.watchlist = watchlist;
|
if (watchlist) cleanedService.widget.watchlist = watchlist;
|
||||||
if (showUSMarketStatus) cleanedService.widget.showUSMarketStatus = showUSMarketStatus;
|
if (showUSMarketStatus) cleanedService.widget.showUSMarketStatus = showUSMarketStatus;
|
||||||
|
if (provider) cleanedService.widget.provider = provider;
|
||||||
}
|
}
|
||||||
if (type === "wgeasy") {
|
if (type === "wgeasy") {
|
||||||
if (threshold !== undefined) cleanedService.widget.threshold = parseInt(threshold, 10);
|
if (threshold !== undefined) cleanedService.widget.threshold = parseInt(threshold, 10);
|
||||||
|
|||||||
@ -46,7 +46,15 @@ function StockItem({ service, ticker }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { widget } = service;
|
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) {
|
if (error || data?.error) {
|
||||||
return <Container service={service} error={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 (
|
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">
|
<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>
|
<span className="font-thin ml-2 flex-none">{ticker}</span>
|
||||||
<div className="flex items-center flex-row-reverse mr-2 text-right">
|
<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"}`}>
|
<span className={`font-bold ml-2 w-10 ${change > 0 ? "text-emerald-300" : "text-rose-300"}`}>
|
||||||
{data.dp?.toFixed(2) ? `${data.dp?.toFixed(2)}%` : t("widget.api_error")}
|
{change != null ? `${change.toFixed(2)}%` : t("widget.api_error")}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
{data.c
|
{price != null
|
||||||
? t("common.number", {
|
? t("common.number", {
|
||||||
value: data?.c,
|
value: price,
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency,
|
||||||
})
|
})
|
||||||
: t("widget.api_error")}
|
: t("widget.api_error")}
|
||||||
</span>
|
</span>
|
||||||
@ -97,7 +119,7 @@ export default function Component({ service }) {
|
|||||||
return (
|
return (
|
||||||
<Container service={service}>
|
<Container service={service}>
|
||||||
<div className={classNames(service.description ? "-top-10" : "-top-8", "absolute right-1 z-20")}>
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-col w-full">
|
<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 = {
|
const widget = {
|
||||||
api: `https://finnhub.io/api/{endpoint}`,
|
api: `{url}`,
|
||||||
proxyHandler: credentialedProxyHandler,
|
proxyHandler: stocksProxyHandler,
|
||||||
|
|
||||||
mappings: {
|
mappings: {
|
||||||
quote: {
|
quote: {
|
||||||
@ -15,6 +15,7 @@ const widget = {
|
|||||||
endpoint: "v1/stock/market-status",
|
endpoint: "v1/stock/market-status",
|
||||||
params: ["exchange"],
|
params: ["exchange"],
|
||||||
},
|
},
|
||||||
|
none: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user