Files
2026-07-13 13:26:28 +08:00

810 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ---
# jupyter:
# jupytext:
# cell_metadata_filter: tags,-all
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.19.3
# kernelspec:
# display_name: Python 3 (ipykernel)
# language: python
# name: python3
# ---
# %% [markdown]
# # Alternative Data Evaluation: DeFi TVL Case Study
#
# **Chapter 4: Fundamental and Alternative Data**
# **Docker image**: `ml4t`
# **Section Reference**: See Section 4.4 for alternative data due diligence concepts
#
# ## Purpose
#
# This notebook demonstrates a rigorous alternative data evaluation framework using
# **real data**: DeFi Llama's Total Value Locked (TVL) metrics. Rather than theoretical
# checklists, we compute actual signal quality, assess real data gaps, and calculate
# whether this free dataset justifies integration into a trading pipeline.
#
# ## Learning Objectives
#
# After completing this notebook, you will be able to:
# - Apply a 4-dimension evaluation framework to real alternative data
# - Calculate Information Coefficients (IC) and signal decay empirically
# - Assess data quality: coverage, gaps, methodology transparency
# - Evaluate legal/compliance considerations for blockchain data
# - Compute break-even alpha requirements for data costs
#
# ## The Evaluation Framework
#
# This notebook implements a **four-gate** evaluation (Signal, Data, Legal, Commercial). In the
# chapter text, these gates expand into a seven-check rubric (uniqueness, decay, quality, coverage,
# latency, legal, engineering/economics). The goal is to avoid false precision: failing any hard
# gate (PIT/legal) should block integration regardless of a "score."
#
# | Gate | Key Questions |
# |------|---------------|
# | **Signal** | Does it predict returns? How quickly does the signal decay? |
# | **Data** | Coverage? Gaps? Methodology? Point-in-time available? |
# | **Legal** | Data source legality? MNPI risk? Usage restrictions? |
# | **Commercial** | Cost vs value? Build vs buy? API reliability? |
#
# ## Cross-References
#
# - **Data Source**: [`09_onchain_fundamentals`](09_onchain_fundamentals.ipynb) loads the TVL data
# - **Framework**: This evaluation methodology applies to ANY alternative dataset
# - **Downstream**: Use findings to decide whether to integrate into Chapter 8 features
# %%
"""Alternative Data Evaluation: DeFi TVL Case Study — measure four evaluation dimensions on a real alt-data feed."""
import warnings
warnings.filterwarnings("ignore")
from dataclasses import dataclass
import plotly.graph_objects as go
import polars as pl
from plotly.subplots import make_subplots
from data import load_coingecko_ohlcv, load_defillama_chain_tvl
from utils.style import COLORS
print("Alternative Data Evaluation: DeFi TVL Case Study")
# %% tags=["parameters"]
# Production defaults — Papermill injects overrides for CI
# %% [markdown]
# ---
#
# ## Section 1: Load the Data
#
# We evaluate whether DeFi TVL predicts ETH returns. Both feeds come
# from the canonical on-chain downloader — run once, then work offline:
#
# ```bash
# python data/crypto/onchain/download.py
# ```
#
# Loaders raise `DataNotFoundError` with the exact command if an
# expected parquet is missing, so there is no hidden network path.
# %%
tvl_data = load_defillama_chain_tvl("total").with_columns((pl.col("tvl_usd") / 1e9).alias("tvl_bn"))
# CoinGecko returns two rows for the download day (an intraday roll-up
# and a daily summary); collapse to one row per date by taking the last
# observation. Without this, the inner join below would propagate the
# duplicate into every downstream feature.
eth_data = (
load_coingecko_ohlcv("ethereum")
.select(pl.col("timestamp"), pl.col("price_usd").alias("eth_price"))
.group_by("timestamp")
.agg(pl.col("eth_price").last())
.sort("timestamp")
)
print(
f"TVL observations: {len(tvl_data):,} ({tvl_data['timestamp'].min()}{tvl_data['timestamp'].max()})"
)
print(
f"ETH observations: {len(eth_data):,} ({eth_data['timestamp'].min()}{eth_data['timestamp'].max()})"
)
# CoinGecko free tier is 365 days, so the merge is bounded by ETH coverage.
data = tvl_data.join(eth_data, on="timestamp", how="inner").sort("timestamp")
print(
f"Combined dataset: {data.shape}, range: {data['timestamp'].min()}{data['timestamp'].max()}"
)
data.tail(5)
# %% [markdown]
# ---
#
# ## Section 2: Signal Quality Assessment
#
# The most important question: **Does this data predict returns?**
#
# We'll calculate:
# - Information Coefficient (IC): Correlation between signal and forward returns
# - Signal decay: How quickly does predictive power diminish?
# - Regime analysis: Does the signal work in different market conditions?
# %%
# Create features and forward returns
analysis = (
data.with_columns(
[
# TVL momentum signals
pl.col("tvl_bn").pct_change(7).alias("tvl_growth_7d"),
pl.col("tvl_bn").pct_change(30).alias("tvl_growth_30d"),
(
(pl.col("tvl_bn") - pl.col("tvl_bn").rolling_mean(90))
/ pl.col("tvl_bn").rolling_std(90)
).alias("tvl_zscore"),
# Forward returns at various horizons
pl.col("eth_price").pct_change(7).shift(-7).alias("fwd_return_7d"),
pl.col("eth_price").pct_change(14).shift(-14).alias("fwd_return_14d"),
pl.col("eth_price").pct_change(30).shift(-30).alias("fwd_return_30d"),
pl.col("eth_price").pct_change(60).shift(-60).alias("fwd_return_60d"),
]
)
.filter(pl.col("tvl_growth_30d").is_not_null())
.filter(pl.col("fwd_return_30d").is_not_null())
)
print(f"Analysis dataset: {analysis.shape}")
# %% [markdown]
# Classical t-statistics are not valid here: forward-return horizons
# overlap by construction (every 30-day forward return shares 29 days
# with the next), inducing strong autocorrelation that inflates naive
# significance. The ICs below are *screening diagnostics*, not hypothesis
# tests. For inference, use HAC / Newey-West standard errors or a block
# bootstrap on non-overlapping samples.
# %%
signals = ["tvl_growth_7d", "tvl_growth_30d", "tvl_zscore"]
horizons = ["fwd_return_7d", "fwd_return_14d", "fwd_return_30d", "fwd_return_60d"]
ic_long = pl.concat(
[
analysis.select(
pl.lit(s).alias("signal"),
pl.lit(h).alias("horizon"),
pl.corr(s, h).alias("IC"),
pl.col(s).is_not_null().sum().alias("n_obs"),
)
for s in signals
for h in horizons
]
)
ic_df = ic_long.filter(pl.col("n_obs") > 30)
ic_df
# %%
# Create IC heatmap (signals × horizons)
# Reshape data for heatmap
signal_order = ["tvl_growth_7d", "tvl_growth_30d", "tvl_zscore"]
horizon_order = ["fwd_return_7d", "fwd_return_14d", "fwd_return_30d", "fwd_return_60d"]
# Build matrix
ic_matrix = []
for signal in signal_order:
row = []
for horizon in horizon_order:
ic_val = ic_df.filter((pl.col("signal") == signal) & (pl.col("horizon") == horizon))[
"IC"
].to_list()
row.append(ic_val[0] if ic_val else 0)
ic_matrix.append(row)
# %%
# Clean labels
signal_labels = ["TVL Growth 7d", "TVL Growth 30d", "TVL Z-Score"]
horizon_labels = ["7d", "14d", "30d", "60d"]
# Create heatmap with custom colorscale
fig = go.Figure(
data=go.Heatmap(
z=ic_matrix,
x=horizon_labels,
y=signal_labels,
colorscale=[
[0.0, "#C62828"], # negative IC (red)
[0.5, "#F5F5F5"], # zero IC (neutral)
[1.0, "#2E7D32"], # positive IC (green)
],
zmid=0, # Center colorscale at 0
text=[[f"{v:.3f}" for v in row] for row in ic_matrix],
texttemplate="%{text}",
textfont={"size": 14, "color": "black"},
colorbar=dict(title="IC", tickformat=".2f"),
)
)
fig.update_layout(
title="IC Reverses Sign with Horizon for the TVL Z-Score",
xaxis_title="Forward Return Horizon",
yaxis_title="Signal",
height=350,
template="plotly_white",
)
fig.show()
# %%
ic_df.sort(pl.col("IC").abs(), descending=True).head(3)
# %%
# Visualize IC by horizon (signal decay)
pivot_data = ic_df.filter(pl.col("signal") == "tvl_growth_30d")
fig = go.Figure()
fig.add_trace(
go.Bar(
x=[h.replace("fwd_return_", "") for h in pivot_data["horizon"]],
y=pivot_data["IC"],
text=[f"{ic:.3f}" if ic is not None else "N/A" for ic in pivot_data["IC"]],
textposition="auto",
marker_color=COLORS["blue"],
)
)
fig.update_layout(
title="TVL 30-Day Growth Signal Peaks at Mid Horizon, Decays Past 60 Days",
xaxis_title="Forward Return Horizon",
yaxis_title="Information Coefficient",
height=400,
template="plotly_white",
)
fig.show()
# %% [markdown]
# ### Rolling IC stability
#
# A point estimate hides regime dependence. The next cell rolls a
# 180-day window across the joined sample to expose how variable the
# IC actually is.
# %%
rolling_window = 180
rolling_ic_df = pl.DataFrame(
{
"timestamp": analysis["timestamp"][rolling_window:],
"rolling_ic": [
analysis.slice(i - rolling_window, rolling_window)
.select(pl.corr("tvl_growth_30d", "fwd_return_30d"))
.item()
for i in range(rolling_window, len(analysis))
],
}
).filter(pl.col("rolling_ic").is_not_null())
ic_mean = rolling_ic_df["rolling_ic"].mean()
ic_std = rolling_ic_df["rolling_ic"].std()
ic_positive_pct = (rolling_ic_df["rolling_ic"] > 0).mean()
rolling_ic_df.select(
pl.lit(rolling_window).alias("window_days"),
pl.lit(len(rolling_ic_df)).alias("n_windows"),
pl.col("rolling_ic").mean().round(3).alias("mean_ic"),
pl.col("rolling_ic").std().round(3).alias("std_ic"),
(pl.col("rolling_ic") > 0).mean().round(3).alias("share_positive"),
)
# %%
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=rolling_ic_df["timestamp"],
y=rolling_ic_df["rolling_ic"],
name="Rolling IC",
line=dict(color=COLORS["slate"], width=1.5),
fill="tozeroy",
fillcolor="rgba(4, 138, 129, 0.2)",
)
)
fig.add_hline(y=0, line_dash="dash", line_color=COLORS["neutral"])
fig.add_hline(
y=ic_mean,
line_dash="dot",
line_color=COLORS["blue"],
annotation_text=f"Mean: {ic_mean:.3f}",
)
# Add bands for positive/negative regions
fig.add_hrect(y0=0, y1=0.2, fillcolor=COLORS["positive"], opacity=0.05)
fig.add_hrect(y0=-0.2, y1=0, fillcolor=COLORS["negative"], opacity=0.05)
fig.update_layout(
title="Rolling 180-Day IC Crosses Zero — Signal Is Regime-Dependent",
xaxis_title="Date",
yaxis_title="Information Coefficient",
height=400,
template="plotly_white",
)
fig.show()
# %% [markdown]
# ---
#
# ## Section 3: Data Quality Assessment
#
# Beyond signal quality, we need to assess the data itself:
# - **Coverage**: What time period? What assets/chains?
# - **Gaps**: Missing data? Inconsistencies?
# - **Methodology**: How is TVL calculated? What's included/excluded?
# - **Point-in-Time**: Can we reconstruct historical views without lookahead bias?
# %% [markdown]
# DefiLlama's series begins in September 2017 when total TVL was a few
# hundred thousand dollars; once we restrict to the post-2019 era when
# TVL exceeded \$100M the early-curve noise drops out and the gap /
# extreme-move statistics describe the modern regime that any signal
# would actually trade.
# %%
tvl_modern = tvl_data.filter(pl.col("tvl_bn") >= 0.1).sort("timestamp")
date_gaps = tvl_modern.with_columns(
(pl.col("timestamp") - pl.col("timestamp").shift(1)).dt.total_days().alias("days_gap")
)
gaps = date_gaps.filter(pl.col("days_gap") > 1)
extreme_changes = tvl_modern.with_columns(
pl.col("tvl_bn").pct_change().alias("daily_change")
).filter(pl.col("daily_change").abs() > 0.20)
tvl_modern.select(
pl.col("timestamp").min().alias("start"),
pl.col("timestamp").max().alias("end"),
pl.len().alias("n_obs"),
pl.col("tvl_bn").min().round(2).alias("min_bn"),
pl.col("tvl_bn").max().round(1).alias("max_bn"),
pl.col("tvl_bn").mean().round(1).alias("mean_bn"),
pl.lit(tvl_modern["tvl_usd"].null_count()).alias("nulls"),
pl.lit(len(gaps)).alias("date_gaps"),
pl.lit(len(extreme_changes)).alias("daily_moves_gt_20pct"),
)
# %% [markdown]
# A weighted composite is a teaching scaffold, not a calibrated metric:
# the weights below come from the chapter rubric, and the absolute
# number is meaningful only relative to other datasets scored the same
# way. Coverage is bounded at 25 because longer-than-five-year history
# adds little marginal evidence for a strategy with a one-year holdout.
# The PIT term gets 15 points because backfill risk is the single most
# common path to inflated alt-data backtests.
# %%
@dataclass
class DataQualityMetrics:
coverage_years: float
missing_pct: float
gap_days: int
extreme_moves_pct: float
methodology_transparent: bool
pit_available: bool
@property
def score(self) -> float:
return min(
100.0,
min(25.0, self.coverage_years * 5)
+ max(0.0, 25.0 - self.missing_pct * 100)
+ max(0.0, 20.0 - self.extreme_moves_pct * 100)
+ (15 if self.methodology_transparent else 0)
+ (15 if self.pit_available else 0),
)
coverage_years = (tvl_modern["timestamp"].max() - tvl_modern["timestamp"].min()).days / 365
missing_pct = tvl_modern["tvl_usd"].null_count() / len(tvl_modern)
extreme_pct = len(extreme_changes) / len(tvl_modern)
quality = DataQualityMetrics(
coverage_years=coverage_years,
missing_pct=missing_pct,
gap_days=int(gaps["days_gap"].max()) if len(gaps) > 0 else 0,
extreme_moves_pct=extreme_pct,
methodology_transparent=True, # docs.llama.fi documents TVL aggregation
pit_available=False, # no vintage endpoint; historical revisions possible
)
pl.DataFrame(
{
"dimension": [
"coverage_years",
"missing_pct",
"max_gap_days",
"extreme_moves_pct",
"methodology_transparent",
"pit_available",
"composite_score",
],
"value": [
f"{quality.coverage_years:.1f}",
f"{quality.missing_pct:.2%}",
str(quality.gap_days),
f"{quality.extreme_moves_pct:.2%}",
str(quality.methodology_transparent),
str(quality.pit_available),
f"{quality.score:.0f}/100",
],
}
)
# %% [markdown]
# ---
#
# ## Section 4: Legal and Compliance Assessment
#
# Alternative data carries legal risks. Key considerations:
# - **Source legality**: Is the data legally obtained?
# - **MNPI risk**: Could this constitute material non-public information?
# - **Usage rights**: Any restrictions on use?
# %% [markdown]
# Legal review is qualitative and deal-killer logic dominates: any
# CRITICAL flag (e.g. unlicensed scraping, MNPI exposure) outweighs
# every quantitative score elsewhere. The rubric below records the
# four standard inputs (source legitimacy, MNPI exposure, usage
# restrictions, jurisdictional notes) for the DefiLlama feed.
# %%
legal = {
"data_source": "DefiLlama (defillama.com)",
"source_type": "Public",
"mnpi_risk": "Low",
"mnpi_reasoning": (
"TVL is computed from on-chain transactions visible to anyone with an "
"RPC endpoint; DefiLlama aggregates publicly verifiable state without "
"insider access."
),
"usage_restrictions": "Attribution requested; API rate limits apply",
"jurisdictional_issues": (
"Crypto regulation varies by jurisdiction; institutional use typically "
"requires a compliance sign-off on the underlying smart-contract exposure."
),
}
pl.DataFrame(
{
"dimension": list(legal.keys()),
"value": list(legal.values()),
}
)
# %% [markdown]
# ---
#
# ## Section 5: Commercial Viability Assessment
#
# Even free data has costs: integration time, maintenance, opportunity cost.
# We calculate whether the signal justifies the investment.
# %% [markdown]
# Even free data has costs: integration hours, ongoing maintenance,
# engineer attention diverted from other research. The break-even alpha
# is the gross trading return required to justify the *total* annual
# cost, scaled to the fraction of AUM that actually deploys this
# signal. The 3× ROI target below is conservative — research budgets
# typically demand a multiple of return-over-cost before continuing
# investment.
# %%
def calculate_required_alpha(
data_cost: float,
annual_hours: float,
hourly_rate: float,
aum: float,
allocation_pct: float = 0.10,
target_roi: float = 3.0,
) -> dict:
total_cost = data_cost + annual_hours * hourly_rate
allocated_capital = aum * allocation_pct
required_alpha_bps = (total_cost * target_roi / allocated_capital) * 10_000
return {
"total_annual_cost": total_cost,
"allocated_capital": allocated_capital,
"required_alpha_bps": required_alpha_bps,
}
integration_hours = 40
maintenance_hours = 20
hourly_rate = 150
annual_hours = integration_hours + maintenance_hours
cost_table = pl.DataFrame(
[
{
"fund_size": label,
"aum_usd": aum,
**calculate_required_alpha(0, annual_hours, hourly_rate, aum),
}
for label, aum in [
("Small ($10M)", 10_000_000),
("Mid ($50M)", 50_000_000),
("Large ($500M)", 500_000_000),
]
]
).with_columns(
pl.col("required_alpha_bps").round(1),
pl.col("total_annual_cost").cast(pl.Int64),
pl.col("allocated_capital").cast(pl.Int64),
)
cost_table
# %% [markdown]
# An IC magnitude does not translate directly to basis points of alpha
# without a full strategy design — portfolio construction, turnover,
# transaction costs, risk model, and capacity constraints all enter
# between IC and realised PnL. The numbers below are screening
# diagnostics that establish whether deeper prototyping is warranted,
# not deployable performance estimates.
# %%
best_ic = ic_df["IC"].abs().max()
avg_ic = ic_df["IC"].abs().mean()
pl.DataFrame(
{
"metric": [
"data_cost_usd_per_year",
"integration_cost_usd_per_year",
"best_abs_ic",
"average_abs_ic",
"rolling_ic_share_positive",
],
"value": [
"0",
f"{annual_hours * hourly_rate:,}",
f"{best_ic:.3f}",
f"{avg_ic:.3f}",
f"{ic_positive_pct:.1%}",
],
}
)
# %% [markdown]
# ---
#
# ## Section 6: Final Evaluation Summary
#
# With all the components in place, we can synthesize an informed decision.
# %%
# Build the four-panel dashboard in a SINGLE cell so the inline backend
# does not capture progressive-render intermediates with only the first
# (or first two) gauges populated (feedback_split_cell_figure_bug).
fig = make_subplots(
rows=2,
cols=2,
subplot_titles=(
"Signal Quality (IC)",
"Data Quality Score",
"IC Stability Over Time",
"Cost-Benefit Analysis",
),
specs=[
[{"type": "indicator"}, {"type": "indicator"}],
[{"type": "scatter"}, {"type": "bar"}],
],
vertical_spacing=0.28,
horizontal_spacing=0.12,
)
# Signal quality gauge
fig.add_trace(
go.Indicator(
mode="gauge+number",
value=avg_ic * 100, # Convert to percentage for readability
title={"text": "Average |IC| × 100"},
gauge={
"axis": {"range": [0, 10]},
"bar": {"color": COLORS["blue"]},
"steps": [
{"range": [0, 2], "color": COLORS["negative"]},
{"range": [2, 5], "color": "#FFC107"},
{"range": [5, 10], "color": COLORS["positive"]},
],
"threshold": {
"line": {"color": "black", "width": 2},
"thickness": 0.75,
"value": avg_ic * 100,
},
},
),
row=1,
col=1,
)
# Data quality gauge
fig.add_trace(
go.Indicator(
mode="gauge+number",
value=quality.score,
title={"text": "Data Quality"},
gauge={
"axis": {"range": [0, 100]},
"bar": {"color": COLORS["slate"]},
"steps": [
{"range": [0, 50], "color": COLORS["negative"]},
{"range": [50, 75], "color": "#FFC107"},
{"range": [75, 100], "color": COLORS["positive"]},
],
},
),
row=1,
col=2,
)
# Rolling IC and cost-benefit panels
fig.add_trace(
go.Scatter(
x=rolling_ic_df["timestamp"],
y=rolling_ic_df["rolling_ic"],
mode="lines",
name="Rolling IC",
line=dict(color=COLORS["slate"], width=2),
fill="tozeroy",
fillcolor="rgba(4, 138, 129, 0.2)",
showlegend=False,
),
row=2,
col=1,
)
# Add zero line on the rolling-IC subplot. Mixed-spec make_subplots places the
# row=2,col=1 scatter on the primary x/y axes (indicator subplots don't claim
# numbered axes), so reference the first scatter axes directly via "x domain".
fig.add_shape(
type="line",
x0=0,
x1=1,
y0=0,
y1=0,
xref="x domain",
yref="y",
line=dict(dash="dash", color=COLORS["neutral"]),
)
risk_to_score = {"Low": 100, "Medium": 50, "High": 20, "Critical": 0}
dimension_names = ["Signal", "Data", "Legal", "Commercial"]
dimension_scores = [
min(100, avg_ic * 1000),
quality.score,
risk_to_score[legal["mnpi_risk"]],
85, # free data + ~$9k/yr engineering at the median fund size
]
dimension_colors = [
COLORS["positive"] if s >= 60 else (COLORS["amber"] if s >= 40 else COLORS["negative"])
for s in dimension_scores
]
fig.add_trace(
go.Bar(
x=dimension_names,
y=dimension_scores,
marker_color=dimension_colors,
text=[f"{s:.0f}" for s in dimension_scores],
textposition="outside",
showlegend=False,
),
row=2,
col=2,
)
fig.update_layout(
height=750,
title="Four-Dimension Evaluation Dashboard — DefiLlama TVL",
template="plotly_white",
margin=dict(t=90, b=60, l=60, r=60),
)
# Force the rolling-IC subplot (row=2, col=1) x-axis to render as datetime.
# In mixed-spec make_subplots (indicator + scatter + bar) the subplot xaxis can
# default to numeric epoch ticks when traces are added before the layout pass;
# the explicit type+range below pins it to the actual data window.
_ts_min = rolling_ic_df["timestamp"].min()
_ts_max = rolling_ic_df["timestamp"].max()
fig.update_xaxes(type="date", range=[_ts_min, _ts_max], row=2, col=1)
fig.update_yaxes(title_text="Rolling IC", row=2, col=1)
fig.update_yaxes(title_text="Dimension Score (0100)", range=[0, 110], row=2, col=2)
fig.show()
# %% [markdown]
# ### Evidence summary
#
# The framework records measurements; it does not declare a single answer.
# The table below restates the four-dimension findings together with
# the most important caveats so a research lead can decide whether to
# fund a TVL prototype or close the file.
# %%
evidence_summary = pl.DataFrame(
{
"dimension": ["Signal", "Data", "Legal", "Commercial"],
"headline_metric": [
f"avg |IC| {avg_ic:.3f}, best |IC| {best_ic:.3f}",
f"{quality.score:.0f}/100 (coverage {quality.coverage_years:.1f}y, missing {quality.missing_pct:.1%})",
f"MNPI risk {legal['mnpi_risk']} (public on-chain data)",
f"~${annual_hours * hourly_rate:,}/yr engineering, $0 data fees",
],
"binding_caveat": [
f"Rolling IC mean {ic_mean:+.3f}, positive in {ic_positive_pct:.0%} of windows; sign reverses with horizon — likely contrarian rather than directional",
"No point-in-time vintages — historical revisions can backfill, inflating any backtest",
"Crypto regulation varies by jurisdiction; institutional deployment requires compliance review of underlying smart-contract exposure",
f"Required alpha at small fund (10% allocation, 3× ROI) is {cost_table.filter(pl.col('fund_size') == 'Small ($10M)')['required_alpha_bps'].item():.1f} bps — small funds carry the cost-recovery burden",
],
}
)
evidence_summary
# %% [markdown]
# Reading this evidence:
#
# - The **signal** dimension passes the screening bar in magnitude
# (`avg |IC|` exceeds 0.05) but the rolling-window analysis shows it
# is regime-dependent and the sign flips with horizon, which is
# characteristic of mean-reverting rather than directional alpha. A
# prototype should test a *reversal* specification before a momentum
# one.
# - The **data** dimension scores well on coverage and completeness but
# fails on point-in-time availability — the binding limitation for
# any production backtest. Either build a snapshot archive going
# forward or accept that historical performance may be optimistic.
# - The **legal** dimension is clean for the DefiLlama feed itself; the
# institutional friction is downstream compliance review of the
# underlying smart-contract exposure, not the data acquisition.
# - The **commercial** dimension is permissive at scale but punishing
# at small AUM; the break-even calculation should drive whether the
# project even starts.
#
# None of these dimensions is decisive in isolation. Combine them with
# the strategy hypothesis and the fund's research-budget constraints
# before committing to a prototype.
# %% [markdown]
# ---
#
# ## Key Takeaways
#
# 1. **The IC matrix shows horizon-dependent sign flips.** The TVL
# z-score reaches IC = -0.28 at the 60-day horizon over the joined
# window — strong in magnitude but consistent with mean reversion,
# not directional alpha. The 30-day TVL growth signal peaks around
# the 14-day horizon and decays past 60 days.
# 2. **Rolling IC stability is the discriminator.** A point estimate
# near 0.10 is uninformative when the rolling 180-day mean sits
# close to zero (and turns negative in segments) while the share of
# positive windows hovers around 50% — record point IC, rolling
# mean, and the share-positive together, never just the headline.
# 3. **Composite scores are scaffolds.** The data-quality 0-100 score is
# only meaningful relative to other datasets scored with the same
# rubric; the binding constraint here is the absence of point-in-time
# vintages, not the headline number.
# 4. **Legal clearance is necessary but not sufficient.** Public on-chain
# data clears MNPI; institutional deployment still requires a
# compliance review of the underlying smart-contract exposure.
# 5. **Cost recovery scales with AUM.** Free data still costs roughly
# \$9,000/year in engineering; small funds need three-digit basis
# points of alpha to justify it, large funds need single digits.
#
# ## Applying This Framework
#
# The same workflow applies to any alt-data candidate:
#
# 1. Load real history; never theorise about availability.
# 2. Compute signal magnitude *and* stability (point IC + rolling IC + sign behaviour vs horizon).
# 3. Score data quality on the same axes, treating the composite as relative.
# 4. Document the legal posture, including downstream compliance
# constraints, not just data acquisition.
# 5. Compute break-even alpha at the relevant fund size.
# 6. Surface the four-dimension evidence to the research lead — let
# them combine it with the strategy hypothesis to make the call.