PnL par temps actif : la métrique qui change le classement des stratégies

Pourquoi le PnL annuel brut est une mauvaise métrique pour comparer des stratégies avec des temps de trading différents. Comment calculer le rendement effectif, pourquoi il faut fill_efficiency, et pourquoi une stratégie avec 27% de PnL peut surpasser une stratégie à 300%.

Vous avez deux stratégies. La première : PnL +300%, 418 trades, position ouverte 45% du temps. La seconde : PnL +27%, 38 trades, position ouverte 5% du temps. Laquelle est la meilleure ?

Si vous avez choisi la première, vous avez répondu incorrectement. Voici pourquoi.

Le problème du PnL brut

Le PnL brut — le rendement total sur l'ensemble de la période de backtest — ne tient pas compte de quelle fraction du temps la stratégie était en position. Une stratégie avec +300% et 45% de temps de trading utilise votre capital moins de la moitié du temps. Les 55% de temps restants, le capital reste inactif.

Une stratégie avec +27% et 5% de temps de trading utilise le capital seulement 5% du temps — mais les 95% restants sont disponibles pour d'autres stratégies.

Si vous exploitez un portefeuille de stratégies via un orchestrateur, le temps d'inactivité d'une stratégie est comblé par les autres. La métrique clé devient alors non pas combien une stratégie a gagné sur un an, mais combien elle gagne par unité de temps actif.

Formule du rendement effectif

PnL per active day strategy ranking comparison

Calcul de base

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}

où :

  • Active days — temps total en position (en jours)
  • fill_efficiency — la fraction du temps que l'orchestrateur peut combler avec des signaux (0...1)
def pnl_per_active_time(
    total_pnl: float,        # total PnL, %
    test_period_days: int,    # backtest length, days
    trading_time_pct: float,  # fraction of active time, 0..1
    fill_efficiency: float = 0.80,  # slot fill efficiency
) -> dict:
    """
    Calculate effective return per active time.
    """
    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,
    }

Recalcul des stratégies réelles

Période : 750 jours (25 mois), fill_efficiency = 0.80 :

StratégiePnLTemps de tradingActive daysPnL/jourAnnualisé (x0.8)
Stratégie C+300%45%337.50.89%/j259%
Stratégie B+27%5%37.50.72%/j210%
Stratégie A+58%15%112.50.51%/j150%

Par PnL brut : Stratégie C (300%) >> Stratégie A (58%) >> Stratégie B (27%). Par rendement effectif : Stratégie C (259%) > Stratégie B (210%) > Stratégie A (150%).

La Stratégie B avec 27% de PnL s'avère comparable à la Stratégie C avec 300% de PnL — car elle gagne le même argent en 9 fois moins de temps actif. Les 95% de temps restants peuvent être comblés par d'autres stratégies.

Extrapolation linéaire vs composée

La formule ci-dessus est linéaire. Elle est plus simple et plus conservatrice. La variante composée tient compte du réinvestissement des profits :

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

Annualizedcompound=(1+Daily return)365×fill_eff−1\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 extrapolation."""
    daily_return = (1 + total_pnl_pct / 100) ** (1 / active_days) - 1
    annualized = (1 + daily_return) ** (365 * fill_efficiency) - 1
    return annualized * 100

# Strategy B: 27% over 37.5 active days
b_compound = compound_annualized(27, 37.5)
# daily_return = 1.27^(1/37.5) - 1 = 0.636%
# annualized = (1.00636)^(292) - 1 = 540%

# Strategy C: 300% over 337.5 active days
c_compound = compound_annualized(300, 337.5)
# daily_return = 4.0^(1/337.5) - 1 = 0.412%
# annualized = (1.00412)^(292) - 1 = 231%

Avec l'extrapolation composée, la Stratégie B dépasse la Stratégie C : 540% contre 231%. Le classement s'inverse.

Recommandation : utilisez l'extrapolation linéaire pour le classement. Elle est plus conservatrice et moins susceptible de récompenser le surajustement sur un petit nombre de trades.

Le piège : un nombre réduit de trades

La Stratégie B avec 38 trades et un PnL/jour = 0.72% semble attrayante. Mais 38 trades constituent un échantillon statistiquement faible. Un PnL/jour élevé pourrait être le résultat d'une coïncidence chanceuse.

Score ajusté par la confiance

Nous utilisons la distribution t pour pénaliser les petits échantillons :

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

où rˉ\bar{r} est le rendement moyen par trade, ss est l'écart-type, nn est le nombre de trades, tα/2,n−1t_{\alpha/2, n-1} est le quantile de la distribution 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:
    """
    Strategy ranking with sample size adjustment.
    """
    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)

    # Lower bound of confidence interval
    alpha = 1 - confidence
    t_crit = st.t.ppf(1 - alpha / 2, df=n - 1)
    ci_lower = mean_ret - t_crit * se

    # Confidence factor: 0..1
    # If CI includes 0 -> factor ~ 0
    # If CI is far from 0 -> factor -> 1
    if mean_ret <= 0:
        confidence_factor = 0
    else:
        confidence_factor = max(0, ci_lower / mean_ret)

    # Active time calculation
    total_pnl = np.sum(returns)
    hold_times = [...]  # holding hours for each trade
    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

    # MaxLev
    # ... equity curve -> max drawdown -> MaxLev

    score = annualized * max_leverage * confidence_factor

    return {
        "score": score,
        "annualized": annualized,
        "confidence_factor": confidence_factor,
        "ci_lower": ci_lower,
        "n_trades": n,
    }

Impact de l'ajustement par la confiance

StratégieTradesRend. moyenSECI inf.Facteur conf.Score ajusté
Stratégie B380.71%0.28%0.14%0.20210% x 0.20 = 42%
Stratégie C4180.72%0.05%0.62%0.86259% x 0.86 = 223%
Stratégie A4910.12%0.02%0.08%0.67150% x 0.67 = 100%

Après ajustement par la confiance, la Stratégie C mène avec assurance : 418 trades donnent un CI étroit et un facteur de confiance élevé. La Stratégie B avec 38 trades est pénalisée — sa performance "brillante" pourrait être le résultat de la variance.

fill_efficiency : où l'obtenir

Fill efficiency and orchestrator slot allocation

Le paramètre fill_efficiency répond à la question : "Quelle fraction du temps l'orchestrateur peut-il garder le capital au travail ?"

Option 1 : constante fixe

L'approche la plus simple : fill_efficiency = 0.80 pour toutes les stratégies. Suppose que l'orchestrateur utilise 80% du temps d'inactivité avec d'autres stratégies/paires.

Avantage : identique pour toutes, facile à comparer. Inconvénient : ne tient pas compte de la corrélation entre les stratégies.

Option 2 : estimation analytique

Si vous avez NN paires, chacune active p%p\% du temps, la probabilité qu'au moins une soit active :

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

Mais les cryptomonnaies sont fortement corrélées — BTC entraîne ETH, SOL et le reste avec lui. Le nombre effectif de paires indépendantes :

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,  # crypto — high correlation
    max_slots: int = 10,
) -> float:
    """
    Analytical estimate of fill_efficiency.

    Args:
        trading_time_pct: fraction of active time for one strategy
        n_pairs: number of trading pairs
        correlation_factor: correlation coefficient (1=independent, 5=strong)
        max_slots: maximum number of simultaneous positions
    """
    effective_n = n_pairs / correlation_factor
    p_at_least_one = 1 - (1 - trading_time_pct) ** effective_n

    # Expected number of active pairs
    expected_active = effective_n * trading_time_pct
    utilization = min(expected_active, max_slots) / max_slots

    return min(p_at_least_one, utilization)

# Strategy B: 5% active, 10 pairs, correlation_factor=3
eff_b = estimate_fill_efficiency(0.05, 10, 3.0)
# effective_N = 3.3, P(>=1) = 1 - 0.95^3.3 = 15.6%

# Strategy C: 45% active, 10 pairs
eff_c = estimate_fill_efficiency(0.45, 10, 3.0)
# effective_N = 3.3, P(>=1) = 1 - 0.55^3.3 = 85.2%

Pour la Stratégie B avec 5% d'activité et 10 paires corrélées, fill_efficiency n'est que d'environ 16%. Cela réduit considérablement le rendement effectif.

Option 3 : simulation à partir des données

L'approche la plus précise consiste à exécuter toutes les stratégies sur toutes les paires et calculer l'utilisation réelle des slots :

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:
    """
    Simulate real orchestrator slot utilization.
    """
    # Build timeline: for each minute — how many positions are active
    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

    # Utilization: min(active, max_slots) / max_slots
    capped = np.minimum(timeline, max_slots)
    fill_efficiency = np.mean(capped) / max_slots

    return fill_efficiency

Formule de classement finale

Combinaison de tous les composants :

def strategy_score(
    trades: list,
    test_period_days: int,
    fill_efficiency: float = 0.80,
    min_trades: int = 30,
    funding_rate: float = 0.0001,
) -> float:
    """
    Final score for strategy ranking.

    Accounts for:
    - PnL per active day (capital usage efficiency)
    - MaxLev (risk-adjusted scaling)
    - Confidence adjustment (penalty for small sample)
    - Funding costs (realistic costs at 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])

    # 1. PnL per active day
    total_pnl = np.sum(returns)
    active_days = np.sum(hold_hours) / 24
    pnl_per_day = total_pnl / active_days

    # 2. MaxLev
    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)))

    # 3. Funding cost per active day
    funding_daily = funding_rate * 3 * max_lev * 100  # in %
    net_pnl_per_day = pnl_per_day - funding_daily

    # 4. Annualize with fill efficiency
    annualized = net_pnl_per_day * 365 * fill_efficiency

    # 5. Confidence adjustment
    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)

    # 6. Final score
    score = annualized * max_lev * conf_factor

    return score

Lien avec les autres métriques de la série

Cette métrique ne remplace pas mais complète les outils des articles précédents :

  • Loss-Profit Asymmetry : le drawdown maximal détermine MaxLev, qui alimente la formule de score. Plus le drawdown est profond, plus le score est bas — de manière non linéaire, en raison de l'asymétrie de récupération.

  • Monte Carlo bootstrap : les intervalles de confiance issus du bootstrap fournissent une estimation plus précise du facteur de confiance que la distribution t. Vous pouvez remplacer le CI de la distribution t par le 5e percentile du bootstrap.

  • Funding rates : les coûts de financement sont déduits du PnL par jour actif. Avec un fort effet de levier et un PnL/jour faible, le funding peut rendre le score net négatif — la stratégie est en réalité non rentable malgré un PnL brut positif.

Pourquoi cela compte pour l'orchestration

Le PnL par temps actif est la métrique principale pour classer les stratégies dans un orchestrateur. Lorsque plusieurs stratégies rivalisent pour le même slot, celle avec le score le plus élevé (tenant compte de l'ajustement par la confiance) l'emporte.

En pratique, cela conduit à des décisions surprenantes : des stratégies au PnL brut "modeste" mais au temps de position court obtiennent souvent la priorité sur des stratégies "spectaculaires" avec un PnL élevé mais des positions longues. Les premières utilisent le capital plus efficacement dans un portefeuille de dizaines de stratégies.

L'idée clé : la seule métrique qui passe à l'échelle est le PnL par jour actif. Le PnL brut ne passe pas à l'échelle : vous ne pouvez pas exécuter la même stratégie deux fois. Mais vous pouvez combler le temps d'inactivité avec d'autres stratégies — et le PnL par jour actif prédit précisément combien vous gagnerez dans un portefeuille.

Conclusion

Le PnL annuel brut est une métrique pratique mais trompeuse. Elle ne tient pas compte de la ressource la plus importante du trader — le temps pendant lequel le capital travaille.

Trois enseignements clés :

  1. Calculez le PnL par jour actif. Une stratégie avec +27% sur 38 jours en position = +0.72%/jour. Une stratégie avec +300% sur 338 jours = +0.89%/jour. La différence n'est pas de 11x, mais de 1.2x.

  2. Tenez compte de fill_efficiency. Dans un portefeuille de paires crypto corrélées, fill_efficiency est plus faible qu'il n'y paraît. 10 paires n'équivalent pas à une diversification 10x. Avec correlation_factor = 3, le nombre effectif de paires n'est que d'environ 3.

  3. Pénalisez les petits échantillons. 38 trades avec une moyenne de +0.71% donnent un CI de +0.14% à +1.28%. 418 trades avec +0.72% donnent un CI de +0.62% à +0.82%. La seconde stratégie est plus fiable, même si les moyennes sont presque identiques.

La métrique PnL par temps actif ne remplace pas PnL@MaxLev — elle la complète en ajoutant la dimension d'efficacité d'utilisation du capital. Pour une seule stratégie, PnL@ML suffit. Pour un portefeuille de stratégies, le PnL par temps actif est essentiel.


Références

  1. Lopez de Prado — Advances in Financial Machine Learning: The Sharpe Ratio
  2. Pardo, R. — The Evaluation and Optimization of Trading Strategies
  3. Bailey, D.H. & Lopez 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

Citation

@article{soloviov2026pnlactivetime,
  author = {Soloviov, Eugen},
  title = {PnL by Active Time: The Metric That Changes Strategy Rankings},
  year = {2026},
  url = {https://marketmaker.cc/ru/blog/post/pnl-active-time-metric},
  version = {0.1.0},
  description = {Why raw annual PnL is a poor metric for comparing strategies with different trading time. How to calculate effective return, why you need fill\_efficiency, and why a strategy with 27\% PnL can outperform one with 300\%.}
}