btc_balance.py
python
| 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") |