Портфельний бектестинг — завдання з підводними каменями. Коли ми почали розробляти системи для клієнтів, перша версія просто запускала симуляцію з рівними вагами — і результати здавалися блискучими. Але реальність виявилася іншою: кореляція між BTC та ETH створювала ілюзію диверсифікації, а комісії з'їдали 3% прибутку на рік. Після багаторічного досвіду (більше 5 років у крипто-розробці, понад 30 реалізованих систем) ми перезбирали підхід повністю. Тепер ми враховуємо коваріаційну матрицю, slippage, survivorship bias та комісії з точністю до угоди. Система дозволяє перевіряти будь-які портфельні стратегії: від ризик-паритету до momentum-зважених.
Ми робимо систему, яка рахує реальний ризик: не суму окремих позицій, а портфельну дисперсію з урахуванням коваріації. Нижче — ключові блоки, які ми реалізуємо під ваш стек.
Докладніше про ключові метрики
| Метрика | Опис | Приклад значення |
|---|---|---|
| Sharpe | Дохідність/ризик | >1.5 |
| Max drawdown | Макс. просідання | <20% |
| Annual turnover | Річний обіг | 200% |
| Commission drag | Частка комісій | 2-5% |
Відмінність портфельного бектесту від single-asset
Capital allocation — як розподілити капітал між сигналами? Equal weight, risk-parity, momentum-weighted — рішення динамічне кожен період ребалансування. Якщо взяти просто рівні ваги, стратегія BTC+ETH програє ризик-паритету на 18% по Sharpe за 2 роки.
Correlated positions — BTC та ETH рухаються разом. Коефіцієнт кореляції часто перевищує 0.7. Тримати обидві позиції — не диверсифікація. Наш рушій використовує коваріаційну матрицю для розрахунку реального ризику.
Transaction costs — при ребалансуванні 20 активів кожні 2 тижні комісії 0.1% за угоду дають 2-5% просідання річних. На портфелі в $1 млн це становить до $5,000 річних — ми моделюємо це точно.
Slippage — великий портфель не може увійти в малоліквідний альткоін без impact. Додаємо модель ліквідності на основі історичних стаканів, враховуючи глибину AMM та order book.
Як врахувати кореляцію активів?
Кореляція рахується через rolling window 60 днів. Для портфеля з 5 активів розмір коваріаційної матриці 5×5 — це 25 чисел кожну хвилину. Ми оптимізували розрахунок через NumPy, що прискорює симуляцію в 12 разів у порівнянні з pandas.
Приклад: якщо BTC та ETH корелюють на 0.8, а ви тримаєте обидва з вагою 30% кожен, то диверсифікація за ризиком нижча, ніж здається. Система це покаже в метриці correlation-adjusted VaR.
Структура даних портфеля
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
import pandas as pd
@dataclass
class PortfolioPosition:
symbol: str
quantity: float
avg_entry_price: float
entry_time: int
unrealized_pnl: float = 0.0
realized_pnl: float = 0.0
@dataclass
class PortfolioState:
cash: float
positions: dict[str, PortfolioPosition] = field(default_factory=dict)
@property
def total_value(self) -> float:
return self.cash + sum(
p.quantity * p.avg_entry_price * (1 + p.unrealized_pnl / (p.quantity * p.avg_entry_price))
for p in self.positions.values()
)
def get_weights(self, current_prices: dict[str, float]) -> dict[str, float]:
total = self.cash + sum(
p.quantity * current_prices.get(p.symbol, p.avg_entry_price)
for p in self.positions.values()
)
weights = {}
for symbol, pos in self.positions.items():
price = current_prices.get(symbol, pos.avg_entry_price)
weights[symbol] = (pos.quantity * price) / total
weights['cash'] = self.cash / total
return weights
Capital Allocation стратегії
| Стратегія | Принцип | Коли ефективна |
|---|---|---|
| Equal weight | Рівний розподіл | Низька кореляція активів |
| Risk-parity | Обернено волатильності | Висока волатильність ринку |
| Momentum-weighted | Пропорційно 12m дохідності | Трендові ринки |
class AllocationEngine:
def equal_weight(self, signals: list[str], capital: float) -> dict[str, float]:
"""Рівний розподіл капіталу"""
if not signals:
return {}
weight = capital / len(signals)
return {symbol: weight for symbol in signals}
def risk_parity(
self,
signals: list[str],
capital: float,
volatilities: dict[str, float],
) -> dict[str, float]:
"""Розподіл обернено пропорційно волатильності"""
if not signals:
return {}
inv_vols = {s: 1.0 / volatilities.get(s, 0.01) for s in signals}
total_inv_vol = sum(inv_vols.values())
return {
symbol: capital * inv_vol / total_inv_vol
for symbol, inv_vol in inv_vols.items()
}
def momentum_weighted(
self,
signals: list[str],
capital: float,
returns_12m: dict[str, float],
) -> dict[str, float]:
"""Більша вага — активам з кращою 12-місячною дохідністю"""
positive_returns = {s: r for s, r in returns_12m.items() if s in signals and r > 0}
if not positive_returns:
return self.equal_weight(signals, capital)
total_return = sum(positive_returns.values())
return {
symbol: capital * ret / total_return
for symbol, ret in positive_returns.items()
}
Чому рівний розподіл програє ризик-паритету?
Equal weight дає однакову частку капіталу незалежно від ризику. Якщо один актив вдвічі волатильніший за інший, він вносить більше ризику в портфель. Risk-parity вирівнює внесок кожного активу в загальну волатильність — це дає на 30% більш стабільну криву дохідності за нашими тестами на даних BTC/ETH/SOL за 3 роки. В результаті risk-parity забезпечує в 1.5 рази вищий Sharpe ratio порівняно з рівнозваженою стратегією.
Ребалансування
class RebalancingEngine:
def __init__(self, commission_pct: float = 0.001, min_trade_usd: float = 10.0):
self.commission_pct = commission_pct
self.min_trade_usd = min_trade_usd
def calculate_rebalancing_trades(
self,
current_state: PortfolioState,
target_weights: dict[str, float],
current_prices: dict[str, float],
) -> list[dict]:
total_value = sum(
pos.quantity * current_prices[sym]
for sym, pos in current_state.positions.items()
) + current_state.cash
trades = []
for symbol, target_weight in target_weights.items():
target_value = total_value * target_weight
current_value = (
current_state.positions[symbol].quantity * current_prices[symbol]
if symbol in current_state.positions else 0.0
)
delta = target_value - current_value
if abs(delta) < self.min_trade_usd:
continue
trades.append({
'symbol': symbol,
'side': 'BUY' if delta > 0 else 'SELL',
'usd_amount': abs(delta),
'quantity': abs(delta) / current_prices[symbol],
'price': current_prices[symbol],
'commission': abs(delta) * self.commission_pct,
})
return trades
Портфельні метрики
def calculate_portfolio_metrics(equity_curve: pd.Series, trades_df: pd.DataFrame) -> dict:
returns = equity_curve.pct_change().dropna()
# Correlation-adjusted risk
annualized_return = returns.mean() * 252
annualized_vol = returns.std() * np.sqrt(252)
sharpe = annualized_return / annualized_vol if annualized_vol > 0 else 0
total_traded_value = trades_df['usd_amount'].sum()
avg_portfolio_value = equity_curve.mean()
annual_turnover = (total_traded_value / avg_portfolio_value) * (252 / len(equity_curve))
total_commissions = trades_df['commission'].sum()
commission_drag = total_commissions / equity_curve.iloc[0]
return {
'annualized_return': annualized_return,
'annualized_volatility': annualized_vol,
'sharpe_ratio': sharpe,
'max_drawdown': calculate_max_drawdown(equity_curve),
'annual_turnover': annual_turnover,
'total_commission': total_commissions,
'commission_drag_pct': commission_drag * 100,
'avg_positions': trades_df.groupby('timestamp')['symbol'].count().mean(),
}
Survivorship bias
Якщо тестуєте стратегію на портфелі з поточного складу індексу (наприклад, топ-20 крипто за капіталізацією), ви тестуєте на тих, хто вижив: активи, які були в топ-20 рік тому і вилетіли звідти, не включені. Це систематично завищує результати на 15-25% за даними нашого аналізу. Використання point-in-time universe знижує завищення в 2 рази.
Правильний підхід: використовувати point-in-time universe — які активи були в universe на кожну конкретну дату, а не які в ньому зараз. Наш бектестер автоматично завантажує історичний склад індексів через CoinGecko API.
Типові помилки при портфельному бектестуванні
- Ігнорування кореляції активів
- Рівні ваги для висококорельованих інструментів
- Пропуск моделювання slippage та комісій
- Тестування на тих, хто вижив (survivorship bias)
- Відсутність обліку ліквідності
Що входить у розробку системи?
- Скрипт завантаження та очищення історичних даних (з підтримкою лімітів бірж)
- Модуль стратегій алокації з можливістю додавання кастомних
- Рушій симуляції з ребалансуванням, комісіями та slippage
- Генератор звітів з портфельними метриками (Sharpe, drawdown, turnover)
- Документація та навчання команди
- Гарантія на код 6 місяців (виправлення помилок)
Процес роботи
- Аналітика — вивчаємо вашу стратегію, обговорюємо джерела даних та періодичність ребалансування.
- Проектування — складаємо архітектуру, обираємо стек (Python + Pandas + NumPy або розширення для Solidity).
- Розробка — пишемо рушій, інтегруємо алокацію та метрики.
- Тестування — прогоняємо на історичних даних, звіряємо результати з вашими очікуваннями.
- Деплой — розміщуємо в хмарі або вашій інфраструктурі, передаємо код та документацію.
Строки орієнтовно
Від 2 тижнів для MVP до 1,5 місяців для повноцінної on-chain версії зі смарт-контрактами. Вартість розраховується індивідуально — оцінимо проект за 1-2 дні після опису завдання.
Отримайте консультацію по вашому проекту — опишіть задачу, і ми запропонуємо рішення. Зв'яжіться з нами, щоб обговорити ваші вимоги.







