gabriel / muse public
btc_fee.py python
122 lines 4.8 KB
15cf97e9 feat(bitcoin): add semantic porcelain layer — 19 Bitcoin-idiomatic CLI … Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 """muse bitcoin fee — fee-market window analysis and sending recommendation.
2
3 Analyses the historical fee-estimate oracle data and gives an actionable
4 recommendation: send now, wait for lower fees, or bump a stuck transaction.
5
6 Usage::
7
8 muse bitcoin fee
9 muse bitcoin fee --target-blocks 1
10 muse bitcoin fee --commit HEAD~5
11 muse bitcoin fee --json
12
13 Output::
14
15 Fee window — working tree (target: 6 blocks)
16
17 Current: 15 sat/vbyte
18 History: min 3 · median 12 · max 85 sat/vbyte
19 Percentile: 42nd (below median — reasonable conditions)
20
21 Recommendation: ✅ send_now
22 "Current rate 15 sat/vbyte is near the historical median. Sending now is reasonable."
23
24 Fee surface (latest): 1blk: 30 | 6blk: 15 | 144blk: 3 sat/vbyte
25 """
26
27 from __future__ import annotations
28
29 import json
30 import logging
31
32 import typer
33
34 from muse.core.errors import ExitCode
35 from muse.core.repo import require_repo
36 from muse.core.store import resolve_commit_ref
37 from muse.plugins.bitcoin._loader import (
38 load_fees,
39 load_fees_from_workdir,
40 load_mempool,
41 load_mempool_from_workdir,
42 read_current_branch,
43 read_repo_id,
44 )
45 from muse.plugins.bitcoin._query import fee_surface_str, latest_fee_estimate
46 from muse.plugins.bitcoin._analytics import fee_window
47
48 logger = logging.getLogger(__name__)
49 app = typer.Typer()
50
51
52 @app.callback(invoke_without_command=True)
53 def fee(
54 ctx: typer.Context,
55 ref: str | None = typer.Option(None, "--commit", "-c", metavar="REF",
56 help="Read from a historical commit instead of the working tree."),
57 target_blocks: int = typer.Option(6, "--target-blocks", "-t", metavar="N",
58 help="Confirmation target in blocks (1, 6, or 144)."),
59 pending_txid: list[str] = typer.Option([], "--pending", "-p", metavar="TXID",
60 help="Txid of a stuck pending transaction (triggers RBF recommendation). Repeatable."),
61 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
62 ) -> None:
63 """Analyse the fee market and recommend whether to send, wait, or RBF.
64
65 Reads the versioned oracle fee history and places the current fee rate in
66 its historical context. If the current rate is in the bottom quartile of
67 historical rates, it recommends sending now. If it is elevated, it
68 recommends waiting and estimates how long. Pass ``--pending`` for any
69 stuck txid to trigger an RBF recommendation.
70
71 Agents use this before every transaction to decide timing. The entire fee
72 history is version-controlled: you can replay the agent's fee decision
73 at any commit to audit whether it was optimal.
74 """
75 root = require_repo()
76 commit_label = "working tree"
77
78 if ref is not None:
79 repo_id = read_repo_id(root)
80 branch = read_current_branch(root)
81 commit = resolve_commit_ref(root, repo_id, branch, ref)
82 if commit is None:
83 typer.echo(f"❌ Commit '{ref}' not found.", err=True)
84 raise typer.Exit(code=ExitCode.USER_ERROR)
85 fees_list = load_fees(root, commit.commit_id)
86 mempool_lst = load_mempool(root, commit.commit_id)
87 commit_label = commit.commit_id[:8]
88 else:
89 fees_list = load_fees_from_workdir(root)
90 mempool_lst = load_mempool_from_workdir(root)
91
92 # Merge explicit pending txids with mempool RBF-eligible entries
93 mempool_rbf = [t["txid"] for t in mempool_lst if t["rbf_eligible"]]
94 all_pending = list(pending_txid) + [t for t in mempool_rbf if t not in pending_txid]
95
96 recommendation = fee_window(fees_list, target_blocks=target_blocks, pending_txids=all_pending or None)
97 latest = latest_fee_estimate(fees_list)
98
99 if as_json:
100 typer.echo(json.dumps({
101 "commit": commit_label,
102 "target_blocks": target_blocks,
103 **{k: v for k, v in recommendation.items()},
104 }, indent=2))
105 return
106
107 rec = recommendation["recommendation"]
108 rec_icon = {"send_now": "✅", "wait": "⏳", "rbf_now": "🔴", "cpfp_eligible": "🟡"}.get(rec, "")
109 pct_int = int(recommendation["percentile"] * 100)
110
111 typer.echo(f"\nFee window — {commit_label} (target: {target_blocks} block{'s' if target_blocks != 1 else ''})\n")
112 typer.echo(f" Current: {recommendation['current_sat_vbyte']} sat/vbyte")
113 typer.echo(f" History: min {recommendation['historical_min_sat_vbyte']} "
114 f"· median {recommendation['historical_median_sat_vbyte']} "
115 f"· max {recommendation['historical_max_sat_vbyte']} sat/vbyte")
116 typer.echo(f" Percentile: {pct_int}th")
117 typer.echo(f"\n Recommendation: {rec_icon} {rec}")
118 typer.echo(f' "{recommendation["reason"]}"\n')
119 if latest:
120 typer.echo(f" Fee surface (latest): {fee_surface_str(latest)}")
121 if recommendation.get("optimal_wait_blocks"):
122 typer.echo(f" Estimated wait: {recommendation['optimal_wait_blocks']} blocks")