PnL par temps actif : la métrique qui change le classement des stratégies
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

Calcul de base
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 :
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 :
où est le rendement moyen par trade, est l'écart-type, est le nombre de trades, 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

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 paires, chacune active du temps, la probabilité qu'au moins une soit active :
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 :
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 :
-
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.
-
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.
-
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
- Lopez de Prado — Advances in Financial Machine Learning: The Sharpe Ratio
- Pardo, R. — The Evaluation and Optimization of Trading Strategies
- Bailey, D.H. & Lopez de Prado — The Deflated Sharpe Ratio
- Kelly, J.L. — A New Interpretation of Information Rate (1956)
- Quantopian — Lecture on Strategy Evaluation Metrics
- 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\%.}
}
Authors
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.