mirror of
https://github.com/ZmnSCPxj/clboss.git
synced 2026-08-14 12:43:19 +02:00
1268 lines
35 KiB
Python
Executable file
1268 lines
35 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import shutil
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from clboss.alias_cache import is_nodeid, load_cache, lookup_alias, lookup_nodeid_by_alias
|
|
from feemon_data import load_merged_peer_records, records_to_rows
|
|
|
|
PLOT_LINEWIDTH = 1.5
|
|
SCID_RE = re.compile(r"^\d+x\d+x\d+$")
|
|
FEE_SYMLOG_LINTHRESH = 1.0
|
|
FEE_SYMLOG_LINSCALE = 0.3
|
|
EARNINGS_SYMLOG_LINTHRESH = 1.0
|
|
EARNINGS_SYMLOG_LINSCALE = 0.3
|
|
|
|
try:
|
|
import numpy as np
|
|
except ImportError:
|
|
np = None
|
|
|
|
|
|
def local_tz():
|
|
return timezone.utc
|
|
|
|
|
|
def normalize_dt(dt):
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return dt.astimezone(timezone.utc)
|
|
|
|
|
|
def parse_ts_iso(ts):
|
|
if ts.endswith("Z"):
|
|
ts = ts[:-1] + "+00:00"
|
|
return normalize_dt(datetime.fromisoformat(ts))
|
|
|
|
|
|
def parse_ts_epoch(ts):
|
|
if isinstance(ts, (int, float)):
|
|
return datetime.fromtimestamp(ts, timezone.utc)
|
|
try:
|
|
return datetime.fromtimestamp(float(ts), timezone.utc)
|
|
except (TypeError, ValueError):
|
|
return parse_ts_iso(ts)
|
|
|
|
|
|
def parse_ts(ts):
|
|
return parse_ts_epoch(ts)
|
|
|
|
|
|
def make_engineering_formatter():
|
|
try:
|
|
from matplotlib.ticker import ScalarFormatter
|
|
except Exception:
|
|
return None
|
|
|
|
class EngineeringScalarFormatter(ScalarFormatter):
|
|
def _set_order_of_magnitude(self, *args, **kwargs):
|
|
super()._set_order_of_magnitude(*args, **kwargs)
|
|
order = self.orderOfMagnitude
|
|
if order != 0:
|
|
self.orderOfMagnitude = int(math.floor(order / 3.0) * 3)
|
|
|
|
formatter = EngineeringScalarFormatter(useMathText=True)
|
|
formatter.set_scientific(True)
|
|
formatter.set_powerlimits((0, 0))
|
|
return formatter
|
|
|
|
|
|
def safe_prefix(value):
|
|
sanitized = []
|
|
for ch in value:
|
|
if ch.isalnum() or ch in "._-":
|
|
sanitized.append(ch)
|
|
else:
|
|
sanitized.append("_")
|
|
return "".join(sanitized).strip("_") or "peer"
|
|
|
|
|
|
def is_scid(value):
|
|
return bool(SCID_RE.match(value))
|
|
|
|
|
|
def parse_time_arg(value):
|
|
if value is None:
|
|
return None
|
|
# Ideally, we'd accept the same time arguments as journalctl.
|
|
if value.endswith("Z"):
|
|
value = value[:-1] + "+00:00"
|
|
if re.match(r"^[0-9]{4}-[0-9]{2}$", value):
|
|
return normalize_dt(datetime.fromisoformat(f"{value}-01T00:00:00"))
|
|
if re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$", value):
|
|
return normalize_dt(datetime.fromisoformat(f"{value}T00:00:00"))
|
|
rel = re.match(r"^([+-]?)(\d+)([smhdw])$", value)
|
|
if rel:
|
|
sign, num_s, unit = rel.groups()
|
|
num = int(num_s)
|
|
delta = {
|
|
"s": timedelta(seconds=num),
|
|
"m": timedelta(minutes=num),
|
|
"h": timedelta(hours=num),
|
|
"d": timedelta(days=num),
|
|
"w": timedelta(weeks=num),
|
|
}[unit]
|
|
if sign == "-":
|
|
delta = -delta
|
|
return normalize_dt(datetime.now(timezone.utc) + delta)
|
|
if value.count(":") in (1, 2) and "T" not in value and "-" not in value:
|
|
today = datetime.now(timezone.utc).date().isoformat()
|
|
return normalize_dt(datetime.fromisoformat(f"{today}T{value}"))
|
|
return normalize_dt(datetime.fromisoformat(value))
|
|
|
|
|
|
def price_level_to_mult(level):
|
|
if np is None:
|
|
if isinstance(level, (int, float)):
|
|
if level < 0:
|
|
return math.pow(0.8, -level)
|
|
if level > 0:
|
|
return math.pow(1.2, level)
|
|
return 1.0
|
|
return [price_level_to_mult(v) for v in level]
|
|
|
|
arr = np.asarray(level, dtype=float)
|
|
mult = np.where(
|
|
arr < 0,
|
|
np.power(0.8, -arr),
|
|
np.where(arr > 0, np.power(1.2, arr), 1.0),
|
|
)
|
|
if np.isscalar(level):
|
|
return float(mult)
|
|
return mult
|
|
|
|
|
|
def price_mult_to_level(mult):
|
|
if np is None:
|
|
if isinstance(mult, (int, float)):
|
|
if mult < 1:
|
|
return -math.log(mult) / math.log(0.8)
|
|
if mult > 1:
|
|
return math.log(mult) / math.log(1.2)
|
|
return 0.0
|
|
return [price_mult_to_level(v) for v in mult]
|
|
|
|
arr = np.asarray(mult, dtype=float)
|
|
level = np.zeros_like(arr, dtype=float)
|
|
lt_one = (arr > 0) & (arr < 1)
|
|
gt_one = arr > 1
|
|
if np.any(lt_one):
|
|
level[lt_one] = -np.log(arr[lt_one]) / np.log(0.8)
|
|
if np.any(gt_one):
|
|
level[gt_one] = np.log(arr[gt_one]) / np.log(1.2)
|
|
if np.isscalar(mult):
|
|
return float(level)
|
|
return level
|
|
|
|
|
|
def format_mult_tick(value):
|
|
if value == 0:
|
|
return "0"
|
|
if value < 1:
|
|
nice_values = [
|
|
(0.5, "0.5"),
|
|
(0.2, "0.2"),
|
|
(0.1, "0.10"),
|
|
(0.05, "0.05"),
|
|
(0.02, "0.02"),
|
|
(0.01, "0.01"),
|
|
]
|
|
for target, label in nice_values:
|
|
if abs(value - target) / target <= 0.08:
|
|
return label
|
|
abs_val = abs(value)
|
|
for decimals in (0, 1, 2):
|
|
rounded = round(value, decimals)
|
|
if rounded != 0 and abs(value - rounded) / abs_val < 0.01:
|
|
if decimals == 0:
|
|
return str(int(round(rounded)))
|
|
text = f"{rounded:.{decimals}f}"
|
|
return text.rstrip("0").rstrip(".")
|
|
return f"{value:.3g}"
|
|
|
|
|
|
def nice_mult_ticks(vmin, vmax):
|
|
if vmin <= 0 or vmax <= 0:
|
|
return []
|
|
if vmin > vmax:
|
|
vmin, vmax = vmax, vmin
|
|
exp_min = int(math.floor(math.log10(vmin)))
|
|
exp_max = int(math.ceil(math.log10(vmax)))
|
|
mantissas = [1, 2, 5]
|
|
ticks = []
|
|
for exp in range(exp_min, exp_max + 1):
|
|
base = 10 ** exp
|
|
for m in mantissas:
|
|
value = m * base
|
|
if vmin <= value <= vmax:
|
|
ticks.append(value)
|
|
if not ticks:
|
|
if vmin == vmax:
|
|
return [vmin]
|
|
return [vmin, vmax]
|
|
return ticks
|
|
|
|
|
|
def pad_top(ax, frac=0.2):
|
|
ymin, ymax = ax.get_ylim()
|
|
data_max = ax.dataLim.ymax
|
|
if data_max <= 0:
|
|
return
|
|
target_top = data_max * (1.0 + frac)
|
|
ax.set_ylim(bottom=ymin, top=target_top)
|
|
|
|
|
|
def set_log_ylim(ax, values, pad_frac=0.2, min_floor=0.9, min_top=10):
|
|
if not values:
|
|
return
|
|
max_val = max(values)
|
|
if max_val < min_floor:
|
|
max_val = min_floor
|
|
top = max(max_val * (1.0 + pad_frac), min_top)
|
|
bottom = min_floor
|
|
ax.set_ylim(bottom=bottom, top=top)
|
|
|
|
|
|
def set_log_ylim_from_ax(ax, pad_frac=0.2, min_floor=0.9, min_top=10):
|
|
max_val = ax.dataLim.ymax
|
|
if max_val <= 0:
|
|
return
|
|
top = max(max_val * (1.0 + pad_frac), min_top)
|
|
bottom = min_floor
|
|
ax.set_ylim(bottom=bottom, top=top)
|
|
|
|
|
|
def set_log_decade_ticks(ax):
|
|
ymin, ymax = ax.get_ylim()
|
|
if ymin <= 0 or ymax <= 0:
|
|
return
|
|
exp_min = int(math.floor(math.log10(ymin)))
|
|
exp_max = int(math.ceil(math.log10(ymax)))
|
|
ticks = [10 ** e for e in range(exp_min, exp_max + 1) if 10 ** e >= 1]
|
|
labels = [str(int(t)) for t in ticks]
|
|
ax.set_yticks(ticks)
|
|
ax.set_yticklabels(labels)
|
|
|
|
|
|
def set_symlog_ylim(
|
|
ax,
|
|
values,
|
|
clamp_zero=False,
|
|
pad_frac=0.1,
|
|
min_pad=1.0,
|
|
min_bottom=None,
|
|
max_bottom=None,
|
|
min_top=None,
|
|
):
|
|
if not values:
|
|
return
|
|
vmin = min(values)
|
|
vmax = max(values)
|
|
if vmin == vmax:
|
|
pad = max(abs(vmin) * pad_frac, min_pad)
|
|
else:
|
|
pad = (vmax - vmin) * pad_frac
|
|
bottom = vmin - pad
|
|
top = vmax + pad
|
|
if clamp_zero and bottom < 0:
|
|
bottom = 0
|
|
if min_bottom is not None and bottom > min_bottom:
|
|
bottom = min_bottom
|
|
if max_bottom is not None and bottom < max_bottom:
|
|
bottom = max_bottom
|
|
if min_top is not None and top < min_top:
|
|
top = min_top
|
|
ax.set_ylim(bottom=bottom, top=top)
|
|
|
|
|
|
def set_symlog_ticks(ax, linthresh, symmetric=True):
|
|
ymin, ymax = ax.get_ylim()
|
|
max_abs = max(abs(ymin), abs(ymax))
|
|
if max_abs <= 0:
|
|
ax.set_yticks([0])
|
|
ax.set_yticklabels(["0"])
|
|
return
|
|
max_exp = int(math.floor(math.log10(max_abs)))
|
|
ticks = [0.0]
|
|
for exp in range(0, max_exp + 1):
|
|
tick = 10 ** exp
|
|
if tick < linthresh or tick > max_abs:
|
|
continue
|
|
if symmetric:
|
|
ticks.extend([-tick, tick])
|
|
else:
|
|
ticks.append(tick)
|
|
ticks = sorted(set(ticks))
|
|
labels = []
|
|
for tick in ticks:
|
|
if tick == 0:
|
|
labels.append("0")
|
|
else:
|
|
labels.append(str(int(tick)))
|
|
ax.set_yticks(ticks)
|
|
ax.set_yticklabels(labels)
|
|
|
|
|
|
def add_common_args(parser):
|
|
parser.add_argument(
|
|
"--db",
|
|
default=None,
|
|
help="Path to legacy sqlite database (optional; default: API only).",
|
|
)
|
|
parser.add_argument(
|
|
"--png",
|
|
nargs="?",
|
|
const="__DEFAULT__",
|
|
default=None,
|
|
help="Output image path.",
|
|
)
|
|
parser.add_argument(
|
|
"--csv",
|
|
nargs="?",
|
|
const="__DEFAULT__",
|
|
default=None,
|
|
help="Output CSV path.",
|
|
)
|
|
parser.add_argument(
|
|
"--show",
|
|
action="store_true",
|
|
help="Show the plot interactively.",
|
|
)
|
|
parser.add_argument(
|
|
"--title",
|
|
nargs="?",
|
|
const="",
|
|
default=None,
|
|
help="Plot title (defaults to peer label; pass empty to omit).",
|
|
)
|
|
parser.add_argument(
|
|
"--since",
|
|
dest="from_ts",
|
|
default=None,
|
|
help="Start time (ISO-8601 or HH:MM[:SS]).",
|
|
)
|
|
parser.add_argument(
|
|
"--before",
|
|
dest="to_ts",
|
|
default=None,
|
|
help="End time (ISO-8601 or HH:MM[:SS]).",
|
|
)
|
|
|
|
|
|
def add_lightning_args(parser):
|
|
parser.add_argument("--mainnet", action="store_true", help="Run on mainnet")
|
|
parser.add_argument("--testnet", action="store_true", help="Run on testnet")
|
|
parser.add_argument("--signet", action="store_true", help="Run on signet")
|
|
parser.add_argument("--regtest", action="store_true", help="Run on regtest")
|
|
parser.add_argument("--network", help="Set the network explicitly")
|
|
parser.add_argument("--lightning-dir", help="lightning data location")
|
|
|
|
|
|
def resolve_network_option(args):
|
|
if args.network:
|
|
return f"--network={args.network}"
|
|
if args.testnet:
|
|
return "--network=testnet"
|
|
if args.signet:
|
|
return "--network=signet"
|
|
if args.regtest:
|
|
return "--network=regtest"
|
|
return "--network=bitcoin"
|
|
|
|
|
|
def resolve_lightning_dir(args):
|
|
if args.lightning_dir:
|
|
if not os.path.isdir(args.lightning_dir):
|
|
raise ValueError(f'"{args.lightning_dir}" is not a valid directory')
|
|
return args.lightning_dir
|
|
return None
|
|
|
|
|
|
def run_lightning_cli_command(lightning_dir, network_option, subcommand, *args):
|
|
try:
|
|
command = ["lightning-cli", network_option, subcommand, *args]
|
|
if lightning_dir:
|
|
command = command[:2] \
|
|
+ [f"--lightning-dir={lightning_dir}"] \
|
|
+ command[2:]
|
|
result = subprocess.run(command, capture_output=True, text=True, check=True)
|
|
return json.loads(result.stdout)
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"Command '{command}' failed with error: {e}", file=sys.stderr)
|
|
except FileNotFoundError:
|
|
print("lightning-cli not found in PATH.", file=sys.stderr)
|
|
except json.JSONDecodeError as e:
|
|
print(f"Failed to parse JSON from command '{command}': {e}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def lookup_nodeid_by_scid(run_lightning_cli_command, lightning_dir, network_option, scid):
|
|
listpeerchannels_data = run_lightning_cli_command(
|
|
lightning_dir, network_option, "listpeerchannels"
|
|
)
|
|
if not listpeerchannels_data:
|
|
return None
|
|
channels = listpeerchannels_data.get("channels", [])
|
|
for channel in channels:
|
|
if channel.get("short_channel_id") == scid:
|
|
return channel.get("peer_id")
|
|
return None
|
|
|
|
|
|
def resolve_peer(args):
|
|
peer_input = args.peer
|
|
if is_nodeid(peer_input):
|
|
return peer_input, peer_input
|
|
|
|
cli_available = shutil.which("lightning-cli") is not None
|
|
network_option = args.network_option
|
|
lightning_dir = args.resolved_lightning_dir
|
|
|
|
if is_scid(peer_input):
|
|
if not cli_available:
|
|
raise SystemExit(
|
|
"Error: lightning-cli not found. Provide a nodeid for SCID inputs."
|
|
)
|
|
peer_id = lookup_nodeid_by_scid(
|
|
run_lightning_cli_command, lightning_dir, network_option, peer_input
|
|
)
|
|
if not peer_id:
|
|
raise SystemExit(f"Error: SCID '{peer_input}' not found.")
|
|
alias = lookup_alias(
|
|
run_lightning_cli_command, lightning_dir, network_option, peer_id
|
|
)
|
|
if alias and alias != peer_id:
|
|
return peer_id, f"{alias} ({peer_input})"
|
|
return peer_id, peer_input
|
|
|
|
alias = peer_input
|
|
if cli_available:
|
|
peer_id = lookup_nodeid_by_alias(
|
|
run_lightning_cli_command, lightning_dir, network_option, alias
|
|
)
|
|
else:
|
|
cache = load_cache()
|
|
peer_id = None
|
|
for node_id, cached_alias in cache.items():
|
|
if cached_alias == alias:
|
|
peer_id = node_id
|
|
break
|
|
if not peer_id:
|
|
if not cli_available:
|
|
raise SystemExit(
|
|
"Error: lightning-cli not found and alias not in cache. "
|
|
"Provide a nodeid or ensure lightning-cli is in PATH."
|
|
)
|
|
raise SystemExit(f"Error: Alias '{alias}' not found.")
|
|
return peer_id, alias
|
|
|
|
|
|
def filter_by_time(rows, from_ts, to_ts):
|
|
if from_ts is None and to_ts is None:
|
|
return rows
|
|
filtered = []
|
|
for row in rows:
|
|
ts = parse_ts(row[0])
|
|
if from_ts and ts < from_ts:
|
|
continue
|
|
if to_ts and ts > to_ts:
|
|
continue
|
|
filtered.append(row)
|
|
return filtered
|
|
|
|
|
|
def fetch_earnings_rows(args):
|
|
if shutil.which("lightning-cli") is None:
|
|
print("warning: lightning-cli not found; skipping earnings plot", file=sys.stderr)
|
|
return []
|
|
|
|
network_option = args.network_option
|
|
lightning_dir = args.resolved_lightning_dir
|
|
data = run_lightning_cli_command(
|
|
lightning_dir, network_option, "clboss-earnings-history", args.peer
|
|
)
|
|
if not data or "history" not in data:
|
|
print("warning: earnings data unavailable; skipping earnings plot", file=sys.stderr)
|
|
return []
|
|
|
|
rows = []
|
|
for entry in data["history"]:
|
|
bucket_time = entry.get("bucket_time")
|
|
if not bucket_time:
|
|
continue
|
|
in_earnings = entry.get("in_earnings", 0)
|
|
out_earnings = entry.get("out_earnings", 0)
|
|
rows.append((bucket_time, in_earnings, out_earnings))
|
|
|
|
return filter_by_time(rows, args.from_ts, args.to_ts)
|
|
|
|
|
|
def get_out_path(args, suffix):
|
|
if args.png is None:
|
|
return None
|
|
if args.png == "__DEFAULT__":
|
|
prefix = safe_prefix(args.peer_input)[:32]
|
|
return f"{prefix}-{suffix}.png"
|
|
return args.png
|
|
|
|
|
|
def get_csv_path(args, suffix):
|
|
if args.csv is None:
|
|
return None
|
|
if args.csv == "__DEFAULT__":
|
|
prefix = safe_prefix(args.peer_input)[:32]
|
|
return f"{prefix}-{suffix}.csv"
|
|
return args.csv
|
|
|
|
|
|
def write_csv(args, rows, suffix):
|
|
path = get_csv_path(args, suffix)
|
|
if not path:
|
|
return
|
|
headers = [
|
|
"ts",
|
|
"peer",
|
|
"set_base",
|
|
"set_ppm",
|
|
"baseline_base",
|
|
"baseline_ppm",
|
|
"size_mult",
|
|
"size_total_peers",
|
|
"size_less_peers",
|
|
"balance_mult",
|
|
"balance_our_msat",
|
|
"balance_total_msat",
|
|
"price_level",
|
|
"price_mult",
|
|
]
|
|
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
writer = csv.writer(f)
|
|
writer.writerow(headers)
|
|
writer.writerows(rows)
|
|
|
|
|
|
CSV_FIELDS = [
|
|
"node_id",
|
|
"set_base",
|
|
"set_ppm",
|
|
"baseline_base",
|
|
"baseline_ppm",
|
|
"size_mult",
|
|
"size_total_peers",
|
|
"size_less_peers",
|
|
"balance_mult",
|
|
"balance_our_msat",
|
|
"balance_total_msat",
|
|
"price_level",
|
|
"price_mult",
|
|
]
|
|
PRICE_LEVEL_FIELDS = ["price_level", "price_mult", "price_center"]
|
|
BASE_PPM_FIELDS = ["set_base", "set_ppm"]
|
|
BASELINE_FIELDS = ["baseline_base", "baseline_ppm"]
|
|
SIZE_MULT_FIELDS = ["size_total_peers", "size_less_peers", "size_mult"]
|
|
BALANCE_MULT_FIELDS = ["balance_our_msat", "balance_total_msat", "balance_mult"]
|
|
WARNED_FEEMON_MESSAGES = set()
|
|
|
|
|
|
def warn_feemon_data(message):
|
|
if message in WARNED_FEEMON_MESSAGES:
|
|
return
|
|
WARNED_FEEMON_MESSAGES.add(message)
|
|
print(f"warning: {message}", file=sys.stderr)
|
|
|
|
|
|
def get_fee_records(args):
|
|
if not hasattr(args, "_fee_records"):
|
|
args._fee_records = load_merged_peer_records(
|
|
args.db,
|
|
args.peer,
|
|
since_dt=args.from_ts,
|
|
before_dt=args.to_ts,
|
|
lightning_dir=args.resolved_lightning_dir,
|
|
network_option=args.network_option,
|
|
warn=warn_feemon_data,
|
|
)
|
|
return args._fee_records
|
|
|
|
|
|
def fetch_rows(args, fields):
|
|
return records_to_rows(get_fee_records(args), fields)
|
|
|
|
|
|
def fetch_csv_rows(args):
|
|
return fetch_rows(args, CSV_FIELDS)
|
|
|
|
|
|
def make_single_axes(plt):
|
|
fig, ax = plt.subplots(figsize=(12, 4))
|
|
return fig, ax, (ax,)
|
|
|
|
|
|
def make_twin_axes(plt):
|
|
fig, ax_left = plt.subplots(figsize=(12, 4))
|
|
ax_right = ax_left.twinx()
|
|
return fig, ax_left, (ax_left, ax_right)
|
|
|
|
|
|
def plot_single_view(
|
|
args, suffix, row_fields, axes_factory, plotter, use_plt_tight_layout=False
|
|
):
|
|
try:
|
|
import matplotlib.pyplot as plt
|
|
except ImportError:
|
|
raise SystemExit("matplotlib is required to output graphs.")
|
|
else:
|
|
import matplotlib as mpl
|
|
|
|
mpl.rcParams["timezone"] = "UTC"
|
|
|
|
rows = fetch_rows(args, row_fields)
|
|
csv_rows = fetch_csv_rows(args)
|
|
|
|
fig, primary_ax, plot_axes = axes_factory(plt)
|
|
plotter(*plot_axes, rows, args.peer_label)
|
|
primary_ax.set_xlabel("time")
|
|
primary_ax.set_title(args.title)
|
|
|
|
if use_plt_tight_layout:
|
|
plt.tight_layout()
|
|
else:
|
|
fig.tight_layout()
|
|
out_path = get_out_path(args, suffix)
|
|
if out_path:
|
|
plt.savefig(out_path, dpi=150)
|
|
|
|
if args.show:
|
|
plt.show()
|
|
write_csv(args, csv_rows, suffix)
|
|
|
|
|
|
def plot_price_level_on(ax_level, rows, peer):
|
|
rows = [r for r in rows if r[1] is not None]
|
|
ts_levels = []
|
|
levels = []
|
|
centers = []
|
|
has_center = False
|
|
for t, level, _, center in rows:
|
|
ts = parse_ts(t)
|
|
ts_levels.append(ts)
|
|
levels.append(level)
|
|
if center is None:
|
|
centers.append(float("nan"))
|
|
else:
|
|
has_center = True
|
|
centers.append(float(center))
|
|
|
|
if not ts_levels:
|
|
raise SystemExit("no theory_level data for peer in time range")
|
|
|
|
level_line, = ax_level.plot(
|
|
ts_levels,
|
|
levels,
|
|
linewidth=PLOT_LINEWIDTH,
|
|
label="theory_level",
|
|
color="orange",
|
|
)
|
|
center_line = None
|
|
if has_center:
|
|
center_line, = ax_level.plot(
|
|
ts_levels,
|
|
centers,
|
|
linewidth=PLOT_LINEWIDTH,
|
|
label="theory_center",
|
|
color="red",
|
|
alpha=0.85,
|
|
)
|
|
|
|
ax_level.set_ylabel("theory_level")
|
|
ax_mult = ax_level.secondary_yaxis(
|
|
"right",
|
|
functions=(price_level_to_mult, price_mult_to_level),
|
|
)
|
|
ax_mult.set_ylabel("theory_mult")
|
|
if center_line is None:
|
|
ax_level.legend([level_line], ["theory_level"])
|
|
else:
|
|
ax_level.legend(
|
|
[level_line, center_line],
|
|
["theory_level", "theory_center"],
|
|
)
|
|
pad_top(ax_level)
|
|
scale_levels = levels
|
|
if has_center:
|
|
scale_levels = levels + [v for v in centers if not math.isnan(v)]
|
|
mult_min = price_level_to_mult(min(scale_levels))
|
|
mult_max = price_level_to_mult(max(scale_levels))
|
|
mult_ticks = nice_mult_ticks(mult_min, mult_max)
|
|
if mult_ticks:
|
|
ax_mult.set_yticks(mult_ticks)
|
|
ax_mult.set_yticklabels([format_mult_tick(v) for v in mult_ticks])
|
|
return ax_mult
|
|
|
|
|
|
def plot_base_ppm_on(ax_base, ax_ppm, rows, peer):
|
|
|
|
rows = [r for r in rows if r[1] is not None and r[2] is not None]
|
|
ts = []
|
|
bases = []
|
|
ppms = []
|
|
for t, base, ppm in rows:
|
|
ts.append(parse_ts(t))
|
|
bases.append(base)
|
|
ppms.append(ppm)
|
|
|
|
if not ts:
|
|
raise SystemExit("no est_base/est_ppm data for peer in time range")
|
|
|
|
base_line, = ax_base.plot(ts, bases, linewidth=PLOT_LINEWIDTH, label="base")
|
|
ppm_line, = ax_ppm.plot(ts, ppms, linewidth=PLOT_LINEWIDTH, label="ppm", color="orange")
|
|
|
|
ax_base.set_ylabel("advertised_base")
|
|
ax_ppm.set_ylabel("advertised_ppm")
|
|
ax_base.set_yscale(
|
|
"symlog",
|
|
linthresh=FEE_SYMLOG_LINTHRESH,
|
|
linscale=FEE_SYMLOG_LINSCALE,
|
|
)
|
|
ax_ppm.set_yscale(
|
|
"symlog",
|
|
linthresh=FEE_SYMLOG_LINTHRESH,
|
|
linscale=FEE_SYMLOG_LINSCALE,
|
|
)
|
|
|
|
base_values = [0.0] + bases
|
|
ppm_values = [0.0] + ppms
|
|
ppm_scale_values = [max(0.0, v) for v in ppm_values]
|
|
base_min = min(base_values)
|
|
ppm_min = min(ppm_scale_values)
|
|
base_max_bottom = None if base_min < 0 else -0.1
|
|
set_symlog_ylim(
|
|
ax_base,
|
|
base_values,
|
|
min_pad=0.1,
|
|
min_bottom=-0.1,
|
|
max_bottom=base_max_bottom,
|
|
min_top=10,
|
|
)
|
|
set_symlog_ylim(
|
|
ax_ppm,
|
|
ppm_scale_values,
|
|
min_pad=0.1,
|
|
min_bottom=-0.1,
|
|
max_bottom=-0.1,
|
|
min_top=10,
|
|
)
|
|
set_symlog_ticks(ax_base, FEE_SYMLOG_LINTHRESH, symmetric=base_min < 0)
|
|
set_symlog_ticks(ax_ppm, FEE_SYMLOG_LINTHRESH, symmetric=ppm_min < 0)
|
|
|
|
ax_base.legend([base_line, ppm_line], ["advertised_base", "advertised_ppm"])
|
|
|
|
|
|
def plot_baseline_on(ax_base, ax_ppm, rows, peer):
|
|
|
|
rows = [r for r in rows if r[1] is not None and r[2] is not None]
|
|
ts = []
|
|
bases = []
|
|
ppms = []
|
|
for t, base, ppm in rows:
|
|
ts.append(parse_ts(t))
|
|
bases.append(base)
|
|
ppms.append(ppm)
|
|
|
|
if not ts:
|
|
raise SystemExit("no baseline data for peer in time range")
|
|
|
|
base_line, = ax_base.plot(ts, bases, linewidth=PLOT_LINEWIDTH, label="base")
|
|
ppm_line, = ax_ppm.plot(ts, ppms, linewidth=PLOT_LINEWIDTH, label="ppm", color="orange")
|
|
|
|
ax_base.set_ylabel("baseline_base")
|
|
ax_ppm.set_ylabel("baseline_ppm")
|
|
|
|
ax_base.set_yscale(
|
|
"symlog",
|
|
linthresh=FEE_SYMLOG_LINTHRESH,
|
|
linscale=FEE_SYMLOG_LINSCALE,
|
|
)
|
|
ax_ppm.set_yscale(
|
|
"symlog",
|
|
linthresh=FEE_SYMLOG_LINTHRESH,
|
|
linscale=FEE_SYMLOG_LINSCALE,
|
|
)
|
|
|
|
base_values = [0.0] + bases
|
|
ppm_values = [0.0] + ppms
|
|
ppm_scale_values = [max(0.0, v) for v in ppm_values]
|
|
base_min = min(base_values)
|
|
ppm_min = min(ppm_scale_values)
|
|
base_max_bottom = None if base_min < 0 else -0.1
|
|
set_symlog_ylim(
|
|
ax_base,
|
|
base_values,
|
|
min_pad=0.1,
|
|
min_bottom=-0.1,
|
|
max_bottom=base_max_bottom,
|
|
min_top=10,
|
|
)
|
|
set_symlog_ylim(
|
|
ax_ppm,
|
|
ppm_scale_values,
|
|
min_pad=0.1,
|
|
min_bottom=-0.1,
|
|
max_bottom=-0.1,
|
|
min_top=10,
|
|
)
|
|
set_symlog_ticks(ax_base, FEE_SYMLOG_LINTHRESH, symmetric=base_min < 0)
|
|
set_symlog_ticks(ax_ppm, FEE_SYMLOG_LINTHRESH, symmetric=ppm_min < 0)
|
|
|
|
ax_base.legend([base_line, ppm_line], ["baseline_base", "baseline_ppm"])
|
|
|
|
|
|
def plot_size_mult_on(ax_left, ax_right, rows, peer):
|
|
rows = [
|
|
r for r in rows
|
|
if r[1] is not None and r[2] is not None and r[3] is not None
|
|
]
|
|
ts = []
|
|
totals = []
|
|
lesses = []
|
|
mults = []
|
|
for t, total, less, mult in rows:
|
|
ts.append(parse_ts(t))
|
|
totals.append(total)
|
|
lesses.append(less)
|
|
mults.append(mult)
|
|
|
|
if not ts:
|
|
raise SystemExit("no size_total_peers data for peer in time range")
|
|
|
|
total_line, = ax_left.plot(
|
|
ts, totals, linewidth=PLOT_LINEWIDTH, label="total_peers", color="green"
|
|
)
|
|
less_line, = ax_left.plot(
|
|
ts, lesses, linewidth=PLOT_LINEWIDTH, label="less_peers"
|
|
)
|
|
mult_line, = ax_right.plot(
|
|
ts, mults, linewidth=PLOT_LINEWIDTH, label="size_mult", color="orange"
|
|
)
|
|
ax_left.set_ylabel("peer_count")
|
|
ax_left.set_ylim(bottom=0)
|
|
pad_top(ax_left)
|
|
ax_left.legend(
|
|
[total_line, less_line, mult_line],
|
|
["total_peers", "less_peers", "size_mult"],
|
|
)
|
|
ax_right.set_ylabel("size_mult")
|
|
ax_right.set_yscale("log")
|
|
ax_right.set_ylim(0.5, 16)
|
|
ax_right.set_yticks(
|
|
[0.5, 1, 2, 4, 8, 16],
|
|
["1/2", "1", "2", "4", "8", "16"],
|
|
)
|
|
|
|
|
|
def plot_balance_mult_on(ax_left, ax_right, rows, peer):
|
|
rows = [
|
|
r for r in rows
|
|
if r[1] is not None and r[2] is not None and r[3] is not None
|
|
]
|
|
ts = []
|
|
our_sats = []
|
|
total_sats = []
|
|
mults = []
|
|
for t, our_val, total_val, mult in rows:
|
|
ts.append(parse_ts(t))
|
|
our_sats.append(our_val / 1000.0)
|
|
total_sats.append(total_val / 1000.0)
|
|
mults.append(mult)
|
|
|
|
if not ts:
|
|
raise SystemExit("no balance data for peer in time range")
|
|
|
|
total_line, = ax_left.plot(
|
|
ts, total_sats, linewidth=PLOT_LINEWIDTH, label="total_balance", color="green"
|
|
)
|
|
peer_line, = ax_left.plot(
|
|
ts, our_sats, linewidth=PLOT_LINEWIDTH, label="our_balance"
|
|
)
|
|
mult_line, = ax_right.plot(
|
|
ts, mults, linewidth=PLOT_LINEWIDTH, color="orange", label="balance_mult"
|
|
)
|
|
ax_left.set_ylabel("balance")
|
|
max_total = max(total_sats) if total_sats else 0
|
|
if max_total > 0:
|
|
ax_left.set_ylim(bottom=0 - 0.05, top=max_total * 1.05)
|
|
else:
|
|
ax_left.set_ylim(bottom=0 - 0.05)
|
|
balance_formatter = make_engineering_formatter()
|
|
if balance_formatter:
|
|
ax_left.yaxis.set_major_formatter(balance_formatter)
|
|
ax_left.legend(
|
|
[total_line, peer_line, mult_line],
|
|
["total_balance", "our_balance", "balance_mult"],
|
|
)
|
|
ax_right.set_ylabel("balance_mult")
|
|
ax_right.set_yscale("log")
|
|
ax_right.set_ylim(1 / 6 * 0.8, 6 * 1.2)
|
|
ax_right.set_yticks(
|
|
[1 / 6, 1 / 3, 1, 3, 6],
|
|
["1/6", "1/3", "1", "3", "6"],
|
|
)
|
|
|
|
|
|
def plot_earnings_split(ax_out, ax_in, rows, peer):
|
|
ts = []
|
|
incoming = []
|
|
outgoing = []
|
|
for t, in_earnings, out_earnings in rows:
|
|
ts.append(parse_ts(t))
|
|
incoming.append(in_earnings / 1000.0)
|
|
outgoing.append(out_earnings / 1000.0)
|
|
|
|
if not ts:
|
|
for ax, label in ((ax_out, "outgoing"), (ax_in, "incoming")):
|
|
ax.set_ylabel(f"earnings (sat/day) {label}")
|
|
ax.set_yscale(
|
|
"symlog",
|
|
linthresh=EARNINGS_SYMLOG_LINTHRESH,
|
|
linscale=EARNINGS_SYMLOG_LINSCALE,
|
|
)
|
|
set_symlog_ylim(
|
|
ax,
|
|
[0.0],
|
|
clamp_zero=True,
|
|
min_bottom=0,
|
|
min_top=EARNINGS_SYMLOG_LINTHRESH,
|
|
)
|
|
set_symlog_ticks(ax, EARNINGS_SYMLOG_LINTHRESH, symmetric=False)
|
|
ax_out.set_title("earnings (no data)")
|
|
return
|
|
|
|
# Earnings buckets are daily; draw near-full-day bars starting at the bucket
|
|
# boundary to preserve UTC alignment while still visually separating days.
|
|
bar_width = timedelta(days=0.8)
|
|
out_bars = ax_out.bar(
|
|
ts,
|
|
outgoing,
|
|
width=bar_width,
|
|
align="edge",
|
|
color="green",
|
|
label="earnings (sat/day) outgoing",
|
|
)
|
|
in_bars = ax_in.bar(
|
|
ts,
|
|
incoming,
|
|
width=bar_width,
|
|
align="edge",
|
|
color="blue",
|
|
label="earnings (sat/day) incoming",
|
|
)
|
|
|
|
for ax, values, label in (
|
|
(ax_out, outgoing, "outgoing"),
|
|
(ax_in, incoming, "incoming"),
|
|
):
|
|
ax.set_ylabel(f"earnings (sat/day) {label}")
|
|
ax.set_yscale(
|
|
"symlog",
|
|
linthresh=EARNINGS_SYMLOG_LINTHRESH,
|
|
linscale=EARNINGS_SYMLOG_LINSCALE,
|
|
)
|
|
set_symlog_ylim(
|
|
ax,
|
|
[0.0] + values,
|
|
clamp_zero=True,
|
|
min_bottom=0,
|
|
min_top=EARNINGS_SYMLOG_LINTHRESH,
|
|
)
|
|
set_symlog_ticks(ax, EARNINGS_SYMLOG_LINTHRESH, symmetric=False)
|
|
|
|
ax_out.legend(handles=[out_bars], labels=["earnings (sat/day) outgoing"])
|
|
ax_in.legend(handles=[in_bars], labels=["earnings (sat/day) incoming"])
|
|
|
|
|
|
def plot_earnings_single_on(ax, rows, direction):
|
|
incoming = direction == "incoming"
|
|
label = "incoming" if incoming else "outgoing"
|
|
color = "blue" if incoming else "green"
|
|
ts = []
|
|
values = []
|
|
for t, in_earnings, out_earnings in rows:
|
|
ts.append(parse_ts(t))
|
|
value = in_earnings if incoming else out_earnings
|
|
values.append(value / 1000.0)
|
|
|
|
ax.set_ylabel(f"earnings (sat/day) {label}")
|
|
ax.set_yscale(
|
|
"symlog",
|
|
linthresh=EARNINGS_SYMLOG_LINTHRESH,
|
|
linscale=EARNINGS_SYMLOG_LINSCALE,
|
|
)
|
|
if not ts:
|
|
set_symlog_ylim(
|
|
ax,
|
|
[0.0],
|
|
clamp_zero=True,
|
|
min_bottom=0,
|
|
min_top=EARNINGS_SYMLOG_LINTHRESH,
|
|
)
|
|
set_symlog_ticks(ax, EARNINGS_SYMLOG_LINTHRESH, symmetric=False)
|
|
ax.set_title(f"earnings (no data) {label}")
|
|
return
|
|
|
|
bar_width = timedelta(days=0.8)
|
|
bars = ax.bar(
|
|
ts,
|
|
values,
|
|
width=bar_width,
|
|
align="edge",
|
|
color=color,
|
|
label=f"earnings (sat/day) {label}",
|
|
)
|
|
set_symlog_ylim(
|
|
ax,
|
|
[0.0] + values,
|
|
clamp_zero=True,
|
|
min_bottom=0,
|
|
min_top=EARNINGS_SYMLOG_LINTHRESH,
|
|
)
|
|
set_symlog_ticks(ax, EARNINGS_SYMLOG_LINTHRESH, symmetric=False)
|
|
ax.legend(handles=[bars], labels=[f"earnings (sat/day) {label}"])
|
|
|
|
|
|
def plot_price_level(args):
|
|
plot_single_view(
|
|
args,
|
|
"theory",
|
|
PRICE_LEVEL_FIELDS,
|
|
make_single_axes,
|
|
plot_price_level_on,
|
|
)
|
|
|
|
|
|
def plot_base_ppm(args):
|
|
plot_single_view(
|
|
args,
|
|
"baseppm",
|
|
BASE_PPM_FIELDS,
|
|
make_twin_axes,
|
|
plot_base_ppm_on,
|
|
)
|
|
# Keep symlog tick formatting from plot_base_ppm_on.
|
|
|
|
|
|
def plot_baseline(args):
|
|
plot_single_view(
|
|
args,
|
|
"baseline",
|
|
BASELINE_FIELDS,
|
|
make_twin_axes,
|
|
plot_baseline_on,
|
|
)
|
|
# Keep symlog tick formatting from plot_baseline_on.
|
|
|
|
|
|
def plot_size_mult(args):
|
|
plot_single_view(
|
|
args,
|
|
"size",
|
|
SIZE_MULT_FIELDS,
|
|
make_twin_axes,
|
|
plot_size_mult_on,
|
|
use_plt_tight_layout=True,
|
|
)
|
|
|
|
|
|
def plot_balance_mult(args):
|
|
plot_single_view(
|
|
args,
|
|
"balance",
|
|
BALANCE_MULT_FIELDS,
|
|
make_twin_axes,
|
|
plot_balance_mult_on,
|
|
use_plt_tight_layout=True,
|
|
)
|
|
|
|
|
|
def plot_combo(args):
|
|
try:
|
|
import matplotlib.pyplot as plt
|
|
except ImportError:
|
|
raise SystemExit("matplotlib is required to output graphs.")
|
|
else:
|
|
import matplotlib as mpl
|
|
|
|
mpl.rcParams["timezone"] = "UTC"
|
|
|
|
def extend_times(acc, rows, end_pad=None):
|
|
for row in rows:
|
|
ts_val = row[0]
|
|
if ts_val is None:
|
|
continue
|
|
ts = parse_ts(ts_val)
|
|
acc.append(ts)
|
|
if end_pad:
|
|
acc.append(ts + end_pad)
|
|
|
|
size_rows = fetch_rows(args, SIZE_MULT_FIELDS)
|
|
baseline_rows = fetch_rows(args, BASELINE_FIELDS)
|
|
balance_rows = fetch_rows(args, BALANCE_MULT_FIELDS)
|
|
price_rows = fetch_rows(args, PRICE_LEVEL_FIELDS)
|
|
base_rows = fetch_rows(args, BASE_PPM_FIELDS)
|
|
csv_rows = fetch_rows(args, CSV_FIELDS)
|
|
earnings_rows = fetch_earnings_rows(args)
|
|
fig, axes = plt.subplots(7, 1, figsize=(12, 21), sharex=True)
|
|
(
|
|
ax_baseline,
|
|
ax_size,
|
|
ax_balance,
|
|
ax_price,
|
|
ax_base,
|
|
ax_earnings_out,
|
|
ax_earnings_in,
|
|
) = axes
|
|
|
|
ax_baseline_base = ax_baseline
|
|
ax_baseline_ppm = ax_baseline_base.twinx()
|
|
plot_baseline_on(
|
|
ax_baseline_base, ax_baseline_ppm, baseline_rows, args.peer_label
|
|
)
|
|
|
|
ax_size_right = ax_size.twinx()
|
|
plot_size_mult_on(ax_size, ax_size_right, size_rows, args.peer_label)
|
|
ax_balance_right = ax_balance.twinx()
|
|
plot_balance_mult_on(ax_balance, ax_balance_right, balance_rows, args.peer_label)
|
|
|
|
ax_price_level = ax_price
|
|
ax_price_mult = plot_price_level_on(ax_price_level, price_rows, args.peer_label)
|
|
|
|
ax_base_fee = ax_base
|
|
ax_base_ppm = ax_base_fee.twinx()
|
|
plot_base_ppm_on(ax_base_fee, ax_base_ppm, base_rows, args.peer_label)
|
|
|
|
plot_earnings_split(ax_earnings_out, ax_earnings_in, earnings_rows, args.peer_label)
|
|
|
|
all_times = []
|
|
extend_times(all_times, baseline_rows)
|
|
extend_times(all_times, size_rows)
|
|
extend_times(all_times, balance_rows)
|
|
extend_times(all_times, price_rows)
|
|
extend_times(all_times, base_rows)
|
|
extend_times(all_times, earnings_rows, end_pad=timedelta(days=1))
|
|
if all_times:
|
|
ax_baseline.set_xlim(min(all_times), max(all_times))
|
|
|
|
fig.suptitle(args.title)
|
|
fig.tight_layout(rect=[0, 0, 1, 0.96])
|
|
fig.subplots_adjust(hspace=0.15)
|
|
|
|
out_path = get_out_path(args, "combo")
|
|
if out_path:
|
|
plt.savefig(out_path, dpi=150)
|
|
|
|
if args.show:
|
|
plt.show()
|
|
write_csv(args, csv_rows, "combo")
|
|
|
|
|
|
def plot_earnings_single(args, direction, suffix):
|
|
try:
|
|
import matplotlib.pyplot as plt
|
|
except ImportError:
|
|
raise SystemExit("matplotlib is required to output graphs.")
|
|
else:
|
|
import matplotlib as mpl
|
|
|
|
mpl.rcParams["timezone"] = "UTC"
|
|
|
|
rows = fetch_earnings_rows(args)
|
|
csv_rows = fetch_csv_rows(args)
|
|
|
|
fig, ax = plt.subplots(figsize=(12, 4))
|
|
ax.set_title(args.title)
|
|
plot_earnings_single_on(ax, rows, direction)
|
|
ax.set_xlabel("time")
|
|
fig.tight_layout()
|
|
|
|
out_path = get_out_path(args, suffix)
|
|
if out_path:
|
|
plt.savefig(out_path, dpi=150)
|
|
|
|
if args.show:
|
|
plt.show()
|
|
write_csv(args, csv_rows, suffix)
|
|
|
|
|
|
def plot_earnings_incoming(args):
|
|
plot_earnings_single(args, "incoming", "earnings-incoming")
|
|
|
|
|
|
def plot_earnings_outgoing(args):
|
|
plot_earnings_single(args, "outgoing", "earnings-outgoing")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Plot fee-related time series from merged API and legacy fee history."
|
|
)
|
|
parser.add_argument(
|
|
"--view",
|
|
required=True,
|
|
choices=[
|
|
"theory",
|
|
"base-ppm",
|
|
"baseline",
|
|
"size",
|
|
"balance",
|
|
"incoming-earnings",
|
|
"outgoing-earnings",
|
|
"combo",
|
|
],
|
|
help="Plot view to render.",
|
|
)
|
|
parser.add_argument(
|
|
"--peer",
|
|
required=True,
|
|
help="Peer nodeid (hex), alias, or SCID.",
|
|
)
|
|
add_lightning_args(parser)
|
|
add_common_args(parser)
|
|
|
|
args = parser.parse_args()
|
|
args.network_option = resolve_network_option(args)
|
|
args.resolved_lightning_dir = resolve_lightning_dir(args)
|
|
args.peer_input = args.peer
|
|
args.from_ts = parse_time_arg(args.from_ts)
|
|
args.to_ts = parse_time_arg(args.to_ts)
|
|
args.peer, args.peer_label = resolve_peer(args)
|
|
args.title = args.peer_label if args.title is None else args.title
|
|
|
|
if args.db is None and shutil.which("lightning-cli") is None:
|
|
raise SystemExit(
|
|
"no data source available: provide --db or ensure lightning-cli is in PATH"
|
|
)
|
|
|
|
if not args.png and not args.show and not args.csv:
|
|
raise SystemExit("use --png to save, --show to display, or --csv to export")
|
|
|
|
view_map = {
|
|
"theory": plot_price_level,
|
|
"base-ppm": plot_base_ppm,
|
|
"baseline": plot_baseline,
|
|
"size": plot_size_mult,
|
|
"balance": plot_balance_mult,
|
|
"incoming-earnings": plot_earnings_incoming,
|
|
"outgoing-earnings": plot_earnings_outgoing,
|
|
"combo": plot_combo,
|
|
}
|
|
view_map[args.view](args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|