Skip to content

Commit f105535

Browse files
authored
Merge pull request #4 from dhruvan2006/glassnode
Add support for glassnode
2 parents c19afc8 + 4240488 commit f105535

File tree

6 files changed

+201
-1
lines changed

6 files changed

+201
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ handles the heavy lifting so you can focus on insights.
3434
- [Bitbo Charts](https://charts.bitbo.io/)
3535
- [Bitcoin Magazine Pro](https://www.bitcoinmagazinepro.com)
3636
- [Blockchain.com](https://www.blockchain.com/explorer/charts)
37+
- [Glassnode](https://studio.glassnode.com/charts/)
3738

3839
## Installation
3940
To install the `chaindl` package, use pip:

chaindl/download.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def download(url, start=None, end=None, **kwargs):
4343
CRYPTOQUANT_BASE_URL = "https://cryptoquant.com"
4444
BITCOINMAGAZINEPRO_BASE_URL = "https://www.bitcoinmagazinepro.com"
4545
BLOCKCHAIN_BASE_URL = "https://www.blockchain.com/explorer/charts"
46+
GLASSNODE_BASE_URL = "https://studio.glassnode.com/charts"
4647

4748
data = pd.DataFrame()
4849

@@ -60,6 +61,8 @@ def download(url, start=None, end=None, **kwargs):
6061
data = scraper.bitcoinmagazinepro._download(url, **kwargs)
6162
elif url.startswith(BLOCKCHAIN_BASE_URL):
6263
data = scraper.blockchain._download(url, **kwargs)
64+
elif url.startswith(GLASSNODE_BASE_URL):
65+
data = scraper.glassnode._download(url, **kwargs)
6366
else:
6467
raise ValueError("Unsupported source. Find the list of supported websites here: https://chaindl.readthedocs.io/")
6568

chaindl/scraper/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
from .woocharts import _download
55
from .cryptoquant import _download
66
from .bitcoinmagazinepro import _download
7-
from .blockchain import _download
7+
from .blockchain import _download
8+
from .glassnode import _download

chaindl/scraper/glassnode.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import re
2+
from urllib.parse import urlparse, parse_qs
3+
import requests
4+
import pandas as pd
5+
6+
SESSION = requests.Session()
7+
8+
def _parse_metric_path(url: str):
9+
"""Convert Glassnode chart URL path into API metric path and asset."""
10+
o = urlparse(url)
11+
path = o.path.replace(".", "/")
12+
query_params = parse_qs(o.query)
13+
asset = query_params.get("a", ["BTC"])[0]
14+
15+
try:
16+
_, charts, *prefix_parts, last_segment = path.split("/")
17+
except ValueError:
18+
raise ValueError(f"Invalid Glassnode URL path: {path}")
19+
20+
# Step 1: handle letter number boundaries: Min1Count -> Min_1Count
21+
number_adjusted = re.sub(r'([a-zA-Z])([0-9])', r'\1_\2', last_segment)
22+
# Step 2: handle Min_1Count -> Min_1_Count
23+
snake_case = re.sub(r'(?<!^)(?=[A-Z])', '_', number_adjusted).lower()
24+
new_path = "/".join(prefix_parts + [snake_case])
25+
return new_path, asset, snake_case
26+
27+
def _fetch_json(url: str):
28+
"""Fetch JSON from a URL with session handling and error checking."""
29+
resp = SESSION.get(url)
30+
resp.raise_for_status()
31+
try:
32+
return resp.json()
33+
except ValueError:
34+
raise ValueError(f"Failed to parse JSON from {url}: {resp.text}")
35+
36+
def _process_metric_json(json_data, column_name: str):
37+
"""Convert Glassnode metric JSON into a clean DataFrame."""
38+
df = pd.DataFrame(json_data)
39+
40+
if 'o' in df.columns:
41+
# Expand nested object
42+
columns = list(df['o'].iloc[0].keys())
43+
expanded_df = pd.DataFrame([row['o'] for row in json_data])
44+
expanded_df['Date'] = df['t']
45+
cols = ['Date'] + columns
46+
expanded_df = expanded_df[cols]
47+
expanded_df.index = pd.to_datetime(expanded_df['Date'], unit='s')
48+
expanded_df = expanded_df.drop(columns=['Date'])
49+
return expanded_df
50+
else:
51+
df.rename(columns={'t': 'Date', 'v': column_name}, inplace=True)
52+
df.index = pd.to_datetime(df['Date'], unit='s')
53+
df = df.drop(columns=['Date'])
54+
return df
55+
56+
def _download(url: str, include_price: bool = True) -> pd.DataFrame:
57+
try:
58+
metric_path, asset, snake_case = _parse_metric_path(url)
59+
60+
# Fetch cookies
61+
SESSION.get(url)
62+
63+
# Fetch metric
64+
api_url = f"https://api.glassnode.com/v1/metrics/{metric_path}?a={asset}&referrer=charts"
65+
json_data = _fetch_json(api_url)
66+
67+
if 'requiredPlan' in json_data:
68+
raise ValueError(f"Metric {snake_case} is not available on the free plan.")
69+
70+
df = _process_metric_json(json_data, snake_case)
71+
72+
# Optionally fetch USD price
73+
if include_price:
74+
price_api = f"https://api.glassnode.com/v1/metrics/market/price_usd_close?a={asset}&referrer=charts"
75+
json_price = _fetch_json(price_api)
76+
df_price = pd.DataFrame(json_price)
77+
df_price.rename(columns={'t': 'Date', 'v': 'price_usd_close'}, inplace=True)
78+
df_price.index = pd.to_datetime(df_price['Date'], unit='s')
79+
df_price = df_price.drop(columns=['Date'])
80+
df = df.join(df_price, how='outer')
81+
82+
return df
83+
84+
except requests.exceptions.RequestException as e:
85+
raise RuntimeError(f"Network error while fetching Glassnode metric: {e}")
86+
except ValueError as e:
87+
raise RuntimeError(f"Data error: {e}")
88+
except Exception as e:
89+
raise RuntimeError(f"Unexpected error: {e}")

docs/index.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ It supports:
3232
- `Bitbo Charts <https://charts.bitbo.io/index/>`__
3333
- `Bitcoin Magazine Pro <https://www.bitcoinmagazinepro.com>`__
3434
- `Blockchain.com <https://www.blockchain.com/explorer/charts/>`__
35+
- `Glassnode <https://studio.glassnode.com/charts/>`__
3536

3637
---
3738

@@ -177,6 +178,19 @@ Example:
177178
178179
---
179180

181+
Glassnode (`studio.glassnode.com <https://studio.glassnode.com/charts/>`__)
182+
---------------------------------------------------------------------------
183+
184+
Only basic (T1) metrics are supported. You can find a list of the basic metrics
185+
on their `website <https://docs.glassnode.com/data/metric-catalog#basic-metrics>`__.
186+
187+
Example:
188+
189+
.. code-block:: python
190+
191+
url = "https://studio.glassnode.com/charts/addresses.ActiveCount?a=BTC"
192+
df = chaindl.download(url)
193+
180194
Optional Arguments
181195
==================
182196

tests/test_glassnode.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import pytest
2+
import pandas as pd
3+
from unittest.mock import patch, Mock
4+
from chaindl.scraper import glassnode
5+
6+
def test_parse_metric_path_basic():
7+
url = "https://studio.glassnode.com/charts/addresses.ActiveCountWithContracts?a=ETH"
8+
path, asset, snake_case = glassnode._parse_metric_path(url)
9+
assert asset == "ETH"
10+
assert "active_count_with_contracts" in snake_case
11+
assert path.endswith(snake_case)
12+
13+
def test_parse_metric_path_default_asset():
14+
url = "https://studio.glassnode.com/charts/addresses.ActiveCountWithContracts"
15+
path, asset, snake_case = glassnode._parse_metric_path(url)
16+
assert asset == "BTC"
17+
18+
19+
def test_process_metric_json_simple():
20+
json_data = [{"t": 1680000000, "v": 42}, {"t": 1680000600, "v": 43}]
21+
df = glassnode._process_metric_json(json_data, "metric_name")
22+
assert isinstance(df, pd.DataFrame)
23+
assert "metric_name" in df.columns
24+
assert df.index.name is 'Date'
25+
26+
def test_process_metric_json_nested():
27+
json_data = [{"t": 1680000000, "o": {"a": 1, "b": 2}}, {"t": 1680000600, "o": {"a": 3, "b": 4}}]
28+
df = glassnode._process_metric_json(json_data, "ignored")
29+
assert isinstance(df, pd.DataFrame)
30+
assert "a" in df.columns and "b" in df.columns
31+
assert df.shape == (2, 2) # Only a and b remain
32+
33+
@patch("chaindl.scraper.glassnode.SESSION.get")
34+
def test_fetch_json_success(mock_get):
35+
mock_resp = Mock()
36+
mock_resp.raise_for_status.return_value = None
37+
mock_resp.json.return_value = {"key": "value"}
38+
mock_get.return_value = mock_resp
39+
data = glassnode._fetch_json("https://fakeurl.com")
40+
assert data["key"] == "value"
41+
42+
@patch("chaindl.scraper.glassnode.SESSION.get")
43+
def test_download_includes_price(mock_get):
44+
# Mock metric response
45+
metric_resp = Mock()
46+
metric_resp.raise_for_status.return_value = None
47+
metric_resp.json.return_value = [{"t": 1680000000, "v": 100}]
48+
# Mock price response
49+
price_resp = Mock()
50+
price_resp.raise_for_status.return_value = None
51+
price_resp.json.return_value = [{"t": 1680000000, "v": 50000}]
52+
53+
mock_get.side_effect = [Mock(), metric_resp, price_resp] # First GET for cookies
54+
url = "https://studio.glassnode.com/charts/addresses.ActiveCountWithContracts?a=ETH"
55+
56+
df = glassnode._download(url)
57+
assert "price_usd_close" in df.columns
58+
assert "active_count_with_contracts" in df.columns
59+
assert isinstance(df, pd.DataFrame)
60+
61+
@patch("chaindl.scraper.glassnode.SESSION.get")
62+
def test_download_raises_on_required_plan(mock_get):
63+
resp = Mock()
64+
resp.raise_for_status.return_value = None
65+
resp.json.return_value = {"requiredPlan": True}
66+
mock_get.side_effect = [Mock(), resp]
67+
68+
url = "https://studio.glassnode.com/charts/addresses.ActiveCountWithContracts?a=ETH"
69+
with pytest.raises(RuntimeError, match="Data error"):
70+
glassnode._download(url)
71+
72+
# Integration tests
73+
def test_download_active_count_btc():
74+
url = "https://studio.glassnode.com/charts/addresses.ActiveCount?a=BTC"
75+
df = glassnode._download(url)
76+
assert "active_count" in df.columns
77+
assert "price_usd_close" in df.columns
78+
assert not df.empty
79+
80+
def test_download_stock_to_flow_btc():
81+
url = "https://studio.glassnode.com/charts/indicators.StockToFlowRatio?a=BTC"
82+
df = glassnode._download(url)
83+
assert "daysTillHalving" in df.columns
84+
assert "price" in df.columns
85+
assert "ratio" in df.columns
86+
assert "price_usd_close" in df.columns
87+
assert not df.empty
88+
89+
def test_download_invalid_url():
90+
url = "https://studio.glassnode.com/charts/addresses.NotARealMetric?a=BTC"
91+
with pytest.raises(RuntimeError):
92+
glassnode._download(url)

0 commit comments

Comments
 (0)