From 5e1a6476949cbc9ba571aee71a655c487987c6d7 Mon Sep 17 00:00:00 2001 From: ahv15 Date: Mon, 4 Jul 2022 20:47:00 +0530 Subject: [PATCH 1/9] update and request from local cache using spotbit --- spotbit-plugin/.gitignore | 10 + spotbit-plugin/MANIFEST.in | 5 + spotbit-plugin/pyproject.toml | 5 + spotbit-plugin/pytest.ini | 11 + spotbit-plugin/requirements.txt | 2 + spotbit-plugin/setup.cfg | 25 + spotbit-plugin/setup.py | 4 + .../src/ahv15/specterext/spotbit/__init__.py | 0 .../src/ahv15/specterext/spotbit/__main__.py | 41 ++ .../src/ahv15/specterext/spotbit/config.py | 27 + .../ahv15/specterext/spotbit/controller.py | 57 ++ .../src/ahv15/specterext/spotbit/service.py | 204 +++++++ .../spotbit/static/spotbit/css/styles.css | 1 + .../spotbit/static/spotbit/img/logo.jpeg | Bin 0 -> 7884 bytes .../static/spotbit/img/spotbit_avatar.jpg | Bin 0 -> 71595 bytes .../spotbit/templates/spotbit/base.jinja | 4 + .../spotbit/components/spotbit_menu.jinja | 16 + .../spotbit/components/spotbit_tab.jinja | 8 + .../spotbit/templates/spotbit/index.jinja | 87 +++ .../spotbit/templates/spotbit/settings.jinja | 106 ++++ spotbit-plugin/tests/conftest.py | 539 ++++++++++++++++++ .../tests/fix_devices_and_wallets.py | 118 ++++ spotbit-plugin/tests/fix_ghost_machine.py | 67 +++ spotbit-plugin/tests/fix_keys_and_seeds.py | 138 +++++ spotbit-plugin/tests/fix_testnet.py | 59 ++ 25 files changed, 1534 insertions(+) create mode 100644 spotbit-plugin/.gitignore create mode 100644 spotbit-plugin/MANIFEST.in create mode 100644 spotbit-plugin/pyproject.toml create mode 100644 spotbit-plugin/pytest.ini create mode 100644 spotbit-plugin/requirements.txt create mode 100644 spotbit-plugin/setup.cfg create mode 100644 spotbit-plugin/setup.py create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/__init__.py create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/__main__.py create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/config.py create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/controller.py create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/service.py create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/css/styles.css create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/img/logo.jpeg create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/img/spotbit_avatar.jpg create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/base.jinja create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/components/spotbit_menu.jinja create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/components/spotbit_tab.jinja create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/index.jinja create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/settings.jinja create mode 100644 spotbit-plugin/tests/conftest.py create mode 100644 spotbit-plugin/tests/fix_devices_and_wallets.py create mode 100644 spotbit-plugin/tests/fix_ghost_machine.py create mode 100644 spotbit-plugin/tests/fix_keys_and_seeds.py create mode 100644 spotbit-plugin/tests/fix_testnet.py diff --git a/spotbit-plugin/.gitignore b/spotbit-plugin/.gitignore new file mode 100644 index 0000000..31b2fdb --- /dev/null +++ b/spotbit-plugin/.gitignore @@ -0,0 +1,10 @@ +__pycache__ +.pytest_cache +*.pyc +.env +*.egg-info +.DS_Store +node_modules +btcd-conn.json +elmd-conn.json +prevent_mining \ No newline at end of file diff --git a/spotbit-plugin/MANIFEST.in b/spotbit-plugin/MANIFEST.in new file mode 100644 index 0000000..d8134d3 --- /dev/null +++ b/spotbit-plugin/MANIFEST.in @@ -0,0 +1,5 @@ +recursive-include src/ahv15/specterext/spotbit/templates * +recursive-include src/ahv15/specterext/spotbit/static * +recursive-include src/ahv15/specterext/spotbit/*/LC_MESSAGES *.mo +recursive-include src/ahv15/specterext/spotbit/translations/*/LC_MESSAGES *.po +include requirements.txt \ No newline at end of file diff --git a/spotbit-plugin/pyproject.toml b/spotbit-plugin/pyproject.toml new file mode 100644 index 0000000..7daa9be --- /dev/null +++ b/spotbit-plugin/pyproject.toml @@ -0,0 +1,5 @@ +[build-system] +requires = [ + "cryptoadvance.specter==1.8.1" +] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/spotbit-plugin/pytest.ini b/spotbit-plugin/pytest.ini new file mode 100644 index 0000000..f8d2ae6 --- /dev/null +++ b/spotbit-plugin/pytest.ini @@ -0,0 +1,11 @@ +[pytest] +norecursedirs = tests/bitcoin* tests/elements* tests/xtestdata_testextensions +log_format = [%(levelname)8s] %(message)s %(name)s (%(filename)s:%(lineno)s) +addopts = --bitcoind-version v22.0.0 --elementsd-version v0.21.0.2 +markers = + slow: mark test as slow. + elm: mark test as elementsd dependent +#log_cli = 1 + +filterwarnings = + ignore::DeprecationWarning:bitbox02[.*] \ No newline at end of file diff --git a/spotbit-plugin/requirements.txt b/spotbit-plugin/requirements.txt new file mode 100644 index 0000000..da58301 --- /dev/null +++ b/spotbit-plugin/requirements.txt @@ -0,0 +1,2 @@ +cryptoadvance.specter==1.8.1 +ccxt>=1.74.49 \ No newline at end of file diff --git a/spotbit-plugin/setup.cfg b/spotbit-plugin/setup.cfg new file mode 100644 index 0000000..3a80188 --- /dev/null +++ b/spotbit-plugin/setup.cfg @@ -0,0 +1,25 @@ +[metadata] +name = ahv15_spotbit +version = 0.0.1 +author = ahv15 +author_email = av.harshith15@gmail.com +description = A small example package +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/ahv15/specterext-spotbit +project_urls = + Bug Tracker = https://github.com/ahv15/specterext-spotbit/issues +classifiers = + Programming Language :: Python :: 3 + License :: OSI Approved :: MIT License + Operating System :: OS Independent + +[options] +include_package_data=true +package_dir = + = src +packages = find_namespace: +python_requires = >=3.6 + +[options.packages.find] +where = src \ No newline at end of file diff --git a/spotbit-plugin/setup.py b/spotbit-plugin/setup.py new file mode 100644 index 0000000..57c026b --- /dev/null +++ b/spotbit-plugin/setup.py @@ -0,0 +1,4 @@ +from setuptools import setup + +if __name__ == "__main__": + setup() \ No newline at end of file diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/__init__.py b/spotbit-plugin/src/ahv15/specterext/spotbit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/__main__.py b/spotbit-plugin/src/ahv15/specterext/spotbit/__main__.py new file mode 100644 index 0000000..a031865 --- /dev/null +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/__main__.py @@ -0,0 +1,41 @@ +from cryptoadvance.specter.cli import entry_point +from cryptoadvance.specter.cli.cli_server import server +import logging +import click + +logger = logging.getLogger(__name__) + +@click.group() +def cli(): + pass + +@cli.command() +@click.pass_context +@click.option( + "--host", + default="127.0.0.1", + help="if you specify --host 0.0.0.0 then Spotbit will be available in your local LAN.", +) +@click.option( + "--ssl/--no-ssl", + is_flag=True, + default=False, + help="By default SSL encryption will not be used. Use -ssl to create a self-signed certificate for SSL encryption.", +) +@click.option("--debug/--no-debug", default=None) +@click.option("--filelog/--no-filelog", default=True) +@click.option( + "--config", + default=None, + help="A class which sets reasonable default values.", +) +def start(ctx, host, ssl, debug, filelog, config): + if config == None: + config = "ahv15.specterext.spotbit.config.AppProductionConfig" + ctx.invoke(server, host=host, ssl=ssl, debug=debug, filelog=filelog, port=8080, config=config) + +entry_point.add_command(start) + +if __name__ == "__main__": + entry_point() + \ No newline at end of file diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/config.py b/spotbit-plugin/src/ahv15/specterext/spotbit/config.py new file mode 100644 index 0000000..4b880ac --- /dev/null +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/config.py @@ -0,0 +1,27 @@ +""" +Here Configuration of your Extension (and maybe your Application) takes place +""" +import os +from cryptoadvance.specter.config import ProductionConfig as SpecterProductionConfig + + +class BaseConfig: + ''' This is a extension-based Config which is used as Base ''' + SPOTBIT_SOMEKEY = "some value" + +class ProductionConfig(BaseConfig): + ''' This is a extension-based Config for Production ''' + pass + + +class AppProductionConfig(SpecterProductionConfig): + ''' The AppProductionConfig class can be used to user this extension as application + ''' + # Where should the User endup if he hits the root of that domain? + ROOT_URL_REDIRECT = "/spc/ext/spotbit" + # I guess this is the only extension which should be available? + EXTENSION_LIST = [ + "ahv15.specterext.spotbit.service" + ] + # You probably also want a different folder here + SPECTER_DATA_FOLDER=os.path.expanduser("~/.spotbit") \ No newline at end of file diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py b/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py new file mode 100644 index 0000000..40b431a --- /dev/null +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py @@ -0,0 +1,57 @@ +from flask import redirect, render_template, request, url_for +from flask import current_app as app + +from cryptoadvance.specter.specter import Specter +from .service import SpotbitService + + +spotbit_endpoint = SpotbitService.blueprint + +def ext() -> SpotbitService: + ''' convenience for getting the extension-object''' + return app.specter.ext["spotbit"] + +def specter() -> Specter: + ''' convenience for getting the specter-object''' + return app.specter + + +@spotbit_endpoint.route("/") +def index(): + return render_template( + "spotbit/index.jinja", + ) + +@spotbit_endpoint.route("/now//") +def current_exchange_rate(currency, exchange): + service = ext() + return(service.current_exchange_rate(currency, exchange)) + +@spotbit_endpoint.route("/settings", methods=["GET"]) +def settings_get(): + return render_template( + "spotbit/settings.jinja", + ) + +@spotbit_endpoint.route("/settings", methods=["POST"]) +def settings_post(): + keepWeeks = request.form["keepWeeks"] + exchanges = request.form["exchanges"] + currencies = request.form["currencies"] + interval = request.form["interval"] + service = ext() + service.scheduler.scheduler.modify_job(job_id = 'prune', args = [keepWeeks]) + service.init_table() + ''' + user = app.specter.user_manager.get_user() + if show_menu == "yes": + user.add_service(SpotbitService.id) + else: + user.remove_service(SpotbitService.id) + used_wallet_alias = request.form.get("used_wallet") + if used_wallet_alias != None: + wallet = current_user.wallet_manager.get_by_alias(used_wallet_alias) + SpotbitService.set_associated_wallet(wallet) + ''' + return redirect(url_for(f"{ SpotbitService.get_blueprint_name()}.settings_get")) + diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/service.py b/spotbit-plugin/src/ahv15/specterext/spotbit/service.py new file mode 100644 index 0000000..f39c078 --- /dev/null +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/service.py @@ -0,0 +1,204 @@ +import logging +import ccxt +import sqlite3 +from pathlib import Path +import time +from datetime import datetime, timedelta + +from cryptoadvance.specter.services.service import Service, devstatus_alpha +from flask_apscheduler import APScheduler + +logger = logging.getLogger(__name__) + +objects = {"aax":ccxt.aax(), "ascendex": ccxt.ascendex(),"bequant":ccxt.bequant(), "bibox":ccxt.bibox(), "bigone":ccxt.bigone(), "binance":ccxt.binance(), "bitbank":ccxt.bitbank(), "liquid":ccxt.liquid(), "phemex":ccxt.phemex(), "bitstamp":ccxt.bitstamp(), "ftx":ccxt.ftx()} +exchanges = ["bitstamp", "ascendex", "bequant", "bibox","bigone"] +def is_ms(timestamp): + if timestamp % 1000 == 0: + return True + return False + +def get_supported_pair_for(currency, exchange): + result = '' + exchange.load_markets() + market_ids_found = [market for market in exchange.markets_by_id.keys() if ((market[:len('BTC')].upper() == 'BTC' or market[:len('XBT')].upper() == 'XBT') and market[-len(currency):].upper() == currency.upper())] + if market_ids_found: + market_id = market_ids_found[0] + market = exchange.markets_by_id[exchange.market_id(market_id)] + if market: + result = market['symbol'] + return result + +class SpotbitService(Service): + id = "spotbit" + name = "Spotbit" + icon = "spotbit/img/spotbit_avatar.jpg" + logo = "spotbit/img/logo.jpeg" + desc = "Price Info Service" + has_blueprint = True + blueprint_module = "ahv15.specterext.spotbit.controller" + devstatus = devstatus_alpha + isolated_client = False + sort_priority = 2 + + SPECTER_WALLET_ALIAS = "wallet" + + def callback_after_serverpy_init_app(self, scheduler: APScheduler): + path = Path("./sb.db") + + def request(ex_objs): + with scheduler.app.app_context(): + tic = time.perf_counter() + currencies = ["usd", "gbp", "jpy", "usdt", "eur", "0xcafebabe"] + interval = 5 + db_n = sqlite3.connect(path, timeout=30) + cursor = db_n.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + if(cursor.fetchall() == []): + return + for e in exchanges: + for curr in currencies: + ticker = get_supported_pair_for(curr, ex_objs[e]) + if(ticker == ''): + continue + success = True + if ex_objs[e].has['fetchOHLCV']: + candle = None + tframe = '1m' + lim = 1000 + if e == "bleutrade" or e == "btcalpha" or e == "rightbtc" or e == "hollaex": + tframe = '1h' + if e == "poloniex": + tframe = '5m' + if e == "bitstamp": + lim = 1000 + if e == "bybit": + lim = 200 + if e == "eterbase": + lim = 1000000 + if e == "exmo": + lim = 3000 + if e == "btcalpha": + lim = 720 + if e == "bitfinex": + params = {'limit':100, 'start':(round((datetime.now()-timedelta(hours=1)).timestamp()*1000)), 'end':round(datetime.now().timestamp()*1000)} + try: + candle = ex_objs[e].fetch_ohlcv(symbol=ticker, timeframe=tframe, since=None, params=params) + if candle == None: + raise Exception(f"candle from {e} is null") + + except Exception as err: #figure out this error type + #the point so far is to gracefully handle the error, but waiting for the next cycle should be good enough + if "does not have" not in str(err): + print(f"error fetching candle: {e} {curr} {err}") + success = False + else: + try: + candle = ex_objs[e].fetch_ohlcv(symbol=ticker, timeframe=tframe, since=None, limit=lim) #'ticker' was listed as 'symbol' before | interval should be determined in the config file + if candle == None: + raise Exception(f"candle from {e} is nulll") + except Exception as err: + print(err) + if "does not have" not in str(err): + print(f"error fetching candle: {e} {curr} {err}") + success = False + if success: + for line in candle: + ts = datetime.fromtimestamp(line[0]/1e3) #check here if we have a ms timestamp or not + for l in line: + if l == None: + l = 0 + statement = "INSERT INTO {} (timestamp, datetime, pair, open, high, low, close, volume) VALUES ({}, '{}', '{}', {}, {}, {}, {}, {});".format(e, line[0], ts, ticker, line[1], line[2], line[3], line[4], line[5]) + try: + db_n.execute(statement) + db_n.commit() + + except sqlite3.OperationalError as op: + nulls = [] + c = 0 + for l in line: + if l == None: + nulls.append(c) + c += 1 + print(f"exchange: {e} currency: {curr}\nsql statement: {statement}\nerror: {op}(moving on)") + else: + try: + price = ex_objs[e].fetch_ticker(ticker) + except Exception as err: + print(f"error fetching ticker: {err}") + success = False + if success: + ts = None + try: + if is_ms(int(price['timestamp'])): + ts = datetime.fromtimestamp(int(price['timestamp'])/1e3) + else: + ts = datetime.fromtimestamp(int(price['timestamp'])) + except OverflowError as oe: + print(f"{oe} caused by {ts}") + ticker = ticker.replace("/", "-") + statement = f"INSERT INTO {e} (timestamp, datetime, pair, open, high, low, close, volume) VALUES ({price['timestamp']}, '{ts}', '{ticker}', 0.0, 0.0, 0.0, {price['last']}, 0.0);" + db_n.execute(statement) + db_n.commit() + time.sleep(interval) + toc = time.perf_counter() + print(f"It took {toc - tic:0.4f} seconds") + + + + + def prune(keepWeeks): + with scheduler.app.app_context(): + db_n = sqlite3.connect(path, timeout=10) + for exchange in exchanges: + check = f"SELECT MAX(timestamp) FROM {exchange};" + cursor = db_n.execute(check) + check_ts = cursor.fetchone() + statement = "" + if check_ts[0] is not None: + if is_ms(int(check_ts[0])): + cutoff = (datetime.now()-timedelta(weeks=int(keepWeeks))).timestamp()*1000 + statement = f"DELETE FROM {exchange} WHERE timestamp < {cutoff};" + else: + cutoff = (datetime.now()-timedelta(weeks=int(keepWeeks))).timestamp() + statement = f"DELETE FROM {exchange} WHERE timestamp < {cutoff};" + while True: + try: + db_n.execute(statement) + break + except sqlite3.OperationalError as op: + print(f"{op}") + db_n.commit() + + scheduler.add_job("prune", prune, trigger = 'interval', args = [10], minutes = 1) + scheduler.add_job("request", request, trigger = 'interval', args =[objects], minutes = 15) + self.scheduler = scheduler + + + @classmethod + def init_table(cls): + p = Path("./sb.db") + db = sqlite3.connect(p) + for exchange in exchanges: + sql = f"CREATE TABLE IF NOT EXISTS {exchange} (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER, datetime TEXT, pair TEXT, open REAL, high REAL, low REAL, close REAL, volume REAL)" + print(f"created table for {exchange}") + db.execute(sql) + db.commit() + db.close() + + @classmethod + def current_exchange_rate(cls, currency, exchange): + p = Path("./sb.db") + db_n = sqlite3.connect(p, timeout=30) + ticker = get_supported_pair_for(currency, objects[exchange]) + print(ticker) + if exchange in exchanges: + statement = f"SELECT * FROM {exchange} WHERE pair = '{ticker}' ORDER BY timestamp DESC LIMIT 1;" + try: + cursor = db_n.execute(statement) + res = cursor.fetchone() + except sqlite3.OperationalError: + print("database is locked. Cannot access it") + return {'err': 'database locked'} + if res != None: + db_n.close() + return {'id':res[0], 'timestamp':res[1], 'datetime':res[2], 'currency_pair':res[3], 'open':res[4], 'high':res[5], 'low':res[6], 'close':res[7], 'vol':res[8]} \ No newline at end of file diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/css/styles.css b/spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/css/styles.css new file mode 100644 index 0000000..de6f013 --- /dev/null +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/css/styles.css @@ -0,0 +1 @@ +/* This is the place to put all your styles */ diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/img/logo.jpeg b/spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/img/logo.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..06407e9ae7da5663bf2c0fce9b428626c94f9601 GIT binary patch literal 7884 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 N@@%E@5nK~E}GnI34hn!Tl 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%0D^M!V}E-1+fMp0S!{=#H`~ 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 z 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 Y5Ku|1^f&GV2HOtz6%2?GA@=__0m~;$UH||9 literal 0 HcmV?d00001 diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/base.jinja b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/base.jinja new file mode 100644 index 0000000..1cbb52f --- /dev/null +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/base.jinja @@ -0,0 +1,4 @@ +{% extends "base.jinja" %} +{% block head %} + +{% endblock %} \ No newline at end of file diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/components/spotbit_menu.jinja b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/components/spotbit_menu.jinja new file mode 100644 index 0000000..13b8e1d --- /dev/null +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/components/spotbit_menu.jinja @@ -0,0 +1,16 @@ +{% from 'components/menu_item.jinja' import menu_item %} + +{# + spotbit_menu - Tabs menu to navigate between the spotbit screens. + Parameters: + - active_menuitem: Current active tab. Options: 'general', 'settings', ... + #} +{% macro spotbit_menu(active_menuitem) -%} + +{%- endmacro %} \ No newline at end of file diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/components/spotbit_tab.jinja b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/components/spotbit_tab.jinja new file mode 100644 index 0000000..0ea075d --- /dev/null +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/components/spotbit_tab.jinja @@ -0,0 +1,8 @@ +{% extends "spotbit/base.jinja" %} +{% block main %} + + {% from 'spotbit/components/spotbit_menu.jinja' import spotbit_menu with context %} + {{ spotbit_menu(tab) }} + {% block content %} + {% endblock %} +{% endblock %} \ No newline at end of file diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/index.jinja b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/index.jinja new file mode 100644 index 0000000..19e3a3c --- /dev/null +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/index.jinja @@ -0,0 +1,87 @@ +{% extends "spotbit/components/spotbit_tab.jinja" %} +{% block title %}Settings{% endblock %} +{% set tab = 'index' %} +{% block content %} + +

+
“ SpotbitService 4thewin.”
+{% endblock %} + + + +{% block scripts %} + +{% endblock %} diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/settings.jinja b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/settings.jinja new file mode 100644 index 0000000..4c3076a --- /dev/null +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/settings.jinja @@ -0,0 +1,106 @@ +{% extends "spotbit/components/spotbit_tab.jinja" %} +{% block title %}Settings{% endblock %} +{% set tab = 'settings_get' %} +{% block content %} +
+ + +
+

{{ _("Configure your extension") }}

+
+ +
+ {{ _("- Maybe you want to put some notes for the user") }}
+ {{ _("- One important setting is whether your extension gets a menu-point on the left") }}
+ {{ _("- Let us assume you want the user to use a specific wallet. That should go here. ") }}
+
+
+ +
+ + + +

+
+ + +

+
+ + +

+
+ + +

+
+ +
+ +
+ +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/spotbit-plugin/tests/conftest.py b/spotbit-plugin/tests/conftest.py new file mode 100644 index 0000000..461cef7 --- /dev/null +++ b/spotbit-plugin/tests/conftest.py @@ -0,0 +1,539 @@ +import atexit +import code +import json +import logging +import os +import signal +import sys +import tempfile +import traceback + +import pytest +from cryptoadvance.specter.config import TestConfig +from cryptoadvance.specter.managers.device_manager import DeviceManager +from cryptoadvance.specter.managers.user_manager import UserManager +from cryptoadvance.specter.process_controller.bitcoind_controller import ( + BitcoindPlainController, +) +from cryptoadvance.specter.process_controller.elementsd_controller import ( + ElementsPlainController, +) +from cryptoadvance.specter.server import SpecterFlask, create_app, init_app +from cryptoadvance.specter.specter import Specter +from cryptoadvance.specter.specter_error import SpecterError +from cryptoadvance.specter.user import User, hash_password +from cryptoadvance.specter.util.common import str2bool +from cryptoadvance.specter.util.shell import which +from cryptoadvance.specter.util.wallet_importer import WalletImporter + +logger = logging.getLogger(__name__) + +pytest_plugins = [ + "fix_ghost_machine", + "fix_keys_and_seeds", + "fix_devices_and_wallets", + "fix_testnet", +] + +# This is from https://stackoverflow.com/questions/132058/showing-the-stack-trace-from-a-running-python-application +# it enables stopping a hanging test via sending the pytest-process a SIGUSR2 (12) +# kill 12 pid-of-pytest +# In the article they claim to open a debug-console which didn't work for me but at least +# you get a stacktrace in the output. +def debug(sig, frame): + """Interrupt running process, and provide a python prompt for + interactive debugging.""" + d = {"_frame": frame} # Allow access to frame object. + d.update(frame.f_globals) # Unless shadowed by global + d.update(frame.f_locals) + + i = code.InteractiveConsole(d) + message = "Signal received : entering python shell.\nTraceback:\n" + message += "".join(traceback.format_stack(frame)) + i.interact(message) + + +def listen(): + signal.signal(signal.SIGUSR2, debug) # Register handler + + +def pytest_addoption(parser): + """Internally called to add options to pytest + see pytest_generate_tests(metafunc) on how to check that + Also used to register the SIGUSR2 (12) as decribed in conftest.py + """ + parser.addoption("--docker", action="store_true", help="run bitcoind in docker") + parser.addoption( + "--bitcoind-version", + action="store", + default="v0.20.1", + help="Version of bitcoind (something which works with git checkout ...)", + ) + parser.addoption( + "--bitcoind-log-stdout", + action="store", + default=False, + help="Whether bitcoind should log to stdout (default:False)", + ) + parser.addoption( + "--elementsd-version", + action="store", + default="master", + help="Version of elementsd (something which works with git checkout ...)", + ) + listen() + + +def pytest_generate_tests(metafunc): + # ToDo: use custom compiled version of bitcoind + # E.g. test again bitcoind version [currentRelease] + master-branch + if "docker" in metafunc.fixturenames: + if metafunc.config.getoption("docker"): + # That's a list because we could do both (see above) but currently that doesn't make sense in that context + metafunc.parametrize("docker", [True], scope="session") + else: + metafunc.parametrize("docker", [False], scope="session") + + +def instantiate_bitcoind_controller(docker, request, rpcport=18543, extra_args=[]): + # logging.getLogger().setLevel(logging.DEBUG) + requested_version = request.config.getoption("--bitcoind-version") + log_stdout = str2bool(request.config.getoption("--bitcoind-log-stdout")) + if docker: + from cryptoadvance.specter.process_controller.bitcoind_docker_controller import ( + BitcoindDockerController, + ) + + bitcoind_controller = BitcoindDockerController( + rpcport=rpcport, docker_tag=requested_version + ) + else: + if os.path.isfile("tests/bitcoin/src/bitcoind"): + bitcoind_controller = BitcoindPlainController( + bitcoind_path="tests/bitcoin/src/bitcoind", rpcport=rpcport + ) # always prefer the self-compiled bitcoind if existing + elif os.path.isfile("tests/bitcoin/bin/bitcoind"): + bitcoind_controller = BitcoindPlainController( + bitcoind_path="tests/bitcoin/bin/bitcoind", rpcport=rpcport + ) # next take the self-installed binary if existing + else: + bitcoind_controller = BitcoindPlainController( + rpcport=rpcport + ) # Alternatively take the one on the path for now + bitcoind_controller.start_bitcoind( + cleanup_at_exit=True, + cleanup_hard=True, + extra_args=extra_args, + log_stdout=log_stdout, + ) + assert not bitcoind_controller.datadir is None + running_version = bitcoind_controller.version() + requested_version = request.config.getoption("--bitcoind-version") + assert running_version == requested_version, ( + "Please make sure that the Bitcoind-version (%s) matches with the version in pytest.ini (%s)" + % (running_version, requested_version) + ) + return bitcoind_controller + + +def instantiate_elementsd_controller(request, rpcport=18643, extra_args=[]): + if os.path.isfile("tests/elements/src/elementsd"): + elementsd_controller = ElementsPlainController( + elementsd_path="tests/elements/src/elementsd", rpcport=rpcport + ) # always prefer the self-compiled bitcoind if existing + elif os.path.isfile("tests/elements/bin/elementsd"): + elementsd_controller = ElementsPlainController( + elementsd_path="tests/elements/bin/elementsd", rpcport=rpcport + ) # next take the self-installed binary if existing + else: + elementsd_controller = ElementsPlainController( + rpcport=rpcport + ) # Alternatively take the one on the path for now + elementsd_controller.start_elementsd( + cleanup_at_exit=True, cleanup_hard=True, extra_args=extra_args + ) + assert not elementsd_controller.datadir is None + running_version = elementsd_controller.version() + requested_version = request.config.getoption("--elementsd-version") + assert running_version == requested_version, ( + "Please make sure that the elementsd-version (%s) matches with the version in pytest.ini (%s)" + % (running_version, requested_version) + ) + return elementsd_controller + + +# Below this point are fixtures. Fixtures have a scope. Check about scopes here: +# https://docs.pytest.org/en/6.2.x/fixture.html#scope-sharing-fixtures-across-classes-modules-packages-or-session +# possible values: function, class, module, package or session. +# The nodes are of scope session. All else is the default (function) + + +@pytest.fixture(scope="session") +def bitcoind_path(): + if os.path.isfile("tests/bitcoin/src/bitcoind"): + return "tests/bitcoin/src/bitcoind" + elif os.path.isfile("tests/bitcoin/bin/bitcoind"): + return "tests/bitcoin/bin/bitcoind" + else: + return which("bitcoind") + + +@pytest.fixture(scope="session") +def bitcoin_regtest(docker, request): + bitcoind_regtest = instantiate_bitcoind_controller(docker, request, extra_args=None) + try: + assert bitcoind_regtest.get_rpc().test_connection() + assert not bitcoind_regtest.datadir is None + yield bitcoind_regtest + finally: + bitcoind_regtest.stop_bitcoind() + + +@pytest.fixture(scope="session") +def elements_elreg(request): + elements_elreg = instantiate_elementsd_controller(request, extra_args=None) + try: + yield elements_elreg + assert not elements_elreg.datadir is None + finally: + elements_elreg.stop_elementsd() + + +@pytest.fixture +def empty_data_folder(): + # Make sure that this folder never ever gets a reasonable non-testing use-case + with tempfile.TemporaryDirectory(prefix="specter_home_tmp_") as data_folder: + yield data_folder + + +@pytest.fixture +def devices_filled_data_folder(empty_data_folder): + os.makedirs(empty_data_folder + "/devices") + with open(empty_data_folder + "/devices/trezor.json", "w") as text_file: + text_file.write( + """ +{ + "name": "Trezor", + "type": "trezor", + "keys": [ + { + "derivation": "m/49h/0h/0h", + "original": "ypub6XFn7hfb676MLm6ZsAviuQKXeRDNgT9Bs32KpRDPnkKgKDjKcrhYCXJ88aBfy8co2k9eujugJX5nwq7RPG4sj6yncDEPWN9dQGmFWPy4kFB", + "fingerprint": "1ef4e492", + "type": "sh-wpkh", + "xpub": "xpub6CRWp2zfwRYsVTuT2p96hKE2UT4vjq9gwvW732KWQjwoG7v6NCXyaTdz7NE5yDxsd72rAGK7qrjF4YVrfZervsJBjsXxvTL98Yhc7poBk7K" + }, + { + "derivation": "m/84h/0h/0h", + "original": "zpub6rGoJTXEhKw7hUFkjMqNctTzojkzRPa3VFuUWAirqpuj13mRweRmnYpGD1aQVFpxNfp17zVU9r7F6oR3c4zL3DjXHdewVvA7kjugHSqz5au", + "fingerprint": "1ef4e492", + "type": "wpkh", + "xpub": "xpub6CcGh8BQPxr9zssX4eG8CiGzToU6Y9b3f2s2wNw65p9xtr8ySL6eYRVzAbfEVSX7ZPaPd3JMEXQ9LEBvAgAJSkNKYxG6L6X9DHnPWNQud4H" + }, + { + "derivation": "m/48h/0h/0h/1h", + "original": "Ypub6jtWQ1r2D7EwqNoxERU28MWZH4WdL3pWdN8guFJRBTmGwstJGzMXJe1VaNZEuAAVsZwpKPhs5GzNPEZR77mmX1mjwzEiouxmQYsrxFBNVNN", + "fingerprint": "1ef4e492", + "type": "sh-wsh", + "xpub": "xpub6EA9y7SfVU96ZWTTTQDR6C5FPJKvB59RPyxoCb8zRgYzGbWAFvogbTVRkTeBLpHgETm2hL7BjQFKNnL66CCoaHyUFBRtpbgHF6YLyi7fr6m" + }, + { + "derivation": "m/48h/0h/0h/2h", + "original": "Zpub74imhgWwMnnRkSPkiNavCQtSBu1fGo8RP96h9eT2GHCgN5eFU9mZVPhGphvGnG26A1cwJxtkmbHR6nLeTw4okpCDjZCEj2HRLJoVHAEsch9", + "fingerprint": "1ef4e492", + "type": "wsh", + "xpub": "xpub6EA9y7SfVU96dGr96zYgxAMd8AgWBCTqEeQafbPi8VcWdhStCS4AA9X4yb3dE1VM7GKLwRhWy4BpD3VkjK5q1riMAQgz9oBSu8QKv5S7KzD" + }, + { + "derivation": "m/49h/1h/0h", + "original": "upub5EKoQv21nQNkhdt4yuLyRnWitA3EGhW1ru1Y8VTG8gdys2JZhqiYkhn4LHp2heHnH41kz95bXPvrYVRuFUrdUMik6YdjFV4uL4EubnesttQ", + "fingerprint": "1ef4e492", + "type": "sh-wpkh", + "xpub": "tpubDDCDr9rSwixeXKeGwAgwFy8bjBaE5wya9sAVqEC4ccXWmcQxY34KmLRJdwmaDsCnHsu5r9P9SUpYtXmCoRwukWDqmAUJgkBbjC2FXUzicn6" + }, + { + "derivation": "m/84h/1h/0h", + "original": "vpub5Y35MNUT8sUR2SnRCU9A9S6z1JDACMTuNnM8WHXvuS7hCwuVuoRAWJGpi66Yo8evGPiecN26oLqx19xf57mqVQjiYb9hbb4QzbNmFfsS9ko", + "fingerprint": "1ef4e492", + "type": "wpkh", + "xpub": "tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc" + }, + { + "derivation": "m/48h/1h/0h/1h", + "original": "Upub5Tk9tZtdzVaTGWtygRTKDDmaN5vfB59pn2L5MQyH6BkVpg2Y5J95rtpQndjmXNs3LNFiy8zxpHCTtvxxeePjgipF7moTHQZhe3E5uPzDXh8", + "fingerprint": "1ef4e492", + "type": "sh-wsh", + "xpub": "tpubDFiVCZzdarbyfdVoh2LJDL3eVKRPmxwnkiqN8tSYCLod75a2966anQbjHajqVAZ97j54xZJPr9hf7ogVuNL4pPCfwvXdKGDQ9SjZF7vXQu1" + }, + { + "derivation": "m/48h/1h/0h/2h", + "original": "Vpub5naRCEZZ9B7wCKLWuqoNdg6ddWEx8ruztUygXFZDJtW5LRMqUP5HV2TsNw1nc74Ba3QPDSH7qzauZ8LdfNmnmofpfmztCGPgP7vaaYSmpgN", + "fingerprint": "1ef4e492", + "type": "wsh", + "xpub": "tpubDFiVCZzdarbyk8kE65tjRhHCambEo8iTx4xkXL8b33BKZj66HWsDnUb3rg4GZz6Mwm6vTNyzRCjYtiScCQJ77ENedb2deDDtcoNQXiUouJQ" + } + ] +} +""" + ) + with open(empty_data_folder + "/devices/specter.json", "w") as text_file: + text_file.write( + """ +{ + "name": "Specter", + "type": "specter", + "keys": [ + { + "derivation": "m/48h/1h/0h/2h", + "original": "Vpub5n9kKePTPPGtw3RddeJWJe29epEyBBcoHbbPi5HhpoG2kTVsSCUzsad33RJUt3LktEUUPPofcZczuudnwR7ZgkAkT6N2K2Z7wdyjYrVAkXM", + "fingerprint": "08686ac6", + "type": "wsh", + "xpub": "tpubDFHpKypXq4kwUrqLotPs6fCic5bFqTRGMBaTi9s5YwwGymE8FLGwB2kDXALxqvNwFxB1dLWYBmmeFVjmUSdt2AsaQuPmkyPLBKRZW8BGCiL" + }, + { + "derivation": "m/84h/1h/0h", + "original": "vpub5ZSem3mLXiSJzgDX6pJb2N9L6sJ8m6ejaksLPLSuB53LBzCi2mMsBg19eEUSDkHtyYp75GATjLgt5p3S43WjaVCXAWU9q9H5GhkwJBrMiAb", + "fingerprint": "08686ac6", + "type": "wpkh", + "xpub": "tpubDDUotcvrYMUiy4ncDirveTfhmvggdj8nxcW5JgHpGzYz3UVscJY5aEzFvgUPk4YyajadBnsTBmE2YZmAtJC14Q21xncJgVaHQ7UdqMRVRbU" + }, + { + "derivation": "m/84h/1h/1h", + "original": "vpub5ZSem3mLXiSK55jPzfLVhbHbTEwGzEFZv3xrGFCw1vGHSNw7WcVuJXysJLWcgENQd3iXSNQaeSXUBW55Hy4GAjSTjrWP4vpKKkUN9jiU1Tc", + "fingerprint": "08686ac6", + "type": "wpkh", + "xpub": "tpubDDUotcvrYMUj3UJV7ZtqKgoy8JKprrjdHubbBb3r7qmwHsEH69g7h6xyanWaCYdVEEV3Yu7a6s4ceFnp8DjXeeFxY8eXvH7XTAC4gxfDNEW" + }, + { + "derivation": "m/84h/1h/2h", + "original": "vpub5ZSem3mLXiSK64v64deytnDCoYqbUSYHvmVurUGVMEnXMyEybtF3FEnNuiFDDC6J18a81fv5ptQXaQaaRiYx8MRxahipgxPLdxubpYt1dkD", + "fingerprint": "08686ac6", + "type": "wpkh", + "xpub": "tpubDDUotcvrYMUj4TVBBYDKWsjaUcE9M52MJd8emp7QTAJBDTY9BRRFdomVCAFAjWMNcKLe8Cd5HJwg3AJKFyEDcGFTNyryYJgYmNdJMhwB2RG" + }, + { + "derivation": "m/84h/1h/3h", + "original": "vpub5ZSem3mLXiSK8cKzh4sHxTvN7mgYQA29HfoAZeCDtX1M2zdejN5XVAtVyqhk8eui18JTtZ9M3VD3AiWCz8VwrybhBUh3HxzS8js3mLVybDT", + "fingerprint": "08686ac6", + "type": "wpkh", + "xpub": "tpubDDUotcvrYMUj6zu5oyRdaZSjnq56GnWCfXRuUz38zSWztUvpJuFjsjscGHhheyAncK4z15rLVukBdUDwpPBDLtRBykqC9KHeG9akJWRipKK" + } + ] +} +""" + ) + return empty_data_folder # no longer empty, though + + +@pytest.fixture +def wallets_filled_data_folder(devices_filled_data_folder): + simple_json = """ +{ + "alias": "simple", + "fullpath": "/home/kim/.specter/wallets/regtest/simple.json", + "name": "Simple", + "address_index": 0, + "keypool": 5, + "address": "bcrt1qcatuhg0gll3h7py4cmn53rjjn9xlsqfwj3zcej", + "change_index": 0, + "change_address": "bcrt1qt28v03278lmmxllys89acddp2p5y4zds94944n", + "change_keypool": 5, + "type": "simple", + "description": "Single (Segwit)", + "keys": [{ + "derivation": "m/84h/1h/0h", + "original": "vpub5Y35MNUT8sUR2SnRCU9A9S6z1JDACMTuNnM8WHXvuS7hCwuVuoRAWJGpi66Yo8evGPiecN26oLqx19xf57mqVQjiYb9hbb4QzbNmFfsS9ko", + "fingerprint": "1ef4e492", + "type": "wpkh", + "xpub": "tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc" + }], + "recv_descriptor": "wpkh([1ef4e492/84h/1h/0h]tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc/0/*)#xp8lv5nr", + "change_descriptor": "wpkh([1ef4e492/84h/1h/0h]tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc/1/*)#h4z73prm", + "device": "Trezor", + "device_type": "trezor", + "address_type": "bech32" +} +""" + another_wallet_json = """ +{ + "name": "sdsd", + "alias": "sdsd", + "description": "Single (Segwit)", + "address_type": "bech32", + "address": "bcrt1q4h86vfanswhsle63hw2muv9h5a45cg2878uez5", + "address_index": 0, + "change_address": "bcrt1qxsj28ddr95xvp7xjyzkkfq6qknrn4kap30zkut", + "change_index": 0, + "keypool": 60, + "change_keypool": 20, + "recv_descriptor": "wpkh([41490ec7/84h/1h/0h]tpubDCTPz7KwyetfhQNMSWiK34pPR2zSTsTybrMPgRVAzouNLqtgsv51o81KjccmTbjkWJ8mVhRJM1LxZD6AfRH2635tHpHeCAKW446iwADNv7C/0/*)#rn833s5g", + "change_descriptor": "wpkh([41490ec7/84h/1h/0h]tpubDCTPz7KwyetfhQNMSWiK34pPR2zSTsTybrMPgRVAzouNLqtgsv51o81KjccmTbjkWJ8mVhRJM1LxZD6AfRH2635tHpHeCAKW446iwADNv7C/1/*)#j8zsv9ys", + "keys": [ + { + "original": "vpub5YRErYARy1rFj1oGKc9yQyJ1jybtbEyvDziem5eFttPiVMbXJNtoQZ2DTAcowHUfu7NFPAiJtaop6TNRqAbkc8GPVY9VLp2HveP2PygjuYh", + "fingerprint": "41490ec7", + "derivation": "m/84h/1h/0h", + "type": "wpkh", + "purpose": "#0 Single Sig (Segwit)", + "xpub": "tpubDCTPz7KwyetfhQNMSWiK34pPR2zSTsTybrMPgRVAzouNLqtgsv51o81KjccmTbjkWJ8mVhRJM1LxZD6AfRH2635tHpHeCAKW446iwADNv7C" + } + ], + "devices": [ + "dsds" + ], + "sigs_required": 1, + "blockheight": 0, + "pending_psbts": {}, + "frozen_utxo": [], + "last_block": "187e2db380eb6d901efd87188f00c7074506c9c3813b8ecec7300ecc4e55eb46" +} +""" + + os.makedirs(os.path.join(devices_filled_data_folder, "wallets", "regtest")) + with open( + os.path.join(devices_filled_data_folder, "wallets", "regtest", "simple.json"), + "w", + ) as json_file: + json_file.write(simple_json) + os.makedirs(os.path.join(devices_filled_data_folder, "wallets_someuser", "regtest")) + with open( + os.path.join( + devices_filled_data_folder, "wallets_someuser", "regtest", "simple.json" + ), + "w", + ) as json_file: + json_file.write(another_wallet_json) + return devices_filled_data_folder # and with wallets obviously + + +@pytest.fixture +def device_manager(devices_filled_data_folder): + return DeviceManager(os.path.join(devices_filled_data_folder, "devices")) + + +# @pytest.fixture +# def user_manager(empty_data_folder) -> UserManager: +# """A UserManager having users alice, bob and eve""" +# specter = Specter(data_folder=empty_data_folder) +# user_manager = UserManager(specter=specter) +# config = {} +# user_manager.get_user("admin").decrypt_user_secret("admin") +# user_manager.create_user( +# user_id="alice", +# username="alice", +# plaintext_password="plain_pass_alice", +# config=config, +# ) +# user_manager.create_user( +# user_id="bob", +# username="bob", +# plaintext_password="plain_pass_bob", +# config=config, +# ) +# user_manager.create_user( +# user_id="eve", +# username="eve", +# plaintext_password="plain_pass_eve", +# config=config, +# ) +# return user_manager + + +@pytest.fixture +def specter_regtest_configured(bitcoin_regtest, devices_filled_data_folder): + assert bitcoin_regtest.get_rpc().test_connection() + config = { + "rpc": { + "autodetect": False, + "datadir": "", + "user": bitcoin_regtest.rpcconn.rpcuser, + "password": bitcoin_regtest.rpcconn.rpcpassword, + "port": bitcoin_regtest.rpcconn.rpcport, + "host": bitcoin_regtest.rpcconn.ipaddress, + "protocol": "http", + }, + "auth": { + "method": "rpcpasswordaspin", + }, + } + specter = Specter(data_folder=devices_filled_data_folder, config=config) + assert specter.chain == "regtest" + # Create a User + someuser = specter.user_manager.add_user( + User.from_json( + user_dict={ + "id": "someuser", + "username": "someuser", + "password": hash_password("somepassword"), + "config": {}, + "is_admin": False, + "services": None, + }, + specter=specter, + ) + ) + specter.user_manager.save() + specter.check() + + assert not specter.wallet_manager.working_folder is None + try: + yield specter + finally: + # Deleting all Wallets (this will also purge them on core) + for user in specter.user_manager.users: + for wallet in list(user.wallet_manager.wallets.values()): + user.wallet_manager.delete_wallet( + wallet, bitcoin_datadir=bitcoin_regtest.datadir, chain="regtest" + ) + + +def specter_app_with_config(config={}, specter=None): + """helper-function to create SpecterFlasks""" + if isinstance(config, dict): + tempClass = type("tempClass", (TestConfig,), {}) + for key, value in config.items(): + setattr(tempClass, key, value) + # service_manager will expect the class to be defined as a direct property of the module: + if hasattr(sys.modules[__name__], "tempClass"): + delattr(sys.modules[__name__], "tempClass") + assert not hasattr(sys.modules[__name__], "tempClass") + setattr(sys.modules[__name__], "tempClass", tempClass) + assert hasattr(sys.modules[__name__], "tempClass") + assert getattr(sys.modules[__name__], "tempClass") == tempClass + config = tempClass + app = create_app(config=config) + app.app_context().push() + app.config["TESTING"] = True + app.testing = True + app.tor_service_id = None + app.tor_enabled = False + init_app(app, specter=specter) + return app + + +@pytest.fixture +def app(specter_regtest_configured) -> SpecterFlask: + """the Flask-App, but uninitialized""" + return specter_app_with_config( + config="cryptoadvance.specter.config.TestConfig", + specter=specter_regtest_configured, + ) + + +@pytest.fixture +def app_no_node(empty_data_folder) -> SpecterFlask: + specter = Specter(data_folder=empty_data_folder) + app = create_app(config="cryptoadvance.specter.config.TestConfig") + app.app_context().push() + app.config["TESTING"] = True + app.testing = True + app.tor_service_id = None + app.tor_enabled = False + init_app(app, specter=specter) + return app + + +@pytest.fixture +def client(app): + """a test_client from an initialized Flask-App""" + return app.test_client() \ No newline at end of file diff --git a/spotbit-plugin/tests/fix_devices_and_wallets.py b/spotbit-plugin/tests/fix_devices_and_wallets.py new file mode 100644 index 0000000..ac5c46f --- /dev/null +++ b/spotbit-plugin/tests/fix_devices_and_wallets.py @@ -0,0 +1,118 @@ +import pytest +import random +import time + +from cryptoadvance.specter.util.mnemonic import generate_mnemonic +from cryptoadvance.specter.process_controller.node_controller import NodeController +from cryptoadvance.specter.specter import Specter +from cryptoadvance.specter.wallet import Wallet, Device + + +def create_hot_wallet_device( + specter_regtest_configured, name=None, mnemonic=None +) -> Device: + if mnemonic == None: + mnemonic = generate_mnemonic(strength=128) + if name == None: + name = "_".join(mnemonic.split(" ")[0:3]) + wallet_manager = specter_regtest_configured.wallet_manager + device_manager = specter_regtest_configured.device_manager + + # Create the device + device = device_manager.add_device(name=name, device_type="bitcoincore", keys=[]) + device.setup_device(file_password=None, wallet_manager=wallet_manager) + device.add_hot_wallet_keys( + mnemonic=mnemonic, + passphrase="", + paths=[ + "m/49h/1h/0h", # Single Sig (Nested) + "m/84h/1h/0h", # Single Sig (Segwit)' + "m/86h/1h/0h", # Single Sig (Taproot) # Taproot ONLY works if this derivation path is enabled. The wallet descriptor is derived from this + "m/48h/1h/0h/1h", # Multisig Sig (Nested) + "m/48h/1h/0h/2h", # Multisig Sig (Segwit) + # "m/44h/0h/0h", # pkh single-legacy + ], + file_password=None, + wallet_manager=wallet_manager, + testnet=True, + keys_range=[0, 1000], + keys_purposes=[], + ) + return device + + +@pytest.fixture +def hot_wallet_device_1(specter_regtest_configured): + return create_hot_wallet_device(specter_regtest_configured) + + +@pytest.fixture +def hot_wallet_device_2(specter_regtest_configured): + return create_hot_wallet_device(specter_regtest_configured) + + +def create_hot_wallet_with_ID( + specter_regtest_configured: Specter, hot_wallet_device_1, wallet_id +) -> Wallet: + device = hot_wallet_device_1 + wallet_manager = specter_regtest_configured.wallet_manager + assert device.taproot_available(specter_regtest_configured.rpc) + + # create the wallet + keys = [key for key in device.keys if key.key_type == "wpkh"] + source_wallet = wallet_manager.create_wallet(wallet_id, 1, "wpkh", keys, [device]) + return source_wallet + + +@pytest.fixture +def unfunded_hot_wallet_1(specter_regtest_configured, hot_wallet_device_1) -> Wallet: + return create_hot_wallet_with_ID( + specter_regtest_configured, + hot_wallet_device_1, + f"a_hotwallet_{random.randint(0, 999999)}", + ) + + +@pytest.fixture +def unfunded_hot_wallet_2(specter_regtest_configured, hot_wallet_device_1) -> Wallet: + return create_hot_wallet_with_ID( + specter_regtest_configured, + hot_wallet_device_1, + f"a_hotwallet_{random.randint(0, 999999)}", + ) + + +@pytest.fixture +def funded_hot_wallet_1( + bitcoin_regtest: NodeController, unfunded_hot_wallet_1: Wallet +) -> Wallet: + funded_hot_wallet_1 = unfunded_hot_wallet_1 + assert len(funded_hot_wallet_1.txlist()) == 0 + for i in range(0, 10): + bitcoin_regtest.testcoin_faucet(funded_hot_wallet_1.getnewaddress(), amount=1) + funded_hot_wallet_1.update() + for i in range(0, 2): + bitcoin_regtest.testcoin_faucet( + funded_hot_wallet_1.getnewaddress(), + amount=2.5, + confirm_payment=False, + ) + time.sleep(1) # needed for tx to propagate + funded_hot_wallet_1.update() + # 12 txs + assert len(funded_hot_wallet_1.txlist()) == 12 + # two of them are unconfirmed + assert ( + len([tx for tx in funded_hot_wallet_1.txlist() if tx["confirmations"] == 0]) + == 2 + ) + return funded_hot_wallet_1 + + +@pytest.fixture +def funded_hot_wallet_2( + bitcoin_regtest: NodeController, unfunded_hot_wallet_2: Wallet +) -> Wallet: + funded_hot_wallet_2 = unfunded_hot_wallet_2 + bitcoin_regtest.testcoin_faucet(funded_hot_wallet_2.getnewaddress()) + return funded_hot_wallet_2 \ No newline at end of file diff --git a/spotbit-plugin/tests/fix_ghost_machine.py b/spotbit-plugin/tests/fix_ghost_machine.py new file mode 100644 index 0000000..99c8df4 --- /dev/null +++ b/spotbit-plugin/tests/fix_ghost_machine.py @@ -0,0 +1,67 @@ +import pytest + +# Using https://iancoleman.io/bip39/ and https://jlopp.github.io/xpub-converter/ +# mnemonic = "ghost ghost ghost ghost ghost ghost ghost ghost ghost ghost ghost machine" + + +# m/44'/0'/0' + + +@pytest.fixture +def ghost_machine_xpub_44(): + xpub = "xpub6CGap5qbgNCEsvXg2gAjEho17zECMA9PbZa7QkrEWTPnPRaubE6qKots5pNwhyFtuYSPa9gQu4jTTZi8WPaXJhtCHrvHQaFRqayN1saQoWv" + return xpub + + +# m/49'/0'/0' + + +@pytest.fixture +def ghost_machine_xpub_49(): + xpub = "xpub6BtcNhqbaFaoC3oEfKky3Sm22pF48U2jmAf78cB3wdAkkGyAgmsVrgyt1ooSt3bHWgzsdUQh2pTJ867yTeUAMmFDKNSBp8J7WPmp7Df7zjv" + return xpub + + +@pytest.fixture +def ghost_machine_ypub(): + ypub = "ypub6WisgNWWiw8H3LzMVgYbFXrXCnPW562EgHBKv14wKdYdoNnPwS34Uke231m2sxFCvL7gNx1FVUor1NjYBLtB9zvpBi8cQ37bn7qTVqo3fjR" + return ypub + + +@pytest.fixture +def ghost_machine_tpub_49(): + tpub = "tpubDC5CZBbVc15fpTeqkyUBKgHqYCqkeaUtPjvGz7RJEttndfcN29psPcxTSj5RNJaWYaRQq8kqovLBrZA2tju3ThSAP9fY1eiSvorchnseFZu" + return tpub + + +@pytest.fixture +def ghost_machine_upub(): + upub = "upub5DCn7wm4SgVmzmtdoi8DVVfxhBJkqL1L6mmKHNgVky1Fj5VyBxV6NzKD957sr5fWXkY5y8THtqSVWWpjLnomBYw4iXpxaPbkXg5Gn6s5tQf" + return upub + + +# m/84'/0'/0' + + +@pytest.fixture +def ghost_machine_xpub_84(): + xpub = "xpub6CjsHfiuBnHMPBkxThQ4DDjTw2Qq3VMEVcPBoMBGejZGkj3WQR15LeJLmymPpSzYHX21C8SdFWHgMw2RUBdAQ2Aj4MMS93a68mxPQeS8oHr" + return xpub + + +@pytest.fixture +def ghost_machine_zpub(): + zpub = "zpub6rQPu14jV9NK5n9C8QyJdPvUGxhivjLEKqRdN8y3QkK2rvfxujLCamccpPgZpGJP6oFch5dkApzn8WFYuaTBzVXvo2kHJsD4gE5gBnCBYj1" + return zpub + + +@pytest.fixture +def ghost_machine_tpub_84(): + tpub = "tpubDC4DsqH5rqHqipMNqUbDFtQT3AkKkUrvLsN6miySvortU3s1LGaNVAb7wX2No2VsuxQV82T8s3HJLv3kdx1CPjsJ3onC1Zo5mWCQzRVaWVX" + return tpub + + +@pytest.fixture +def ghost_machine_vpub(): + vpub = "vpub5Y24kG7ZrCFRkRnHia2sdnt5N7MmsrNry1jMrP8XptMEcZZqkjQA6bc1f52RGiEoJmdy1Vk9Qck9tAL1ohKvuq3oFXe3ADVse6UiTHzuyKx" + return vpub \ No newline at end of file diff --git a/spotbit-plugin/tests/fix_keys_and_seeds.py b/spotbit-plugin/tests/fix_keys_and_seeds.py new file mode 100644 index 0000000..de08d05 --- /dev/null +++ b/spotbit-plugin/tests/fix_keys_and_seeds.py @@ -0,0 +1,138 @@ +from binascii import hexlify +import json +import pytest +from cryptoadvance.specter.key import Key + +from embit.bip39 import mnemonic_to_seed +from embit.bip32 import HDKey, NETWORKS +from embit import script + +mnemonic_ghost_machine = ( + "ghost ghost ghost ghost ghost ghost ghost ghost ghost ghost ghost machine" +) + +mnemonic_zoo_when = ( + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when" +) + + +@pytest.fixture +def mnemonic_keen_join(): + return 11 * "keen " + "join" + + +# hold hold hold hold hold hold hold hold hold hold hold accident +# This is a formal creation of all major bitcoin artifacts from the +# hold accident mnemonic + + +@pytest.fixture +def mnemonic_hold_accident(): + return 11 * "hold " + "accident" + + +@pytest.fixture +def seed_hold_accident(mnemonic_hold_accident): + seed = mnemonic_to_seed(mnemonic_hold_accident) + print(f"Hold Accident seed: {hexlify(seed)}") + return mnemonic_to_seed(mnemonic_hold_accident) + + +@pytest.fixture +def rootkey_hold_accident(seed_hold_accident): + rootkey = HDKey.from_seed(seed_hold_accident) + print(f"Hold Accident rootkey: {rootkey.to_base58()}") + # xprv9s21ZrQH143K45uYUg7zhHku3bik5a2nw8XcanYCUGHn7RE1Bhkr53RWcjAQVFDTmruDceNDAGbc7yYsZCGveKMDrPr18hMsMcvYTGJ4Mae + print(f"Hold Accident rootkey fp: {hexlify(rootkey.my_fingerprint)}") + return rootkey + + +@pytest.fixture +def acc0xprv_hold_accident(rootkey_hold_accident: HDKey): + xprv = rootkey_hold_accident.derive("m/84h/1h/0h") + print(f"Hold Accident acc0xprv: {xprv.to_base58(version=NETWORKS['test']['xprv'])}") + # tprv8g6WHqYgjvGrEU6eEdJxXzNUqN8DvLFb3iv3yUVomNRcNqT5JSKpTVNBzBD3qTDmmhRHPLcjE5fxFcGmU3FqU5u9zHm9W6sGX2isPMZAKq2 + + return xprv + + +@pytest.fixture +def acc0xpub_hold_accident(acc0xprv_hold_accident: HDKey): + xpub = acc0xprv_hold_accident.to_public() + print(f"Hold Accident acc0xpub: {xpub.to_base58(version=NETWORKS['test']['xpub'])}") + # vpub5YkPJgRQsev79YZM1NRDKJWDjLFcD2xSFAt6LehC5iiMMqQgMHyCFQzwsu16Rx9rBpXZVXPjWAxybuCpsayaw8qCDZtjwH9vifJ7WiQkHwu + return xpub + + +@pytest.fixture +def acc0key0pubkey_hold_accident(acc0xpub_hold_accident: HDKey): + pubkey = acc0xpub_hold_accident.derive("m/0/0") + print("------------") + print(pubkey.key) + # 03584dc8282f626ce5570633018be0760baae68f1ecd6e801192c466ada55f5f31 + print(hexlify(pubkey.sec())) + # b'03584dc8282f626ce5570633018be0760baae68f1ecd6e801192c466ada55f5f31' + return pubkey + + +@pytest.fixture +def acc0key0addr_hold_accident(acc0key0pubkey_hold_accident): + sc = script.p2wpkh(acc0key0pubkey_hold_accident) + address = sc.address(NETWORKS["test"]) + print(address) # m/84'/1'/0'/0/0 + # tb1qnwc84tkupy5v0tzgt27zkd3uxex3nmyr6vfhdd + return address + + +@pytest.fixture +def key_hold_accident(acc0key0pubkey_hold_accident): + sc = script.p2wpkh(acc0key0pubkey_hold_accident) + address = sc.address(NETWORKS["test"]) + print(address) # m/84'/1'/0'/0/0 + # tb1qnwc84tkupy5v0tzgt27zkd3uxex3nmyr6vfhdd + return address + + +@pytest.fixture +def acc0key_hold_accident(acc0xpub_hold_accident, rootkey_hold_accident: HDKey): + + key: Key = Key( + acc0xpub_hold_accident.to_base58( + version=NETWORKS["test"]["xpub"] + ), # original (ToDo: better original) + hexlify(rootkey_hold_accident.my_fingerprint).decode("utf-8"), # fingerprint + "m/84h/1h/0h", # derivation + "wpkh", # key_type + "Muuh", # purpose + acc0xpub_hold_accident.to_base58(version=NETWORKS["test"]["xpub"]), # xpub + ) + mydict = key.json + print(json.dumps(mydict)) + + return key + + +# random other keys + + +@pytest.fixture +def a_key(): + a_key = Key( + "Vpub5n9kKePTPPGtw3RddeJWJe29epEyBBcoHbbPi5HhpoG2kTVsSCUzsad33RJUt3LktEUUPPofcZczuudnwR7ZgkAkT6N2K2Z7wdyjYrVAkXM", + "08686ac6", + "m/48h/1h/0h/2h", + "wsh", + "", + "tpubDFHpKypXq4kwUrqLotPs6fCic5bFqTRGMBaTi9s5YwwGymE8FLGwB2kDXALxqvNwFxB1dLWYBmmeFVjmUSdt2AsaQuPmkyPLBKRZW8BGCiL", + ) + return a_key + + +@pytest.fixture +def a_tpub_only_key(): + a_tpub_only_key = Key.from_json( + { + "original": "tpubDDZ5jjGT5RvrAyjoLZfdCfv1PAPmicnhNctwZGKiCMF1Zy5hCGMqppxwYZzWgvPqk7LucMMHo7rkB6Dyj5ZLd2W62FAEP3U6pV4jD5gb9ma" + } + ) + return a_tpub_only_key \ No newline at end of file diff --git a/spotbit-plugin/tests/fix_testnet.py b/spotbit-plugin/tests/fix_testnet.py new file mode 100644 index 0000000..e0e767e --- /dev/null +++ b/spotbit-plugin/tests/fix_testnet.py @@ -0,0 +1,59 @@ +""" A set of fixtures which assume a testnet-node on localhost. This can be helpfull while developing. + +""" + +from cryptoadvance.specter.user import User, hash_password +import pytest +from cryptoadvance.specter.specter import Specter +from cryptoadvance.specter.user import User + + +@pytest.fixture +def specter_testnet_configured(bitcoin_regtest, devices_filled_data_folder): + """This assumes a bitcoin-testnet-node is running on loalhost""" + + config = { + "rpc": { + "autodetect": False, + "datadir": "", + "user": "bitcoin", # change this to your credential in bitcoin.conf (for testnet) + "password": "secret", + "port": 18332, + "host": "localhost", + "protocol": "http", + }, + "auth": { + "method": "rpcpasswordaspin", + }, + } + specter = Specter(data_folder=devices_filled_data_folder, config=config) + specter.check() + assert specter.chain == "test" + + # Create a User + someuser = specter.user_manager.add_user( + User.from_json( + user_dict={ + "id": "someuser", + "username": "someuser", + "password": hash_password("somepassword"), + "config": {}, + "is_admin": False, + "services": None, + }, + specter=specter, + ) + ) + specter.user_manager.save() + specter.check() + + assert not specter.wallet_manager.working_folder is None + try: + yield specter + finally: + # Deleting all Wallets (this will also purge them on core) + for user in specter.user_manager.users: + for wallet in list(user.wallet_manager.wallets.values()): + user.wallet_manager.delete_wallet( + wallet, bitcoin_datadir=bitcoin_regtest.datadir, chain="regtest" + ) \ No newline at end of file From 47d08e7bc205d0b2e12b6d27a1613371dd9d4726 Mon Sep 17 00:00:00 2001 From: ahv15 Date: Mon, 8 Aug 2022 00:20:27 +0530 Subject: [PATCH 2/9] improve performance and store historical data --- .../ahv15/specterext/spotbit/controller.py | 6 + .../src/ahv15/specterext/spotbit/service.py | 295 ++++++++++++------ 2 files changed, 204 insertions(+), 97 deletions(-) diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py b/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py index 40b431a..2e3e8b4 100644 --- a/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py @@ -21,6 +21,12 @@ def index(): return render_template( "spotbit/index.jinja", ) + + +@spotbit_endpoint.route("/hist////") +def historical_exchange_rate(currency, exchange, date_start, date_end): + service = ext() + return(service.historical_exchange_rate(currency, exchange, date_start, date_end)) @spotbit_endpoint.route("/now//") def current_exchange_rate(currency, exchange): diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/service.py b/spotbit-plugin/src/ahv15/specterext/spotbit/service.py index f39c078..dc802b7 100644 --- a/spotbit-plugin/src/ahv15/specterext/spotbit/service.py +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/service.py @@ -1,17 +1,77 @@ import logging -import ccxt +import ccxt as ccxt +import ccxt.async_support as ccxt_async import sqlite3 from pathlib import Path import time from datetime import datetime, timedelta +import asyncio +from asyncio import gather +import threading +import itertools from cryptoadvance.specter.services.service import Service, devstatus_alpha from flask_apscheduler import APScheduler logger = logging.getLogger(__name__) + +# https://stackoverflow.com/a/58616001 + +class EventLoopThread(threading.Thread): + loop = None + _count = itertools.count(0) + + def __init__(self): + self.started = threading.Event() + name = f"{type(self).__name__}-{next(self._count)}" + super().__init__(name=name, daemon=True) + + def run(self): + self.loop = loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.call_later(0, self.started.set) + + try: + loop.run_forever() + finally: + try: + shutdown_asyncgens = loop.shutdown_asyncgens() + except AttributeError: + pass + else: + loop.run_until_complete(shutdown_asyncgens) + try: + shutdown_executor = loop.shutdown_default_executor() + except AttributeError: + pass + else: + loop.run_until_complete(shutdown_executor) + asyncio.set_event_loop(None) + loop.close() + +_lock = threading.Lock() +_loop_thread = None + +def get_event_loop(): + global _loop_thread + if _loop_thread is None: + with _lock: + if _loop_thread is None: + _loop_thread = EventLoopThread() + _loop_thread.start() + _loop_thread.started.wait(1) + + return _loop_thread.loop + +def run_coroutine(coro): + return asyncio.run_coroutine_threadsafe(coro, get_event_loop()) + objects = {"aax":ccxt.aax(), "ascendex": ccxt.ascendex(),"bequant":ccxt.bequant(), "bibox":ccxt.bibox(), "bigone":ccxt.bigone(), "binance":ccxt.binance(), "bitbank":ccxt.bitbank(), "liquid":ccxt.liquid(), "phemex":ccxt.phemex(), "bitstamp":ccxt.bitstamp(), "ftx":ccxt.ftx()} -exchanges = ["bitstamp", "ascendex", "bequant", "bibox","bigone"] +async_objects = {"aax":ccxt_async.aax(), "ascendex": ccxt_async.ascendex(),"bequant":ccxt_async.bequant(), "bibox":ccxt_async.bibox(), "bigone":ccxt_async.bigone(), "binance":ccxt_async.binance(), "bitbank":ccxt_async.bitbank(), "liquid":ccxt_async.liquid(), "phemex":ccxt_async.phemex(), "bitstamp":ccxt_async.bitstamp(), "ftx":ccxt_async.ftx()} +exchanges = ["binance", "ascendex", "bequant", "bibox","bigone", "bitstamp"] +historicalExchanges = ["bitstamp"] + def is_ms(timestamp): if timestamp % 1000 == 0: return True @@ -20,13 +80,22 @@ def is_ms(timestamp): def get_supported_pair_for(currency, exchange): result = '' exchange.load_markets() - market_ids_found = [market for market in exchange.markets_by_id.keys() if ((market[:len('BTC')].upper() == 'BTC' or market[:len('XBT')].upper() == 'XBT') and market[-len(currency):].upper() == currency.upper())] + market_ids_found = [market for market in exchange.markets_by_id.keys() if ((market[:len('BTC')].upper() == 'BTC' or market[:len('XBT')].upper() == 'XBT') and market[-len(currency):].upper() == currency.upper() and len(market) == (3 + len(currency)))] if market_ids_found: market_id = market_ids_found[0] market = exchange.markets_by_id[exchange.market_id(market_id)] if market: result = market['symbol'] return result + +async def fetch_ohlcv(exchange, symbol, timeframe, limit, exchange_name): + since = None + try: + ohlcv = await exchange.fetch_ohlcv(symbol, timeframe, since, limit) + if len(ohlcv): + return [exchange_name, symbol, ohlcv] + except Exception as e: + print(type(e).__name__, str(e)) class SpotbitService(Service): id = "spotbit" @@ -44,107 +113,87 @@ class SpotbitService(Service): def callback_after_serverpy_init_app(self, scheduler: APScheduler): path = Path("./sb.db") + path_hist = Path("./sb_hist.db") - def request(ex_objs): - with scheduler.app.app_context(): - tic = time.perf_counter() + def request_history(objects, exchange, currency, start_date, end_date): + db_n = sqlite3.connect(path_hist, timeout=10) + cur = db_n.cursor() + ticker = get_supported_pair_for(currency, objects[exchange]) + while start_date < end_date: + params = {'start': start_date, 'end': int(end_date)} + tick = objects[exchange].fetch_ohlcv(symbol=ticker, timeframe='1m', params=params, limit = 1000) + records = [] + dt = None + for line in tick: + dt = None + try: + if is_ms(int(line['timestamp'])): + dt = datetime.fromtimestamp(line['timestamp'] / 1e3) + else: + dt = datetime.fromtimestamp(line['timestamp']) + records.append([line['timestamp'], dt, ticker, 0.0, 0.0, 0.0, line['last'], 0.0]) + except TypeError: + if line[0] % 1000 == 0: + dt = datetime.fromtimestamp(line[0] / 1e3) + else: + dt = datetime.fromtimestamp(line[0]) + records.append([line[0], dt, ticker, line[1], line[2], line[3], line[4], line[5]]) + statement = f"INSERT INTO {exchange} (timestamp, datetime, pair, open, high, low, close, volume) VALUES (?, ?, ?, ?, ?, ?, ?, ?);" + cur.executemany(statement, records) + db_n.commit() + l = len(tick) + end_date = int(datetime.timestamp(datetime.fromtimestamp(end_date) - timedelta(minutes=l))) + + def request_history_periodically(histExchanges): + history_threads = [] + historyEnd = 0 + for h in histExchanges: + hThread = threading.Thread(target=request_history, args=(objects, h, "USD", historyEnd, datetime.now().timestamp())) + hThread.start() + history_threads.append(hThread) + return history_threads + + async def request(objects, async_objects): + while True: currencies = ["usd", "gbp", "jpy", "usdt", "eur", "0xcafebabe"] - interval = 5 - db_n = sqlite3.connect(path, timeout=30) - cursor = db_n.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") - if(cursor.fetchall() == []): - return + loops = [] for e in exchanges: for curr in currencies: - ticker = get_supported_pair_for(curr, ex_objs[e]) + ticker = get_supported_pair_for(curr, objects[e]) if(ticker == ''): continue - success = True - if ex_objs[e].has['fetchOHLCV']: - candle = None + if async_objects[e].has['fetchOHLCV']: tframe = '1m' - lim = 1000 + lim = 1 if e == "bleutrade" or e == "btcalpha" or e == "rightbtc" or e == "hollaex": tframe = '1h' if e == "poloniex": tframe = '5m' - if e == "bitstamp": - lim = 1000 - if e == "bybit": - lim = 200 - if e == "eterbase": - lim = 1000000 - if e == "exmo": - lim = 3000 - if e == "btcalpha": - lim = 720 - if e == "bitfinex": - params = {'limit':100, 'start':(round((datetime.now()-timedelta(hours=1)).timestamp()*1000)), 'end':round(datetime.now().timestamp()*1000)} - try: - candle = ex_objs[e].fetch_ohlcv(symbol=ticker, timeframe=tframe, since=None, params=params) - if candle == None: - raise Exception(f"candle from {e} is null") - - except Exception as err: #figure out this error type - #the point so far is to gracefully handle the error, but waiting for the next cycle should be good enough - if "does not have" not in str(err): - print(f"error fetching candle: {e} {curr} {err}") - success = False - else: - try: - candle = ex_objs[e].fetch_ohlcv(symbol=ticker, timeframe=tframe, since=None, limit=lim) #'ticker' was listed as 'symbol' before | interval should be determined in the config file - if candle == None: - raise Exception(f"candle from {e} is nulll") - except Exception as err: - print(err) - if "does not have" not in str(err): - print(f"error fetching candle: {e} {curr} {err}") - success = False - if success: - for line in candle: - ts = datetime.fromtimestamp(line[0]/1e3) #check here if we have a ms timestamp or not - for l in line: - if l == None: - l = 0 - statement = "INSERT INTO {} (timestamp, datetime, pair, open, high, low, close, volume) VALUES ({}, '{}', '{}', {}, {}, {}, {}, {});".format(e, line[0], ts, ticker, line[1], line[2], line[3], line[4], line[5]) - try: - db_n.execute(statement) - db_n.commit() - - except sqlite3.OperationalError as op: - nulls = [] - c = 0 - for l in line: - if l == None: - nulls.append(c) - c += 1 - print(f"exchange: {e} currency: {curr}\nsql statement: {statement}\nerror: {op}(moving on)") - else: try: - price = ex_objs[e].fetch_ticker(ticker) + loops.append(fetch_ohlcv(async_objects[e], ticker, tframe, lim, e)) except Exception as err: - print(f"error fetching ticker: {err}") - success = False - if success: - ts = None - try: - if is_ms(int(price['timestamp'])): - ts = datetime.fromtimestamp(int(price['timestamp'])/1e3) - else: - ts = datetime.fromtimestamp(int(price['timestamp'])) - except OverflowError as oe: - print(f"{oe} caused by {ts}") - ticker = ticker.replace("/", "-") - statement = f"INSERT INTO {e} (timestamp, datetime, pair, open, high, low, close, volume) VALUES ({price['timestamp']}, '{ts}', '{ticker}', 0.0, 0.0, 0.0, {price['last']}, 0.0);" - db_n.execute(statement) - db_n.commit() - time.sleep(interval) - toc = time.perf_counter() - print(f"It took {toc - tic:0.4f} seconds") - - - + if "does not have" not in str(err): + print(f"error fetching candle: {e} {curr} {err}") + responses = await gather(*loops) + db_n = sqlite3.connect(path, timeout=30) + cursor = db_n.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + if(cursor.fetchall() == []): + continue + for response in responses: + datetime_ = [] + for line in response[2]: + datetime_.append(datetime.fromtimestamp(line[0]/1e3)) #check here if we have a ms timestamp or not + for l in line: + if l == None: + l = 0 + statement = "INSERT INTO {} (timestamp, datetime, pair, open, high, low, close, volume) VALUES ({}, '{}', '{}', {}, {}, {}, {}, {});".format(response[0], response[2][0][0], datetime_, response[1], response[2][0][1], response[2][0][2], response[2][0][3], response[2][0][4], response[2][0][5]) + db_n.execute(statement) + db_n.commit() + for each_exchange in async_objects.values(): + await each_exchange.close() + await asyncio.sleep(60) + def prune(keepWeeks): with scheduler.app.app_context(): @@ -168,10 +217,12 @@ def prune(keepWeeks): except sqlite3.OperationalError as op: print(f"{op}") db_n.commit() - + + SpotbitService.init_table() + run_coroutine(request(objects, async_objects)) + request_history_periodically(historicalExchanges) scheduler.add_job("prune", prune, trigger = 'interval', args = [10], minutes = 1) - scheduler.add_job("request", request, trigger = 'interval', args =[objects], minutes = 15) - self.scheduler = scheduler + self.scheduler = scheduler @classmethod @@ -184,13 +235,23 @@ def init_table(cls): db.execute(sql) db.commit() db.close() + + p = Path("./sb_hist.db") + db = sqlite3.connect(p) + for exchange in historicalExchanges: + sql = f"CREATE TABLE IF NOT EXISTS {exchange} (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER, datetime TEXT, pair TEXT, open REAL, high REAL, low REAL, close REAL, volume REAL)" + print(f"created table for {exchange}") + db.execute(sql) + db.commit() + db.close() @classmethod def current_exchange_rate(cls, currency, exchange): p = Path("./sb.db") - db_n = sqlite3.connect(p, timeout=30) + db_n = sqlite3.connect(p, timeout=5) + exchange = 'binance' + currency = 'usdt' ticker = get_supported_pair_for(currency, objects[exchange]) - print(ticker) if exchange in exchanges: statement = f"SELECT * FROM {exchange} WHERE pair = '{ticker}' ORDER BY timestamp DESC LIMIT 1;" try: @@ -201,4 +262,44 @@ def current_exchange_rate(cls, currency, exchange): return {'err': 'database locked'} if res != None: db_n.close() - return {'id':res[0], 'timestamp':res[1], 'datetime':res[2], 'currency_pair':res[3], 'open':res[4], 'high':res[5], 'low':res[6], 'close':res[7], 'vol':res[8]} \ No newline at end of file + return {'id':res[0], 'timestamp':res[1], 'datetime':res[2], 'currency_pair':res[3], 'open':res[4], 'high':res[5], 'low':res[6], 'close':res[7], 'vol':res[8]} + + + + @classmethod + def historical_exchange_rate(cls, currency, exchange, date_start, date_end): + p = Path("./sb_hist.db") + db_n = sqlite3.connect(p, timeout=5) + ticker = get_supported_pair_for(currency, objects[exchange]) + if (str(date_start)).isdigit(): + date_s = int(date_start) + date_e = int(date_end) + else: + #error checking for malformed dates + try: + date_s = (datetime.fromisoformat(date_start.replace("T", " "))).timestamp()*1000 + date_e = (datetime.fromisoformat(date_end.replace("T", " "))).timestamp()*1000 + except Exception: + return "malformed dates. Provide both dates in the same format: use YYYY-MM-DDTHH:mm:SS or millisecond timestamps" + check = f"SELECT timestamp FROM {exchange} ORDER BY timestamp DESC LIMIT 1;" + cursor = db_n.execute(check) + statement = "" + ts = cursor.fetchone() + if ts != None and is_ms(int(ts[0])): + statement = f"SELECT * FROM {exchange} WHERE timestamp > {date_s} AND timestamp < {date_e} AND pair = '{ticker}' ORDER BY timestamp DESC;" + else: + # for some exchanges we cannot use ms precision timestamps (such as coinbase) + date_s /= 1e3 + date_e /= 1e3 + statement = f"SELECT * FROM {exchange} WHERE timestamp > {date_s} AND timestamp < {date_e} AND pair = '{ticker}';" + # keep trying in case of database locked error + while True: + try: + cursor = db_n.execute(statement) + break + except sqlite3.OperationalError as oe: + time.sleep(5) + + res = cursor.fetchall() + db_n.close() + return {'columns': ['id', 'timestamp', 'datetime', 'currency_pair', 'open', 'high', 'low', 'close', 'vol'], 'data':res} \ No newline at end of file From 1136ec603ae8181973874c467b8ba0a8130186c1 Mon Sep 17 00:00:00 2001 From: ahv15 Date: Thu, 18 Aug 2022 18:58:22 +0530 Subject: [PATCH 3/9] add user configuration --- .../ahv15/specterext/spotbit/controller.py | 21 +- .../src/ahv15/specterext/spotbit/service.py | 201 ++++++------ .../spotbit/templates/spotbit/settings.jinja | 293 ++++++++++++++---- 3 files changed, 340 insertions(+), 175 deletions(-) diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py b/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py index 2e3e8b4..b5907c3 100644 --- a/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py @@ -4,6 +4,8 @@ from cryptoadvance.specter.specter import Specter from .service import SpotbitService +from datetime import datetime + spotbit_endpoint = SpotbitService.blueprint @@ -41,13 +43,20 @@ def settings_get(): @spotbit_endpoint.route("/settings", methods=["POST"]) def settings_post(): - keepWeeks = request.form["keepWeeks"] - exchanges = request.form["exchanges"] - currencies = request.form["currencies"] - interval = request.form["interval"] + print(request.values) + exchange1 = request.form.get('exchange1', "None") + exchange2 = request.form.get('exchange2', "None") + exchange3 = request.form.get('exchange3', "None") + exchange4 = request.form.get('exchange4', "None") + currency1 = request.form.get('currency1', "None") + currency2 = request.form.get('currency2', "None") + currency3 = request.form.get('currency3', "None") + currency4 = request.form.get('currency4', "None") + start_date = request.form.get('start_date', "None") + frequencies = request.form.get('frequencies', "None") service = ext() - service.scheduler.scheduler.modify_job(job_id = 'prune', args = [keepWeeks]) - service.init_table() + service.scheduler.scheduler.modify_job(job_id = 'prune', args = [datetime.timestamp(start_date)]) + service.init_table([exchange1, exchange2, exchange3, exchange4], [currency1, currency2, currency3, currency4], frequencies) ''' user = app.specter.user_manager.get_user() if show_menu == "yes": diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/service.py b/spotbit-plugin/src/ahv15/specterext/spotbit/service.py index dc802b7..4aa5579 100644 --- a/spotbit-plugin/src/ahv15/specterext/spotbit/service.py +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/service.py @@ -14,6 +14,8 @@ from flask_apscheduler import APScheduler logger = logging.getLogger(__name__) +path = Path("./sb.db") +path_hist = Path("./sb_hist.db") # https://stackoverflow.com/a/58616001 @@ -96,6 +98,87 @@ async def fetch_ohlcv(exchange, symbol, timeframe, limit, exchange_name): return [exchange_name, symbol, ohlcv] except Exception as e: print(type(e).__name__, str(e)) + + +def request_history(objects, exchange, currency, start_date, end_date, frequencies): + db_n = sqlite3.connect(path_hist, timeout=10) + cur = db_n.cursor() + ticker = get_supported_pair_for(currency, objects[exchange]) + while start_date < end_date: + params = {'start': start_date, 'end': int(end_date)} + tick = objects[exchange].fetch_ohlcv(symbol=ticker, timeframe='1m', params=params, limit = 1000) + records = [] + dt = None + for line in tick: + dt = None + try: + if is_ms(int(line['timestamp'])): + dt = datetime.fromtimestamp(line['timestamp'] / 1e3) + else: + dt = datetime.fromtimestamp(line['timestamp']) + records.append([line['timestamp'], dt, ticker, 0.0, 0.0, 0.0, line['last'], 0.0]) + except TypeError: + if line[0] % 1000 == 0: + dt = datetime.fromtimestamp(line[0] / 1e3) + else: + dt = datetime.fromtimestamp(line[0]) + records.append([line[0], dt, ticker, line[1], line[2], line[3], line[4], line[5]]) + statement = f"INSERT INTO {exchange} (timestamp, datetime, pair, open, high, low, close, volume) VALUES (?, ?, ?, ?, ?, ?, ?, ?);" + cur.executemany(statement, records) + db_n.commit() + l = len(tick) + end_date = int(datetime.timestamp(datetime.fromtimestamp(end_date) - timedelta(minutes=l))) + +def request_history_periodically(histExchanges, frequencies): + history_threads = [] + historyEnd = 0 + for h in histExchanges: + hThread = threading.Thread(target=request_history, args=(objects, h, "USD", historyEnd, datetime.now().timestamp(), frequencies)) + hThread.start() + history_threads.append(hThread) + return history_threads + +async def request(exchanges, currencies): + while True: + loops = [] + for e in exchanges: + for curr in currencies: + if(currencies == "None"): + continue + ticker = get_supported_pair_for(curr, objects[e]) + if(ticker == ''): + continue + if async_objects[e].has['fetchOHLCV']: + tframe = '1m' + lim = 1 + if e == "bleutrade" or e == "btcalpha" or e == "rightbtc" or e == "hollaex": + tframe = '1h' + if e == "poloniex": + tframe = '5m' + try: + loops.append(fetch_ohlcv(async_objects[e], ticker, tframe, lim, e)) + except Exception as err: + if "does not have" not in str(err): + print(f"error fetching candle: {e} {curr} {err}") + responses = await gather(*loops) + db_n = sqlite3.connect(path, timeout=30) + cursor = db_n.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + if(cursor.fetchall() == []): + continue + for response in responses: + datetime_ = [] + for line in response[2]: + datetime_.append(datetime.fromtimestamp(line[0]/1e3)) #check here if we have a ms timestamp or not + for l in line: + if l == None: + l = 0 + statement = "INSERT INTO {} (timestamp, datetime, pair, open, high, low, close, volume) VALUES ({}, '{}', '{}', {}, {}, {}, {}, {});".format(response[0], response[2][0][0], datetime_, response[1], response[2][0][1], response[2][0][2], response[2][0][3], response[2][0][4], response[2][0][5]) + db_n.execute(statement) + db_n.commit() + for each_exchange in async_objects.values(): + await each_exchange.close() + await asyncio.sleep(60) class SpotbitService(Service): id = "spotbit" @@ -112,90 +195,8 @@ class SpotbitService(Service): SPECTER_WALLET_ALIAS = "wallet" def callback_after_serverpy_init_app(self, scheduler: APScheduler): - path = Path("./sb.db") - path_hist = Path("./sb_hist.db") - - def request_history(objects, exchange, currency, start_date, end_date): - db_n = sqlite3.connect(path_hist, timeout=10) - cur = db_n.cursor() - ticker = get_supported_pair_for(currency, objects[exchange]) - while start_date < end_date: - params = {'start': start_date, 'end': int(end_date)} - tick = objects[exchange].fetch_ohlcv(symbol=ticker, timeframe='1m', params=params, limit = 1000) - records = [] - dt = None - for line in tick: - dt = None - try: - if is_ms(int(line['timestamp'])): - dt = datetime.fromtimestamp(line['timestamp'] / 1e3) - else: - dt = datetime.fromtimestamp(line['timestamp']) - records.append([line['timestamp'], dt, ticker, 0.0, 0.0, 0.0, line['last'], 0.0]) - except TypeError: - if line[0] % 1000 == 0: - dt = datetime.fromtimestamp(line[0] / 1e3) - else: - dt = datetime.fromtimestamp(line[0]) - records.append([line[0], dt, ticker, line[1], line[2], line[3], line[4], line[5]]) - statement = f"INSERT INTO {exchange} (timestamp, datetime, pair, open, high, low, close, volume) VALUES (?, ?, ?, ?, ?, ?, ?, ?);" - cur.executemany(statement, records) - db_n.commit() - l = len(tick) - end_date = int(datetime.timestamp(datetime.fromtimestamp(end_date) - timedelta(minutes=l))) - - def request_history_periodically(histExchanges): - history_threads = [] - historyEnd = 0 - for h in histExchanges: - hThread = threading.Thread(target=request_history, args=(objects, h, "USD", historyEnd, datetime.now().timestamp())) - hThread.start() - history_threads.append(hThread) - return history_threads - - async def request(objects, async_objects): - while True: - currencies = ["usd", "gbp", "jpy", "usdt", "eur", "0xcafebabe"] - loops = [] - for e in exchanges: - for curr in currencies: - ticker = get_supported_pair_for(curr, objects[e]) - if(ticker == ''): - continue - if async_objects[e].has['fetchOHLCV']: - tframe = '1m' - lim = 1 - if e == "bleutrade" or e == "btcalpha" or e == "rightbtc" or e == "hollaex": - tframe = '1h' - if e == "poloniex": - tframe = '5m' - try: - loops.append(fetch_ohlcv(async_objects[e], ticker, tframe, lim, e)) - except Exception as err: - if "does not have" not in str(err): - print(f"error fetching candle: {e} {curr} {err}") - responses = await gather(*loops) - db_n = sqlite3.connect(path, timeout=30) - cursor = db_n.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") - if(cursor.fetchall() == []): - continue - for response in responses: - datetime_ = [] - for line in response[2]: - datetime_.append(datetime.fromtimestamp(line[0]/1e3)) #check here if we have a ms timestamp or not - for l in line: - if l == None: - l = 0 - statement = "INSERT INTO {} (timestamp, datetime, pair, open, high, low, close, volume) VALUES ({}, '{}', '{}', {}, {}, {}, {}, {});".format(response[0], response[2][0][0], datetime_, response[1], response[2][0][1], response[2][0][2], response[2][0][3], response[2][0][4], response[2][0][5]) - db_n.execute(statement) - db_n.commit() - for each_exchange in async_objects.values(): - await each_exchange.close() - await asyncio.sleep(60) - - - def prune(keepWeeks): + + def prune(start_date): with scheduler.app.app_context(): db_n = sqlite3.connect(path, timeout=10) for exchange in exchanges: @@ -204,32 +205,25 @@ def prune(keepWeeks): check_ts = cursor.fetchone() statement = "" if check_ts[0] is not None: - if is_ms(int(check_ts[0])): - cutoff = (datetime.now()-timedelta(weeks=int(keepWeeks))).timestamp()*1000 - statement = f"DELETE FROM {exchange} WHERE timestamp < {cutoff};" - else: - cutoff = (datetime.now()-timedelta(weeks=int(keepWeeks))).timestamp() - statement = f"DELETE FROM {exchange} WHERE timestamp < {cutoff};" - while True: - try: - db_n.execute(statement) - break - except sqlite3.OperationalError as op: - print(f"{op}") + statement = f"DELETE FROM {exchange} WHERE timestamp < {start_date};" + try: + db_n.execute(statement) + break + except sqlite3.OperationalError as op: + print(f"{op}") db_n.commit() - - SpotbitService.init_table() - run_coroutine(request(objects, async_objects)) - request_history_periodically(historicalExchanges) - scheduler.add_job("prune", prune, trigger = 'interval', args = [10], minutes = 1) + + scheduler.add_job("prune", prune, trigger = 'interval', args = [0], minutes = 1) self.scheduler = scheduler @classmethod - def init_table(cls): + def init_table(cls, exchanges, currencies, frequencies): p = Path("./sb.db") db = sqlite3.connect(p) for exchange in exchanges: + if(exchange == "None"): + continue sql = f"CREATE TABLE IF NOT EXISTS {exchange} (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER, datetime TEXT, pair TEXT, open REAL, high REAL, low REAL, close REAL, volume REAL)" print(f"created table for {exchange}") db.execute(sql) @@ -244,6 +238,9 @@ def init_table(cls): db.execute(sql) db.commit() db.close() + + run_coroutine(request(exchanges, currencies)) + request_history_periodically(historicalExchanges, frequencies) @classmethod def current_exchange_rate(cls, currency, exchange): diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/settings.jinja b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/settings.jinja index 4c3076a..f726da0 100644 --- a/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/settings.jinja +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/settings.jinja @@ -4,93 +4,194 @@ {% block content %}

{{ _("Configure your extension") }}

-
- -
- {{ _("- Maybe you want to put some notes for the user") }}
- {{ _("- One important setting is whether your extension gets a menu-point on the left") }}
- {{ _("- Let us assume you want the user to use a specific wallet. That should go here. ") }}
-
-
- - - -

-
+
+
+ Select Exchanges + +
+ +
    +
  • + + Bitstamp +
  • +
  • + + Coindesk +
  • +
  • + + Coinbase +
  • +
  • + + Kraken +
  • +
+
- -

-
- -

-
- -

-
+
+
+ Select Currencies + +
+ +
    +
  • + + USD +
  • +
  • + + EUR +
  • +
  • + + GBP +
  • +
  • + + CHF +
  • +
+
+ + + + + + + +
+ + + + + + + + + +
+ {{ _("Frequency:") }} +
+
+ +
+
+
@@ -103,4 +204,62 @@
+ + + {% endblock %} \ No newline at end of file From eee27a234659fc82575de04e9baa7f5ccc8edc14 Mon Sep 17 00:00:00 2001 From: ahv15 Date: Thu, 18 Aug 2022 19:03:44 +0530 Subject: [PATCH 4/9] fix css --- .../ahv15/specterext/spotbit/templates/spotbit/settings.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/settings.jinja b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/settings.jinja index f726da0..4adefbb 100644 --- a/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/settings.jinja +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/settings.jinja @@ -33,7 +33,7 @@ cursor: pointer; box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1); } - .select-btn .btn-text, .select-btn1 .btn-text1t{ + .select-btn .btn-text, .select-btn1 .btn-text1{ font-size: 17px; font-weight: 400; color: #FFFFFF; From 1a8c5dacd5d670ddeda9ba06b633411c9fb37873 Mon Sep 17 00:00:00 2001 From: ahv15 Date: Thu, 18 Aug 2022 19:13:24 +0530 Subject: [PATCH 5/9] fix type error --- spotbit-plugin/src/ahv15/specterext/spotbit/controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py b/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py index b5907c3..29edce3 100644 --- a/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py @@ -55,7 +55,7 @@ def settings_post(): start_date = request.form.get('start_date', "None") frequencies = request.form.get('frequencies', "None") service = ext() - service.scheduler.scheduler.modify_job(job_id = 'prune', args = [datetime.timestamp(start_date)]) + service.scheduler.scheduler.modify_job(job_id = 'prune', args = [datetime.timestamp(datetime. strptime(start_date, '%Y-%m-%d'))]) service.init_table([exchange1, exchange2, exchange3, exchange4], [currency1, currency2, currency3, currency4], frequencies) ''' user = app.specter.user_manager.get_user() From 10961578c309b701cd6d0c3bfd7c505e9bdeb42c Mon Sep 17 00:00:00 2001 From: ahv15 Date: Sun, 28 Aug 2022 20:17:36 +0530 Subject: [PATCH 6/9] add status page --- .../ahv15/specterext/spotbit/controller.py | 62 +-- .../src/ahv15/specterext/spotbit/service.py | 358 ++++++++++-------- .../spotbit/static/spotbit/img/close.svg | 1 + .../static/spotbit/img/stop_symbol.svg | 1 + .../spotbit/components/spotbit_menu.jinja | 2 +- .../spotbit/templates/spotbit/index.jinja | 235 ++++++++---- .../spotbit/templates/spotbit/settings.jinja | 234 +++++++----- 7 files changed, 561 insertions(+), 332 deletions(-) create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/img/close.svg create mode 100644 spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/img/stop_symbol.svg diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py b/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py index 29edce3..2df9108 100644 --- a/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/controller.py @@ -4,15 +4,17 @@ from cryptoadvance.specter.specter import Specter from .service import SpotbitService -from datetime import datetime +from datetime import datetime spotbit_endpoint = SpotbitService.blueprint + def ext() -> SpotbitService: ''' convenience for getting the extension-object''' return app.specter.ext["spotbit"] + def specter() -> Specter: ''' convenience for getting the specter-object''' return app.specter @@ -20,20 +22,40 @@ def specter() -> Specter: @spotbit_endpoint.route("/") def index(): + service = ext() + status_info = service.status_info() return render_template( - "spotbit/index.jinja", + "spotbit/index.jinja", status=status_info ) - - + + +@spotbit_endpoint.route("/", methods=["POST"]) +def index_post(): + service = ext() + info = request.form.get('pair', "None") + print("Test") + print(info) + if (info == ""): + service.remove_db() + else: + service.remove_exchange(info.strip('][').split(', ')) + status_info = service.status_info() + return render_template( + "spotbit/index.jinja", status=status_info + ) + + @spotbit_endpoint.route("/hist////") def historical_exchange_rate(currency, exchange, date_start, date_end): service = ext() - return(service.historical_exchange_rate(currency, exchange, date_start, date_end)) + return (service.historical_exchange_rate(currency, exchange, date_start, date_end)) + @spotbit_endpoint.route("/now//") def current_exchange_rate(currency, exchange): service = ext() - return(service.current_exchange_rate(currency, exchange)) + return (service.current_exchange_rate(currency, exchange)) + @spotbit_endpoint.route("/settings", methods=["GET"]) def settings_get(): @@ -41,32 +63,22 @@ def settings_get(): "spotbit/settings.jinja", ) + @spotbit_endpoint.route("/settings", methods=["POST"]) def settings_post(): - print(request.values) - exchange1 = request.form.get('exchange1', "None") - exchange2 = request.form.get('exchange2', "None") - exchange3 = request.form.get('exchange3', "None") - exchange4 = request.form.get('exchange4', "None") + exchange = request.form.get('exchange', "None") currency1 = request.form.get('currency1', "None") currency2 = request.form.get('currency2', "None") currency3 = request.form.get('currency3', "None") currency4 = request.form.get('currency4', "None") + currency5 = request.form.get('currency5', "None") + currency6 = request.form.get('currency6', "None") + currency7 = request.form.get('currency7', "None") + currency8 = request.form.get('currency8', "None") + currency9 = request.form.get('currency9', "None") start_date = request.form.get('start_date', "None") frequencies = request.form.get('frequencies', "None") service = ext() - service.scheduler.scheduler.modify_job(job_id = 'prune', args = [datetime.timestamp(datetime. strptime(start_date, '%Y-%m-%d'))]) - service.init_table([exchange1, exchange2, exchange3, exchange4], [currency1, currency2, currency3, currency4], frequencies) - ''' - user = app.specter.user_manager.get_user() - if show_menu == "yes": - user.add_service(SpotbitService.id) - else: - user.remove_service(SpotbitService.id) - used_wallet_alias = request.form.get("used_wallet") - if used_wallet_alias != None: - wallet = current_user.wallet_manager.get_by_alias(used_wallet_alias) - SpotbitService.set_associated_wallet(wallet) - ''' + service.init_table([exchange.lower()], [currency1, currency2, currency3, currency4, currency5, currency6, + currency7, currency8, currency9], frequencies, datetime.timestamp(datetime.strptime(start_date, '%Y-%m-%d'))) return redirect(url_for(f"{ SpotbitService.get_blueprint_name()}.settings_get")) - diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/service.py b/spotbit-plugin/src/ahv15/specterext/spotbit/service.py index 4aa5579..7367a0d 100644 --- a/spotbit-plugin/src/ahv15/specterext/spotbit/service.py +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/service.py @@ -1,78 +1,30 @@ import logging import ccxt as ccxt -import ccxt.async_support as ccxt_async import sqlite3 from pathlib import Path import time from datetime import datetime, timedelta -import asyncio -from asyncio import gather import threading -import itertools +from threading import Event +import os from cryptoadvance.specter.services.service import Service, devstatus_alpha -from flask_apscheduler import APScheduler logger = logging.getLogger(__name__) path = Path("./sb.db") path_hist = Path("./sb_hist.db") -# https://stackoverflow.com/a/58616001 -class EventLoopThread(threading.Thread): - loop = None - _count = itertools.count(0) - - def __init__(self): - self.started = threading.Event() - name = f"{type(self).__name__}-{next(self._count)}" - super().__init__(name=name, daemon=True) - - def run(self): - self.loop = loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.call_later(0, self.started.set) - - try: - loop.run_forever() - finally: - try: - shutdown_asyncgens = loop.shutdown_asyncgens() - except AttributeError: - pass - else: - loop.run_until_complete(shutdown_asyncgens) - try: - shutdown_executor = loop.shutdown_default_executor() - except AttributeError: - pass - else: - loop.run_until_complete(shutdown_executor) - asyncio.set_event_loop(None) - loop.close() - -_lock = threading.Lock() -_loop_thread = None - -def get_event_loop(): - global _loop_thread - if _loop_thread is None: - with _lock: - if _loop_thread is None: - _loop_thread = EventLoopThread() - _loop_thread.start() - _loop_thread.started.wait(1) - - return _loop_thread.loop - -def run_coroutine(coro): - return asyncio.run_coroutine_threadsafe(coro, get_event_loop()) - -objects = {"aax":ccxt.aax(), "ascendex": ccxt.ascendex(),"bequant":ccxt.bequant(), "bibox":ccxt.bibox(), "bigone":ccxt.bigone(), "binance":ccxt.binance(), "bitbank":ccxt.bitbank(), "liquid":ccxt.liquid(), "phemex":ccxt.phemex(), "bitstamp":ccxt.bitstamp(), "ftx":ccxt.ftx()} -async_objects = {"aax":ccxt_async.aax(), "ascendex": ccxt_async.ascendex(),"bequant":ccxt_async.bequant(), "bibox":ccxt_async.bibox(), "bigone":ccxt_async.bigone(), "binance":ccxt_async.binance(), "bitbank":ccxt_async.bitbank(), "liquid":ccxt_async.liquid(), "phemex":ccxt_async.phemex(), "bitstamp":ccxt_async.bitstamp(), "ftx":ccxt_async.ftx()} -exchanges = ["binance", "ascendex", "bequant", "bibox","bigone", "bitstamp"] -historicalExchanges = ["bitstamp"] +objects = {"bitstamp":ccxt.bitstamp(), "bitfinex":ccxt.bitfinex(), "coinbase":ccxt.coinbase(), "kraken":ccxt.kraken(), "okcoin":ccxt.okcoin()} +exchanges = ['bitstamp', 'coinbase', 'kraken', 'bitfinex', 'okcoin'] +currencies = ['usd', 'eur'] +history_threads = [] +event = "None" +chosen_exchanges = [] +chosen_currencies = {} +frequency_config = 0 +start_date_config = 0 def is_ms(timestamp): if timestamp % 1000 == 0: @@ -90,95 +42,113 @@ def get_supported_pair_for(currency, exchange): result = market['symbol'] return result -async def fetch_ohlcv(exchange, symbol, timeframe, limit, exchange_name): - since = None - try: - ohlcv = await exchange.fetch_ohlcv(symbol, timeframe, since, limit) - if len(ohlcv): - return [exchange_name, symbol, ohlcv] - except Exception as e: - print(type(e).__name__, str(e)) - - -def request_history(objects, exchange, currency, start_date, end_date, frequencies): +def clear_threads(event): + if (event != "None"): + event.set() + + +def request_history(objects, exchange, currency, start_date, end_date, frequency, event): db_n = sqlite3.connect(path_hist, timeout=10) cur = db_n.cursor() ticker = get_supported_pair_for(currency, objects[exchange]) - while start_date < end_date: - params = {'start': start_date, 'end': int(end_date)} - tick = objects[exchange].fetch_ohlcv(symbol=ticker, timeframe='1m', params=params, limit = 1000) - records = [] - dt = None - for line in tick: + true_end_date = end_date + while(True): + while start_date < end_date: + if event.is_set(): + return + params = {'start': int(start_date), 'end': int(end_date)} + tick = objects[exchange].fetch_ohlcv(symbol=ticker, timeframe=frequency, params=params, limit = 1000) + records = [] dt = None - try: - if is_ms(int(line['timestamp'])): - dt = datetime.fromtimestamp(line['timestamp'] / 1e3) - else: - dt = datetime.fromtimestamp(line['timestamp']) - records.append([line['timestamp'], dt, ticker, 0.0, 0.0, 0.0, line['last'], 0.0]) - except TypeError: - if line[0] % 1000 == 0: - dt = datetime.fromtimestamp(line[0] / 1e3) - else: - dt = datetime.fromtimestamp(line[0]) - records.append([line[0], dt, ticker, line[1], line[2], line[3], line[4], line[5]]) - statement = f"INSERT INTO {exchange} (timestamp, datetime, pair, open, high, low, close, volume) VALUES (?, ?, ?, ?, ?, ?, ?, ?);" - cur.executemany(statement, records) - db_n.commit() - l = len(tick) - end_date = int(datetime.timestamp(datetime.fromtimestamp(end_date) - timedelta(minutes=l))) + for line in tick: + dt = None + try: + if is_ms(int(line['timestamp'])): + dt = datetime.fromtimestamp(line['timestamp'] / 1e3) + else: + dt = datetime.fromtimestamp(line['timestamp']) + records.append([line['timestamp'], dt, ticker, 0.0, 0.0, 0.0, line['last'], 0.0]) + except TypeError: + if line[0] % 1000 == 0: + dt = datetime.fromtimestamp(line[0] / 1e3) + else: + dt = datetime.fromtimestamp(line[0]) + records.append([line[0], dt, ticker, line[1], line[2], line[3], line[4], line[5]]) + statement = f"INSERT INTO {exchange} (timestamp, datetime, pair, open, high, low, close, volume) VALUES (?, ?, ?, ?, ?, ?, ?, ?);" + cur.executemany(statement, records) + db_n.commit() + l = len(tick) + end_date = int(datetime.timestamp(datetime.fromtimestamp(end_date) - timedelta(minutes=l))) + time.sleep(60) + start_date = true_end_date + end_date = datetime.now().timestamp() + true_end_date = end_date -def request_history_periodically(histExchanges, frequencies): - history_threads = [] - historyEnd = 0 +def request_history_periodically(histExchanges, currencies, frequency, start_date, event): for h in histExchanges: - hThread = threading.Thread(target=request_history, args=(objects, h, "USD", historyEnd, datetime.now().timestamp(), frequencies)) - hThread.start() - history_threads.append(hThread) + for currency in currencies[h]: + hThread = threading.Thread(target=request_history, args=(objects, h, currency, int(start_date), datetime.now().timestamp(), frequency, event)) + hThread.start() + history_threads.append(hThread) return history_threads -async def request(exchanges, currencies): + +def request_periodically(exchanges, currencies, event): + thread = threading.Thread(target=request, args=(exchanges, currencies, event)) + thread.start() + return thread + +def request(exchanges, currencies, event): while True: - loops = [] + if event.is_set(): + break + candles = [] for e in exchanges: - for curr in currencies: + for curr in currencies[e]: if(currencies == "None"): continue ticker = get_supported_pair_for(curr, objects[e]) if(ticker == ''): continue - if async_objects[e].has['fetchOHLCV']: + if objects[e].has['fetchOHLCV']: tframe = '1m' lim = 1 - if e == "bleutrade" or e == "btcalpha" or e == "rightbtc" or e == "hollaex": - tframe = '1h' - if e == "poloniex": - tframe = '5m' try: - loops.append(fetch_ohlcv(async_objects[e], ticker, tframe, lim, e)) + candles.append([e, ticker, objects[e].fetch_ohlcv(symbol=ticker, timeframe=tframe, since=None, limit=lim)]) except Exception as err: if "does not have" not in str(err): print(f"error fetching candle: {e} {curr} {err}") - responses = await gather(*loops) + else: + print("check4") + try: + price = objects[e].fetch_ticker(ticker) + print("check") + print(price) + candles.append(e, ticker, [[price['timestamp'], 0.0, 0.0, 0.0, price['last'], 0.0]]) + except Exception as err: + print(f"error fetching ticker: {err}") db_n = sqlite3.connect(path, timeout=30) cursor = db_n.cursor() cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") if(cursor.fetchall() == []): continue - for response in responses: + for response in candles: datetime_ = [] - for line in response[2]: - datetime_.append(datetime.fromtimestamp(line[0]/1e3)) #check here if we have a ms timestamp or not - for l in line: - if l == None: - l = 0 + ts = None + try: + if is_ms(int(response[2][0][0])): + datetime_ = datetime.fromtimestamp(int(response[2][0][0])/1e3) + else: + datetime_ = datetime.fromtimestamp(int(response[2][0][0])) + except OverflowError as oe: + print(f"{oe} caused by {ts}") + for l in response[2][0]: + if l == None: + l = 0 statement = "INSERT INTO {} (timestamp, datetime, pair, open, high, low, close, volume) VALUES ({}, '{}', '{}', {}, {}, {}, {}, {});".format(response[0], response[2][0][0], datetime_, response[1], response[2][0][1], response[2][0][2], response[2][0][3], response[2][0][4], response[2][0][5]) db_n.execute(statement) db_n.commit() - for each_exchange in async_objects.values(): - await each_exchange.close() - await asyncio.sleep(60) + time.sleep(60) class SpotbitService(Service): id = "spotbit" @@ -191,39 +161,20 @@ class SpotbitService(Service): devstatus = devstatus_alpha isolated_client = False sort_priority = 2 + SPECTER_WALLET_ALIAS = "wallet" - SPECTER_WALLET_ALIAS = "wallet" - - def callback_after_serverpy_init_app(self, scheduler: APScheduler): - - def prune(start_date): - with scheduler.app.app_context(): - db_n = sqlite3.connect(path, timeout=10) - for exchange in exchanges: - check = f"SELECT MAX(timestamp) FROM {exchange};" - cursor = db_n.execute(check) - check_ts = cursor.fetchone() - statement = "" - if check_ts[0] is not None: - statement = f"DELETE FROM {exchange} WHERE timestamp < {start_date};" - try: - db_n.execute(statement) - break - except sqlite3.OperationalError as op: - print(f"{op}") - db_n.commit() - - scheduler.add_job("prune", prune, trigger = 'interval', args = [0], minutes = 1) - self.scheduler = scheduler - - @classmethod - def init_table(cls, exchanges, currencies, frequencies): + def init_table(cls, exchanges, currencies, frequency, start_date): + global event + global frequency_config + global start_date_config p = Path("./sb.db") db = sqlite3.connect(p) for exchange in exchanges: if(exchange == "None"): continue + if (exchange not in chosen_exchanges): + chosen_exchanges.append(exchange) sql = f"CREATE TABLE IF NOT EXISTS {exchange} (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER, datetime TEXT, pair TEXT, open REAL, high REAL, low REAL, close REAL, volume REAL)" print(f"created table for {exchange}") db.execute(sql) @@ -232,15 +183,32 @@ def init_table(cls, exchanges, currencies, frequencies): p = Path("./sb_hist.db") db = sqlite3.connect(p) - for exchange in historicalExchanges: + for exchange in exchanges: sql = f"CREATE TABLE IF NOT EXISTS {exchange} (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER, datetime TEXT, pair TEXT, open REAL, high REAL, low REAL, close REAL, volume REAL)" print(f"created table for {exchange}") db.execute(sql) db.commit() db.close() - run_coroutine(request(exchanges, currencies)) - request_history_periodically(historicalExchanges, frequencies) + for exchange in exchanges: + if(exchange == "None"): + continue + for currency in currencies: + if(currency == "None"): + continue + if(chosen_currencies.__contains__(exchange)): + if(currency not in chosen_currencies[exchange]): + chosen_currencies[exchange].append(currency) + else: + chosen_currencies[exchange] = [currency] + + clear_threads(event) + event = Event() + frequency_config = frequency + start_date_config = start_date + request_periodically(chosen_exchanges, chosen_currencies, event) + if(start_date != "None" and frequency != "None"): + request_history_periodically(chosen_exchanges, chosen_currencies, frequency, start_date, event) @classmethod def current_exchange_rate(cls, currency, exchange): @@ -272,7 +240,6 @@ def historical_exchange_rate(cls, currency, exchange, date_start, date_end): date_s = int(date_start) date_e = int(date_end) else: - #error checking for malformed dates try: date_s = (datetime.fromisoformat(date_start.replace("T", " "))).timestamp()*1000 date_e = (datetime.fromisoformat(date_end.replace("T", " "))).timestamp()*1000 @@ -285,18 +252,105 @@ def historical_exchange_rate(cls, currency, exchange, date_start, date_end): if ts != None and is_ms(int(ts[0])): statement = f"SELECT * FROM {exchange} WHERE timestamp > {date_s} AND timestamp < {date_e} AND pair = '{ticker}' ORDER BY timestamp DESC;" else: - # for some exchanges we cannot use ms precision timestamps (such as coinbase) date_s /= 1e3 date_e /= 1e3 statement = f"SELECT * FROM {exchange} WHERE timestamp > {date_s} AND timestamp < {date_e} AND pair = '{ticker}';" - # keep trying in case of database locked error while True: try: cursor = db_n.execute(statement) break except sqlite3.OperationalError as oe: time.sleep(5) - res = cursor.fetchall() db_n.close() - return {'columns': ['id', 'timestamp', 'datetime', 'currency_pair', 'open', 'high', 'low', 'close', 'vol'], 'data':res} \ No newline at end of file + return {'columns': ['id', 'timestamp', 'datetime', 'currency_pair', 'open', 'high', 'low', 'close', 'vol'], 'data':res} + + + @classmethod + def status_info(cls): + p = Path("./sb.db") + status_info = [] + db_n = sqlite3.connect(p, timeout=5) + info_check = False + for exchange in chosen_exchanges: + for currency in chosen_currencies[exchange]: + ticker = get_supported_pair_for(currency, objects[exchange]) + if(ticker == '') : + status_info.append([exchange, currency, 'Not Available']) + else: + try: + statement = f"SELECT * FROM {exchange} WHERE pair = '{ticker}' ORDER BY timestamp DESC LIMIT 1;" + cursor = db_n.execute(statement) + res = cursor.fetchone() + except sqlite3.OperationalError: + status_info.append([exchange, currency, 'Syncing']) + info_check = True + if res != None: + difference = (datetime.now() - datetime.strptime(res[2], '%Y-%m-%d %H:%M:%S')).total_seconds() + if(difference < 300): + status_info.append([exchange, currency, 'Updated']) + else: + status_info.append([exchange, currency, 'Syncing']) + else: + if(not info_check): + status_info.append([exchange, currency, 'Syncing']) + else: + info_check = False + db_n.close() + + + p = Path("./sb_hist.db") + db_n = sqlite3.connect(p, timeout=5) + info_check = False + for exchange in chosen_exchanges: + for currency in chosen_currencies[exchange]: + ticker = get_supported_pair_for(currency, objects[exchange]) + if(ticker == '') : + status_info.append([exchange, currency, 'Not Available']) + else: + try: + statement = f"SELECT * FROM {exchange} WHERE pair = '{ticker}' ORDER BY timestamp ASC LIMIT 1;" + cursor = db_n.execute(statement) + res = cursor.fetchone() + except sqlite3.OperationalError: + status_info.append([exchange, currency, 'Historical Data is Syncing']) + info_check = True + if res != None: + difference = (datetime.fromtimestamp(start_date_config) - datetime.strptime(res[2], '%Y-%m-%d %H:%M:%S')).total_seconds() + if(abs(difference) < 300): + status_info.append([exchange, currency, 'Historical Data is Updated']) + else: + status_info.append([exchange, currency, 'Historical Data is Syncing']) + else: + if(not info_check): + status_info.append([exchange, currency, 'Historical Data is Syncing']) + else: + info_check = False + return status_info + + @classmethod + def remove_exchange(cls, info): + global event + global chosen_currencies + clear_threads(event) + event = Event() + chosen_currencies[info[0].strip('\'')].remove(info[1].strip('\'')) + request_periodically(chosen_exchanges, chosen_currencies, event) + request_history_periodically(chosen_exchanges, chosen_currencies, frequency_config, start_date_config, event) + + @classmethod + def remove_db(cls): + global event + global chosen_currencies + global chosen_exchanges + clear_threads(event) + event = Event() + time.sleep(1) + chosen_exchanges = [] + chosen_currencies = {} + path = Path("./sb.db") + path_hist = Path("./sb_hist.db") + os.remove(path) + os.remove(path_hist) + + \ No newline at end of file diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/img/close.svg b/spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/img/close.svg new file mode 100644 index 0000000..989837c --- /dev/null +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/img/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/img/stop_symbol.svg b/spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/img/stop_symbol.svg new file mode 100644 index 0000000..9373136 --- /dev/null +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/static/spotbit/img/stop_symbol.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/components/spotbit_menu.jinja b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/components/spotbit_menu.jinja index 13b8e1d..55852cc 100644 --- a/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/components/spotbit_menu.jinja +++ b/spotbit-plugin/src/ahv15/specterext/spotbit/templates/spotbit/components/spotbit_menu.jinja @@ -7,7 +7,7 @@ #} {% macro spotbit_menu(active_menuitem) -%}