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