feat: use lnbits price aggregator to save loads of request (#4026)
Co-authored-by: alan <alan@lnbits.com>
This commit is contained in:
+28
-11
@@ -26,7 +26,7 @@ from lnbits.core.services.notifications import (
|
|||||||
from lnbits.db import Filters
|
from lnbits.db import Filters
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.cache import cache
|
from lnbits.utils.cache import cache
|
||||||
from lnbits.utils.exchange_rates import btc_rates
|
from lnbits.utils.exchange_rates import btc_price_from_aggregator, btc_rates
|
||||||
|
|
||||||
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
|
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
|
||||||
|
|
||||||
@@ -151,17 +151,34 @@ async def collect_exchange_rates_data() -> None:
|
|||||||
|
|
||||||
if sleep_time > 0:
|
if sleep_time > 0:
|
||||||
try:
|
try:
|
||||||
rates = await btc_rates(currency)
|
if (
|
||||||
if rates:
|
settings.lnbits_price_aggregator_enabled
|
||||||
rates_values = [r[1] for r in rates]
|
and settings.lnbits_price_aggregator_url
|
||||||
lnbits_rate = sum(rates_values) / len(rates_values)
|
):
|
||||||
rates.append(("LNbits", lnbits_rate))
|
price = await btc_price_from_aggregator(currency)
|
||||||
cache.set(
|
if price:
|
||||||
f"btc-price-{currency}",
|
cache.set(
|
||||||
lnbits_rate,
|
f"btc-price-{currency}",
|
||||||
expiry=settings.lnbits_exchange_rate_cache_seconds,
|
price,
|
||||||
|
expiry=settings.lnbits_exchange_rate_cache_seconds,
|
||||||
|
)
|
||||||
|
settings.append_exchange_rate_datapoint(
|
||||||
|
{"Aggregator": price}, max_history_size
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rates = await btc_rates(currency)
|
||||||
|
if rates:
|
||||||
|
rates_values = [r[1] for r in rates]
|
||||||
|
lnbits_rate = sum(rates_values) / len(rates_values)
|
||||||
|
rates.append(("LNbits", lnbits_rate))
|
||||||
|
cache.set(
|
||||||
|
f"btc-price-{currency}",
|
||||||
|
lnbits_rate,
|
||||||
|
expiry=settings.lnbits_exchange_rate_cache_seconds,
|
||||||
|
)
|
||||||
|
settings.append_exchange_rate_datapoint(
|
||||||
|
dict(rates), max_history_size
|
||||||
)
|
)
|
||||||
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -362,6 +362,8 @@ class ExchangeProvidersSettings(LNbitsSettings):
|
|||||||
lnbits_exchange_rate_cache_seconds: int = Field(default=60, ge=0)
|
lnbits_exchange_rate_cache_seconds: int = Field(default=60, ge=0)
|
||||||
lnbits_exchange_history_size: int = Field(default=60, ge=0)
|
lnbits_exchange_history_size: int = Field(default=60, ge=0)
|
||||||
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0)
|
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0)
|
||||||
|
lnbits_price_aggregator_enabled: bool = Field(default=True)
|
||||||
|
lnbits_price_aggregator_url: str = Field(default="https://price.lnbits.com")
|
||||||
|
|
||||||
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
|
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
|
||||||
default=[
|
default=[
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -62,12 +62,6 @@ window.app.component('lnbits-admin-exchange-providers', {
|
|||||||
mounted() {
|
mounted() {
|
||||||
this.getExchangeRateHistory()
|
this.getExchangeRateHistory()
|
||||||
},
|
},
|
||||||
created() {
|
|
||||||
const hash = window.location.hash.replace('#', '')
|
|
||||||
if (hash === 'exchange_providers') {
|
|
||||||
this.showExchangeProvidersTab(hash)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
getDefaultSetting(fieldName) {
|
getDefaultSetting(fieldName) {
|
||||||
LNbits.api.getDefaultSetting(fieldName).then(response => {
|
LNbits.api.getDefaultSetting(fieldName).then(response => {
|
||||||
@@ -127,18 +121,21 @@ window.app.component('lnbits-admin-exchange-providers', {
|
|||||||
this.exchangeData.showTickerConversion = true
|
this.exchangeData.showTickerConversion = true
|
||||||
},
|
},
|
||||||
initExchangeChart(data) {
|
initExchangeChart(data) {
|
||||||
|
if (this.exchangeRatesChart) {
|
||||||
|
this.exchangeRatesChart.destroy()
|
||||||
|
this.exchangeRatesChart = null
|
||||||
|
}
|
||||||
const xValues = data.map(d =>
|
const xValues = data.map(d =>
|
||||||
this.utils.formatTimestamp(d.timestamp, 'HH:mm')
|
this.utils.formatTimestamp(d.timestamp, 'HH:mm')
|
||||||
)
|
)
|
||||||
const exchanges = [
|
const exchanges = this.formData.lnbits_price_aggregator_enabled
|
||||||
...this.formData.lnbits_exchange_rate_providers,
|
? [{name: 'Aggregator'}]
|
||||||
{name: 'LNbits'}
|
: [...this.formData.lnbits_exchange_rate_providers, {name: 'LNbits'}]
|
||||||
]
|
|
||||||
const datasets = exchanges.map(exchange => ({
|
const datasets = exchanges.map(exchange => ({
|
||||||
label: exchange.name,
|
label: exchange.name,
|
||||||
data: data.map(d => d.rates[exchange.name]),
|
data: data.map(d => d.rates[exchange.name]),
|
||||||
pointStyle: true,
|
pointStyle: true,
|
||||||
borderWidth: exchange.name === 'LNbits' ? 4 : 1,
|
borderWidth: exchange.name === 'LNbits' ? 4 : 2,
|
||||||
tension: 0.4
|
tension: 0.4
|
||||||
}))
|
}))
|
||||||
this.exchangeRatesChart = new Chart(
|
this.exchangeRatesChart = new Chart(
|
||||||
@@ -148,7 +145,11 @@ window.app.component('lnbits-admin-exchange-providers', {
|
|||||||
options: {
|
options: {
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
display: false
|
display: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Bitcoin Price History'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,46 @@
|
|||||||
<template id="lnbits-admin-exchange-providers">
|
<template id="lnbits-admin-exchange-providers">
|
||||||
<h6 class="q-my-none q-mb-sm">
|
<h6 class="q-my-none q-mb-xs">LNbits Price Aggregator</h6>
|
||||||
<span v-text="$t('exchange_providers')"></span>
|
<p class="q-mb-md text-caption text-grey">
|
||||||
</h6>
|
A privacy-friendly, open-source Bitcoin price aggregator maintained by the
|
||||||
|
LNbits team. Aggregates prices from multiple exchanges and returns a median,
|
||||||
|
no API keys required.
|
||||||
|
<a href="https://price.lnbits.com" target="_blank" rel="noopener"
|
||||||
|
>price.lnbits.com</a
|
||||||
|
>
|
||||||
|
—
|
||||||
|
<a
|
||||||
|
href="https://github.com/lnbits/lnbits-price-aggregator"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>GitHub</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row q-mb-md items-start">
|
||||||
|
<div class="col-auto q-mr-md q-mt-sm">
|
||||||
|
<q-toggle
|
||||||
|
v-model="formData.lnbits_price_aggregator_enabled"
|
||||||
|
@update:model-value="formData.touch = null"
|
||||||
|
label="Use Price Aggregator"
|
||||||
|
>
|
||||||
|
</q-toggle>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-7">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
v-model="formData.lnbits_price_aggregator_url"
|
||||||
|
type="text"
|
||||||
|
label="Price Aggregator URL"
|
||||||
|
hint="Fetch BTC price from this aggregator instead of individual providers below."
|
||||||
|
:disable="!formData.lnbits_price_aggregator_enabled"
|
||||||
|
@update:model-value="formData.touch = null"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-separator class="q-my-md"></q-separator>
|
||||||
|
<h6 class="q-my-none q-mb-sm">Bitcoin Price History</h6>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 col-md-8">
|
<div class="col-12 col-md-8">
|
||||||
@@ -53,6 +92,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<q-separator class="q-my-md"></q-separator>
|
||||||
|
<h6 class="q-my-none q-mb-sm">
|
||||||
|
<span v-text="$t('exchange_providers')"></span>
|
||||||
|
</h6>
|
||||||
|
|
||||||
<div class="row q-mt-md">
|
<div class="row q-mt-md">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -60,6 +104,7 @@
|
|||||||
label="Add Exchange Provider"
|
label="Add Exchange Provider"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
|
:disable="formData.lnbits_price_aggregator_enabled"
|
||||||
>
|
>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,12 +115,20 @@
|
|||||||
:label="$t('reset_defaults')"
|
:label="$t('reset_defaults')"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="float-right"
|
class="float-right"
|
||||||
|
:disable="formData.lnbits_price_aggregator_enabled"
|
||||||
>
|
>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-auto">
|
<div
|
||||||
|
class="overflow-auto"
|
||||||
|
:style="
|
||||||
|
formData.lnbits_price_aggregator_enabled
|
||||||
|
? 'opacity:0.4;pointer-events:none'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
<q-table
|
<q-table
|
||||||
row-key="name"
|
row-key="name"
|
||||||
:rows="formData.lnbits_exchange_rate_providers"
|
:rows="formData.lnbits_exchange_rate_providers"
|
||||||
|
|||||||
@@ -289,7 +289,32 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
|
|||||||
return apply_trimmed_mean_filter(all_rates)
|
return apply_trimmed_mean_filter(all_rates)
|
||||||
|
|
||||||
|
|
||||||
|
async def btc_price_from_aggregator(currency: str) -> float | None:
|
||||||
|
url = settings.lnbits_price_aggregator_url.rstrip("/")
|
||||||
|
try:
|
||||||
|
headers = {"User-Agent": settings.user_agent}
|
||||||
|
async with httpx.AsyncClient(headers=headers) as client:
|
||||||
|
r = await client.get(f"{url}/rate/{currency.upper()}", timeout=3)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
median = data.get("rates", {}).get("median")
|
||||||
|
if median:
|
||||||
|
return float(median)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch price from aggregator {url}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def btc_price(currency: str) -> float:
|
async def btc_price(currency: str) -> float:
|
||||||
|
if (
|
||||||
|
settings.lnbits_price_aggregator_enabled
|
||||||
|
and settings.lnbits_price_aggregator_url
|
||||||
|
):
|
||||||
|
price = await btc_price_from_aggregator(currency)
|
||||||
|
if price:
|
||||||
|
return price
|
||||||
|
logger.warning("Price aggregator failed, falling back to exchange providers.")
|
||||||
|
|
||||||
rates = await btc_rates(currency)
|
rates = await btc_rates(currency)
|
||||||
if not rates:
|
if not rates:
|
||||||
logger.warning("Could not fetch any Bitcoin price.")
|
logger.warning("Could not fetch any Bitcoin price.")
|
||||||
|
|||||||
@@ -275,6 +275,10 @@ async def test_btc_rates_skips_unsupported_and_failing_providers(
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_btc_price_handles_empty_single_and_multiple_rates(mocker: MockerFixture):
|
async def test_btc_price_handles_empty_single_and_multiple_rates(mocker: MockerFixture):
|
||||||
|
mocker.patch(
|
||||||
|
"lnbits.utils.exchange_rates.btc_price_from_aggregator",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
)
|
||||||
mocker.patch("lnbits.utils.exchange_rates.btc_rates", AsyncMock(return_value=[]))
|
mocker.patch("lnbits.utils.exchange_rates.btc_rates", AsyncMock(return_value=[]))
|
||||||
assert await btc_price("usd") == 0.0
|
assert await btc_price("usd") == 0.0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user