gabriel / muse public
btc_balance.py python
179 lines 6.1 KB
15cf97e9 feat(bitcoin): add semantic porcelain layer — 19 Bitcoin-idiomatic CLI … Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 """muse bitcoin balance — on-chain wallet balance breakdown.
2
3 Shows confirmed, unconfirmed, and immature coinbase balances; script-type
4 and label-category breakdowns; and a USD value from the latest oracle price.
5
6 Usage::
7
8 muse bitcoin balance
9 muse bitcoin balance --commit HEAD~5
10 muse bitcoin balance --json
11
12 Output::
13
14 Bitcoin balance — working tree
15
16 Confirmed 0.12340000 BTC ($7,651.80)
17 Unconfirmed 0.00050000 BTC
18 Immature (cb) 0.00000000 BTC
19 Spendable 0.12340000 BTC ($7,651.80)
20 ─────────────────────────────────────────────
21 Total portfolio 0.12340000 BTC ($7,651.80)
22
23 By script type:
24 p2wpkh 0.10000000 BTC 81 %
25 p2tr 0.02340000 BTC 19 %
26
27 By category:
28 cold storage 0.10000000 BTC 81 %
29 trading 0.02340000 BTC 19 %
30
31 Oracle: $62,000.00 / BTC · 6 UTXOs · 0 dust
32 """
33
34 from __future__ import annotations
35
36 import json
37 import logging
38 import pathlib
39
40 import typer
41
42 from muse.core.errors import ExitCode
43 from muse.core.repo import require_repo
44 from muse.core.store import get_head_commit_id, resolve_commit_ref
45 from muse.plugins.bitcoin._loader import (
46 load_fees_from_workdir,
47 load_fees,
48 load_labels,
49 load_labels_from_workdir,
50 load_prices,
51 load_prices_from_workdir,
52 load_utxos,
53 load_utxos_from_workdir,
54 read_current_branch,
55 read_repo_id,
56 )
57 from muse.plugins.bitcoin._query import (
58 balance_by_category,
59 balance_by_script_type,
60 confirmed_balance_sat,
61 dust_threshold_sat,
62 format_sat,
63 is_dust,
64 latest_fee_estimate,
65 latest_price,
66 total_balance_sat,
67 )
68
69 logger = logging.getLogger(__name__)
70 app = typer.Typer()
71
72 _SATS_PER_BTC = 100_000_000
73
74
75 def _usd(sats: int, price: float | None) -> str:
76 if price is None:
77 return ""
78 return f" (${sats / _SATS_PER_BTC * price:,.2f})"
79
80
81 @app.callback(invoke_without_command=True)
82 def balance(
83 ctx: typer.Context,
84 ref: str | None = typer.Option(
85 None, "--commit", "-c", metavar="REF",
86 help="Read from a historical commit instead of the working tree.",
87 ),
88 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
89 ) -> None:
90 """Show the on-chain Bitcoin balance with full breakdown.
91
92 Displays confirmed, unconfirmed, and immature coinbase balances, broken
93 down by script type and address label category. USD value is computed
94 from the latest oracle price tick stored in the versioned state.
95
96 Unlike a block explorer, ``muse bitcoin balance`` can show you the wallet
97 state at *any* historical commit — letting agents and humans audit exactly
98 what was in the wallet when a strategy decision was made.
99 """
100 root = require_repo()
101 commit_label = "working tree"
102
103 if ref is not None:
104 repo_id = read_repo_id(root)
105 branch = read_current_branch(root)
106 commit = resolve_commit_ref(root, repo_id, branch, ref)
107 if commit is None:
108 typer.echo(f"❌ Commit '{ref}' not found.", err=True)
109 raise typer.Exit(code=ExitCode.USER_ERROR)
110 utxos = load_utxos(root, commit.commit_id)
111 labels = load_labels(root, commit.commit_id)
112 prices = load_prices(root, commit.commit_id)
113 fees = load_fees(root, commit.commit_id)
114 commit_label = commit.commit_id[:8]
115 else:
116 utxos = load_utxos_from_workdir(root)
117 labels = load_labels_from_workdir(root)
118 prices = load_prices_from_workdir(root)
119 fees = load_fees_from_workdir(root)
120
121 price = latest_price(prices)
122 fee_est = latest_fee_estimate(fees)
123 fee_rate = fee_est["target_6_block_sat_vbyte"] if fee_est else 10
124
125 total = total_balance_sat(utxos)
126 confirmed = confirmed_balance_sat(utxos)
127 unconfirmed = total - confirmed
128 immature = sum(
129 u["amount_sat"] for u in utxos
130 if u["coinbase"] and u["confirmations"] < 100
131 )
132 spendable = confirmed - immature
133 dust_count = sum(1 for u in utxos if is_dust(u, fee_rate))
134
135 script_breakdown = balance_by_script_type(utxos)
136 cat_breakdown = balance_by_category(utxos, labels)
137
138 if as_json:
139 typer.echo(json.dumps({
140 "commit": commit_label,
141 "total_sat": total,
142 "confirmed_sat": confirmed,
143 "unconfirmed_sat": unconfirmed,
144 "immature_coinbase_sat": immature,
145 "spendable_sat": spendable,
146 "price_usd": price,
147 "total_usd": (total / _SATS_PER_BTC * price) if price else None,
148 "spendable_usd": (spendable / _SATS_PER_BTC * price) if price else None,
149 "utxo_count": len(utxos),
150 "dust_count": dust_count,
151 "script_type_breakdown": script_breakdown,
152 "category_breakdown": cat_breakdown,
153 }, indent=2))
154 return
155
156 typer.echo(f"\nBitcoin balance — {commit_label}\n")
157 typer.echo(f" Confirmed {format_sat(confirmed):<22}{_usd(confirmed, price)}")
158 if unconfirmed:
159 typer.echo(f" Unconfirmed {format_sat(unconfirmed):<22}")
160 if immature:
161 typer.echo(f" Immature (cb) {format_sat(immature):<22}")
162 typer.echo(f" Spendable {format_sat(spendable):<22}{_usd(spendable, price)}")
163 typer.echo(" " + "─" * 45)
164 typer.echo(f" Total portfolio {format_sat(total):<22}{_usd(total, price)}")
165
166 if script_breakdown:
167 typer.echo("\n By script type:")
168 for stype, sats in sorted(script_breakdown.items(), key=lambda kv: kv[1], reverse=True):
169 pct = int(sats / total * 100) if total else 0
170 typer.echo(f" {stype:<10} {format_sat(sats):<20} {pct:>3} %")
171
172 if cat_breakdown:
173 typer.echo("\n By category:")
174 for cat, sats in sorted(cat_breakdown.items(), key=lambda kv: kv[1], reverse=True):
175 pct = int(sats / total * 100) if total else 0
176 typer.echo(f" {cat:<14} {format_sat(sats):<20} {pct:>3} %")
177
178 price_str = f"${price:,.2f} / BTC" if price else "no oracle data"
179 typer.echo(f"\n Oracle: {price_str} · {len(utxos)} UTXOs · {dust_count} dust")