Browse Source
Add a new helper module to calculate fidelity bonds values and stats, and a script to use it. The goal is to give as much information as possible to the user before committing to a fidelity bond.master
5 changed files with 328 additions and 1 deletions
@ -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 |
||||
@ -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) |
||||
@ -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() |
||||
Loading…
Reference in new issue