A comprehensive backtesting framework for evaluating lending market curation strategies on Morpho Blue.
- Overview
- Architecture
- Installation
- Database Setup
- Quick Start
- CLI Reference
- Strategy Configuration
- Component System
- Example Strategies
- Development
Morpho Curator Backtest enables you to:
- Ingest historical market data from the Morpho Blue GraphQL API
- Define curation strategies via YAML configuration files
- Backtest strategies against historical data with realistic cost modeling
- Evaluate performance using standard risk-adjusted metrics (Sharpe, Sortino, max drawdown)
- Compare strategies against benchmark allocations (equal-weight, top-APY greedy, TVL-weighted)
┌─────────────────────────────────────────────────────────────────────────┐
│ CLI (cli.py) │
│ curator-bt data sync | db init | strategy validate | run backtest │
└─────────────────────────────────────────────────────────────────────────┘
│
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ Data Layer │ │ Strategy Layer │ │ Engine Layer │
│ src/data/ │ │ src/strategy/ │ │ src/engine/ │
│ │ │ │ │ │
│ • ingestion.py │ │ • filters/ │ │ • backtester.py │
│ • provider.py │ │ • constraints/ │ │ • optimizer.py │
│ • models.py │ │ • utilities/ │ │ • portfolio.py │
│ • queries.py │ │ • config.py │ │ • cost_model.py │
└───────────────────┘ │ • registry.py │ │ • schedule.py │
│ └───────────────────┘ └───────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ PostgreSQL Database │
│ chains | assets | markets | market_snapshots | asset_prices | ... │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────┐
│ Evaluation Layer │
│ src/evaluation/ │
│ │
│ • metrics.py │
│ • benchmarks.py │
│ • comparator.py │
│ • export.py │
└───────────────────┘
| Layer | Purpose |
|---|---|
| Data Layer | Ingests data from Morpho API, stores in PostgreSQL, provides time-bounded queries via SafeDataProvider |
| Strategy Layer | Defines composable filters, constraints, and utility functions loaded from YAML configs |
| Engine Layer | Orchestrates the backtest loop: scheduling, filtering, optimizing, portfolio tracking |
| Evaluation Layer | Computes performance metrics, runs benchmarks, exports results |
- Python 3.11+
- PostgreSQL 14+
- pip or pipx
# Clone the repository
git clone <repository-url>
cd morpho-curator-backtest
# Create virtual environment
python -m venv .venv
source .venv/bin/activate # Linux/macOS
# or: .venv\Scripts\activate # Windows
# Install dependencies
pip install -e .
# Install dev dependencies (optional)
pip install -e ".[dev]"curator-bt --help# Connect to PostgreSQL
psql -U postgres
# Create database and user
CREATE USER curator WITH PASSWORD 'curator';
CREATE DATABASE morpho_backtest OWNER curator;
GRANT ALL PRIVILEGES ON DATABASE morpho_backtest TO curator;
\qSet environment variables or use defaults:
export CURATOR_BT_DB_HOST=localhost
export CURATOR_BT_DB_PORT=5432
export CURATOR_BT_DB_USER=curator
export CURATOR_BT_DB_PASSWORD=curator
export CURATOR_BT_DB_NAME=morpho_backtest# Create tables from ORM models
curator-bt db init
# Or run Alembic migrations (recommended for production)
curator-bt db migrate# Full sync from Morpho API (Ethereum + Base by default)
curator-bt data sync
# Incremental sync (only new data since last sync)
curator-bt data sync --incremental
# Specify chains explicitly
curator-bt data sync --chains 1,8453
# Check data coverage
curator-bt data statuscurator-bt strategy validate config/strategies/aggressive_yield.yamlcurator-bt run backtest config/strategies/aggressive_yield.yamlcurator-bt results export config/strategies/aggressive_yield.yaml --output-dir ./outputcurator-bt db init # Create all tables from ORM models
curator-bt db migrate # Run Alembic migrations to latest revision
curator-bt db drop -y # Drop all tables (requires confirmation)curator-bt data sync # Full sync from Morpho API
curator-bt data sync --incremental # Only fetch new data
curator-bt data sync --chains 1 # Ethereum only
curator-bt data sync --interval HOUR # Hourly snapshots
curator-bt data status # Show data coverage reportcurator-bt strategy list # List saved strategies
curator-bt strategy show <name> # Print strategy YAML
curator-bt strategy validate <path> # Validate and check components
curator-bt strategy components # List all registered componentscurator-bt run backtest <strategy.yaml> # Run backtest
curator-bt run backtest <strategy.yaml> --start 2024-06-01 # Override start date
curator-bt run backtest <strategy.yaml> --end 2025-01-01 # Override end date
curator-bt run backtest <strategy.yaml> --capital 500000 # Override capital
curator-bt run backtest <strategy.yaml> --frequency daily # Override frequencycurator-bt results export <strategy.yaml> --output-dir ./output --fmt csv
curator-bt results export <strategy.yaml> --fmt json
curator-bt results export <strategy.yaml> --fmt allStrategies are defined in YAML files. The configuration schema:
strategy:
name: "my_strategy_v1"
description: "Description of the strategy"
# Rebalance frequency: daily | weekly | biweekly | monthly | quarterly
rebalance_frequency: "weekly"
# Starting capital in USD
initial_capital: 1000000
# Backtest period
backtest:
start_date: "2024-01-01"
end_date: "2025-01-01"
# Transaction cost model
cost_model:
enabled: true
fixed_cost_per_rebalance_usd: 5.0 # Gas + fixed fees
proportional_cost_bps: 1.0 # Slippage in basis points
# Filters applied sequentially to eliminate ineligible markets
filters:
- name: "whitelist"
- name: "min_market_age"
params:
min_days: 14
- name: "min_trailing_apy"
params:
min_apy: 0.05
lookback_days: 14
# Constraints for the optimizer
constraints:
- name: "max_weight_per_market"
params:
max_weight: 0.30
# Utility function to maximize
utility:
name: "trailing_net_supply_apy"
params:
lookback_days: 14
field: "net_supply_apy"All filters, constraints, and utilities are registered in a central registry and referenced by name in YAML configs.
Filters eliminate ineligible markets. Applied sequentially in YAML order.
| Category | Filters |
|---|---|
| Basic | whitelist, min_market_age, loan_asset_type |
| Yield | min_trailing_apy, max_trailing_apy, min_apy_stability |
| Utilization | min_avg_utilization, max_avg_utilization, max_peak_utilization |
| Liquidity | min_avg_tvl, min_liquidity_ratio |
| Risk | max_collateral_drawdown, no_bad_debt, no_red_warnings |
Constraints define bounds for the optimizer. Return scipy-compatible constraint dicts.
| Category | Constraints |
|---|---|
| Allocation | max_weight_per_market, min_weight_per_market, max_weight_per_collateral, max_weight_per_loan_asset |
| Utilization | max_avg_utilization, max_peak_utilization, min_liquidity_ratio |
| Risk | max_portfolio_concentration |
Utilities score allocation vectors. The optimizer maximizes this score.
| Category | Utilities |
|---|---|
| Yield | trailing_net_supply_apy, weighted_avg_apy |
| Risk-Adjusted | sharpe_ratio, sortino_ratio, min_variance |
| Composite | composite (weighted combination of other utilities) |
curator-bt strategy componentsMaximizes net supply APY with minimal constraints.
# config/strategies/aggressive_yield.yaml
strategy:
name: "aggressive_yield_v1"
filters:
- name: "whitelist"
- name: "min_market_age"
params: { min_days: 14 }
- name: "min_trailing_apy"
params: { min_apy: 0.05, lookback_days: 14 }
- name: "min_avg_utilization"
params: { min_util: 0.30, lookback_days: 14 }
- name: "min_avg_tvl"
params: { min_usd: 1000000, lookback_days: 14 }
- name: "no_bad_debt"
constraints:
- name: "max_weight_per_market"
params: { max_weight: 0.30 }
utility:
name: "trailing_net_supply_apy"
params: { lookback_days: 14, field: "net_supply_apy" }Low-risk stablecoin lending with yield stability focus.
# config/strategies/conservative_stable.yaml
strategy:
name: "conservative_stablecoin_v1"
filters:
- name: "whitelist"
- name: "min_market_age"
params: { min_days: 30 }
- name: "loan_asset_type"
params: { allowed_tags: ["stablecoin"] }
- name: "min_avg_tvl"
params: { min_usd: 5000000, lookback_days: 30 }
- name: "max_collateral_drawdown"
params: { max_drawdown: 0.15, lookback_days: 90 }
- name: "no_bad_debt"
- name: "no_red_warnings"
constraints:
- name: "max_weight_per_market"
params: { max_weight: 0.25 }
- name: "max_weight_per_collateral"
params: { max_weight: 0.40 }
utility:
name: "composite"
params:
components:
- name: "sharpe_ratio"
weight: 0.6
params: { lookback_days: 60, field: "net_supply_apy" }
- name: "min_variance"
weight: 0.4
params: { lookback_days: 60, field: "net_supply_apy" }The backtest engine follows this flow for each evaluation date:
1. Load Strategy Config
└── Parse YAML → Validate → Instantiate components
2. Generate Schedule
├── Rebalance dates (weekly/monthly/etc.)
└── Daily evaluation grid
3. For each evaluation date:
│
├── If REBALANCE DAY:
│ │
│ ├── Get all active markets
│ │
│ ├── FILTER CHAIN (sequential)
│ │ ├── Filter 1: whitelist
│ │ ├── Filter 2: min_market_age
│ │ ├── Filter 3: min_trailing_apy
│ │ └── ... → survivors
│ │
│ ├── BUILD CONSTRAINTS
│ │ └── Each constraint → scipy constraint dict
│ │
│ ├── OPTIMIZE
│ │ └── scipy.minimize(-utility, constraints) → weights
│ │
│ └── APPLY REBALANCE
│ └── Update portfolio, deduct costs
│
└── MARK TO MARKET
└── Fetch actual APYs, update portfolio value
4. Compute Aggregate Metrics
└── Total return, Sharpe, max drawdown, etc.
| Table | Description |
|---|---|
chains |
Blockchain network metadata (Ethereum, Base, etc.) |
assets |
ERC-20 token information (symbol, decimals, tags) |
markets |
Morpho Blue market parameters (LLTV, oracle, IRM) |
market_snapshots |
Time-series state: APY, utilization, TVL, etc. |
asset_prices |
Historical USD prices per asset |
| Table | Description |
|---|---|
backtest_runs |
Execution metadata and aggregate results |
backtest_snapshots |
Per-timestep portfolio state |
backtest_allocations |
Per-market allocation at each snapshot |
backtest_filter_log |
Audit log of filter eliminations |
All settings can be overridden via environment variables:
| Variable | Default | Description |
|---|---|---|
CURATOR_BT_DB_HOST |
localhost |
PostgreSQL host |
CURATOR_BT_DB_PORT |
5432 |
PostgreSQL port |
CURATOR_BT_DB_USER |
curator |
Database user |
CURATOR_BT_DB_PASSWORD |
curator |
Database password |
CURATOR_BT_DB_NAME |
morpho_backtest |
Database name |
CURATOR_BT_MORPHO_GRAPHQL_URL |
https://blue-api.morpho.org/graphql |
Morpho API endpoint |
CURATOR_BT_DEFAULT_CHAINS |
[1, 8453] |
Default chains to ingest |
CURATOR_BT_DEFAULT_CAPITAL |
1000000 |
Default initial capital |
CURATOR_BT_LOG_LEVEL |
INFO |
Logging level |
# Run all tests
pytest
# Run with coverage
pytest --cov=src --cov-report=html
# Run specific test file
pytest tests/test_engine.py -vmorpho-curator-backtest/
├── cli.py # CLI entry point
├── pyproject.toml # Dependencies and build config
├── alembic.ini # Alembic migration config
├── alembic/
│ ├── env.py
│ └── versions/ # Migration scripts
├── config/
│ ├── settings.py # Pydantic settings
│ └── strategies/ # Example strategy YAMLs
│ ├── aggressive_yield.yaml
│ └── conservative_stable.yaml
├── src/
│ ├── data/
│ │ ├── models.py # SQLAlchemy ORM models
│ │ ├── ingestion.py # API data ingestion
│ │ ├── provider.py # Data provider interface
│ │ └── queries.py # Database queries
│ ├── strategy/
│ │ ├── base.py # Abstract base classes
│ │ ├── config.py # YAML loader
│ │ ├── registry.py # Component registry
│ │ ├── filters/ # Filter implementations
│ │ ├── constraints/ # Constraint implementations
│ │ └── utilities/ # Utility implementations
│ ├── engine/
│ │ ├── backtester.py # Main backtest orchestrator
│ │ ├── optimizer.py # scipy-based optimizer
│ │ ├── portfolio.py # Portfolio tracking
│ │ ├── cost_model.py # Transaction cost model
│ │ └── schedule.py # Rebalance scheduling
│ ├── evaluation/
│ │ ├── metrics.py # Performance metrics
│ │ ├── benchmarks.py # Benchmark strategies
│ │ ├── comparator.py # Strategy comparison
│ │ └── export.py # Result export
│ └── dashboard/ # (Future) Web dashboard
└── tests/
├── test_engine.py
├── test_evaluation.py
└── test_strategy.py
- Create filter class in
src/strategy/filters/:
from src.strategy.base import BacktestContext, BaseFilter
from src.strategy.registry import register
@register("filter", "my_custom_filter")
class MyCustomFilter(BaseFilter):
"""Description of what this filter does."""
@property
def name(self) -> str:
return "my_custom_filter"
async def apply(self, ctx: BacktestContext, market_ids: list[str]) -> list[str]:
threshold = self.params.get("threshold", 0.5)
survivors = []
for mid in market_ids:
# Your filtering logic here
if should_include(mid):
survivors.append(mid)
return survivors-
Import in
src/strategy/filters/__init__.py -
Use in YAML:
filters:
- name: "my_custom_filter"
params:
threshold: 0.5[Add license information]
[Add contribution guidelines]