-
Notifications
You must be signed in to change notification settings - Fork 179
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge #1254: Script: Add bond-calculator.py
72bf447 Script: Add bond-calculator.py (PulpCattel)
- Loading branch information
Showing
5 changed files
with
328 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() |