← Volver a los artículos
March 18, 2026
5 min de lectura

PnL por tiempo activo: la métrica que cambia el ranking de estrategias

PnL por tiempo activo: la métrica que cambia el ranking de estrategias
#algotrading
#backtest
#metrics
#PnL
#orchestration
#portfolio
#risk management

Tiene dos estrategias. La primera: PnL +300%, 418 operaciones, posición abierta el 45% del tiempo. La segunda: PnL +27%, 38 operaciones, posición abierta el 5% del tiempo. ¿Cuál es mejor?

Si eligió la primera, respondió incorrectamente. Aquí está el porqué.

El problema del PnL bruto

El PnL bruto — el retorno total durante todo el período de backtest — no tiene en cuenta qué fracción del tiempo la estrategia estuvo en posición. Una estrategia con +300% y 45% de tiempo de trading usa su capital menos de la mitad del tiempo. El 55% restante del tiempo, el capital permanece ocioso.

Una estrategia con +27% y 5% de tiempo de trading usa el capital solo el 5% del tiempo — pero el 95% restante está disponible para otras estrategias.

Si opera una cartera de estrategias a través de un orquestador, el tiempo ocioso de una estrategia es cubierto por otras. La métrica clave entonces no es cuánto ganó una estrategia en un año, sino cuánto gana por unidad de tiempo activo.

Fórmula del retorno efectivo

PnL per active day strategy ranking comparison

Cálculo básico

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}

donde:

  • Active days — tiempo total en posiciones (en días)
  • fill_efficiency — la fracción de tiempo que el orquestador puede llenar con señales (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,
    }

Recalculando estrategias reales

Período: 750 días (25 meses), fill_efficiency = 0.80:

Estrategia PnL Tiempo de trading Active days PnL/día Anualizado (x0.8)
Estrategia C +300% 45% 337.5 0.89%/d 259%
Estrategia B +27% 5% 37.5 0.72%/d 210%
Estrategia A +58% 15% 112.5 0.51%/d 150%

Por PnL bruto: Estrategia C (300%) >> Estrategia A (58%) >> Estrategia B (27%). Por retorno efectivo: Estrategia C (259%) > Estrategia B (210%) > Estrategia A (150%).

La Estrategia B con 27% de PnL resulta comparable a la Estrategia C con 300% de PnL — porque gana el mismo dinero en 9 veces menos tiempo activo. El 95% restante del tiempo puede llenarse con otras estrategias.

Extrapolación lineal vs. compuesta

La fórmula anterior es lineal. Es más simple y conservadora. La variante compuesta tiene en cuenta la reinversión de las ganancias:

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)

Con la extrapolación compuesta, la Estrategia B supera a la Estrategia C: 540% frente a 231%. El ranking se invierte.

Recomendación: use extrapolación lineal para el ranking. Es más conservadora y menos propensa a premiar el sobreajuste con un número reducido de operaciones.

La trampa: número reducido de operaciones

La Estrategia B con 38 operaciones y PnL/día = 0.72% parece atractiva. Pero 38 operaciones son una muestra estadísticamente débil. Un PnL/día alto podría ser el resultado de una coincidencia afortunada.

Puntuación ajustada por confianza

Usamos la distribución t para penalizar muestras pequeñas:

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

donde rˉ\bar{r} es el retorno medio por operación, ss es la desviación estándar, nn es el número de operaciones, tα/2,n1t_{\alpha/2, n-1} es el cuantil de la distribución 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,
    }

Impacto del ajuste por confianza

Estrategia Operaciones Ret. medio SE CI inferior Factor conf. Puntuación ajustada
Estrategia B 38 0.71% 0.28% 0.14% 0.20 210% x 0.20 = 42%
Estrategia C 418 0.72% 0.05% 0.62% 0.86 259% x 0.86 = 223%
Estrategia A 491 0.12% 0.02% 0.08% 0.67 150% x 0.67 = 100%

Tras el ajuste por confianza, la Estrategia C lidera con confianza: 418 operaciones dan un CI estrecho y un factor de confianza alto. La Estrategia B con 38 operaciones es penalizada — su rendimiento "brillante" puede ser resultado de la varianza.

fill_efficiency: dónde obtenerla

Fill efficiency and orchestrator slot allocation

El parámetro fill_efficiency responde a la pregunta: "¿Qué fracción del tiempo puede el orquestador mantener el capital trabajando?"

Opción 1: constante fija

El enfoque más simple: fill_efficiency = 0.80 para todas las estrategias. Asume que el orquestador utiliza el 80% del tiempo ocioso con otras estrategias/pares.

Ventaja: idéntico para todas, fácil de comparar. Desventaja: no tiene en cuenta la correlación entre estrategias.

Opción 2: estimación analítica

Si tiene NN pares, cada uno activo p%p\% del tiempo, la probabilidad de que al menos uno esté activo:

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

Pero las criptomonedas están altamente correlacionadas — BTC arrastra a ETH, SOL y al resto. El número efectivo de pares independientes:

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)

Para la Estrategia B con 5% de actividad y 10 pares correlacionados, fill_efficiency es solo ~16%. Esto reduce drásticamente el retorno efectivo.

Opción 3: simulación a partir de datos

El enfoque más preciso es ejecutar todas las estrategias en todos los pares y calcular la utilización real de los 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

Fórmula final de ranking

Combinando todos los componentes:

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

Conexión con otras métricas de la serie

Esta métrica no reemplaza, sino que complementa las herramientas de artículos anteriores:

  • Loss-Profit Asymmetry: el drawdown máximo determina MaxLev, que alimenta la fórmula de puntuación. Cuanto más profundo el drawdown, menor la puntuación — de forma no lineal, debido a la asimetría de la recuperación.

  • Monte Carlo bootstrap: los intervalos de confianza del bootstrap proporcionan una estimación más precisa del factor de confianza que la distribución t. Puede reemplazar el CI de la distribución t por el percentil 5 del bootstrap.

  • Funding rates: los costos de funding se restan del PnL por día activo. Con apalancamiento alto y PnL/día bajo, el funding puede hacer que la puntuación neta sea negativa — la estrategia es en realidad no rentable a pesar de un PnL bruto positivo.

Por qué esto importa para la orquestación

El PnL por tiempo activo es la métrica principal para clasificar estrategias en un orquestador. Cuando varias estrategias compiten por el mismo slot, gana la que tiene la puntuación más alta (teniendo en cuenta el ajuste por confianza).

En la práctica, esto lleva a decisiones sorprendentes: estrategias con PnL bruto "modesto" pero tiempo corto en posición a menudo tienen prioridad sobre estrategias "llamativas" con PnL alto pero posiciones largas. Las primeras usan el capital de forma más eficiente en una cartera de decenas de estrategias.

La idea clave: la única métrica que escala es el PnL por día activo. El PnL bruto no escala: no se puede ejecutar la misma estrategia dos veces. Pero se puede llenar el tiempo ocioso con otras estrategias — y el PnL por día activo predice con precisión cuánto se ganará en una cartera.

Conclusión

El PnL anual bruto es una métrica cómoda pero engañosa. No tiene en cuenta el recurso más importante del trader — el tiempo durante el cual el capital está trabajando.

Tres conclusiones clave:

  1. Calcule el PnL por día activo. Una estrategia con +27% en 38 días en posición = +0.72%/día. Una estrategia con +300% en 338 días = +0.89%/día. La diferencia no es de 11 veces, sino de 1.2 veces.

  2. Tenga en cuenta fill_efficiency. En una cartera de pares cripto correlacionados, fill_efficiency es menor de lo que parece. 10 pares no equivalen a 10 veces la diversificación. Con correlation_factor = 3, el número efectivo de pares es solo ~3.

  3. Penalice las muestras pequeñas. 38 operaciones con una media de +0.71% dan un CI de +0.14% a +1.28%. 418 operaciones con +0.72% dan un CI de +0.62% a +0.82%. La segunda estrategia es más confiable, aunque las medias sean casi idénticas.

La métrica de PnL por tiempo activo no reemplaza a PnL@MaxLev — la complementa añadiendo la dimensión de eficiencia en el uso del capital. Para una sola estrategia, PnL@ML es suficiente. Para una cartera de estrategias, el PnL por tiempo activo es esencial.


Referencias

  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

Cita

@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

Mantente a la vanguardia

Suscríbete a nuestro boletín para recibir información exclusiva sobre trading con IA, análisis de mercado y actualizaciones de la plataforma.

Respetamos tu privacidad. Puedes darte de baja en cualquier momento.