PnL по активному времени: метрика, которая меняет ранжирование стратегий

MarketMaker.cc Team
Количественные исследования и стратегии

MarketMaker.cc Team
Количественные исследования и стратегии
У вас две стратегии. Первая: PnL +300%, 418 сделок, позиция открыта 45% времени. Вторая: PnL +27%, 38 сделок, позиция открыта 5% времени. Какая лучше?
Если вы выбрали первую — вы ответили неправильно. И вот почему.
Raw PnL — итоговая доходность за весь период бэктеста — не учитывает, какую долю времени стратегия была в позиции. Стратегия с +300% и 45% trading time использует ваш капитал меньше половины времени. Остальные 55% капитал простаивает.
Стратегия с +27% и 5% trading time использует капитал лишь 5% времени — но оставшиеся 95% доступны для других стратегий.
Если вы запускаете портфель стратегий через оркестратор, время простоя одной стратегии заполняется другими. И тогда ключевая метрика — не сколько стратегия заработала за год, а сколько она зарабатывает за единицу активного времени.

где:
def pnl_per_active_time(
total_pnl: float, # итоговый PnL, %
test_period_days: int, # длина бэктеста, дней
trading_time_pct: float, # доля активного времени, 0..1
fill_efficiency: float = 0.80, # эффективность заполнения слотов
) -> dict:
"""
Расчёт эффективной доходности по активному времени.
"""
active_days = test_period_days * trading_time_pct
pnl_per_day = total_pnl / active_days
annualized_raw = pnl_per_day * 365
annualized_effective = annualized_raw * fill_efficiency
return {
"active_days": active_days,
"pnl_per_day": pnl_per_day,
"annualized_raw": annualized_raw,
"annualized_effective": annualized_effective,
}
Период: 750 дней (25 месяцев), fill_efficiency = 0.80:
| Стратегия | PnL | Trading time | Active days | PnL/day | Annualized (×0.8) |
|---|---|---|---|---|---|
| Strategy C | +300% | 45% | 337.5 | 0.89%/d | 259% |
| Strategy B | +27% | 5% | 37.5 | 0.72%/d | 210% |
| Strategy A | +58% | 15% | 112.5 | 0.51%/d | 150% |
По raw PnL: Strategy C (300%) >> Strategy A (58%) >> Strategy B (27%). По эффективной доходности: Strategy C (259%) > Strategy B (210%) > Strategy A (150%).
Strategy B с 27% PnL оказывается сопоставимой с Strategy C с 300% PnL — потому что зарабатывает те же деньги за в 9 раз меньше активного времени. Оставшиеся 95% времени можно заполнить другими стратегиями.
Формула выше — линейная. Она проще и консервативнее. Compound-вариант учитывает реинвестирование прибыли:
import numpy as np
def compound_annualized(total_pnl_pct, active_days, fill_efficiency=0.80):
"""Compound экстраполяция."""
daily_return = (1 + total_pnl_pct / 100) ** (1 / active_days) - 1
annualized = (1 + daily_return) ** (365 * fill_efficiency) - 1
return annualized * 100
b_compound = compound_annualized(27, 37.5)
c_compound = compound_annualized(300, 337.5)
При compound экстраполяции Strategy B обгоняет Strategy C: 540% vs 231%. Ранжирование перевернулось.
Рекомендация: используйте линейную экстраполяцию для ранжирования. Она консервативнее и менее склонна к поощрению overfitting на малом количестве сделок.
Strategy B с 38 сделками и PnL/day = 0.72% выглядит привлекательно. Но 38 сделок — это статистически слабая выборка. Высокий PnL/day может быть результатом удачного стечения обстоятельств.
Используем t-распределение для штрафа за малую выборку:
где — средняя доходность на сделку, — стандартное отклонение, — количество сделок, — квантиль t-распределения.
import scipy.stats as st
import numpy as np
def confidence_adjusted_score(
trade_returns: list,
test_period_days: int,
fill_efficiency: float = 0.80,
min_trades: int = 30,
confidence: float = 0.95,
) -> dict:
"""
Ранжирование стратегий с поправкой на размер выборки.
"""
n = len(trade_returns)
if n < min_trades:
return {"score": 0, "reason": f"Too few trades ({n} < {min_trades})"}
returns = np.array(trade_returns)
mean_ret = np.mean(returns)
se = np.std(returns, ddof=1) / np.sqrt(n)
alpha = 1 - confidence
t_crit = st.t.ppf(1 - alpha / 2, df=n - 1)
ci_lower = mean_ret - t_crit * se
if mean_ret <= 0:
confidence_factor = 0
else:
confidence_factor = max(0, ci_lower / mean_ret)
total_pnl = np.sum(returns)
hold_times = [...] # часы удержания каждой сделки
active_days = sum(hold_times) / 24
pnl_per_day = total_pnl / active_days if active_days > 0 else 0
annualized = pnl_per_day * 365 * fill_efficiency
score = annualized * max_leverage * confidence_factor
return {
"score": score,
"annualized": annualized,
"confidence_factor": confidence_factor,
"ci_lower": ci_lower,
"n_trades": n,
}
| Стратегия | Trades | Mean ret | SE | CI lower | Conf. factor | Adjusted score |
|---|---|---|---|---|---|---|
| Strategy B | 38 | 0.71% | 0.28% | 0.14% | 0.20 | 210% × 0.20 = 42% |
| Strategy C | 418 | 0.72% | 0.05% | 0.62% | 0.86 | 259% × 0.86 = 223% |
| Strategy A | 491 | 0.12% | 0.02% | 0.08% | 0.67 | 150% × 0.67 = 100% |
После confidence adjustment Strategy C уверенно лидирует: 418 сделок дают узкий CI и высокий confidence factor. Strategy B с 38 сделками штрафуется — её «блестящие» показатели могут быть результатом дисперсии.

Параметр fill_efficiency — это ответ на вопрос: «Какую долю времени оркестратор может держать капитал в работе?»
Простейший подход: fill_efficiency = 0.80 для всех стратегий. Предполагает, что оркестратор утилизирует 80% свободного времени другими стратегиями/парами.
Плюс: одинаков для всех, легко сравнивать. Минус: не учитывает корреляцию между стратегиями.
Если у вас пар, каждая активна времени, вероятность того, что хотя бы одна активна:
Но криптовалюты высоко коррелированы — BTC тянет за собой ETH, SOL и остальных. Эффективное число независимых пар:
def estimate_fill_efficiency(
trading_time_pct: float,
n_pairs: int,
correlation_factor: float = 3.0, # крипто — высокая корреляция
max_slots: int = 10,
) -> float:
"""
Аналитическая оценка fill_efficiency.
Args:
trading_time_pct: доля активного времени одной стратегии
n_pairs: количество торговых пар
correlation_factor: коэффициент корреляции (1=независимые, 5=сильная)
max_slots: максимальное число одновременных позиций
"""
effective_n = n_pairs / correlation_factor
p_at_least_one = 1 - (1 - trading_time_pct) ** effective_n
expected_active = effective_n * trading_time_pct
utilization = min(expected_active, max_slots) / max_slots
return min(p_at_least_one, utilization)
eff_b = estimate_fill_efficiency(0.05, 10, 3.0)
eff_c = estimate_fill_efficiency(0.45, 10, 3.0)
Для Strategy B с 5% активности и 10 коррелированными парами fill_efficiency всего ~16%. Это драматически снижает эффективную доходность.
Наиболее точный подход — прогнать все стратегии на всех парах и посчитать реальную загрузку слотов:
def simulate_fill_efficiency(
all_signals: dict, # {(strategy, pair): [(entry_time, exit_time), ...]}
max_slots: int = 10,
test_period_minutes: int = 750 * 24 * 60,
) -> float:
"""
Симуляция реальной загрузки слотов оркестратора.
"""
timeline = np.zeros(test_period_minutes)
for signals in all_signals.values():
for entry_min, exit_min in signals:
timeline[entry_min:exit_min] += 1
capped = np.minimum(timeline, max_slots)
fill_efficiency = np.mean(capped) / max_slots
return fill_efficiency
Объединяем все компоненты:
def strategy_score(
trades: list,
test_period_days: int,
fill_efficiency: float = 0.80,
min_trades: int = 30,
funding_rate: float = 0.0001,
) -> float:
"""
Финальный score для ранжирования стратегий.
Учитывает:
- PnL per active day (эффективность использования капитала)
- MaxLev (risk-adjusted масштабирование)
- Confidence adjustment (штраф за малую выборку)
- Funding costs (реалистичные costs при leverage)
"""
n = len(trades)
if n < min_trades:
return 0
returns = np.array([t.pnl_pct for t in trades])
hold_hours = np.array([t.hold_hours for t in trades])
total_pnl = np.sum(returns)
active_days = np.sum(hold_hours) / 24
pnl_per_day = total_pnl / active_days
equity = np.cumprod(1 + returns / 100)
peak = np.maximum.accumulate(equity)
max_dd = ((equity - peak) / peak).min()
max_lev = max(1, int(50 / abs(max_dd * 100)))
funding_daily = funding_rate * 3 * max_lev * 100 # в %
net_pnl_per_day = pnl_per_day - funding_daily
annualized = net_pnl_per_day * 365 * fill_efficiency
se = np.std(returns, ddof=1) / np.sqrt(n)
mean_ret = np.mean(returns)
if mean_ret <= 0:
return 0
t_crit = st.t.ppf(0.975, df=n - 1)
ci_lower = mean_ret - t_crit * se
conf_factor = max(0, ci_lower / mean_ret)
score = annualized * max_lev * conf_factor
return score
Эта метрика не заменяет, а дополняет инструменты из предыдущих статей:
Асимметрия убытков: max drawdown определяет MaxLev, который входит в формулу score. Чем глубже просадка, тем ниже score — нелинейно, из-за асимметрии восстановления.
Monte Carlo bootstrap: confidence intervals из bootstrap дают более точную оценку confidence factor, чем t-распределение. Можно заменить CI из t-distribution на 5th percentile из bootstrap.
Funding rates: funding costs вычитаются из PnL per active day. При высоком leverage и низкой PnL/day funding может сделать net score отрицательным — стратегия убыточна в реальности, несмотря на положительный raw PnL.
PnL per active time — основная метрика для ранжирования стратегий в оркестраторе. Когда несколько стратегий конкурируют за один слот — побеждает та, у которой выше score с учётом confidence adjustment.
На практике это приводит к неожиданным решениям: стратегии с «скромным» raw PnL, но коротким временем в позиции, часто получают приоритет над «яркими» стратегиями с высоким PnL, но длинными позициями. Первые эффективнее используют капитал в портфеле из десятков стратегий.
Ключевой инсайт: единственная метрика, которая масштабируется — это PnL per active day. Raw PnL не масштабируется: вы не можете запустить одну стратегию дважды. Но вы можете заполнить простои другими стратегиями — и PnL per active day точно предсказывает, сколько вы заработаете в портфеле.
Raw PnL за год — удобная, но обманчивая метрика. Она не учитывает главный ресурс трейдера — время, в которое капитал работает.
Три вывода:
Считайте PnL per active day. Стратегия с +27% за 38 дней в позиции = +0.72%/day. Стратегия с +300% за 338 дней = +0.89%/day. Разница не 11×, а 1.2×.
Учитывайте fill_efficiency. В портфеле из коррелированных крипто-пар fill_efficiency ниже, чем кажется. 10 пар ≠ 10× диверсификация. С correlation_factor = 3 эффективных пар всего ~3.
Штрафуйте за малую выборку. 38 сделок с средним +0.71% — это CI от +0.14% до +1.28%. 418 сделок с +0.72% — это CI от +0.62% до +0.82%. Вторая стратегия надёжнее, хотя средние почти равны.
Метрика PnL per active time не заменяет PnL@MaxLev — она дополняет его, добавляя измерение эффективности использования капитала. Для одиночной стратегии PnL@ML достаточен. Для портфеля стратегий — PnL per active time необходим.
@article{soloviov2026pnlactivetime, author = {Soloviov, Eugen}, title = {PnL по активному времени: метрика, которая меняет ранжирование стратегий}, year = {2026}, url = {https://marketmaker.cc/ru/blog/post/pnl-active-time-metric}, version = {0.1.0}, description = {Почему raw PnL за год — плохая метрика для сравнения стратегий с разным trading time. Как считать эффективную доходность, зачем нужен fill_efficiency, и почему стратегия с 27\% PnL может быть лучше стратегии с 300\%.} }