← К списку статей
March 18, 2026
5 мин. чтения

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

PnL по активному времени: метрика, которая меняет ранжирование стратегий
#алготрейдинг
#бэктест
#метрики
#PnL
#оркестрация
#портфель
#риск-менеджмент

У вас две стратегии. Первая: PnL +300%, 418 сделок, позиция открыта 45% времени. Вторая: PnL +27%, 38 сделок, позиция открыта 5% времени. Какая лучше?

Если вы выбрали первую — вы ответили неправильно. И вот почему.

Проблема raw PnL

Raw PnL — итоговая доходность за весь период бэктеста — не учитывает, какую долю времени стратегия была в позиции. Стратегия с +300% и 45% trading time использует ваш капитал меньше половины времени. Остальные 55% капитал простаивает.

Стратегия с +27% и 5% trading time использует капитал лишь 5% времени — но оставшиеся 95% доступны для других стратегий.

Если вы запускаете портфель стратегий через оркестратор, время простоя одной стратегии заполняется другими. И тогда ключевая метрика — не сколько стратегия заработала за год, а сколько она зарабатывает за единицу активного времени.

Формула эффективной доходности

PnL per active day strategy ranking comparison

Базовый расчёт

PnLdaily=Total PnLActive days\text{PnL}_{daily} = \frac{\text{Total PnL}}{\text{Active days}}

Annualizedraw=PnLdaily×365\text{Annualized}_{raw} = \text{PnL}_{daily} \times 365

Annualizedeffective=Annualizedraw×fill_efficiency\text{Annualized}_{effective} = \text{Annualized}_{raw} \times \text{fill\_efficiency}

где:

  • Active days — суммарное время в позициях (в днях)
  • fill_efficiency — доля времени, которую оркестратор может заполнить сигналами (0...1)
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% времени можно заполнить другими стратегиями.

Линейная vs compound экстраполяция

Формула выше — линейная. Она проще и консервативнее. Compound-вариант учитывает реинвестирование прибыли:

Daily return (compound)=(1+Total PnL)1/Active days1\text{Daily return (compound)} = (1 + \text{Total PnL})^{1/\text{Active days}} - 1

Annualizedcompound=(1+Daily return)365×fill_eff1\text{Annualized}_{compound} = (1 + \text{Daily return})^{365 \times \text{fill\_eff}} - 1

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 может быть результатом удачного стечения обстоятельств.

Confidence-adjusted scoring

Используем t-распределение для штрафа за малую выборку:

CIlower=rˉtα/2,n1×sn\text{CI}_{lower} = \bar{r} - t_{\alpha/2, n-1} \times \frac{s}{\sqrt{n}}

где rˉ\bar{r} — средняя доходность на сделку, ss — стандартное отклонение, nn — количество сделок, tα/2,n1t_{\alpha/2, n-1} — квантиль 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,
    }

Влияние confidence adjustment

Стратегия 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 and orchestrator slot allocation

Параметр fill_efficiency — это ответ на вопрос: «Какую долю времени оркестратор может держать капитал в работе?»

Вариант 1: фиксированная константа

Простейший подход: fill_efficiency = 0.80 для всех стратегий. Предполагает, что оркестратор утилизирует 80% свободного времени другими стратегиями/парами.

Плюс: одинаков для всех, легко сравнивать. Минус: не учитывает корреляцию между стратегиями.

Вариант 2: аналитическая оценка

Если у вас NN пар, каждая активна p%p\% времени, вероятность того, что хотя бы одна активна:

P(1 active)=1(1p)NP(\geq 1\ \text{active}) = 1 - (1 - p)^N

Но криптовалюты высоко коррелированы — BTC тянет за собой ETH, SOL и остальных. Эффективное число независимых пар:

Neff=Ncorrelation factorN_{eff} = \frac{N}{\text{correlation factor}}

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%. Это драматически снижает эффективную доходность.

Вариант 3: симуляция из данных

Наиболее точный подход — прогнать все стратегии на всех парах и посчитать реальную загрузку слотов:

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 за год — удобная, но обманчивая метрика. Она не учитывает главный ресурс трейдера — время, в которое капитал работает.

Три вывода:

  1. Считайте PnL per active day. Стратегия с +27% за 38 дней в позиции = +0.72%/day. Стратегия с +300% за 338 дней = +0.89%/day. Разница не 11×, а 1.2×.

  2. Учитывайте fill_efficiency. В портфеле из коррелированных крипто-пар fill_efficiency ниже, чем кажется. 10 пар ≠ 10× диверсификация. С correlation_factor = 3 эффективных пар всего ~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 необходим.


Полезные ссылки

  1. López de Prado — Advances in Financial Machine Learning: The Sharpe Ratio
  2. Pardo, R. — The Evaluation and Optimization of Trading Strategies
  3. Bailey, D.H. & López de Prado — The Deflated Sharpe Ratio
  4. Kelly, J.L. — A New Interpretation of Information Rate (1956)
  5. Quantopian — Lecture on Strategy Evaluation Metrics
  6. Ernest Chan — Algorithmic Trading: Portfolio Management

Цитирование

@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\%.}
}
Дисклеймер: Информация в этой статье предоставлена исключительно в образовательных и ознакомительных целях и не является финансовым, инвестиционным или торговым советом. Торговля криптовалютами сопряжена с высоким риском убытков.

MarketMaker.cc Team

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

Обсудить в Telegram
Newsletter

Будьте в курсе событий

Подпишитесь на нашу рассылку, чтобы получать эксклюзивную аналитику по AI-трейдингу и обновления платформы.

Мы уважаем вашу конфиденциальность. Отписаться можно в любой момент.