currencyrate: new plugin to do currency conversions.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
This commit is contained in:
Rusty Russell
2020-12-15 11:39:29 +10:30
committed by Christian Decker
parent bfb1cf5d27
commit db5ca7f64a
4 changed files with 172 additions and 0 deletions

7
currencyrate/Makefile Normal file
View File

@@ -0,0 +1,7 @@
#! /usr/bin/make
check:
@# E501 line too long (N > 79 characters)
@# E731 do not assign a lambda expression, use a def
@# W503: line break before binary operator
@flake8 --ignore=E501,E731,W503 *.py

41
currencyrate/README.md Normal file
View File

@@ -0,0 +1,41 @@
# Currencyrate plugin
This plugin provides Bitcoin currency conversion functions using various
different backends and taking the median. It caches results for an hour.
## Installation
For general plugin installation instructions see the repos main
[README.md](https://github.com/lightningd/plugins/blob/master/README.md#Installation)
## Options:
* --add-source: Add a source, of form NAME,URL,MEMBERS where URL and MEMBERS
can have `{currency}` and `{currency_lc}` to substitute for upper-case and
lower-case currency names. MEMBERS is how to deconstruct the result, for
example if the result is `{"USD": {"last_trade": 12456.79}}` then MEMBERS
would be "USD,last_trade".
* --disable-source: Disable the source with this name.
## Commands
`currencyrate` returns the number of msats per unit from every backend, eg:
```
$ lightning-cli currencyrate USD
{
"localbitcoins": "5347227msat",
"bitstamp": "5577515msat",
"coingecko": "5579273msat",
}
```
`currencyconvert` converts the given amount and currency into msats, using the
median from the above results. eg:
```
$ lightning-cli currencyconvert 100 USD
{
"msat": "515941800msat"
}
```

119
currencyrate/currencyrate.py Executable file
View File

@@ -0,0 +1,119 @@
#!/usr/bin/env python3
from pyln.client import Plugin
from collections import namedtuple
from pyln.client import Millisatoshi
from cachetools import cached, TTLCache
import requests
import statistics
import time
plugin = Plugin()
Source = namedtuple('Source', ['name', 'urlformat', 'replymembers'])
sources = [
# e.g. {"GBP": {"volume_btc": "24.36647424", "rates": {"last": "13667.63"}, "avg_1h": "13786.86", "avg_6h": "13723.65", "avg_12h": "13680.23", "avg_24h": "13739.56"}, "USD": {"volume_btc": "27.97517017", "rates": {"last": "18204.21"}, "avg_1h": "19349.46", "avg_6h": "18621.72", "avg_12h": "18642.28", "avg_24h": "18698.94"}
# ...
# "GTQ": {"volume_btc": "0.03756101", "rates": {"last": "148505.46"}, "avg_1h": "148505.46", "avg_6h": "162463.69", "avg_12h": "162003.10", "avg_24h": "162003.10"}, "DKK": {"volume_btc": "0.00339923", "rates": {"last": "139737.53"}, "avg_12h": "139737.53", "avg_24h": "139737.53"}, "HTG": {"volume_btc": "0.00024758", "rates": {"last": "2019549.24"}, "avg_6h": "2019549.24", "avg_12h": "2019549.24", "avg_24h": "2019549.24"}, "NAD": {"volume_btc": "0.00722222", "rates": {"last": "360000.11"}, "avg_12h": "360000.11", "avg_24h": "360000.11"}}
Source('localbitcoins',
'https://localbitcoins.com/bitcoinaverage/ticker-all-currencies/',
['{currency}', "avg_6h"]),
# e.g. {"high": "18502.56", "last": "17970.41", "timestamp": "1607650787", "bid": "17961.87", "vwap": "18223.42", "volume": "7055.63066541", "low": "17815.92", "ask": "17970.41", "open": "18250.30"}
Source('bitstamp',
'https://www.bitstamp.net/api/v2/ticker/btc{currency_lc}/',
['last']),
# e.g. {"bitcoin":{"usd":17885.84}}
Source('coingecko',
'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies={currency_lc}',
['bitcoin', '{currency_lc}']),
]
def get_currencyrate(plugin, currency, req_template, response_members):
# NOTE: Bitstamp has a DNS/Proxy issues that can return 404
# Workaround: retry up to 5 times with a delay
currency_lc = currency.lower()
url = req_template.format(currency_lc=currency_lc, currency=currency)
for _ in range(5):
r = requests.get(url, proxies=plugin.proxies)
if r.status_code != 200:
time.sleep(1)
continue
break
if r.status_code != 200:
plugin.log(level='info', message='{}: bad response {}'.format(url, r.status_code))
return None
json = r.json()
for m in response_members:
expanded = m.format(currency_lc=currency_lc, currency=currency)
if expanded not in json:
plugin.log(level='debug', message='{}: {} not in {}'.format(url, expanded, json))
return None
json = json[expanded]
try:
return Millisatoshi(int(10**11 / float(json)))
except Exception:
plugin.log(level='info', message='{}: could not convert {} to msat'.format(url, json))
return None
def set_proxies(plugin):
config = plugin.rpc.listconfigs()
if 'always-use-proxy' in config and config['always-use-proxy']:
paddr = config['proxy']
# Default port in 9050
if ':' not in paddr:
paddr += ':9050'
plugin.proxies = {'https': 'socks5h://' + paddr,
'http': 'socks5h://' + paddr}
else:
plugin.proxies = None
# Don't grab these more than once per hour.
@cached(cache=TTLCache(maxsize=1024, ttl=3600))
def get_rates(plugin, currency):
rates = {}
for s in sources:
r = get_currencyrate(plugin, currency, s.urlformat, s.replymembers)
if r is not None:
rates[s.name] = r
return rates
@plugin.method("currencyrate")
def currencyrate(plugin, currency):
"""Gets currency from given APIs."""
return get_rates(plugin, currency.upper())
@plugin.method("currencyconvert")
def currencyconvert(plugin, amount, currency):
"""Converts currency using given APIs."""
val = statistics.median([m.millisatoshis for m in get_rates(plugin, currency.upper()).values()]) * float(amount)
return {"msat": Millisatoshi(round(val))}
@plugin.init()
def init(options, configuration, plugin):
set_proxies(plugin)
if options['add-source'] != '':
parts = options['add-source'].split(',')
sources.append(Source(parts[0], parts[1], parts[2:]))
if options['disable-source'] != '':
for s in sources[:]:
if s.name == options['disable-source']:
sources.remove(s)
# As a bad example: binance,https://api.binance.com/api/v3/ticker/price?symbol=BTC{currency}T,price
plugin.add_option(name='add-source', default='', description='Add source name,urlformat,resultmembers...')
plugin.add_option(name='disable-source', default='', description='Disable source by name')
plugin.run()

View File

@@ -0,0 +1,5 @@
pyln-client>=0.7.3
requests>=2.10.0
requests[socks]>=2.10.0
packaging>=14.1
cachetools