Modelo EOQ: Cantidad Económica de Pedido

Teoría, fórmulas e implementación en Python

Author

LCPallares

Published

March 25, 2026

¿Qué es el EOQ?

El EOQ (Economic Order Quantity — Cantidad Económica de Pedido) es un modelo clásico de administración de inventarios que responde a una pregunta fundamental:

¿Cuántas unidades debo pedir cada vez para minimizar los costos totales de inventario?

El modelo fue desarrollado por Ford W. Harris en 1913 y sigue siendo uno de los más utilizados en operaciones y logística.


El problema de los dos costos

Gestionar inventario implica dos costos que van en direcciones opuestas:

Costo de Ordenar (Co) Costo FIJO de hacer un pedido: • Trámites administrativos • Transporte y envío • Tiempo del equipo de compras Si pides POCO y SEGUIDO → este costo sube ↑ Ejemplo: $45 por cada pedido Costo de Mantener (Cm) Costo de tener stock almacenado: • Bodega y almacenamiento • Seguros y mermas • Capital inmovilizado Si pides MUCHO y RARA VEZ → este costo sube ↑ Ejemplo: $2 por unidad por año punto óptimo

El EOQ encuentra el punto de equilibrio donde la suma de ambos costos es mínima.


La fórmula EOQ

\[EOQ = \sqrt{\frac{2 \times D \times C_o}{C_m}}\]

Símbolo Nombre Descripción
\(D\) Demanda anual Unidades consumidas o vendidas por año
\(C_o\) Costo por orden Costo fijo de realizar un pedido ($)
\(C_m\) Costo de mantener Costo de almacenar 1 unidad durante 1 año ($)
Note¿De dónde sale la raíz cuadrada?

El costo total anual es \(CT = \frac{D}{Q} \cdot C_o + \frac{Q}{2} \cdot C_m\). Para encontrar el mínimo se deriva respecto a \(Q\), se iguala a cero y se despeja. El resultado es la fórmula EOQ. En ese punto óptimo, el costo de ordenar siempre es igual al costo de mantener.


Gráfica de costos

La siguiente gráfica muestra por qué el EOQ es óptimo: es el punto donde se cruzan el costo de ordenar y el costo de mantener, minimizando el costo total.

Cantidad pedida (Q) Costo anual ($) EOQ ≈ 245 Costo total Costo de ordenar Costo de mantener mínimo

Implementación en Python

EOQ básico

Code
import math

# ── Fórmulas ────────────────────────────────────────────────────────────
def eoq(demanda_anual, costo_orden, costo_mant):
    """
    EOQ = √(2 × D × Co / Cm)
    """
    return round(math.sqrt((2 * demanda_anual * costo_orden) / costo_mant), 2)

def costo_total_anual(demanda_anual, cantidad_pedido, costo_orden, costo_mant):
    """
    CT = (D/Q) × Co  +  (Q/2) × Cm
    """
    return round(
        (demanda_anual / cantidad_pedido) * costo_orden +
        (cantidad_pedido / 2) * costo_mant,
        2
    )

# ── Ejemplo ─────────────────────────────────────────────────────────────
D  = 1200   # unidades/año
Co = 50     # $ por orden
Cm = 2      # $ por unidad por año

Q_optima = eoq(D, Co, Cm)
pedidos_año = round(D / Q_optima, 1)
dias_entre_pedidos = round(365 / pedidos_año)
ct = costo_total_anual(D, Q_optima, Co, Cm)

print(f"EOQ (cantidad óptima por pedido) : {Q_optima} unidades")
print(f"Pedidos por año                  : {pedidos_año}")
print(f"Días entre pedidos               : {dias_entre_pedidos} días")
print(f"Costo total anual                : ${ct:,.2f}")
print()
print("Verificación del punto de equilibrio:")
print(f"  Costo de ordenar  = ${round((D/Q_optima)*Co, 2):,.2f}")
print(f"  Costo de mantener = ${round((Q_optima/2)*Cm, 2):,.2f}")
print("  (deben ser iguales en el EOQ ✓)")
EOQ (cantidad óptima por pedido) : 244.95 unidades
Pedidos por año                  : 4.9
Días entre pedidos               : 74 días
Costo total anual                : $489.90

Verificación del punto de equilibrio:
  Costo de ordenar  = $244.95
  Costo de mantener = $244.95
  (deben ser iguales en el EOQ ✓)

Stock de Seguridad y Punto de Reorden

En la realidad, la demanda y el lead time varían. Para cubrirse existe el Stock de Seguridad (SS) y el Punto de Reorden (ROP).

Fórmulas

\[SS = Z \times \sigma \times \sqrt{L}\]

\[ROP = \bar{d} \times L + SS\]

Símbolo Descripción
\(Z\) Factor de nivel de servicio (ver tabla)
\(\sigma\) Desviación estándar de la demanda diaria
\(L\) Lead time en días
\(\bar{d}\) Demanda diaria promedio (\(D / 365\))
Nivel de servicio \(Z\)
90% 1.28
95% 1.65
99% 2.33

Flujo del inventario

Tiempo Stock 245 ROP SS pedir aquí lead time stock seguridad EOQ

Implementación en Python

Code
Z_TABLE = {0.90: 1.28, 0.95: 1.65, 0.99: 2.33}

def stock_seguridad(desviacion_diaria, lead_time, nivel_servicio=0.95):
    """
    SS = Z × σ × √L
    """
    z = Z_TABLE[nivel_servicio]
    return round(z * desviacion_diaria * math.sqrt(lead_time), 2)

def rop(demanda_anual, lead_time, desviacion_diaria, nivel_servicio=0.95):
    """
    ROP = (d × L) + SS
    donde d = D / 365
    """
    d  = demanda_anual / 365
    ss = stock_seguridad(desviacion_diaria, lead_time, nivel_servicio)
    return round(d * lead_time + ss, 2)

# ── Ejemplo ─────────────────────────────────────────────────────────────
sigma    = 1.5   # desviación estándar de la demanda diaria
L        = 7     # días de lead time
nivel    = 0.95  # nivel de servicio

ss  = stock_seguridad(sigma, L, nivel)
r   = rop(D, L, sigma, nivel)

print(f"Stock de seguridad : {ss} unidades")
print(f"Punto de reorden   : {r} unidades")
print()
print(f"→ Cuando el inventario llegue a {r} unidades, pedir {Q_optima} unidades (EOQ)")
Stock de seguridad : 6.55 unidades
Punto de reorden   : 29.56 unidades

→ Cuando el inventario llegue a 29.56 unidades, pedir 244.95 unidades (EOQ)

EOQ con Descuentos por Cantidad

Los proveedores suelen ofrecer precios menores si se compra mayor volumen. Esto agrega una tercera dimensión al análisis: el ahorro en precio de compra.

Fórmula del costo total con precio

\[CT = D \times P + \frac{D}{Q} \times C_o + \frac{Q}{2} \times (CMi \times P)\]

Símbolo Descripción
\(P\) Precio unitario del tramo
\(CMi\) Tasa de mantenimiento (fracción del precio, ej: 0.25)
\(D \times P\) Costo anual de compra
ImportantDiferencia clave con el EOQ básico

En el EOQ básico, \(C_m\) es un valor fijo. Con descuentos, el costo de mantener es \(CMi \times P\), y como \(P\) cambia por tramo, hay que recalcular todo para cada precio.

Algoritmo paso a paso

Paso 1 Calcular EOQ natural para cada tramo de precio Paso 2 ¿EOQ cae en el rango válido? Usar mínimo del rango como Q válida NO Paso 3 Calcular CT completo con Q y P del tramo Paso 4 Elegir el tramo con menor CT → decisión ↺ repetir para cada tramo de precio

Implementación en Python

Code
def eoq_con_descuentos(demanda_anual, costo_orden, tasa_mantenimiento, tramos):
    """
    EOQ con descuentos por cantidad.

    CT = D×P + (D/Q)×Co + (Q/2)×(CMi×P)

    tramos: lista de dicts {'precio', 'min_q', 'max_q'}
    ordenados de menor a mayor precio (mayor a menor cantidad).
    """
    detalle = []

    for tramo in tramos:
        P     = tramo['precio']
        min_q = tramo['min_q']
        max_q = tramo['max_q']

        # Paso 1: EOQ natural con Cm = CMi × P
        costo_mant = tasa_mantenimiento * P
        q_natural  = math.sqrt((2 * demanda_anual * costo_orden) / costo_mant)

        # Paso 2: ¿cae en el rango válido?
        limite_sup = max_q if max_q is not None else float('inf')
        en_rango   = min_q <= q_natural <= limite_sup
        q_valida   = round(q_natural, 2) if en_rango else float(min_q)

        # Paso 3: costo total completo
        ct = round(
            demanda_anual * P +
            (demanda_anual / q_valida) * costo_orden +
            (q_valida / 2) * costo_mant,
            2
        )

        detalle.append({
            'precio':      P,
            'rango':       f"{min_q}{max_q or '∞'}",
            'eoq_natural': round(q_natural, 2),
            'q_valida':    q_valida,
            'en_rango':    en_rango,
            'costo_total': ct,
        })

    # Paso 4: elegir el de menor CT
    mejor = min(detalle, key=lambda x: x['costo_total'])
    return mejor, detalle

Ejemplo del análisis

Code
import pandas as pd

tramos = [
    {'precio': 60.00, 'min_q': 0,   'max_q': 299},
    {'precio': 58.80, 'min_q': 300, 'max_q': 499},
    {'precio': 57.00, 'min_q': 500, 'max_q': None},
]

mejor, detalle = eoq_con_descuentos(
    demanda_anual      = 936,
    costo_orden        = 45,
    tasa_mantenimiento = 0.25,
    tramos             = tramos
)

df = pd.DataFrame(detalle)
df['válido'] = df['en_rango'].map({True: '✓ EOQ natural', False: '→ mínimo del rango'})
df_show = df[['precio','rango','eoq_natural','q_valida','costo_total','válido']]
df_show.columns = ['Precio','Rango','EOQ natural','Q usada','Costo total','Nota']

print(df_show.to_string(index=False))
print()
print(f"DECISIÓN ÓPTIMA: pedir {mejor['q_valida']:.0f} unidades a ${mejor['precio']}")
print(f"Costo total anual: ${mejor['costo_total']:,.2f}")
 Precio   Rango  EOQ natural  Q usada  Costo total               Nota
   60.0   0–299        74.94    74.94     57284.10      ✓ EOQ natural
   58.8 300–499        75.70   300.00     57382.20 → mínimo del rango
   57.0   500–∞        76.89   500.00     56998.74 → mínimo del rango

DECISIÓN ÓPTIMA: pedir 500 unidades a $57.0
Costo total anual: $56,998.74

Comparación visual de los tramos

Comparación de costos totales por tramo $57,284 Precio $60 Q = 75 u. $57,382 Precio $58.80 Q = 300 u. $56,999 Precio $57 Q = 500 u. costo mínimo ✓
TipIntuición del resultado

Aunque pedir 500 unidades implica mayor costo de almacenamiento, el ahorro en precio de compra ($57 vs $60 por unidad × 936 unidades/año = $2,808 de ahorro) compensa con creces ese costo adicional. El análisis EOQ con descuentos captura exactamente esa compensación.


Resumen de fórmulas

Modelo Fórmula Responde a
EOQ básico \(\sqrt{2DC_o/C_m}\) ¿Cuánto pedir?
Stock de seguridad \(Z \cdot \sigma \cdot \sqrt{L}\) ¿Qué colchón guardar?
Punto de reorden \(\bar{d} \cdot L + SS\) ¿Cuándo pedir?
Costo total anual \(\frac{D}{Q}C_o + \frac{Q}{2}C_m\) ¿Cuánto cuesta?
EOQ con descuentos \(D \cdot P + \frac{D}{Q}C_o + \frac{Q}{2}(CMi \cdot P)\) ¿Conviene el descuento?

Documento generado con Quarto. Código fuente disponible en el repositorio del proyecto.

Back to top