This project builds a Bitcoin long‑term valuation dashboard from:
- The public BigQuery Bitcoin dataset (
bigquery-public-data.crypto_bitcoin) - Your own BigQuery dataset (for a daily BTC price mirror, you will set that up with the below steps)
- A local SQLite database (
Database/btc_data.db) - A final Python dashboard (
sdca.py) that shows:- An SDCA valuation table (fundamentals + technicals, color‑coded by Z‑score)
- A regime chart: average valuation Z‑score + BTC price + top/bottom zones
Everything is stored in SQLite and visualized via Plotly.
You cloned the repo as:
ZScoreValuation/
Key files and directories:
../
.env # Contains SDCA_PROJECT_ID (one level above repo)
BigQuerycredentials/ # One level above the repo
sdca-bigquery.json # Your BigQuery service account JSON
ZScoreValuation/
sdca.py # Main dashboard (builds dataset + plots)
build_data_from_metrics_bigquery.py # Merges all tables & computes z-scores
zscore_utils.py # Z-score helpers
Metrics_Indicators.py # RSI, Sharpe, Sortino, Omega, etc.
Database/
init_db.py # Creates/initializes SQLite schema
sqlite_manager.py # Wrapper around sqlite3 + schema
bigquery_client.py # Thin BigQuery client (pandas DataFrame)
upload_btc_price_bigquery.py # Fetch BTC price (CoinMetrics) → BigQuery
price_bigquery_to_sqlite.py # Mirror BigQuery btc_price → SQLite
utxo_bigquery_pipeline.py # SOPR, realized PnL, CDD → SQLite (btc_sopr)
realized_cap_bigquery_pipeline.py # Realized cap, MVRV, NUPL → SQLite (btc_realized)
cvdd_bigquery_pipeline.py # CVDD/VDD → SQLite (btc_cvdd)
tx_volume_bigquery_pipeline.py # Tx volume + NVTS → SQLite (btc_nvts)
sql_loader.py # Helper: loads SQL & replaces {{PROJECT_ID}}
sql/
sopr_utxo_bigquery.sql # SOPR + realized metrics query
realized_cap_bigquery.sql # Realized cap / MVRV / NUPL query
cvdd_bigquery.sql # CVDD query
tx_volume_bigquery.sql # Tx volume + NVTS base query
btc_data.db # Main local SQLite DB (created/updated at runtime)
BigQuerycredentials/
sdca-bigquery.json # Your BigQuery service account JSON (you add this)
The live DB is Database/btc_data.db.
All paths in the code are relative to the project root ZScoreValuation/.
Whenever you run a command, make sure your working directory is the project root:
cd /your/path/to/ZScoreValuationYou need Python 3.10+ and a few libraries.
From the project root:
pip install pandas numpy plotly google-cloud-bigquery google-api-core requestsThat’s enough to run all scripts in this repo.
If you use a virtual environment (recommended), just activate it before installing.
The pipelines depend on two things:
- Google’s public Bitcoin dataset:
bigquery-public-data.crypto_bitcoin - Your own project + dataset to store a mirrored BTC price table
-
Go to the Google Cloud console.
-
Create a project, e.g.:
my-sdca-project -
DO NOT add a billing account. BigQuery can run in Sandbox mode, which lets you query public datasets and run this project completely for free, including up to 1 TiB of query processing per month. Read below for an implementation idea for Free-Tier–Optimized Query Windows (with Dry Run + Byte Limits).
- To ensure all BigQuery queries stay within the 1 TiB/month free tier (Sandbox mode), each query can be given a fixed daily byte budget and automatically adjust its time window to fit that budget.
- Choose a safe daily budget (e.g. 30 GiB/day).
- Divide it across all queries in the pipeline:
daily_budget_gib = 1 * 1024 / 30 # ~34 GiB/day available query_count = 6 # number of queries in your pipeline per_query_limit_gib = daily_budget_gib / query_count per_query_limit_bytes = per_query_limit_gib * 1024**3
- Use
dry_run=Trueto estimate how many bytes the query would scan. - Shrink the timestamp window until the estimated bytes fit the limit.
- The final query is executed with
maximum_bytes_billedto guarantee BigQuery never scans more than the allowed limit (Sandbox remains 100% free). - Execute the query only when it fits safely under the free-tier budget.
- A BigQuery dry run does not execute the query. It only returns the estimated scan size, without consuming free-tier bytes:
from google.cloud import bigquery
client = bigquery.Client()
def estimate_bytes(sql: str) -> int:
job_config = bigquery.QueryJobConfig(
dry_run=True,
use_query_cache=False
)
job = client.query(sql, job_config=job_config)
return job.total_bytes_processed- You control the date window; SQL syntax always uses:
-
WHERE timestamp BETWEEN @start AND @end
-
Python adjusts the window dynamically:
window_days = 30 # start with a wide window
while window_days > 0:
start = end - timedelta(days=window_days)
sql = f"""
SELECT ...
FROM ...
WHERE timestamp BETWEEN '{start}' AND '{end}'
"""
est = estimate_bytes(sql)
if est <= per_query_limit_bytes:
# Safe to run
job_config = bigquery.QueryJobConfig(
maximum_bytes_billed=per_query_limit_bytes
)
client.query(sql, job_config=job_config).result()
break # window accepted
else:
# Too big → shrink window
window_days //= 2- Each query stays under its safe daily byte limit.
- The script fetches as much history as possible per day.
- Subsequent runs only fetch new missing data.
- Project never exceeds the 1 TiB/month free tier.
In the same project:
- Open BigQuery in the console.
- If prompted, enable the BigQuery API.
In BigQuery, create a dataset, for example:
Dataset ID: sdca
Location: (any region, e.g. US)
You will then refer to tables as:
my-sdca-project.sdca.btc_price
(adjust my-sdca-project / sdca to your own IDs).
-
Open IAM & Admin → Service Accounts.
-
Create a service account, for example:
Name: sdca-bigquery -
Grant it roles (a simple option):
- BigQuery Job User
- BigQuery Data Editor
-
Create a JSON key for this service account and download it.
One level above your local repo, create the folder:
BigQuerycredentials/
Place the JSON file inside and rename it to:
../BigQuerycredentials/sdca-bigquery.json
Or, if you prefer a different name/path, you must also update the constants described in the next section.
In the same top-level SDCA folder (one level above the repo), create a file:
../.env
Put your Google Cloud project ID in it:
SDCA_PROJECT_ID=your-gcp-project-id-here
All Python scripts that talk to BigQuery will read this via python-dotenv, so you usually do not need to edit PROJECT_ID inside the code files.
The SDCA pipelines do not require you to manually edit PROJECT_ID inside each script.
Instead, configuration is handled cleanly through:
- A
.envfile containing your Google Cloud project ID - A
BigQuerycredentials/folder containing your service account JSON - SQL files that use a placeholder
{{PROJECT_ID}} - A helper loader (
sql_loader.py) that replaces the placeholder at runtime
This eliminates hardcoded project IDs and keeps your repo clean.
Create a .env file one level above the repo folder:
../
.env
BigQuerycredentials/
ZScoreValuation/
Inside .env, put:
SDCA_PROJECT_ID=your-gcp-project-id
All BigQuery‑related Python files automatically read this via python-dotenv.
Place your credentials file here:
SDCA/BigQuerycredentials/sdca-bigquery.json
From inside the repo (ZScoreValuation/), scripts refer to it as:
../BigQuerycredentials/sdca-bigquery.json
This keeps credentials outside the repo, but still accessible.
All SQL files in:
ZScoreValuation/Database/sql/
use:
{{PROJECT_ID}}Example:
JOIN `{{PROJECT_ID}}.sdca.btc_price` AS pThe helper:
Database/sql_loader.py
automatically loads the SQL file, replaces {{PROJECT_ID}} with the real project ID from .env, and then executes the query.
You normally do not need to modify SQL files manually.
Some Python scripts still define non‑secret constants like:
DATASET_ID = "sdca"
TABLE_ID = "btc_price"
SQLITE_PATH = "Database/btc_data.db"These are safe to leave unchanged unless you modify your dataset layout.
You do not need to modify:
PROJECT_IDCREDENTIALSBQ_CREDENTIALS
because they now come from:
.env- The relative credentials path (
../BigQuerycredentials/sdca-bigquery.json)
This keeps configuration centralized and secure.
All metrics used by the dashboard are stored in Database/btc_data.db.
The schema (from sqlite_manager.py) creates these tables:
Daily BTC price mirror:
CREATE TABLE IF NOT EXISTS btc_price (
date TEXT PRIMARY KEY, -- YYYY-MM-DD
price_usd REAL NOT NULL,
open REAL,
high REAL,
low REAL,
close REAL,
volume REAL
);UTXO‑based realized metrics:
CREATE TABLE IF NOT EXISTS btc_sopr (
date TEXT PRIMARY KEY, -- YYYY-MM-DD
spent_btc REAL, -- Σ value_btc spent that day
realized_value_usd REAL, -- Σ(value_btc * spend_price_usd)
cost_basis_usd REAL, -- Σ(value_btc * create_price_usd)
sopr REAL, -- realized_value_usd / cost_basis_usd
realized_profit_usd REAL, -- Σ max(PnL, 0)
realized_loss_usd REAL, -- Σ min(PnL, 0) (negative)
net_realized_pnl_usd REAL, -- profit + loss
cdd REAL, -- Coin Days Destroyed
avg_utxo_age_days REAL, -- BTC-weighted avg age of spent UTXOs
n_spent_outputs INTEGER -- number of spent UTXOs that day
);Realized cap / MVRV / NUPL:
CREATE TABLE IF NOT EXISTS btc_realized (
date TEXT PRIMARY KEY,
circulating_supply_btc REAL,
market_cap_usd REAL,
realized_cap_usd REAL,
mvrv REAL,
unrealized_profit_usd REAL,
unrealized_loss_usd REAL,
nupl REAL
);CVDD metrics:
CREATE TABLE IF NOT EXISTS btc_cvdd (
date TEXT PRIMARY KEY,
cvdd REAL,
vdd REAL,
cumulative_vdd REAL
);Transaction volume + NVTS:
CREATE TABLE IF NOT EXISTS btc_nvts (
date TEXT PRIMARY KEY,
tx_volume_usd REAL, -- daily tx volume in USD
tx_volume_ma90 REAL, -- 90-day MA of tx_volume_usd
nvts REAL -- market_cap_usd / tx_volume_ma90
);Generic key/value store for pipeline metadata (currently minimal use):
CREATE TABLE IF NOT EXISTS metadata_pipeline (
key TEXT PRIMARY KEY,
value TEXT
);The DB is created/initialized by Database/init_db.py.
This section assumes:
- You already edited project IDs / dataset / credential paths as in §4.
- You have a working Python environment with dependencies installed.
All commands are run from project root:
cd /your/path/to/ZScoreValuationpython -m Database.init_dbThis will create Database/btc_data.db (if it doesn’t exist) and ensure all tables described above exist.
python -m Database.upload_btc_price_bigqueryWhat this does:
-
Calls the CoinMetrics community API for BTC daily metrics.
-
Normalizes the date/column types.
-
Uploads the result into BigQuery table:
<PROJECT_ID>.<DATASET_ID>.btc_price -
Uses a load job that overwrites or upserts rows by
date(depending on config).
This price table is the anchor for all later BigQuery SQL (SOPR, CVDD, Realized Cap, NVTS all join against it).
python -m Database.price_bigquery_to_sqliteWhat this does:
- Reads the BigQuery
btc_pricetable viaBigQueryClient. - Filters for rows not yet present in SQLite (
btc_pricetable). - Upserts them into
Database/btc_data.db→btc_price.
On first run it will pull the full history.
On later runs it only pulls new dates.
python -m Database.utxo_bigquery_pipelineCore logic (incremental_update_btc_sopr):
-
Loads
Database/sql/sopr_utxo_bigquery.sql. -
Determines date range:
- If
from_datenot provided:- If
btc_sopris empty → use2011‑01‑01. - Else → use (last available date + 1 day).
- If
- If
to_datenot provided:- Uses
today - 1 day.
- Uses
- If
-
Queries BigQuery:
- Public dataset:
bigquery-public-data.crypto_bitcoin - Your price table:
<PROJECT_ID>.<DATASET_ID>.btc_price
- Public dataset:
-
Computes per‑day:
spent_btcrealized_value_usdcost_basis_usdsoprrealized_profit_usdrealized_loss_usdnet_realized_pnl_usdcddavg_utxo_age_daysn_spent_outputs
-
Normalizes column types and upserts into SQLite table
btc_sopr``.
You can optionally call main(from_date, to_date) programmatically if you want tighter control over the date range.
python -m Database.realized_cap_bigquery_pipelineCore logic:
-
Loads
Database/sql/realized_cap_bigquery.sql. -
Queries:
- UTXO values from
bigquery-public-data.crypto_bitcoin - Prices from your BigQuery
btc_pricetable
- UTXO values from
-
Produces per‑day:
circulating_supply_btcmarket_cap_usdrealized_cap_usdmvrvunrealized_profit_usdunrealized_loss_usdnupl
-
Normalizes dates and upserts into
btc_realized.
Like the SOPR pipeline, main(from_date=None, to_date=None) supports optional manual date overrides.
python -m Database.cvdd_bigquery_pipelineCore logic:
-
Loads
Database/sql/cvdd_bigquery.sql. -
Uses public Bitcoin UTXO data + your price table to compute:
- Daily VDD = Σ(value_btc × age_days × price_at_creation)
- Cumulative VDD (running sum)
- CVDD = cumulative_vdd / 6,000,000
-
Normalizes and upserts into
btc_cvdd.
python -m Database.tx_volume_bigquery_pipelineThis script has two stages:
-
incremental_update_tx_volume()- Loads
Database/sql/tx_volume_bigquery.sql. - Computes daily transaction volume in USD via the public dataset and your price table.
- Upserts
date+tx_volume_usdintobtc_nvts.
- Loads
-
recompute_ma90_and_nvts()-
Reads all rows in
btc_nvtsandbtc_realized. -
Recomputes:
tx_volume_ma90(90‑day MA of tx_volume_usd)nvts=market_cap_usd/tx_volume_ma90
-
Upserts the full NVTS series back into
btc_nvts.
-
After this step:
btc_price,btc_sopr,btc_realized,btc_cvdd, andbtc_nvtsin SQLite are all populated.
You can explicitly build the dataset with:
python build_data_from_metrics_bigquery.pyThe core function is:
from build_data_from_metrics_bigquery import build_dataset
df = build_dataset("Database/btc_data.db")What it does:
-
Loads each table from SQLite:
btc_pricebtc_soprbtc_realizedbtc_cvddbtc_nvts
-
Merges them on
date. -
Ensures a
PriceUSDcolumn (fromprice_usdinbtc_price). -
Computes:
log_returnfromPriceUSD- Canonical metrics:
MVRV,NUPL,SOPR,RealizedPnL_Mom_30d(30‑day rolling sum ofnet_realized_pnl_usd),CVDD,NVTS, technical indicators (RSI, realized vol, Sharpe, Sortino, Omega, etc.).
-
Computes static Z‑scores (
*_z) usingzscore_utils. -
Computes rolling Z‑scores:
- Fundamentals (
MVRV,NUPL,SOPR,RealizedPnL_Mom_30d,CVDD,NVTS) with 730‑day window (*_z730). - Technicals (RSI, RealizedVol, Sharpe, Sortino, Omega) with 1200‑day window (
*_z1200).
- Fundamentals (
-
Sets
dateas index and returns the finalDataFrame.
Running the script as __main__ just builds the dataset and prints the tail, no plots.
The main entrypoint is:
python sdca.pyThis:
-
Calls
build_dataset()frombuild_data_from_metrics_bigquery. -
Passes the result through
compute_average_z(df)insdca.py, which:-
Selects all configured fundamental/technical Z‑score columns (
*_z730,*_z1200). -
Normalizes them into a clipped percentile scale (e.g. −2.5 to +2.5).
-
Smooths them with an exponential moving average (
*_scaled_smooth). -
Aggregates them into:
- Per‑metric smoothed scores, e.g.:
MVRV_scaled_smoothNUPL_scaled_smoothSOPR_scaled_smoothRealizedPnL_Mom_30d_scaled_smoothCVDD_scaled_smoothNVTS_scaled_smoothRSI_14_scaled_smooth- etc.
- Global
avg_zscore: the portfolio‑style average of all active metrics.
- Per‑metric smoothed scores, e.g.:
-
-
Classifies each day into a valuation regime via
classify_regime(avg_zscore):-
+2.0 →
darkred(extreme overvaluation) - +1.0 to +2.0 →
lightred - −2.0 to −1.0 →
lightgreen - < −2.0 →
darkgreen - otherwise →
neutral
-
-
Calls
sdca_dashboard(df), which:-
Prints a console summary of the latest values.
-
Shows two Plotly windows:
-
SDCA Table (
plot_sdca_table):- Left: fundamentals + Z.
- Right: technicals + Z.
- Cells are color‑coded by the current scaled Z.
-
Regime Chart (
plot_regime_z_with_price):- Bottom panel:
avg_zscorewith horizontal lines (e.g. +2, −1.5, 0). - Background shading for top/bottom regimes.
- Top panel: BTC price (on secondary Y‑axis) overlaid as a dashed line.
- Bottom panel:
-
-
Once the system is set up and you have run the full pipeline once, daily or periodic updates are straightforward.
From the project root:
cd /your/path/to/ZScoreValuationRun, in this order:
python -m Database.upload_btc_price_bigquery # Update BTC price table in BigQuery
python -m Database.price_bigquery_to_sqlite # Mirror new price rows into SQLite
python -m Database.utxo_bigquery_pipeline # Update SOPR / realized PnL / CDD
python -m Database.realized_cap_bigquery_pipeline# Update realized cap / MVRV / NUPL
python -m Database.cvdd_bigquery_pipeline # Update CVDD
python -m Database.tx_volume_bigquery_pipeline # Update tx_volume_usd / NVTS
python sdca.py # Rebuild dataset & show updated dashboardNotes:
- All BigQuery pipelines are incremental by default:
- They look at the last available date in SQLite.
- They start from
last_date + 1(or a hardcoded early date on first run). - They default
to_dateto yesterday.
- You can override ranges by calling the
main(from_date, to_date)functions from Python if needed.
This separation keeps a clear distinction between:
- Initial setup: fill the whole history
- Maintenance: only append new days and rebuild the dashboard
From project root:
python -m Database.init_db
python -m Database.upload_btc_price_bigquery
python -m Database.price_bigquery_to_sqlite
python -m Database.utxo_bigquery_pipeline
python -m Database.realized_cap_bigquery_pipeline
python -m Database.cvdd_bigquery_pipeline
python -m Database.tx_volume_bigquery_pipeline
python sdca.pypython -m Database.upload_btc_price_bigquery
python -m Database.price_bigquery_to_sqlite
python -m Database.utxo_bigquery_pipeline
python -m Database.realized_cap_bigquery_pipeline
python -m Database.cvdd_bigquery_pipeline
python -m Database.tx_volume_bigquery_pipeline
python sdca.pyAfter that last command, you will see the updated SDCA valuation table and the regime chart based on the newest available Bitcoin data.