diff --git a/docs/fidelity-bonds.md b/docs/fidelity-bonds.md index cb2d9865c..8568809d9 100644 --- a/docs/fidelity-bonds.md +++ b/docs/fidelity-bonds.md @@ -208,7 +208,41 @@ miner fees, you can probably wait until fees are low). The full details on valuing a time-locked fidelity bond are [found in the relevant section of the "Financial mathematics of fidelity bonds" document](https://gist.github.com/chris-belcher/87ebbcbb639686057a389acb9ab3e25b#time-locked-fidelity-bonds). -At any time you can use the orderbook watcher script to see your own fidelity bond value. +To see how valuable a bond would be, and to compare it with the orderbook, you can use the `bond-calculator.py` script. + +For example (see `-h` option for more): + +``` +(jmvenv) $ python3 scripts/bond-calculator.py -o /home/user/Downloads/orderbook.json 1btc +User data location: /home/user/.joinmarket/ +Amount locked: 100000000 (1.00000000 btc) +Confirmation time: 2022-06-16 17:29:26.849274 +Interest rate: 0.015 (1.5%) +Exponent: 1.3 + +FIDELITY BOND VALUES (BTC^1.3) + +See /docs/fidelity-bonds.md for complete formula and more + +Locktime: 2022-7 +Bond value: 0.0000000001579529 +Weight: 0.00001 (0.00% of all bonds) +Top 85% of the orderbook by value + +Locktime: 2022-8 +Bond value: 0.0000000007090171 +Weight: 0.00005 (0.00% of all bonds) +Top 64% of the orderbook by value + +Locktime: 2022-9 +Bond value: 0.0000000013980293 +Weight: 0.00010 (0.01% of all bonds) +Top 59% of the orderbook by value + +[...snipped...] +``` + +At any time you can use the [orderbook watcher](orderbook.md) script to see your own fidelity bond value, and to download the orderbook in JSON format (`Export orders`). Consider also the [warning on the bitcoin wiki page on timelocks](https://en.bitcoin.it/wiki/Timelock#Far-future_locks). diff --git a/jmclient/jmclient/__init__.py b/jmclient/jmclient/__init__.py index 3495c8d7d..a7396334d 100644 --- a/jmclient/jmclient/__init__.py +++ b/jmclient/jmclient/__init__.py @@ -70,6 +70,7 @@ from .websocketserver import JmwalletdWebSocketServerFactory, \ JmwalletdWebSocketServerProtocol from .wallet_rpc import JMWalletDaemon +from .bond_calc import get_bond_values # Set default logging handler to avoid "No handler found" warnings. try: diff --git a/jmclient/jmclient/bond_calc.py b/jmclient/jmclient/bond_calc.py new file mode 100644 index 000000000..2eff6fb1d --- /dev/null +++ b/jmclient/jmclient/bond_calc.py @@ -0,0 +1,87 @@ +""" +Utilities to calculate fidelity bonds values and statistics. +""" +from bisect import bisect_left +from datetime import datetime +from statistics import quantiles +from typing import Optional, Dict, Any, Mapping, Tuple, List + +from jmclient import FidelityBondMixin, jm_single, get_interest_rate + + +def get_next_locktime(dt: datetime) -> datetime: + """ + Return the next valid fidelity bond locktime. + """ + year = dt.year + dt.month // 12 + month = dt.month % 12 + 1 + return datetime(year, month, 1) + + +def get_bond_values(amount: int, + months: int, + confirm_time: Optional[float] = None, + interest: Optional[float] = None, + exponent: Optional[float] = None, + orderbook: Optional[Mapping[str, Any]] = None) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: + """ + Conveniently generate values [and statistics] for multiple possible fidelity bonds. + + Args: + amount: Fidelity bond UTXO amount in satoshi + months: For how many months to calculate the results + confirm_time: Fidelity bond UTXO confirmation time as timestamp, if None, current time is used. + I.e., like if the fidelity bond UTXO with given amount has just confirmed on the blockchain. + interest: Interest rate, if None, value is taken from config + exponent: Exponent, if None, value is taken from config + orderbook: Orderbook data, if given, additional statistics are included in the results. + Returns: + A tuple with 2 elements. + First is a dictionary with all the parameters used to perform fidelity bond calculations. + Second is a list of dictionaries, one for each month, with the results. + """ + current_time = datetime.now().timestamp() + if confirm_time is None: + confirm_time = current_time + if interest is None: + interest = get_interest_rate() + if exponent is None: + exponent = jm_single().config.getfloat("POLICY", "bond_value_exponent") + use_config_exp = True + else: + old_exponent = jm_single().config.get("POLICY", "bond_value_exponent") + jm_single().config.set("POLICY", "bond_value_exponent", str(exponent)) + use_config_exp = False + if orderbook: + bond_values = [fb["bond_value"] for fb in orderbook["fidelitybonds"]] + bonds_sum = sum(bond_values) + percentiles = quantiles(bond_values, n=100, method="inclusive") + + parameters = { + "amount": amount, + "confirm_time": confirm_time, + "current_time": current_time, + "interest": interest, + "exponent": exponent, + } + locktime = get_next_locktime(datetime.fromtimestamp(current_time)) + results = [] + for _ in range(months): + fb_value = FidelityBondMixin.calculate_timelocked_fidelity_bond_value( + amount, + confirm_time, + locktime.timestamp(), + current_time, + interest, + ) + result = {"locktime": locktime.timestamp(), + "value": fb_value} + if orderbook: + result["weight"] = fb_value / (bonds_sum + fb_value) + result["percentile"] = 100 - bisect_left(percentiles, fb_value) + results.append(result) + locktime = get_next_locktime(locktime) + if not use_config_exp: + # We don't want the modified exponent value to persist in memory, so we reset to whatever it was before + jm_single().config.set("POLICY", "bond_value_exponent", old_exponent) + return parameters, results diff --git a/jmclient/test/test_bond_calc.py b/jmclient/test/test_bond_calc.py new file mode 100644 index 000000000..9185a96e0 --- /dev/null +++ b/jmclient/test/test_bond_calc.py @@ -0,0 +1,73 @@ +from datetime import datetime + +import pytest +from jmclient import jm_single, load_test_config, FidelityBondMixin +from jmclient.bond_calc import get_next_locktime, get_bond_values + + +@pytest.mark.parametrize(('date', 'next_locktime'), + ((datetime(2022, 1, 1, 1, 1), datetime(2022, 2, 1)), + (datetime(2022, 11, 1, 1, 1), datetime(2022, 12, 1)), + (datetime(2022, 12, 1, 1, 1), datetime(2023, 1, 1)))) +def test_get_next_locktime(date: datetime, next_locktime: datetime) -> None: + assert get_next_locktime(date) == next_locktime + + +def test_get_bond_values() -> None: + load_test_config() + # 1 BTC + amount = pow(10, 8) + months = 1 + interest = jm_single().config.getfloat("POLICY", "interest_rate") + exponent = jm_single().config.getfloat("POLICY", "bond_value_exponent") + parameters, results = get_bond_values(amount, months) + assert parameters["amount"] == amount + assert parameters["current_time"] == parameters["confirm_time"] + assert parameters["interest"] == interest + assert parameters["exponent"] == exponent + assert len(results) == months + locktime = datetime.fromtimestamp(results[0]["locktime"]) + assert locktime.month == get_next_locktime(datetime.now()).month + value = FidelityBondMixin.calculate_timelocked_fidelity_bond_value( + parameters["amount"], + parameters["confirm_time"], + results[0]["locktime"], + parameters["current_time"], + parameters["interest"], + ) + assert results[0]["value"] == value + + months = 12 + interest = 0.02 + exponent = 2 + confirm_time = datetime(2021, 12, 1).timestamp() + parameters, results = get_bond_values(amount, + months, + confirm_time, + interest, + exponent) + assert parameters["amount"] == amount + assert parameters["current_time"] != parameters["confirm_time"] + assert parameters["confirm_time"] == confirm_time + assert parameters["interest"] == interest + assert parameters["exponent"] == exponent + assert len(results) == months + current_time = datetime.now() + # get_bond_values(), at the end, reset the exponent to the config one. + # So we have to set the exponent here, otherwise the bond value calculation + # won't match and the assert would fail. + old_exponent = jm_single().config.get("POLICY", "bond_value_exponent") + jm_single().config.set("POLICY", "bond_value_exponent", str(exponent)) + for result in results: + locktime = datetime.fromtimestamp(result["locktime"]) + assert locktime.month == get_next_locktime(current_time).month + current_time = locktime + value = FidelityBondMixin.calculate_timelocked_fidelity_bond_value( + parameters["amount"], + parameters["confirm_time"], + result["locktime"], + parameters["current_time"], + parameters["interest"], + ) + assert result["value"] == value + jm_single().config.set("POLICY", "bond_value_exponent", old_exponent) diff --git a/scripts/bond-calculator.py b/scripts/bond-calculator.py new file mode 100755 index 000000000..52ba9907a --- /dev/null +++ b/scripts/bond-calculator.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +import sys +from datetime import datetime +from decimal import Decimal +from json import loads +from optparse import OptionParser + +from jmbase import EXIT_ARGERROR, jmprint, get_log, utxostr_to_utxo, EXIT_FAILURE +from jmbitcoin import amount_to_sat, sat_to_btc +from jmclient import add_base_options, load_program_config, jm_single, get_bond_values + +DESCRIPTION = """Given either a Bitcoin UTXO in the form TXID:n +(e.g., 0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098:0) +or an amount in either satoshi or bitcoin (e.g., 150000, 0.1, 10.123, 10btc), +calculate fidelity bond values for all possible locktimes in a one-year period +(12 months, you can change that with the `-m --months` option). +By default it uses the values from your joinmarket.cfg, +you can override these with the `-i --interest` and `-e --exponent` options. +Additionally, you can export the orderbook from ob-watcher.py and use the data here +with the `-o --orderbook` option, this will compare the results from this script +with the fidelity bonds in the orderbook. +""" + +log = get_log() + + +def main() -> None: + parser = OptionParser( + usage="usage: %prog [options] UTXO or amount", + description=DESCRIPTION, + ) + add_base_options(parser) + parser.add_option( + "-i", + "--interest", + action="store", + type="float", + dest="interest", + help="Interest rate to use for fidelity bond calculation (instead of interest_rate config)", + ) + parser.add_option( + "-e", + "--exponent", + action="store", + type="float", + dest="exponent", + help="Exponent to use for fidelity bond calculation (instead of bond_value_exponent config)", + ) + parser.add_option( + "-m", + "--months", + action="store", + type="int", + dest="months", + help="For how many months to calculate the fidelity bond values, each month has its own stats (default 12)", + default=12, + ) + parser.add_option( + "-o", + "--orderbook", + action="store", + type="str", + dest="path_to_json", + help="Path to the exported orderbook in JSON format", + ) + + options, args = parser.parse_args() + load_program_config(config_path=options.datadir) + if len(args) != 1: + log.error("Invalid arguments, see --help") + sys.exit(EXIT_ARGERROR) + if options.path_to_json: + try: + with open(options.path_to_json, "r", encoding="UTF-8") as orderbook: + orderbook = loads(orderbook.read()) + except FileNotFoundError as exc: + log.error(exc) + sys.exit(EXIT_ARGERROR) + else: + orderbook = None + try: + amount = amount_to_sat(args[0]) + confirm_time = None + except ValueError: + # If it's not a valid amount then it has to be a UTXO + if jm_single().bc_interface is None: + log.error("For calculation based on UTXO access to Bitcoin Core is required") + sys.exit(EXIT_FAILURE) + success, utxo = utxostr_to_utxo(args[0]) + if not success: + # utxo contains the error message + log.error(utxo) + sys.exit(EXIT_ARGERROR) + utxo_data = jm_single().bc_interface.query_utxo_set(utxo, includeconfs=True)[0] + amount = utxo_data["value"] + if utxo_data["confirms"] == 0: + log.warning("Given UTXO is unconfirmed, current time will be used as confirmation time") + confirm_time = None + elif utxo_data["confirms"] < 0: + log.error("Given UTXO is invalid, reason: conflicted") + sys.exit(EXIT_ARGERROR) + else: + current_height = jm_single().bc_interface.get_current_block_height() + block_hash = jm_single().bc_interface.get_block_hash(current_height - utxo_data["confirms"] + 1) + confirm_time = jm_single().bc_interface.get_block_time(block_hash) + + parameters, results = get_bond_values(amount, + options.months, + confirm_time, + options.interest, + options.exponent, + orderbook) + jmprint(f"Amount locked: {amount} ({sat_to_btc(amount)} btc)") + jmprint(f"Confirmation time: {datetime.fromtimestamp(parameters['confirm_time'])}") + jmprint(f"Interest rate: {parameters['interest']} ({parameters['interest'] * 100}%)") + jmprint(f"Exponent: {parameters['exponent']}") + jmprint(f"\nFIDELITY BOND VALUES (BTC^{parameters['exponent']})") + jmprint("\nSee /docs/fidelity-bonds.md for complete formula and more") + + for result in results: + locktime = datetime.fromtimestamp(result["locktime"]) + # Mimic the locktime value the user would have to insert to create such fidelity bond + jmprint(f"\nLocktime: {locktime.year}-{locktime.month}") + # Mimic orderbook value + jmprint(f"Bond value: {float(Decimal(result['value']) / Decimal(1e16)):.16f}") + if options.path_to_json: + jmprint(f"Weight: {result['weight']:.5f} ({result['weight'] * 100:.2f}% of all bonds)") + jmprint(f"Top {result['percentile']}% of the orderbook by value") + + +if __name__ == "__main__": + main()