Skip to content

Commit 6e965f2

Browse files
committed
Add pool data from cardano node
1 parent 4712eea commit 6e965f2

8 files changed

Lines changed: 43642 additions & 3874 deletions

File tree

consensus_decentralization/metrics/concentration_ratio.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
def compute_concentration_ratio(block_distribution, topn):
22
"""
3-
Calculates the n-concentration ratio of a distribution of balances
3+
Calculates the n-concentration ratio of a distribution of blocks
44
:param block_distribution: a list of integers, each being the blocks that an entity has produced, sorted in descending order
55
:param topn: the number of top block producers to consider
66
:returns: float that represents the ratio of blocks produced by the top n block producers (0 if there weren't any)

mapping_information/cardano_preprocessing/cardano_pool_data.json renamed to mapping_information/cardano_preprocessing/cardano_pool_data_BQ.json

File renamed without changes.

mapping_information/cardano_preprocessing/cardano_pool_data_node.json

Lines changed: 21224 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
import pathlib
2+
import google.cloud.bigquery as bq
3+
import json
4+
import argparse
5+
from collections import defaultdict
6+
import logging
7+
import os
8+
import re
9+
from urllib.parse import urlparse
10+
from difflib import SequenceMatcher
11+
12+
13+
def get_pool_data_BQ(force_query):
14+
"""
15+
Queries the BigQuery database for pool data and writes it to a json file.
16+
:param force_query: flag to specify whether to query for project data regardless if the relevant data already exist.
17+
:returns: dictionary, where each key is a pool's hash and the corresponding value is a dictionary with the
18+
pool's metadata (name, ticker, homepage, description)
19+
"""
20+
logging.info("Getting pool data..")
21+
filename = 'cardano_pool_data_BQ.json'
22+
file = pathlib.Path(mapping_info_dir / "cardano_preprocessing" / filename)
23+
if not force_query and file.is_file():
24+
# logging.info('Pool data already exists locally. '
25+
# 'For querying anyway please run the script using the flag --force-query')
26+
with open(file) as f:
27+
pool_data = json.load(f)
28+
return pool_data
29+
30+
client = bq.Client.from_service_account_json(json_credentials_path=mapping_info_dir / "google-service-account-key.json")
31+
32+
query = ('SELECT pool_hash, json as metadata FROM `iog-data-analytics.cardano_mainnet.pool_offline_data`')
33+
logging.info("Fetching pool data from BigQuery..")
34+
query_job = client.query(query)
35+
try:
36+
rows = query_job.result()
37+
except Exception as e:
38+
logging.info(f'The following exception was raised: {repr(e)}')
39+
return None
40+
pool_data = {row[0]: eval(row[1]) for row in rows}
41+
# write json to file
42+
with open(file, 'w') as f:
43+
json.dump(pool_data, f, indent=4)
44+
logging.info('Pool data saved locally.')
45+
return pool_data
46+
47+
48+
def get_pool_data_node():
49+
"""
50+
Gets pool data that has been fetched from a Cardano node.
51+
:returns: dictionary, where each key is a pool's hash and the corresponding value is a dictionary with the
52+
pool's metadata (name, ticker, homepage, description)
53+
"""
54+
filename = 'cardano_pool_data_node.json'
55+
file = pathlib.Path(mapping_info_dir / "cardano_preprocessing" / filename)
56+
with open(file) as f:
57+
pool_data = json.load(f)
58+
return pool_data
59+
60+
61+
def merge_pool_data(pool_data_BQ, pool_data_node):
62+
"""
63+
Merges pool data from BigQuery and from a Cardano node, giving priority to node data in case of conflicts.
64+
:param pool_data_BQ: dictionary with pool data that was fetched from BigQuery
65+
:param pool_data_node: dictionary with pool data that was fetched from a Cardano node
66+
:returns: dictionary, where each key is a pool's hash and the corresponding value is a dictionary with the
67+
pool's metadata (name, ticker, homepage, description).
68+
"""
69+
logging.info("Merging pool data sources..")
70+
merged = pool_data_BQ | pool_data_node
71+
return merged
72+
73+
def parse_pool_identifiers(pool_data):
74+
"""
75+
Extracts identifier information from pool data .
76+
Specifically, for each unique identifier (ticker) it saves the corresponding pool name and homepage and writes
77+
all of them to a json file.
78+
Conflicting tickers (same ticker, different domains) are flagged and
79+
excluded from identifiers to avoid false mapping.
80+
:param pool_data: dictionary, where each key is a pool's hash and the corresponding value is a dictionary with the
81+
pool's metadata (name, ticker, homepage, description)
82+
"""
83+
logging.info("Parsing pool identifiers..")
84+
85+
identifiers = dict()
86+
conflicts = dict()
87+
88+
for pool in pool_data.values():
89+
ticker = pool.get('ticker', '').strip().upper()
90+
name = pool.get('name', '').strip()
91+
homepage = pool.get('homepage', '').strip()
92+
93+
if not ticker:
94+
continue
95+
96+
if ticker not in identifiers:
97+
identifiers[ticker] = {'name': name, 'link': homepage}
98+
else:
99+
existing = identifiers[ticker]
100+
same_domain = (
101+
get_domain(existing['link']) == get_domain(homepage)
102+
and bool(filter_homepage(homepage))
103+
)
104+
if not same_domain:
105+
# Genuine conflict — flag it
106+
if ticker not in conflicts:
107+
conflicts[ticker] = [{'name': existing['name'], 'link': existing['link']}]
108+
conflicts[ticker].append({'name': name, 'link': homepage})
109+
110+
# Remove conflicting tickers from identifiers to avoid false mapping
111+
for ticker in conflicts:
112+
identifiers.pop(ticker, None)
113+
114+
return identifiers, conflicts
115+
116+
117+
def score_same_entity(p1, p2):
118+
"""
119+
Scores how likely two pools belong to the same operator entity.
120+
Returns an integer score; higher = more likely same entity.
121+
122+
Scoring:
123+
+3 same non-trivial domain
124+
+2 same ticker
125+
+2 name similarity > 0.85 (after stripping trailing digits)
126+
+1 same description
127+
-1 same ticker but different non-trivial domains (conflict signal)
128+
"""
129+
score = 0
130+
131+
# Domain match
132+
d1 = get_domain(p1.get('homepage', ''))
133+
d2 = get_domain(p2.get('homepage', ''))
134+
filtered_d1 = filter_homepage(p1.get('homepage', ''))
135+
filtered_d2 = filter_homepage(p2.get('homepage', ''))
136+
domains_valid = bool(filtered_d1 and filtered_d2 and d1 and d2)
137+
domains_match = domains_valid and d1 == d2
138+
139+
# Ticker match
140+
t1 = p1.get('ticker', '').strip().upper()
141+
t2 = p2.get('ticker', '').strip().upper()
142+
tickers_match = bool(t1 and t2 and t1 == t2)
143+
144+
if domains_match:
145+
score += 3
146+
if tickers_match:
147+
score += 2
148+
if domains_valid and not domains_match:
149+
score -= 1 # same ticker, different real domains → possibly different entities
150+
151+
# Name similarity (strip trailing digits to catch "Pool1" vs "Pool2")
152+
n1 = re.sub(r'\s*\d+$', '', p1.get('name', '').strip().lower())
153+
n2 = re.sub(r'\s*\d+$', '', p2.get('name', '').strip().lower())
154+
if n1 and n2 and name_similarity(n1, n2) > 0.85:
155+
score += 2
156+
157+
# Description match
158+
desc1 = p1.get('description', '').strip()
159+
desc2 = p2.get('description', '').strip()
160+
if desc1 and desc2 and desc1 == desc2:
161+
score += 1
162+
163+
return score
164+
165+
166+
def parse_pool_clusters(pool_data, score_threshold=3):
167+
"""
168+
Clusters pools by operator entity.
169+
Step 1: group pools that share the same valid domain (strong anchor).
170+
Step 2: merge any two domain-groups that share a ticker AND score >= threshold
171+
(catches operators with different domains but same ticker/name).
172+
Step 3: assign cluster names and return.
173+
:param pool_data: dictionary, where each key is a pool's hash and the corresponding value is a dictionary with the
174+
pool's metadata (name, ticker, homepage, description)
175+
:param score_threshold: the minimum score (as returned by score_same_entity) for two pools to be clustered together
176+
:returns: dictionary, where each key is a pool's hash and the corresponding value is a dictionary with the pool's name, the name of the cluster it belongs to and the source of the clustering information
177+
"""
178+
logging.info("Clustering pools by operator entity..")
179+
180+
pool_hashes = list(pool_data.keys())
181+
pools = list(pool_data.values())
182+
183+
# --- Step 1: seed clusters from shared domain ---
184+
domain_to_indices = defaultdict(list)
185+
for i, pool in enumerate(pools):
186+
domain = get_domain(pool.get('homepage', ''))
187+
if domain and filter_homepage(pool.get('homepage', '')):
188+
domain_to_indices[domain].append(i)
189+
190+
# Each cluster is a set of pool indices. Start one cluster per shared domain.
191+
# Pools with unique/no domain each get their own singleton cluster.
192+
clusters = []
193+
index_to_cluster = {} # pool index -> cluster list index
194+
index_to_source = {} # pool index -> how it was clustered
195+
196+
for domain, indices in domain_to_indices.items():
197+
if len(indices) > 1:
198+
cluster_id = len(clusters)
199+
clusters.append(set(indices))
200+
for i in indices:
201+
index_to_cluster[i] = cluster_id
202+
index_to_source[i] = 'homepage'
203+
204+
for i in range(len(pools)):
205+
if i not in index_to_cluster:
206+
cluster_id = len(clusters)
207+
clusters.append({i})
208+
index_to_cluster[i] = cluster_id
209+
index_to_source[i] = None
210+
211+
# --- Step 2: merge clusters that share a ticker and score >= threshold
212+
ticker_to_cluster_ids = defaultdict(set)
213+
for i, pool in enumerate(pools):
214+
ticker = pool.get('ticker', '').strip().upper()
215+
if ticker:
216+
ticker_to_cluster_ids[ticker].add(index_to_cluster[i])
217+
218+
def merge_clusters(id_a, id_b):
219+
"""Merge cluster id_b into cluster id_a, updating index_to_cluster.
220+
Pools moving from id_b get source 'multi_signal' unless they already
221+
had a more specific source (e.g. 'homepage') assigned in Step 1."""
222+
if id_a == id_b:
223+
return
224+
for i in clusters[id_b]:
225+
index_to_cluster[i] = id_a
226+
if index_to_source[i] is None:
227+
index_to_source[i] = 'multi_signal'
228+
clusters[id_a].update(clusters[id_b])
229+
clusters[id_b] = set()
230+
231+
for ticker, cluster_ids in ticker_to_cluster_ids.items():
232+
cluster_ids = list(cluster_ids)
233+
for a in range(len(cluster_ids)):
234+
for b in range(a + 1, len(cluster_ids)):
235+
if not clusters[cluster_ids[a]] or not clusters[cluster_ids[b]]:
236+
continue
237+
id_a = index_to_cluster[next(iter(clusters[cluster_ids[a]]))]
238+
id_b = index_to_cluster[next(iter(clusters[cluster_ids[b]]))]
239+
if id_a == id_b:
240+
continue
241+
rep_a = pools[next(iter(clusters[id_a]))]
242+
rep_b = pools[next(iter(clusters[id_b]))]
243+
if score_same_entity(rep_a, rep_b) >= score_threshold:
244+
for i in clusters[id_a]:
245+
if index_to_source[i] is None:
246+
index_to_source[i] = 'multi_signal'
247+
merge_clusters(id_a, id_b)
248+
249+
# --- Step 3: build output ---
250+
output = {}
251+
for cluster in clusters:
252+
if not cluster:
253+
continue
254+
pool_names = [pools[i].get('name', '') for i in cluster]
255+
cluster_name = determine_cluster_name(pool_names)
256+
for i in cluster:
257+
pool_hash = pool_hashes[i]
258+
output[pool_hash] = {
259+
'cluster': cluster_name,
260+
'pool': pools[i].get('name', ''),
261+
'source': index_to_source[i] if index_to_source[i] is not None else 'singleton'
262+
}
263+
264+
return output
265+
266+
def get_domain(url):
267+
"""Extracts the domain from a URL, stripping www."""
268+
try:
269+
domain = urlparse(url).netloc.lower()
270+
return re.sub(r'^www\.', '', domain)
271+
except:
272+
return ''
273+
274+
def name_similarity(a, b):
275+
"""Returns similarity ratio between two strings."""
276+
return SequenceMatcher(None, a.lower(), b.lower()).ratio()
277+
278+
def filter_homepage(homepage):
279+
"""
280+
Filters out dummy homepages. Specifically, it ignores homepage entries that are empty,
281+
have an 'empty' name (e.g. 'n/a') or include a dummy keyword in their name (e.g. foo.com)
282+
:param homepage: the homepage to be filtered (string)
283+
:returns: the homepage (as it is) if it is not a dummy homepage, else None
284+
"""
285+
if not homepage:
286+
return None
287+
homepage = homepage.strip()
288+
if not homepage:
289+
return None
290+
291+
homepage_lower = homepage.lower()
292+
293+
INVALID_EXACT = {
294+
'https://', 'http://', 'n/a', 'na', '-', '--', '---', '....', '...',
295+
'tbd', 'coming', 'coming soon', 'in process', 'no webside', 'no website',
296+
'none', 'null', 'undefined', 'unknown'
297+
}
298+
if homepage_lower in INVALID_EXACT:
299+
return None
300+
301+
INVALID_SUBSTRINGS = [
302+
'foo.com', 'example.com', 'invalidurl', 'test.com',
303+
'localhost', '127.0.0.1', 'yourdomain', 'yoursite',
304+
'mysite.com', 'mypool.com', 'poolname.com'
305+
]
306+
307+
if any(kw in homepage_lower for kw in INVALID_SUBSTRINGS):
308+
return None
309+
310+
return homepage
311+
312+
313+
def determine_cluster_name(pool_names):
314+
"""
315+
Determines the name of a cluster of pools.
316+
First, it checks if there is a common prefix among all pool names. If there is, it uses that as the cluster name.
317+
If no common prefix exists, then it names the cluster after the first pool name (in alphabetical order and
318+
prioritizing names that don't start with a digit)
319+
:param pool_names: list of pool names that belong to the same cluster
320+
:returns: the name of the cluster
321+
"""
322+
# make sure pool names have consistent case and filter out empty names
323+
pool_names = [pool_name.title() for pool_name in pool_names if pool_name]
324+
if not pool_names:
325+
return ''
326+
common_prefix = os.path.commonprefix(pool_names)
327+
if common_prefix:
328+
return common_prefix
329+
# if there is no common prefix, sort pool names alphabetically, prioritizing names that don't start with a digit
330+
# and use the first one as the cluster name
331+
pool_names = sorted(pool_names, key=lambda x: (x[0].isdigit(), x))
332+
return pool_names[0]
333+
334+
335+
if __name__ == '__main__':
336+
logging.basicConfig(format='[%(asctime)s] %(message)s', datefmt='%Y/%m/%d %I:%M:%S %p', level=logging.INFO)
337+
338+
parser = argparse.ArgumentParser()
339+
parser.add_argument(
340+
'--force-query',
341+
action='store_true',
342+
default=False,
343+
help='Flag to specify whether to query for project data regardless if the relevant data already exist.'
344+
)
345+
args = parser.parse_args()
346+
347+
mapping_info_dir = pathlib.Path(__file__).parent.parent
348+
pool_data_dir = pathlib.Path(__file__).parent
349+
350+
pool_data_BQ = get_pool_data_BQ(force_query=args.force_query)
351+
pool_data_node = get_pool_data_node()
352+
353+
merged_pool_data = merge_pool_data(pool_data_BQ, pool_data_node)
354+
355+
identifiers, conflicts = parse_pool_identifiers(merged_pool_data)
356+
clusters = parse_pool_clusters(merged_pool_data)
357+
358+
identifiers_dir = mapping_info_dir / 'identifiers'
359+
with open(identifiers_dir / 'cardano.json', 'w') as f:
360+
json.dump(identifiers, f, indent=4)
361+
362+
clusters_dir = mapping_info_dir / 'clusters'
363+
with open(clusters_dir / 'cardano.json', 'w') as f:
364+
json.dump(clusters, f, indent=4)
365+
366+
with open(mapping_info_dir / 'cardano_preprocessing' / 'ticker_conflicts.json', 'w') as f:
367+
json.dump(conflicts, f, indent=4)
368+
369+
logging.info(f"Done. {len(identifiers)} identifiers, {len(clusters)} clustered pools, {len(conflicts)} conflicts.")

0 commit comments

Comments
 (0)