mirror of
https://github.com/ZmnSCPxj/clboss.git
synced 2026-08-15 12:50:42 +02:00
- add fee-log-parser and plot-fees to contrib - Minor grammar nit: use hyphen in compound adjective. - Remove commented-out debug code. - Better hardcoded default path. - Reduce code duplication across plot functions - better periodic commit - allow SCID or alias instead of peerid - add earnings graph to the ensemble - use engineering scale values - switch earnings graph to stacked bar chart - tweak balance graph value padding - fixed review nits - add plot-aggregate showing channel distribution - reconciled naming of different views/labels - Fixed duplicate view name in documentation - better horizontal min/max value formatting (padding) - tightened the left-side base axes so they don't drift
504 lines
16 KiB
Python
Executable file
504 lines
16 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
LINE_RE = re.compile(
|
|
r"^(?:\d+:)?(\d{4}-\d{2}-\d{2}T[^ ]+)\s+\w+\s+[^:]+: (.*)$"
|
|
)
|
|
BASELINE_RE = re.compile(r"PeerCompetitorFeeMonitor: Weighted median fees: (.*)$")
|
|
BASELINE_ENTRY_RE = re.compile(r"([0-9a-f]{66})\(b = (\d+), p = (\d+)\)")
|
|
SIZE_RE = re.compile(
|
|
r"FeeModderBySize: Peer ([0-9a-f]{66}) has (\d+) other peers, "
|
|
r"(\d+) of which have less capacity than us, (\d+) have more\.\s+"
|
|
r"Multiplier: ([0-9.]+)"
|
|
)
|
|
BALANCE_MOVED_RE = re.compile(
|
|
r"FeeModderByBalance: Peer ([0-9a-f]{66}) moved from bin (\d+) to bin (\d+) "
|
|
r"of (\d+) due to balance (\d+)msat / (\d+)msat: ([0-9.]+)"
|
|
)
|
|
BALANCE_SET_RE = re.compile(
|
|
r"FeeModderByBalance: Peer ([0-9a-f]{66}) set to bin (\d+) of (\d+) "
|
|
r"due to balance (\d+)msat / (\d+)msat: ([0-9.]+)"
|
|
)
|
|
PRICE_RE = re.compile(
|
|
r"FeeModderByPriceTheory: ([0-9a-f]{66}): level = (-?\d+), mult = ([0-9.]+)"
|
|
)
|
|
SETCHANNEL_RE = re.compile(r"^Rpc out: setchannel (.*)$")
|
|
SETCHANNELFEE_RE = re.compile(r"^Rpc out: setchannelfee (.*)$")
|
|
|
|
ID_RE = re.compile(r'"id"\s*:\s*"([0-9a-f]{66})"')
|
|
FEEBASE_RE = re.compile(r'"feebase"\s*:\s*(\d+)')
|
|
FEEPPM_RE = re.compile(r'"feeppm"\s*:\s*(\d+)')
|
|
BASE_RE = re.compile(r'"base"\s*:\s*(\d+)')
|
|
PPM_RE = re.compile(r'"ppm"\s*:\s*(\d+)')
|
|
|
|
|
|
def parse_line(line):
|
|
line = line.rstrip("\n")
|
|
m = LINE_RE.match(line)
|
|
if not m:
|
|
return None
|
|
ts, msg = m.group(1), m.group(2)
|
|
try:
|
|
ts_epoch = parse_timestamp(ts)
|
|
except ValueError:
|
|
return None
|
|
return ts_epoch, msg, line
|
|
|
|
|
|
def local_tz():
|
|
return datetime.now().astimezone().tzinfo
|
|
|
|
|
|
def normalize_dt(dt):
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=local_tz())
|
|
return dt.astimezone(timezone.utc)
|
|
|
|
|
|
def parse_timestamp(ts):
|
|
if ts.endswith("Z"):
|
|
ts = ts[:-1] + "+00:00"
|
|
return normalize_dt(datetime.fromisoformat(ts)).timestamp()
|
|
|
|
|
|
def parse_json_payload(payload):
|
|
try:
|
|
return json.loads(payload)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
if '\\"' in payload:
|
|
try:
|
|
return json.loads(payload.replace('\\"', '"'))
|
|
except json.JSONDecodeError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def parse_setchannel_payload(payload):
|
|
data = parse_json_payload(payload)
|
|
if data:
|
|
node = data.get("id")
|
|
base = data.get("feebase", data.get("base"))
|
|
ppm = data.get("feeppm", data.get("ppm"))
|
|
if node and base is not None and ppm is not None:
|
|
return node, int(base), int(ppm)
|
|
|
|
cleaned = payload.replace('\\"', '"')
|
|
id_m = ID_RE.search(cleaned)
|
|
base_m = FEEBASE_RE.search(cleaned) or BASE_RE.search(cleaned)
|
|
ppm_m = FEEPPM_RE.search(cleaned) or PPM_RE.search(cleaned)
|
|
if id_m and base_m and ppm_m:
|
|
return id_m.group(1), int(base_m.group(1)), int(ppm_m.group(1))
|
|
return None
|
|
|
|
|
|
def ensure_schema(conn):
|
|
cur = conn.cursor()
|
|
cur.execute("PRAGMA foreign_keys = ON")
|
|
cur.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS peers (
|
|
id INTEGER PRIMARY KEY,
|
|
node_id TEXT NOT NULL UNIQUE
|
|
)
|
|
"""
|
|
)
|
|
cur.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS fee_change_events (
|
|
id INTEGER PRIMARY KEY,
|
|
ts REAL NOT NULL,
|
|
peer_id INTEGER NOT NULL,
|
|
set_base INTEGER,
|
|
set_ppm INTEGER,
|
|
baseline_base INTEGER,
|
|
baseline_ppm INTEGER,
|
|
size_mult REAL,
|
|
size_total_peers INTEGER,
|
|
size_less_peers INTEGER,
|
|
balance_mult REAL,
|
|
balance_our_msat INTEGER,
|
|
balance_total_msat INTEGER,
|
|
price_level INTEGER,
|
|
price_mult REAL,
|
|
mult_product REAL,
|
|
est_base INTEGER,
|
|
est_ppm INTEGER,
|
|
dedupe_hash TEXT NOT NULL,
|
|
FOREIGN KEY(peer_id) REFERENCES peers(id)
|
|
)
|
|
"""
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
CREATE UNIQUE INDEX IF NOT EXISTS fee_change_events_unique
|
|
ON fee_change_events(peer_id, ts, dedupe_hash)
|
|
"""
|
|
)
|
|
cur.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS fee_change_events_peer_ts_idx
|
|
ON fee_change_events(peer_id, ts)
|
|
"""
|
|
)
|
|
cur.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS fee_change_events_ts_peer_idx
|
|
ON fee_change_events(ts, peer_id)
|
|
"""
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def update_state(state, peer, key, value):
|
|
if peer not in state:
|
|
state[peer] = {}
|
|
state[peer][key] = value
|
|
|
|
|
|
def compute_estimate(state, peer):
|
|
s = state.get(peer, {})
|
|
base = s.get("baseline_base")
|
|
ppm = s.get("baseline_ppm")
|
|
if base is None or ppm is None:
|
|
return None
|
|
|
|
size_mult = s.get("size_mult")
|
|
balance_mult = s.get("balance_mult")
|
|
price_mult = s.get("price_mult")
|
|
if size_mult is None or balance_mult is None or price_mult is None:
|
|
return {
|
|
"mult_product": None,
|
|
"est_base": None,
|
|
"est_ppm": None,
|
|
}
|
|
|
|
mult_product = size_mult * balance_mult * price_mult
|
|
est_base = int(round(base * mult_product))
|
|
est_ppm = int(round(ppm * mult_product))
|
|
if est_ppm == 0:
|
|
est_ppm = 1
|
|
|
|
return {
|
|
"mult_product": mult_product,
|
|
"est_base": est_base,
|
|
"est_ppm": est_ppm,
|
|
}
|
|
|
|
|
|
def build_dedupe_hash(ts, peer_id, snapshot):
|
|
fields = [
|
|
str(ts),
|
|
str(peer_id),
|
|
str(snapshot["set_base"]),
|
|
str(snapshot["set_ppm"]),
|
|
str(snapshot["baseline_base"]),
|
|
str(snapshot["baseline_ppm"]),
|
|
str(snapshot["size_mult"]),
|
|
str(snapshot["size_total_peers"]),
|
|
str(snapshot["size_less_peers"]),
|
|
str(snapshot["balance_mult"]),
|
|
str(snapshot["balance_our_msat"]),
|
|
str(snapshot["balance_total_msat"]),
|
|
str(snapshot["price_level"]),
|
|
str(snapshot["price_mult"]),
|
|
str(snapshot["mult_product"]),
|
|
str(snapshot["est_base"]),
|
|
str(snapshot["est_ppm"]),
|
|
]
|
|
payload = "|".join(fields)
|
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
|
|
|
|
class Output:
|
|
def __init__(self, conn=None, commit_interval_seconds=60):
|
|
self.conn = conn
|
|
self.cur = conn.cursor() if conn else None
|
|
self.commit_interval_seconds = commit_interval_seconds
|
|
self.last_commit = time.monotonic() if conn else None
|
|
self.peer_cache = {}
|
|
self.pending_writes = False
|
|
|
|
def commit(self):
|
|
if self.conn:
|
|
self.conn.commit()
|
|
self.last_commit = time.monotonic()
|
|
self.pending_writes = False
|
|
|
|
def maybe_commit(self):
|
|
if not self.conn:
|
|
return
|
|
if not self.pending_writes:
|
|
return
|
|
if self.commit_interval_seconds is None:
|
|
return
|
|
elapsed = time.monotonic() - self.last_commit
|
|
if elapsed >= self.commit_interval_seconds:
|
|
self.commit()
|
|
|
|
def get_peer_id(self, peer):
|
|
if peer in self.peer_cache:
|
|
return self.peer_cache[peer]
|
|
self.cur.execute(
|
|
"INSERT OR IGNORE INTO peers (node_id) VALUES (?)",
|
|
(peer,),
|
|
)
|
|
self.cur.execute("SELECT id FROM peers WHERE node_id = ?", (peer,))
|
|
row = self.cur.fetchone()
|
|
peer_id = row[0]
|
|
self.peer_cache[peer] = peer_id
|
|
return peer_id
|
|
|
|
def insert_fee_change(self, ts, peer, snapshot):
|
|
if self.cur:
|
|
peer_id = self.get_peer_id(peer)
|
|
dedupe_hash = build_dedupe_hash(ts, peer_id, snapshot)
|
|
self.cur.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO fee_change_events (
|
|
ts, peer_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, mult_product, est_base, est_ppm,
|
|
dedupe_hash
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
ts,
|
|
peer_id,
|
|
snapshot["set_base"],
|
|
snapshot["set_ppm"],
|
|
snapshot["baseline_base"],
|
|
snapshot["baseline_ppm"],
|
|
snapshot["size_mult"],
|
|
snapshot["size_total_peers"],
|
|
snapshot["size_less_peers"],
|
|
snapshot["balance_mult"],
|
|
snapshot["balance_our_msat"],
|
|
snapshot["balance_total_msat"],
|
|
snapshot["price_level"],
|
|
snapshot["price_mult"],
|
|
snapshot["mult_product"],
|
|
snapshot["est_base"],
|
|
snapshot["est_ppm"],
|
|
dedupe_hash,
|
|
),
|
|
)
|
|
self.pending_writes = True
|
|
self.maybe_commit()
|
|
else:
|
|
sys.stdout.write(
|
|
"{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},{mult_product},{est_base},{est_ppm}\n".format(
|
|
ts=ts,
|
|
peer=peer,
|
|
set_base=snapshot["set_base"],
|
|
set_ppm=snapshot["set_ppm"],
|
|
baseline_base=snapshot["baseline_base"],
|
|
baseline_ppm=snapshot["baseline_ppm"],
|
|
size_mult=snapshot["size_mult"],
|
|
size_total_peers=snapshot["size_total_peers"],
|
|
size_less_peers=snapshot["size_less_peers"],
|
|
balance_mult=snapshot["balance_mult"],
|
|
balance_our_msat=snapshot["balance_our_msat"],
|
|
balance_total_msat=snapshot["balance_total_msat"],
|
|
price_level=snapshot["price_level"],
|
|
price_mult=snapshot["price_mult"],
|
|
mult_product=snapshot["mult_product"],
|
|
est_base=snapshot["est_base"],
|
|
est_ppm=snapshot["est_ppm"],
|
|
)
|
|
)
|
|
|
|
|
|
def extract_events_from_msg(msg):
|
|
m = SETCHANNEL_RE.match(msg) or SETCHANNELFEE_RE.match(msg)
|
|
if m:
|
|
payload = m.group(1)
|
|
parsed_payload = parse_setchannel_payload(payload)
|
|
if parsed_payload:
|
|
peer, base, ppm = parsed_payload
|
|
return [("setchannel", (peer, base, ppm))]
|
|
return []
|
|
|
|
m = BASELINE_RE.search(msg)
|
|
if m:
|
|
entries = BASELINE_ENTRY_RE.findall(m.group(1))
|
|
if entries:
|
|
return [("baseline", entries)]
|
|
return []
|
|
|
|
m = SIZE_RE.search(msg)
|
|
if m:
|
|
peer, total_s, less_s, more_s, mult_s = m.groups()
|
|
total = int(total_s)
|
|
less = int(less_s)
|
|
more = int(more_s)
|
|
if total == less + more:
|
|
mult = float(mult_s)
|
|
return [("size", (peer, total, less, mult))]
|
|
return []
|
|
|
|
m = BALANCE_MOVED_RE.search(msg)
|
|
if m:
|
|
peer = m.group(1)
|
|
to_us = int(m.group(5))
|
|
total = int(m.group(6))
|
|
mult = float(m.group(7))
|
|
return [("balance", (peer, mult, to_us, total))]
|
|
|
|
m = BALANCE_SET_RE.search(msg)
|
|
if m:
|
|
peer = m.group(1)
|
|
to_us = int(m.group(4))
|
|
total = int(m.group(5))
|
|
mult = float(m.group(6))
|
|
return [("balance", (peer, mult, to_us, total))]
|
|
|
|
m = PRICE_RE.search(msg)
|
|
if m:
|
|
peer, level_s, mult_s = m.groups()
|
|
level = int(level_s)
|
|
mult = float(mult_s)
|
|
return [("price", (peer, level, mult))]
|
|
|
|
return []
|
|
|
|
|
|
def apply_event(ts, kind, data, state, output):
|
|
if kind == "baseline":
|
|
for peer, base_s, ppm_s in data:
|
|
base = int(base_s)
|
|
ppm = int(ppm_s)
|
|
update_state(state, peer, "baseline_base", base)
|
|
update_state(state, peer, "baseline_ppm", ppm)
|
|
return
|
|
|
|
if kind == "size":
|
|
peer, total, less, mult = data
|
|
update_state(state, peer, "size_mult", mult)
|
|
update_state(state, peer, "size_total_peers", total)
|
|
update_state(state, peer, "size_less_peers", less)
|
|
return
|
|
|
|
if kind == "balance":
|
|
peer, mult, to_us, total = data
|
|
update_state(state, peer, "balance_mult", mult)
|
|
update_state(state, peer, "balance_our_msat", to_us)
|
|
update_state(state, peer, "balance_total_msat", total)
|
|
return
|
|
|
|
if kind == "price":
|
|
peer, level, mult = data
|
|
update_state(state, peer, "price_level", level)
|
|
update_state(state, peer, "price_mult", mult)
|
|
return
|
|
|
|
if kind == "setchannel":
|
|
peer, set_base, set_ppm = data
|
|
est = compute_estimate(state, peer)
|
|
snapshot = {
|
|
"set_base": set_base,
|
|
"set_ppm": set_ppm,
|
|
"baseline_base": state.get(peer, {}).get("baseline_base"),
|
|
"baseline_ppm": state.get(peer, {}).get("baseline_ppm"),
|
|
"size_mult": state.get(peer, {}).get("size_mult"),
|
|
"size_total_peers": state.get(peer, {}).get("size_total_peers"),
|
|
"size_less_peers": state.get(peer, {}).get("size_less_peers"),
|
|
"balance_mult": state.get(peer, {}).get("balance_mult"),
|
|
"balance_our_msat": state.get(peer, {}).get("balance_our_msat"),
|
|
"balance_total_msat": state.get(peer, {}).get("balance_total_msat"),
|
|
"price_level": state.get(peer, {}).get("price_level"),
|
|
"price_mult": state.get(peer, {}).get("price_mult"),
|
|
"mult_product": est.get("mult_product") if est else None,
|
|
"est_base": est.get("est_base") if est else None,
|
|
"est_ppm": est.get("est_ppm") if est else None,
|
|
}
|
|
output.insert_fee_change(ts, peer, snapshot)
|
|
|
|
|
|
def process_stream(lines, output):
|
|
state = {}
|
|
for line in lines:
|
|
parsed = parse_line(line)
|
|
if parsed:
|
|
ts, msg, _ = parsed
|
|
for kind, data in extract_events_from_msg(msg):
|
|
apply_event(ts, kind, data, state, output)
|
|
output.maybe_commit()
|
|
output.commit()
|
|
|
|
|
|
def open_lines(paths):
|
|
if not paths:
|
|
for line in sys.stdin:
|
|
yield line
|
|
return
|
|
|
|
for path in paths:
|
|
if os.path.isdir(path):
|
|
for root, _, files in os.walk(path):
|
|
for name in files:
|
|
full = os.path.join(root, name)
|
|
with open(full, "r", encoding="utf-8", errors="replace") as f:
|
|
for line in f:
|
|
yield line
|
|
else:
|
|
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
for line in f:
|
|
yield line
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Parse CLBOSS fee-setting logs into sqlite."
|
|
)
|
|
parser.add_argument(
|
|
"--db",
|
|
help="Path to sqlite database (created if missing).",
|
|
)
|
|
parser.add_argument(
|
|
"paths",
|
|
nargs="*",
|
|
help="Log files or directories (reads stdin if omitted).",
|
|
)
|
|
parser.add_argument(
|
|
"--commit-seconds",
|
|
type=float,
|
|
default=60,
|
|
help="Commit database writes every N seconds (default: 60).",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.db:
|
|
conn = sqlite3.connect(args.db)
|
|
ensure_schema(conn)
|
|
output = Output(conn=conn, commit_interval_seconds=args.commit_seconds)
|
|
process_stream(open_lines(args.paths), output)
|
|
conn.close()
|
|
return
|
|
|
|
sys.stdout.write(
|
|
"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,mult_product,est_base,"
|
|
"est_ppm\n"
|
|
)
|
|
output = Output()
|
|
process_stream(open_lines(args.paths), output)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|