From a65ca7631535fe6bdc2040320cb526a2dba2e770 Mon Sep 17 00:00:00 2001 From: Bortlesboat Date: Fri, 24 Apr 2026 10:18:37 -0400 Subject: [PATCH 1/3] feat: add fee forecast benchmark export --- README.md | 11 + docs/OPERATIONS.md | 15 ++ docs/SCOPE_OF_WORK.md | 16 +- scripts/export_fee_forecast_benchmark.py | 7 + src/bitcoin_api/benchmark_export_cli.py | 50 +++++ .../migrations/012_add_research_tables.sql | 35 +++ src/bitcoin_api/services/benchmark_export.py | 138 ++++++++++++ tests/test_fee_benchmark_export.py | 205 ++++++++++++++++++ 8 files changed, 473 insertions(+), 4 deletions(-) create mode 100644 scripts/export_fee_forecast_benchmark.py create mode 100644 src/bitcoin_api/benchmark_export_cli.py create mode 100644 src/bitcoin_api/migrations/012_add_research_tables.sql create mode 100644 src/bitcoin_api/services/benchmark_export.py create mode 100644 tests/test_fee_benchmark_export.py diff --git a/README.md b/README.md index e54ec2d..9965c3a 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,17 @@ cloudflared tunnel --url http://localhost:9332 See [self-hosting guide](docs/self-hosting.md) for full production setup. +## Research Export + +For offline fee-forecasting work, the repo now includes a local JSONL export path that emits the same merged row shape used by the companion benchmark importer. + +```powershell +$env:PYTHONPATH='src' +python scripts/export_fee_forecast_benchmark.py data/fee-forecast-benchmark.jsonl --hours 168 --interval-minutes 10 +``` + +The export joins local `fee_history` observations to the next `1-6` confirmed blocks from the research tables in `data/bitcoin_api.db`. Very recent observations without six future block outcomes are skipped automatically. + ## Contributing Issues and PRs welcome. Run the test suite before submitting: diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index b747527..ccea3a6 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -148,6 +148,21 @@ Requires the Fee Observatory to be collecting data (`bitcoin-fee-observatory` re **Dashboard:** `GET /fee-observatory` — branded page with iframe to Streamlit dashboard (port 8505). +### Export fee forecast benchmark rows + +Use the local benchmark export when you want importer-compatible JSONL for offline forecasting work or the companion `bitcoin-fee-forecast-bench` repo. + +```powershell +$env:PYTHONPATH='src' +python scripts/export_fee_forecast_benchmark.py data/fee-forecast-benchmark.jsonl --hours 168 --interval-minutes 10 +``` + +Notes: +- Reads `fee_history` observations from the main API DB and joins them to the next `1-6` `block_confirmations` +- Emits one JSONL row per usable observation with `observation_id`, `observed_at`, `features`, and `clearing_fee_bin_by_horizon` +- Skips observations that do not yet have six future confirmed blocks +- The research tables come from migration `012_add_research_tables.sql` + ### x402 Stablecoin Micropayments (optional) Enables pay-per-call via the x402 protocol (USDC on Base). Requires the `bitcoin-api-x402` package. diff --git a/docs/SCOPE_OF_WORK.md b/docs/SCOPE_OF_WORK.md index d557a48..4b02771 100644 --- a/docs/SCOPE_OF_WORK.md +++ b/docs/SCOPE_OF_WORK.md @@ -1,7 +1,7 @@ # Satoshi API -- Scope of Work **Version:** 0.3.4 -**Date:** 2026-03-08 +**Date:** 2026-04-24 **Author:** Bortlesboat **Status:** Live -- https://bitcoinsapi.com @@ -58,11 +58,11 @@ Bitcoin Core RPC (port 8332, localhost only) | `rate_limit.py` | Per-minute sliding window (in-memory or Upstash Redis) + daily limits | Token bucket / sliding window | | `notifications.py` | Transactional email (Resend) + analytics events (PostHog) | Fire-and-forget side effects | | `cache.py` | TTL caching with reorg-safe depth awareness, stale fallback for graceful degradation, `get_cached_node_info()` helper for non-RPC contexts | Cache-aside with lock-per-cache + stale-while-error | -| `db.py` | SQLite (WAL mode), usage logging, key storage | Repository pattern | +| `db.py` | SQLite (WAL mode), fee history, fee research tables, usage logging, key storage | Repository pattern | | `config.py` | 12-factor env var config via Pydantic | Settings singleton | | `dependencies.py` | Lazy singleton RPC connection | Dependency injection | | `models.py` | Response envelope, typed data models | DTO / envelope pattern | -| `services/` | Business logic: fee analysis, tx broadcast, exchange comparison, serializers | Service layer (pure functions) | +| `services/` | Business logic: fee analysis, benchmark export, tx broadcast, exchange comparison, serializers | Service layer (pure functions) | | `routers/` | 28 thin HTTP routers (25 core + 3 indexer) — parameter validation, auth, response envelope | RESTful resource routing | ### 2.3 Design Principles Applied @@ -439,6 +439,7 @@ Errors follow the same structure: | ~~No webhook support~~ | ~~Clients must poll~~ | **RESOLVED** -- WebSocket `/api/v1/ws` with pub/sub | | No address transaction history | Cannot provide `/address/{addr}/txs` | Deliberate -- Bitcoin Core RPC has no `getaddresshistory`. Requires external indexer (Electrs, Fulcrum). We offer `scantxoutset` via POST `/address/utxos` for UTXO lookup by address. Adding Electrs increases deployment complexity significantly. | | Email delivery depends on Resend | Welcome email fails silently if Resend is down | Graceful degradation -- registration succeeds regardless, key always returned in response | +| Fee benchmark export needs six future confirmed blocks per observation | Very recent fee-history rows are skipped until enough blocks confirm | Acceptable for offline research export; full `1-6` block outcomes matter more than max recency | --- @@ -525,6 +526,7 @@ Errors follow the same structure: - `tests/test_indexer_services.py` -- 12 tests (address balance/history, transaction detail) - `tests/test_price_service.py` -- 13 tests (price service provider fallback, caching, error handling) - `tests/test_observatory.py` -- 13 tests (Fee Observatory endpoints: scoreboard, block-stats, estimates, 503 fallback, static page) +- `tests/test_fee_benchmark_export.py` -- 2 tests (benchmark export row builder + CLI writer) - `tests/test_x402_stats.py` -- 6 tests (x402 payment analytics) - `tests/test_e2e.py` -- 21 e2e tests (against live node) - `tests/locustfile.py` -- Load test (8 weighted endpoints) @@ -548,8 +550,9 @@ Errors follow the same structure: **Project config (1 file):** - `CLAUDE.md` -- Project instructions for AI-assisted development -**Scripts (14 files):** +**Scripts (15 files):** - `scripts/create_api_key.py`, `scripts/seed_db.py` +- `scripts/export_fee_forecast_benchmark.py` (writes benchmark-ready JSONL from local fee research data) - `scripts/security_check.sh` (requires `SATOSHI_API_KEY` env var for POST tests) - `scripts/security_audit.py` (10 automated security checks) - `scripts/staging-check.sh` (pre-deploy validation: starts staging server, checks CSP/headers/docs/endpoints) @@ -564,6 +567,11 @@ Errors follow the same structure: - `scripts/smoke-test-api.sh` (5-point health check for cron monitoring; supports --quiet) - `scripts/doc_consistency.py` (CI-enforced doc consistency checks) +**Research export surfaces (3 files):** +- `src/bitcoin_api/services/benchmark_export.py` (joins fee history to future block outcomes for offline benchmark export) +- `src/bitcoin_api/benchmark_export_cli.py` (CLI entrypoint for benchmark-ready JSONL export) +- `src/bitcoin_api/migrations/012_add_research_tables.sql` (fee research tables for block confirmations and estimate logs) + **Legal (3 files):** - `static/terms.html` -- Terms of Service (FL governing law, liability limitation, acceptable use) - `static/privacy.html` -- Privacy Policy (data collection, retention, third-party services) diff --git a/scripts/export_fee_forecast_benchmark.py b/scripts/export_fee_forecast_benchmark.py new file mode 100644 index 0000000..0903b60 --- /dev/null +++ b/scripts/export_fee_forecast_benchmark.py @@ -0,0 +1,7 @@ +"""Local wrapper for exporting benchmark-ready fee forecast rows.""" + +from bitcoin_api.benchmark_export_cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/bitcoin_api/benchmark_export_cli.py b/src/bitcoin_api/benchmark_export_cli.py new file mode 100644 index 0000000..6788cbb --- /dev/null +++ b/src/bitcoin_api/benchmark_export_cli.py @@ -0,0 +1,50 @@ +"""CLI for exporting benchmark-ready fee forecast datasets.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from .services.benchmark_export import write_fee_forecast_benchmark_export + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Export fee research tables into benchmark-ready JSONL rows.", + ) + parser.add_argument("output_path", type=Path, help="Destination JSONL path") + parser.add_argument( + "--hours", + type=int, + default=168, + help="How many recent hours of fee history to inspect (default: 168)", + ) + parser.add_argument( + "--interval-minutes", + type=int, + default=10, + help="Fee history downsampling interval in minutes (default: 10)", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + help="Optional cap on exported examples (keeps the most recent rows)", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + write_fee_forecast_benchmark_export( + args.output_path, + hours=args.hours, + interval_minutes=args.interval_minutes, + limit=args.limit, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/bitcoin_api/migrations/012_add_research_tables.sql b/src/bitcoin_api/migrations/012_add_research_tables.sql new file mode 100644 index 0000000..5a01de8 --- /dev/null +++ b/src/bitcoin_api/migrations/012_add_research_tables.sql @@ -0,0 +1,35 @@ +-- Block confirmation stats with feerate percentiles for research +CREATE TABLE IF NOT EXISTS block_confirmations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + block_height INTEGER NOT NULL UNIQUE, + block_hash TEXT NOT NULL, + block_time TEXT NOT NULL, + captured_at TEXT NOT NULL DEFAULT (datetime('now')), + tx_count INTEGER NOT NULL, + total_fees_sat INTEGER NOT NULL, + min_feerate REAL NOT NULL, + max_feerate REAL NOT NULL, + p10_feerate REAL NOT NULL, + p25_feerate REAL NOT NULL, + p50_feerate REAL NOT NULL, + p75_feerate REAL NOT NULL, + p90_feerate REAL NOT NULL, + core_est_1 REAL, + core_est_6 REAL, + core_est_144 REAL, + mempool_local_est REAL, + mempool_space_est REAL +); +CREATE INDEX IF NOT EXISTS idx_bc_height ON block_confirmations(block_height); +CREATE INDEX IF NOT EXISTS idx_bc_time ON block_confirmations(block_time); + +-- Multi-source fee estimate log +CREATE TABLE IF NOT EXISTS fee_estimates_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL DEFAULT (datetime('now')), + source TEXT NOT NULL, + target INTEGER NOT NULL, + feerate REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_fel_ts ON fee_estimates_log(ts); +CREATE INDEX IF NOT EXISTS idx_fel_source_target ON fee_estimates_log(source, target); diff --git a/src/bitcoin_api/services/benchmark_export.py b/src/bitcoin_api/services/benchmark_export.py new file mode 100644 index 0000000..c1eef7f --- /dev/null +++ b/src/bitcoin_api/services/benchmark_export.py @@ -0,0 +1,138 @@ +"""Export fee research data into benchmark-ready JSONL rows.""" + +from __future__ import annotations + +import json +from bisect import bisect_right +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +from ..db import get_db, get_fee_history + +BENCHMARK_FEE_RATE_BINS: tuple[int, ...] = (1, 2, 3, 5, 8, 13, 21, 34, 55) +BENCHMARK_FORECAST_HORIZONS: tuple[int, ...] = (1, 2, 3, 4, 5, 6) + + +@dataclass(frozen=True) +class BenchmarkBlockOutcome: + block_time: str + min_feerate: float + p50_feerate: float + + +def build_fee_forecast_benchmark_rows( + *, + hours: int = 168, + interval_minutes: int = 10, + limit: int | None = None, +) -> list[dict[str, Any]]: + """Build benchmark-importer-compatible rows from fee research tables.""" + observations = get_fee_history(hours=hours, interval_minutes=interval_minutes) + if not observations: + return [] + + outcomes = _load_block_outcomes() + if not outcomes: + return [] + + outcome_times = [_parse_sql_timestamp(row.block_time) for row in outcomes] + + rows: list[dict[str, Any]] = [] + for observation in observations: + observation_time = _parse_sql_timestamp(observation["ts"]) + next_block_index = bisect_right(outcome_times, observation_time) + future_blocks = outcomes[next_block_index: next_block_index + len(BENCHMARK_FORECAST_HORIZONS)] + if len(future_blocks) < len(BENCHMARK_FORECAST_HORIZONS): + continue + + prior_block = outcomes[next_block_index - 1] if next_block_index > 0 else None + recent_block_median = ( + float(prior_block.p50_feerate) + if prior_block is not None + else float(observation.get("median_fee") or 0.0) + ) + + rows.append( + { + "observation_id": _build_observation_id(observation_time), + "observed_at": _to_utc_z(observation_time), + "features": { + "next_block_fee": float(observation["next_block_fee"]), + "median_fee": float(observation["median_fee"]), + "low_fee": float(observation["low_fee"]), + "pending_tx_count": int(observation["mempool_size"]), + "mempool_vbytes": int(observation["mempool_vsize"]), + "congestion": observation["congestion"], + "recent_block_median_sat_vb": round(recent_block_median, 3), + }, + "clearing_fee_bin_by_horizon": { + horizon: _fee_rate_to_bin_index(float(block.min_feerate)) + for horizon, block in zip(BENCHMARK_FORECAST_HORIZONS, future_blocks, strict=True) + }, + } + ) + + if limit is not None and limit >= 0: + rows = rows[-limit:] if limit else [] + + return rows + + +def write_fee_forecast_benchmark_export( + output_path: Path, + *, + hours: int = 168, + interval_minutes: int = 10, + limit: int | None = None, +) -> int: + """Write benchmark export rows as JSONL and return the row count.""" + rows = build_fee_forecast_benchmark_rows( + hours=hours, + interval_minutes=interval_minutes, + limit=limit, + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + serialized = "\n".join(json.dumps(row, sort_keys=True) for row in rows) + output_path.write_text(f"{serialized}\n" if serialized else "", encoding="utf-8") + return len(rows) + + +def _load_block_outcomes() -> list[BenchmarkBlockOutcome]: + conn = get_db() + rows = conn.execute( + "SELECT block_time, min_feerate, p50_feerate " + "FROM block_confirmations ORDER BY block_time ASC" + ).fetchall() + return [ + BenchmarkBlockOutcome( + block_time=row["block_time"], + min_feerate=float(row["min_feerate"]), + p50_feerate=float(row["p50_feerate"]), + ) + for row in rows + ] + + +def _parse_sql_timestamp(value: str) -> datetime: + return datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + + +def _to_utc_z(value: datetime) -> str: + return value.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _build_observation_id(value: datetime) -> str: + return f"fee-history-{value.strftime('%Y%m%dT%H%M%SZ')}" + + +def _fee_rate_to_bin_index(fee_rate: float) -> int: + if fee_rate <= BENCHMARK_FEE_RATE_BINS[0]: + return 0 + + for index, upper_edge in enumerate(BENCHMARK_FEE_RATE_BINS[1:], start=0): + if fee_rate < upper_edge: + return index + + return len(BENCHMARK_FEE_RATE_BINS) - 2 diff --git a/tests/test_fee_benchmark_export.py b/tests/test_fee_benchmark_export.py new file mode 100644 index 0000000..df921da --- /dev/null +++ b/tests/test_fee_benchmark_export.py @@ -0,0 +1,205 @@ +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +def _sql_time(minutes_offset: int) -> str: + return (datetime.now(timezone.utc) + timedelta(minutes=minutes_offset)).strftime("%Y-%m-%d %H:%M:%S") + + +def _seed_fee_history_row( + conn, + *, + ts: str, + next_block_fee: float, + median_fee: float, + low_fee: float, + mempool_size: int, + mempool_vsize: int, + congestion: str, +) -> None: + conn.execute( + "INSERT INTO fee_history (ts, next_block_fee, median_fee, low_fee, mempool_size, mempool_vsize, congestion) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (ts, next_block_fee, median_fee, low_fee, mempool_size, mempool_vsize, congestion), + ) + + +def _seed_block_confirmation( + conn, + *, + block_height: int, + block_time: str, + min_feerate: float, + p50_feerate: float, +) -> None: + conn.execute( + "INSERT OR REPLACE INTO block_confirmations " + "(block_height, block_hash, block_time, tx_count, total_fees_sat, " + "min_feerate, max_feerate, p10_feerate, p25_feerate, p50_feerate, " + "p75_feerate, p90_feerate, core_est_1, core_est_6, core_est_144) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + block_height, + f"hash-{block_height}", + block_time, + 2500, + 10_000_000, + min_feerate, + max(min_feerate + 20.0, p50_feerate), + max(min_feerate, 1.0), + max(min_feerate + 1.0, 2.0), + p50_feerate, + p50_feerate + 10.0, + p50_feerate + 20.0, + None, + None, + None, + ), + ) + + +def test_build_fee_forecast_benchmark_rows_returns_importer_shape(): + from bitcoin_api.db import get_db + from bitcoin_api.services.benchmark_export import build_fee_forecast_benchmark_rows + + conn = get_db() + observation_time = _sql_time(-60) + skipped_time = _sql_time(-10) + + _seed_fee_history_row( + conn, + ts=observation_time, + next_block_fee=18.0, + median_fee=11.0, + low_fee=3.0, + mempool_size=22000, + mempool_vsize=1_500_000, + congestion="high", + ) + _seed_fee_history_row( + conn, + ts=skipped_time, + next_block_fee=7.0, + median_fee=5.0, + low_fee=2.0, + mempool_size=8000, + mempool_vsize=700_000, + congestion="normal", + ) + + _seed_block_confirmation( + conn, + block_height=880000, + block_time=_sql_time(-70), + min_feerate=2.0, + p50_feerate=12.0, + ) + + future_blocks = [ + (-55, 1.0, 10.0), + (-45, 2.0, 11.0), + (-35, 4.0, 12.0), + (-25, 6.0, 13.0), + (-15, 15.0, 14.0), + (-5, 40.0, 16.0), + ] + for index, (minutes_offset, min_feerate, p50_feerate) in enumerate(future_blocks, start=1): + _seed_block_confirmation( + conn, + block_height=880000 + index, + block_time=_sql_time(minutes_offset), + min_feerate=min_feerate, + p50_feerate=p50_feerate, + ) + conn.commit() + + rows = build_fee_forecast_benchmark_rows(hours=2, interval_minutes=1) + + assert len(rows) == 1 + row = rows[0] + assert row["observation_id"].startswith("fee-history-") + assert row["observed_at"].endswith("Z") + assert row["features"] == { + "next_block_fee": 18.0, + "median_fee": 11.0, + "low_fee": 3.0, + "pending_tx_count": 22000, + "mempool_vbytes": 1_500_000, + "congestion": "high", + "recent_block_median_sat_vb": 12.0, + } + assert row["clearing_fee_bin_by_horizon"] == { + 1: 0, + 2: 1, + 3: 2, + 4: 3, + 5: 5, + 6: 7, + } + + +def test_fee_forecast_benchmark_cli_writes_jsonl(tmp_path): + from bitcoin_api.benchmark_export_cli import main + from bitcoin_api.db import get_db + + conn = get_db() + observation_time = _sql_time(-60) + + _seed_fee_history_row( + conn, + ts=observation_time, + next_block_fee=12.0, + median_fee=8.0, + low_fee=2.0, + mempool_size=14000, + mempool_vsize=990_000, + congestion="normal", + ) + _seed_block_confirmation( + conn, + block_height=881000, + block_time=_sql_time(-70), + min_feerate=1.0, + p50_feerate=9.0, + ) + for index, min_feerate in enumerate((1.0, 2.0, 3.0, 5.0, 8.0, 13.0), start=1): + _seed_block_confirmation( + conn, + block_height=881000 + index, + block_time=_sql_time(-60 + (index * 8)), + min_feerate=min_feerate, + p50_feerate=min_feerate + 4.0, + ) + conn.commit() + + output_path = tmp_path / "historical-export.jsonl" + exit_code = main( + [ + str(output_path), + "--hours", + "2", + "--interval-minutes", + "1", + ] + ) + + assert exit_code == 0 + assert output_path.exists() + + rows = [json.loads(line) for line in output_path.read_text(encoding="utf-8").splitlines() if line.strip()] + assert len(rows) == 1 + assert set(rows[0]) == { + "observation_id", + "observed_at", + "features", + "clearing_fee_bin_by_horizon", + } + assert rows[0]["clearing_fee_bin_by_horizon"] == { + "1": 0, + "2": 1, + "3": 2, + "4": 3, + "5": 4, + "6": 5, + } From 896a8467f84f029a70b24e4719599d0127c007b6 Mon Sep 17 00:00:00 2001 From: Bortlesboat Date: Fri, 24 Apr 2026 11:58:21 -0400 Subject: [PATCH 2/3] fix: self-populate fee research tables --- README.md | 2 +- docs/OPERATIONS.md | 7 +- docs/SCOPE_OF_WORK.md | 13 +- src/bitcoin_api/db.py | 62 +++++++++ src/bitcoin_api/jobs.py | 274 ++++++++++++++++++++++++++++++---------- tests/conftest.py | 16 +++ tests/test_jobs.py | 115 +++++++++++++++++ 7 files changed, 415 insertions(+), 74 deletions(-) create mode 100644 tests/test_jobs.py diff --git a/README.md b/README.md index 9965c3a..7a998f7 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ $env:PYTHONPATH='src' python scripts/export_fee_forecast_benchmark.py data/fee-forecast-benchmark.jsonl --hours 168 --interval-minutes 10 ``` -The export joins local `fee_history` observations to the next `1-6` confirmed blocks from the research tables in `data/bitcoin_api.db`. Very recent observations without six future block outcomes are skipped automatically. +The export joins local `fee_history` observations to the next `1-6` confirmed blocks from the research tables in `data/bitcoin_api.db`. After migration `012_add_research_tables.sql`, the background fee collector also fills `block_confirmations` on each detected new block and logs fee estimates every cycle, so a normal local API run can build this export without extra manual seeding. Very recent observations without six future block outcomes are skipped automatically. ## Contributing diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index ccea3a6..7663ed6 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -162,6 +162,7 @@ Notes: - Emits one JSONL row per usable observation with `observation_id`, `observed_at`, `features`, and `clearing_fee_bin_by_horizon` - Skips observations that do not yet have six future confirmed blocks - The research tables come from migration `012_add_research_tables.sql` +- On a normal local API run, the background fee collector fills those research tables automatically as new blocks arrive ### x402 Stablecoin Micropayments (optional) @@ -403,11 +404,9 @@ Replace `YOUR_KEY` with the value from your `.env` `ADMIN_API_KEY`. The background fee collector thread automatically prunes old data once per 24 hours: - Usage logs older than 90 days are deleted -- Fee history older than 30 days is downsampled to hourly averages -- Fee history older than 365 days is deleted -- Research data (block_confirmations, fee_estimates_log) older than 365 days is deleted +- Fee history older than 30 days is deleted -The fee collector also logs multi-source fee estimates every 5 minutes (Core 8 targets, mempool.space 4 targets, local mempool 1 target) and captures block confirmation feerate percentiles on each new block. +The fee collector also logs Core fee estimates for targets `1`, `6`, and `144` every 5 minutes, adds mempool.space estimates for `1`, `3`, `6`, and `144` when that public API is reachable, and captures block confirmation feerate percentiles on each detected new block after the collector has seen a prior tip. Check API logs for `Auto-prune:` messages to confirm it's running. diff --git a/docs/SCOPE_OF_WORK.md b/docs/SCOPE_OF_WORK.md index 4b02771..26ba6a9 100644 --- a/docs/SCOPE_OF_WORK.md +++ b/docs/SCOPE_OF_WORK.md @@ -50,7 +50,7 @@ Bitcoin Core RPC (port 8332, localhost only) | `main.py` | App creation, lifespan, router registration (~177 lines) | Composition root | | `middleware.py` | Security headers, CORS, auth + rate limiting middleware, gzip compression | Middleware chain | | `exceptions.py` | RPC, validation, HTTP, and generic exception handlers; RFC 7807 `type` URIs | Exception handler registry | -| `jobs.py` | Background fee collector thread lifecycle | Background worker | +| `jobs.py` | Background fee collector thread lifecycle, fee estimate logging, and block confirmation capture for research tables | Background worker | | `static_routes.py` | Landing page, robots.txt, sitemap, decision pages | Static file serving | | `usage_buffer.py` | Batch usage logging (flush at 50 rows or 30s) | Write-behind buffer | | `migrations/` | SQL migration files + runner, tracked in `schema_migrations` | Sequential migrations | @@ -58,7 +58,7 @@ Bitcoin Core RPC (port 8332, localhost only) | `rate_limit.py` | Per-minute sliding window (in-memory or Upstash Redis) + daily limits | Token bucket / sliding window | | `notifications.py` | Transactional email (Resend) + analytics events (PostHog) | Fire-and-forget side effects | | `cache.py` | TTL caching with reorg-safe depth awareness, stale fallback for graceful degradation, `get_cached_node_info()` helper for non-RPC contexts | Cache-aside with lock-per-cache + stale-while-error | -| `db.py` | SQLite (WAL mode), fee history, fee research tables, usage logging, key storage | Repository pattern | +| `db.py` | SQLite (WAL mode), fee history, self-populating fee research tables, usage logging, key storage | Repository pattern | | `config.py` | 12-factor env var config via Pydantic | Settings singleton | | `dependencies.py` | Lazy singleton RPC connection | Dependency injection | | `models.py` | Response envelope, typed data models | DTO / envelope pattern | @@ -427,6 +427,9 @@ Errors follow the same structure: 39. **Pro checkout dead end** -- "Upgrade to Pro" button returned 503; changed to "Contact for Pro" mailto link 40. **Watchdog stale code** -- `API_DIR` resolved relative to script location (broke when Task Scheduler ran old release copy); now uses `releases/bitcoin-api-current` symlink +**Benchmark Export Self-Sufficiency (Apr 24):** +41. **Clean exporter branch could not bootstrap its own research data** -- The background fee collector now writes `block_confirmations` on detected new blocks and logs fee estimates into `fee_estimates_log`, so fresh installs can produce real benchmark export rows after migration `012_add_research_tables.sql`. + ### 5.3 Known Limitations (Acceptable for v0.1) | Limitation | Impact | When to Address | @@ -500,7 +503,7 @@ Errors follow the same structure: - `src/bitcoin_api/indexer/routers/` -- indexed_address, indexed_tx, indexer_status - `src/bitcoin_api/indexer/migrations/` -- 001_initial_schema.sql -**Tests (23 test files + 2 support files):** +**Tests (current repo test files + support files):** - `tests/test_health.py` -- 11 tests (health, root, status, healthz, docs, visualizer) - `tests/test_blocks.py` -- 18 tests (block-related endpoints) - `tests/test_fees.py` -- 45 tests (fee endpoints + fee research infrastructure) @@ -527,6 +530,7 @@ Errors follow the same structure: - `tests/test_price_service.py` -- 13 tests (price service provider fallback, caching, error handling) - `tests/test_observatory.py` -- 13 tests (Fee Observatory endpoints: scoreboard, block-stats, estimates, 503 fallback, static page) - `tests/test_fee_benchmark_export.py` -- 2 tests (benchmark export row builder + CLI writer) +- `tests/test_jobs.py` -- 2 tests (single-iteration fee collector coverage for research table population) - `tests/test_x402_stats.py` -- 6 tests (x402 payment analytics) - `tests/test_e2e.py` -- 21 e2e tests (against live node) - `tests/locustfile.py` -- Load test (8 weighted endpoints) @@ -567,10 +571,11 @@ Errors follow the same structure: - `scripts/smoke-test-api.sh` (5-point health check for cron monitoring; supports --quiet) - `scripts/doc_consistency.py` (CI-enforced doc consistency checks) -**Research export surfaces (3 files):** +**Research export surfaces (5 files):** - `src/bitcoin_api/services/benchmark_export.py` (joins fee history to future block outcomes for offline benchmark export) - `src/bitcoin_api/benchmark_export_cli.py` (CLI entrypoint for benchmark-ready JSONL export) - `src/bitcoin_api/migrations/012_add_research_tables.sql` (fee research tables for block confirmations and estimate logs) +- `src/bitcoin_api/jobs.py` + `src/bitcoin_api/db.py` (background collector now populates those research tables during normal API operation) **Legal (3 files):** - `static/terms.html` -- Terms of Service (FL governing law, liability limitation, acceptable use) diff --git a/src/bitcoin_api/db.py b/src/bitcoin_api/db.py index 1d1218f..2140640 100644 --- a/src/bitcoin_api/db.py +++ b/src/bitcoin_api/db.py @@ -110,6 +110,68 @@ def record_fee_snapshot( conn.commit() +def record_block_confirmation( + block_height: int, + block_hash: str, + block_time: str, + tx_count: int, + total_fees_sat: int, + min_feerate: float, + max_feerate: float, + p10_feerate: float, + p25_feerate: float, + p50_feerate: float, + p75_feerate: float, + p90_feerate: float, + core_est_1: float | None = None, + core_est_6: float | None = None, + core_est_144: float | None = None, + mempool_local_est: float | None = None, + mempool_space_est: float | None = None, +) -> None: + conn = get_db() + conn.execute( + "INSERT OR REPLACE INTO block_confirmations " + "(block_height, block_hash, block_time, tx_count, total_fees_sat, " + "min_feerate, max_feerate, p10_feerate, p25_feerate, p50_feerate, " + "p75_feerate, p90_feerate, core_est_1, core_est_6, core_est_144, " + "mempool_local_est, mempool_space_est) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + block_height, + block_hash, + block_time, + tx_count, + total_fees_sat, + min_feerate, + max_feerate, + p10_feerate, + p25_feerate, + p50_feerate, + p75_feerate, + p90_feerate, + core_est_1, + core_est_6, + core_est_144, + mempool_local_est, + mempool_space_est, + ), + ) + conn.commit() + + +def record_fee_estimates_batch(entries: list[tuple[str, int, float]]) -> None: + if not entries: + return + + conn = get_db() + conn.executemany( + "INSERT INTO fee_estimates_log (source, target, feerate) VALUES (?, ?, ?)", + entries, + ) + conn.commit() + + def get_fee_history(hours: int = 24, interval_minutes: int = 10) -> list[dict]: conn = get_db() rows = conn.execute( diff --git a/src/bitcoin_api/jobs.py b/src/bitcoin_api/jobs.py index b013557..d926f19 100644 --- a/src/bitcoin_api/jobs.py +++ b/src/bitcoin_api/jobs.py @@ -1,11 +1,20 @@ """Background jobs: fee collector thread + fast market data ticker with health monitoring.""" +import json import logging import threading import time - -from .db import record_fee_snapshot, prune_fee_history, prune_old_logs -from .cache import record_mempool_snapshot, feerate_to_sat_vb, set_market_data +import urllib.request +from datetime import datetime, timezone + +from .cache import feerate_to_sat_vb, record_mempool_snapshot, set_market_data +from .db import ( + prune_fee_history, + prune_old_logs, + record_block_confirmation, + record_fee_estimates_batch, + record_fee_snapshot, +) from .metrics import BLOCK_HEIGHT, JOB_ERRORS from .pubsub import hub @@ -15,7 +24,7 @@ _bg_thread: threading.Thread | None = None _ticker_thread: threading.Thread | None = None -# Health monitoring — exposed to /health/deep +# Health monitoring - exposed to /health/deep _last_run_time: float | None = None _last_success_time: float | None = None _run_count: int = 0 @@ -51,6 +60,177 @@ def get_job_health(tier: str = "free") -> dict: _last_prune: float = 0.0 +def _fetch_mempool_space_fees() -> dict | None: + """Fetch public mempool.space fee recommendations for observability.""" + try: + request = urllib.request.Request( + "https://mempool.space/api/v1/fees/recommended", + headers={"User-Agent": "SatoshiAPI/1.0", "Connection": "close"}, + ) + with urllib.request.urlopen(request, timeout=5) as response: + return json.loads(response.read()) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + log.debug("mempool.space fee fetch failed: %s", exc) + return None + + +def _capture_block_confirmation( + rpc, + height: int, + *, + core_est_1: float | None = None, + core_est_6: float | None = None, + core_est_144: float | None = None, + mempool_space_est: float | None = None, +) -> None: + """Persist confirmation stats for a mined block.""" + block_hash = rpc.call("getblockhash", height) + stats = rpc.call("getblockstats", height) + percentiles = stats.get("feerate_percentiles", [0, 0, 0, 0, 0]) + block_time = datetime.fromtimestamp( + stats.get("time", stats.get("mediantime", 0)), + tz=timezone.utc, + ).strftime("%Y-%m-%d %H:%M:%S") + + record_block_confirmation( + block_height=height, + block_hash=block_hash, + block_time=block_time, + tx_count=stats.get("txs", 0), + total_fees_sat=stats.get("totalfee", stats.get("total_fee", 0)), + min_feerate=stats.get("minfeerate", 0), + max_feerate=stats.get("maxfeerate", 0), + p10_feerate=percentiles[0] if len(percentiles) > 0 else 0, + p25_feerate=percentiles[1] if len(percentiles) > 1 else 0, + p50_feerate=percentiles[2] if len(percentiles) > 2 else 0, + p75_feerate=percentiles[3] if len(percentiles) > 3 else 0, + p90_feerate=percentiles[4] if len(percentiles) > 4 else 0, + core_est_1=core_est_1, + core_est_6=core_est_6, + core_est_144=core_est_144, + mempool_space_est=mempool_space_est, + ) + + +def _build_fee_estimate_entries( + next_block_fee: float, + median_fee: float, + low_fee: float, + *, + mempool_space_fees: dict | None = None, +) -> list[tuple[str, int, float]]: + entries: list[tuple[str, int, float]] = [ + ("core", 1, round(next_block_fee, 2)), + ("core", 6, round(median_fee, 2)), + ("core", 144, round(low_fee, 2)), + ] + + if mempool_space_fees: + mapping = { + 1: "fastestFee", + 3: "halfHourFee", + 6: "hourFee", + 144: "economyFee", + } + for target, key in mapping.items(): + value = mempool_space_fees.get(key) + if value is not None: + entries.append(("mempool_space", target, float(value))) + + return entries + + +def _run_fee_collector_iteration(rpc, *, previous_block_height: int | None = None) -> int: + """Run one fee collector pass and return the current tip height.""" + info = rpc.call("getmempoolinfo") + next_block_fee = feerate_to_sat_vb(rpc.call("estimatesmartfee", 1)) + median_fee = feerate_to_sat_vb(rpc.call("estimatesmartfee", 6)) + low_fee = feerate_to_sat_vb(rpc.call("estimatesmartfee", 144)) + mempool_size = info.get("size", 0) + mempool_vsize = info.get("bytes", 0) + + record_mempool_snapshot( + rpc, + mempool_info=info, + next_block_fee=next_block_fee, + low_fee=low_fee, + ) + + if mempool_vsize < 1_000_000: + congestion = "low" + elif mempool_vsize < 10_000_000: + congestion = "normal" + elif mempool_vsize < 50_000_000: + congestion = "elevated" + else: + congestion = "high" + + record_fee_snapshot( + next_block_fee=round(next_block_fee, 2), + median_fee=round(median_fee, 2), + low_fee=round(low_fee, 2), + mempool_size=mempool_size, + mempool_vsize=mempool_vsize, + congestion=congestion, + ) + + block_count = rpc.call("getblockcount") + BLOCK_HEIGHT.set(block_count) + + hub.publish( + "new_fees", + { + "next_block_fee": round(next_block_fee, 2), + "median_fee": round(median_fee, 2), + "low_fee": round(low_fee, 2), + "congestion": congestion, + "timestamp": int(time.time()), + }, + ) + hub.publish( + "mempool_update", + { + "size": mempool_size, + "vsize": mempool_vsize, + "congestion": congestion, + "timestamp": int(time.time()), + }, + ) + + mempool_space_fees = _fetch_mempool_space_fees() + record_fee_estimates_batch( + _build_fee_estimate_entries( + next_block_fee, + median_fee, + low_fee, + mempool_space_fees=mempool_space_fees, + ) + ) + + if previous_block_height is not None and previous_block_height != block_count: + hub.publish( + "new_block", + { + "height": block_count, + "timestamp": int(time.time()), + }, + ) + _capture_block_confirmation( + rpc, + block_count, + core_est_1=round(next_block_fee, 2), + core_est_6=round(median_fee, 2), + core_est_144=round(low_fee, 2), + mempool_space_est=( + float(mempool_space_fees["fastestFee"]) + if mempool_space_fees and mempool_space_fees.get("fastestFee") is not None + else None + ), + ) + + return block_count + + def _fee_collector(): """Background thread: snapshot mempool every 5 min for trend analysis + fee history. @@ -68,61 +248,12 @@ def _fee_collector(): _run_count += 1 try: rpc = _get_rpc_dep() - - # Fetch RPC data once, share with snapshot recorder - info = rpc.call("getmempoolinfo") - next_block_fee = feerate_to_sat_vb(rpc.call("estimatesmartfee", 1)) - median_fee = feerate_to_sat_vb(rpc.call("estimatesmartfee", 6)) - low_fee = feerate_to_sat_vb(rpc.call("estimatesmartfee", 144)) - mempool_size = info.get("size", 0) - mempool_vsize = info.get("bytes", 0) - - # Reuse fetched data — avoids duplicate RPC calls - record_mempool_snapshot(rpc, mempool_info=info, - next_block_fee=next_block_fee, low_fee=low_fee) - - if mempool_vsize < 1_000_000: - congestion = "low" - elif mempool_vsize < 10_000_000: - congestion = "normal" - elif mempool_vsize < 50_000_000: - congestion = "elevated" - else: - congestion = "high" - - record_fee_snapshot( - next_block_fee=round(next_block_fee, 2), - median_fee=round(median_fee, 2), - low_fee=round(low_fee, 2), - mempool_size=mempool_size, - mempool_vsize=mempool_vsize, - congestion=congestion, + previous_block_height = getattr(_fee_collector, "_last_block", None) + block_count = _run_fee_collector_iteration( + rpc, + previous_block_height=previous_block_height, ) _last_success_time = time.time() - - # Update Prometheus gauge - block_count = rpc.call("getblockcount") - BLOCK_HEIGHT.set(block_count) - - # Publish events to WebSocket subscribers - hub.publish("new_fees", { - "next_block_fee": round(next_block_fee, 2), - "median_fee": round(median_fee, 2), - "low_fee": round(low_fee, 2), - "congestion": congestion, - "timestamp": int(time.time()), - }) - hub.publish("mempool_update", { - "size": mempool_size, - "vsize": mempool_vsize, - "congestion": congestion, - "timestamp": int(time.time()), - }) - if hasattr(_fee_collector, "_last_block") and _fee_collector._last_block != block_count: - hub.publish("new_block", { - "height": block_count, - "timestamp": int(time.time()), - }) _fee_collector._last_block = block_count # Auto-prune old data once per 24h @@ -131,8 +262,11 @@ def _fee_collector(): pruned_logs = prune_old_logs(90) pruned_fees = prune_fee_history(30) _last_prune = time.time() - log.info("Auto-prune: removed %d usage logs (>90d) and %d fee rows (>30d)", - pruned_logs, pruned_fees) + log.info( + "Auto-prune: removed %d usage logs (>90d) and %d fee rows (>30d)", + pruned_logs, + pruned_fees, + ) except Exception as prune_exc: log.warning("Auto-prune failed: %s", prune_exc) @@ -142,8 +276,12 @@ def _fee_collector(): _error_count += 1 _last_error = str(exc) JOB_ERRORS.inc() - log.warning("Background fee collector failed (attempt %d, errors %d): %s", - _run_count, _error_count, exc) + log.warning( + "Background fee collector failed (attempt %d, errors %d): %s", + _run_count, + _error_count, + exc, + ) _bg_stop.wait(300) @@ -154,8 +292,13 @@ def _fee_collector(): _error_count += 1 _last_error = f"FATAL restart #{_restart_count}: {fatal}" JOB_ERRORS.inc() - log.error("Fee collector crashed (restart #%d, backoff %ds): %s", - _restart_count, backoff, fatal, exc_info=True) + log.error( + "Fee collector crashed (restart #%d, backoff %ds): %s", + _restart_count, + backoff, + fatal, + exc_info=True, + ) _bg_stop.wait(backoff) backoff = min(backoff * 2, 300) @@ -179,15 +322,16 @@ def _market_data_ticker(): # Mempool info (fast RPC, ~5ms) mempool = rpc.call("getmempoolinfo") - # Fee estimates — reuse cached values when available (30s TTL) + # Fee estimates - reuse cached values when available (30s TTL) from .cache import cached_fee_estimates + try: estimates = cached_fee_estimates(rpc) fee_dict = {e.conf_target: e.fee_rate_sat_vb for e in estimates} except Exception: fee_dict = {} - # Price (60s cache, external API — never blocks on network here) + # Price (60s cache, external API - never blocks on network here) price_data = {} try: price_data = _get_cached_price() diff --git a/tests/conftest.py b/tests/conftest.py index 8040df8..a2bf717 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -315,6 +315,22 @@ def use_temp_db(tmp_path): db._local = __import__("threading").local() +@pytest.fixture(autouse=True) +def reset_background_jobs(use_temp_db): + """Stop background threads cleanly between tests so temp DBs stay isolated.""" + from bitcoin_api.jobs import _fee_collector, stop_background_jobs + + stop_background_jobs() + if hasattr(_fee_collector, "_last_block"): + delattr(_fee_collector, "_last_block") + + yield + + stop_background_jobs() + if hasattr(_fee_collector, "_last_block"): + delattr(_fee_collector, "_last_block") + + @pytest.fixture def client(mock_rpc): app.dependency_overrides[get_rpc] = lambda: mock_rpc diff --git a/tests/test_jobs.py b/tests/test_jobs.py new file mode 100644 index 0000000..57df59f --- /dev/null +++ b/tests/test_jobs.py @@ -0,0 +1,115 @@ +from unittest.mock import MagicMock, patch + + +def _make_fee_collector_rpc() -> MagicMock: + rpc = MagicMock() + + def _call(method, *args): + if method == "getmempoolinfo": + return { + "size": 18_500, + "bytes": 12_400_000, + "total_fee": 1.75, + } + if method == "estimatesmartfee": + mapping = { + 1: {"feerate": 0.00021, "blocks": 1}, + 6: {"feerate": 0.00011, "blocks": 6}, + 144: {"feerate": 0.00003, "blocks": 144}, + } + target = args[0] + if target not in mapping: + raise RuntimeError(f"unsupported target {target}") + return mapping[target] + if method == "getblockcount": + return 880_001 + if method == "getblockhash": + return "000000000000000000001234abcd" + if method == "getblockstats": + return { + "time": 1_709_654_400, + "txs": 2_450, + "totalfee": 12_345_678, + "minfeerate": 2.0, + "maxfeerate": 44.0, + "feerate_percentiles": [2.0, 4.0, 9.0, 18.0, 33.0], + } + raise AssertionError(f"Unexpected RPC method {method}") + + rpc.call.side_effect = _call + return rpc + + +def test_run_fee_collector_iteration_records_block_confirmation_and_estimates(): + from bitcoin_api.db import get_db + from bitcoin_api.jobs import _run_fee_collector_iteration + + rpc = _make_fee_collector_rpc() + + with patch("bitcoin_api.jobs._fetch_mempool_space_fees", return_value={ + "fastestFee": 26, + "halfHourFee": 14, + "hourFee": 9, + "economyFee": 2, + }), patch("bitcoin_api.jobs.hub.publish"), patch("bitcoin_api.jobs.BLOCK_HEIGHT.set"): + block_height = _run_fee_collector_iteration(rpc, previous_block_height=880_000) + + assert block_height == 880_001 + + conn = get_db() + block_row = conn.execute( + "SELECT block_height, block_hash, tx_count, min_feerate, p50_feerate, core_est_1, core_est_6, " + "core_est_144, mempool_space_est FROM block_confirmations" + ).fetchone() + assert block_row is not None + assert dict(block_row) == { + "block_height": 880_001, + "block_hash": "000000000000000000001234abcd", + "tx_count": 2_450, + "min_feerate": 2.0, + "p50_feerate": 9.0, + "core_est_1": 21.0, + "core_est_6": 11.0, + "core_est_144": 3.0, + "mempool_space_est": 26.0, + } + + estimate_rows = conn.execute( + "SELECT source, target, feerate FROM fee_estimates_log ORDER BY source, target" + ).fetchall() + assert [tuple(row) for row in estimate_rows] == [ + ("core", 1, 21.0), + ("core", 6, 11.0), + ("core", 144, 3.0), + ("mempool_space", 1, 26.0), + ("mempool_space", 3, 14.0), + ("mempool_space", 6, 9.0), + ("mempool_space", 144, 2.0), + ] + + +def test_run_fee_collector_iteration_skips_block_confirmation_without_new_block(): + from bitcoin_api.db import get_db + from bitcoin_api.jobs import _run_fee_collector_iteration + + rpc = _make_fee_collector_rpc() + + with patch("bitcoin_api.jobs._fetch_mempool_space_fees", return_value=None), patch( + "bitcoin_api.jobs.hub.publish" + ), patch("bitcoin_api.jobs.BLOCK_HEIGHT.set"): + block_height = _run_fee_collector_iteration(rpc, previous_block_height=880_001) + + assert block_height == 880_001 + + conn = get_db() + confirmation_count = conn.execute("SELECT COUNT(*) FROM block_confirmations").fetchone()[0] + estimate_rows = conn.execute( + "SELECT source, target, feerate FROM fee_estimates_log ORDER BY source, target" + ).fetchall() + + assert confirmation_count == 0 + assert [tuple(row) for row in estimate_rows] == [ + ("core", 1, 21.0), + ("core", 6, 11.0), + ("core", 144, 3.0), + ] From 27cbc8fe9d0e19ac732f4ca7aa64de8256466661 Mon Sep 17 00:00:00 2001 From: Bortlesboat Date: Sat, 25 Apr 2026 22:39:18 -0400 Subject: [PATCH 3/3] test: isolate app fixtures from background jobs --- tests/conftest.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a2bf717..a35ce9b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ import pytest from fastapi.testclient import TestClient -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from bitcoin_api.main import app from bitcoin_api.dependencies import get_rpc @@ -334,8 +334,9 @@ def reset_background_jobs(use_temp_db): @pytest.fixture def client(mock_rpc): app.dependency_overrides[get_rpc] = lambda: mock_rpc - with TestClient(app) as c: - yield c + with patch("bitcoin_api.main.start_background_jobs"), patch("bitcoin_api.main.stop_background_jobs"): + with TestClient(app) as c: + yield c app.dependency_overrides.clear() @@ -355,6 +356,7 @@ def authed_client(mock_rpc, use_temp_db): db.commit() app.dependency_overrides[get_rpc] = lambda: mock_rpc - with TestClient(app, headers={"X-API-Key": key}) as c: - yield c + with patch("bitcoin_api.main.start_background_jobs"), patch("bitcoin_api.main.stop_background_jobs"): + with TestClient(app, headers={"X-API-Key": key}) as c: + yield c app.dependency_overrides.clear()