← Retour aux articles
March 18, 2026
5 min de lecture

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

PnL par temps actif : la métrique qui change le classement des stratégies
#algotrading
#backtest
#metrics
#PnL
#orchestration
#portfolio
#risk management

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égie PnL Temps de trading Active days PnL/jour Annualisé (x0.8)
Stratégie C +300% 45% 337.5 0.89%/j 259%
Stratégie B +27% 5% 37.5 0.72%/j 210%
Stratégie A +58% 15% 112.5 0.51%/j 150%

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 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 extrapolation."""
    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)

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,n1×sn\text{CI}_{lower} = \bar{r} - t_{\alpha/2, n-1} \times \frac{s}{\sqrt{n}}

rˉ\bar{r} est le rendement moyen par trade, ss est l'écart-type, nn est le nombre de trades, tα/2,n1t_{\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)

    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 = [...]  # 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


    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égie Trades Rend. moyen SE CI inf. Facteur conf. Score ajusté
Stratégie B 38 0.71% 0.28% 0.14% 0.20 210% x 0.20 = 42%
Stratégie C 418 0.72% 0.05% 0.62% 0.86 259% x 0.86 = 223%
Stratégie A 491 0.12% 0.02% 0.08% 0.67 150% 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(1p)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_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)

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.
    """
    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

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])

    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  # in %
    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

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\%.}
}
blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

Gardez une longueur d'avance sur le marché

Abonnez-vous à notre newsletter pour des insights exclusifs sur le trading IA, des analyses de marché et des mises à jour de la plateforme.

Nous respectons votre vie privée. Désabonnement possible à tout moment.