clboss/contrib/fee-log-parser
Ken Sedgwick 95276e44d0
contrib: add debug log post-processor and plotting utilities for fee algos
- 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
2026-02-27 12:58:15 -08:00

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()