Skip to content

Commit

Permalink
Merge #1254: Script: Add bond-calculator.py
Browse files Browse the repository at this point in the history
72bf447 Script: Add bond-calculator.py (PulpCattel)
  • Loading branch information
AdamISZ committed Jun 19, 2022
2 parents 3b28703 + 72bf447 commit 9e41885
Show file tree
Hide file tree
Showing 5 changed files with 328 additions and 1 deletion.
36 changes: 35 additions & 1 deletion docs/fidelity-bonds.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
1 change: 1 addition & 0 deletions jmclient/jmclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
87 changes: 87 additions & 0 deletions jmclient/jmclient/bond_calc.py
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
73 changes: 73 additions & 0 deletions jmclient/test/test_bond_calc.py
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)
132 changes: 132 additions & 0 deletions scripts/bond-calculator.py
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()

0 comments on commit 9e41885

Please sign in to comment.