Guillermo Meléndez Alonso

Guillermo Meléndez Alonso (Grupo BME)

Responsable del laboratorio de Inteligencia Artificial de Bolsas y Mercados Españoles Inntech Director del máster de IA Aplicada a los Mercados Financieros (MIAX), de Instituto BME
Finanzas y bolsa

La frontera de Markowitz es el conjunto de carteras eficientes, las cuales ofrecen una mayor rentabilidad esperada según los diferentes niveles de riesgo que se pueden asumir (o el menor riesgo para una rentabilidad esperada).

Se representa gráficamente como una curva, en dónde cualquier cartera que no se encuentre sobre la línea de la frontera no será eficiente, y por lo tanto estará corriendo riesgos innecesarios o recibiendo una rentabilidad inferior a la que podría obtener, con respecto al riesgo que está asumiendo.

La frontera de carteras eficiente representa la relación óptima que encontramos en una cartera de inversión entre volatilidad y rentabilidad, es decir, entre los beneficios que el inversor podrá obtener y los riesgos que deberá afrontar para hacerlo. Y en este problema, la correlación entre activos tiene mucho que decir en la formación de la cartera eficiente.

Image
carteras
Cálculo de la frontera de Markowitz con 5 activos y 50.000 simulaciones (programación en R)
  # Cargamos los datos
  datos <- read.csv("indices.csv", sep=';',stringsAsFactors = FALSE) 
  activos<- datos[,2:length(datos)] # Extraemos los activos quitando la primera.
  
  # Calculamos la rentabilidad de los activos.
  rent_activos<-as.data.frame(matrix(0,nrow=dim(activos)[1],ncol=length(activos),byrow=F)) 
  names(rent_activos)<-names(activos) # Ponemos nombres a las columnas
  rownames(rent_activos) <- datos[,1] # Ponemos nombres a las filas.
  
	for (dia in 2:dim(activos)[1]){
	  for (activo in 1:length(activos)){ 
		rent_activos[dia,activo]<-log(activos[dia,activo]/activos[dia-1,activo])
	  }  
	}
  
  rent_activos<-rent_activos[2:dim(activos)[1],1:length(activos)] # Quitamos la primera línea dado que son ceros.
  
  # Calculamos la matriz de correlaciones
  matriz_correlaciones<-cor(rent_activos)
  
  # Calculamos la matriz de varianzas / covarianzas.
  matriz_var_covarianzas<-cov(rent_activos) # Obtenemos diréctamente la matriz de varianzas covarianzas 
  
  # calculamos la rentabilidad, riesgo y eficiencia de N carteras aleatorias.
  num_simulaciones <- 50000
  matriz_pesos <- as.data.frame(matrix(0,nrow=dim(activos)[1],ncol=length(activos),byrow=F)) 
  names(matriz_pesos)<-names(activos)
  rentabilidad_carteras <- c(1:num_simulaciones)
  riesgo_carteras <- c(1:num_simulaciones)
  eficiencia_carteras <- c(1:num_simulaciones)
  
  progreso <- winProgressBar(title= "Barra de progreso", min = 0, max = num_simulaciones, width=300)
  for (cartera in 1:num_simulaciones){
  
	# Sacamos los pesos de la cartera 
	pesos <- runif(dim(activos)[2], min = 0, max = 100)
	pesos[1:dim(activos)[2]]<-(pesos[1:dim(activos)[2]]/sum(pesos)) # Los pesos deben sumar 100%.
	matriz_pesos[cartera,1:dim(activos)[2]]<- pesos
	
	# Calculamos la rentabilidad diaria del periodo para cada activo: LN(precio final/ precio inicial)/nº de datos
	rent_diaria <-as.data.frame(matrix(0,nrow=1,ncol=length(activos),byrow=F))
	names(rent_diaria)<-names(activos) # Ponemos nombres a las columnas 
	
	for (activo in 1:length(activos)){ # Una manera más lenta de hacerlo.
	  rent_diaria[activo]<-log(activos[dim(activos)[1],activo]/activos[1,activo])/dim(rent_activos)[1]
	}  
	# Calculamos la rentabilidad de la cartera según los pesos (suma producto pesos con rentabilidad diaria)
	rentabilidad_carteras[cartera]<-sum(pesos*rent_diaria)
	
	# calculamos el riesgo de la cartera (desviación), en función de los pesos.
	vector<-matriz_var_covarianzas%*%pesos # Multiplicamos la matriz de cov/var por el vector fila de pesos.
	riesgo_carteras[cartera]<-(t(vector)%*%pesos)^0.5 # Riesgo = vector anterior por vector pesos elevado a 0,5
	
	# calculamos la eficiencia de la cartera (pendiente), en función de los pesos.
	  # (y2 - y1)/(x2-x1) donde Y1 e Y1 son el origen (0,0)
	eficiencia_carteras[cartera] <- (rentabilidad_carteras[cartera])/(riesgo_carteras[cartera])
  
	setWinProgressBar(progreso, cartera, title=paste(round(cartera/num_simulaciones*100,0), "%"))
  }
  close(progreso)
  
  # Graficamos la frontera de Markowitz (riesgo y rentabilidad para cada vector de pesos)
  plot(riesgo_carteras,rentabilidad_carteras, cex = .5, pch=19)
  
  # Localizamos la cartera con mayor rentabilidad, menor riesgo y mayor eficiencia.
  matriz_pesos$rentabilidad_cartera <-rentabilidad_carteras # Unimos todos los datos en un único DF.
  matriz_pesos$riesgo_cartera <-riesgo_carteras
  matriz_pesos$eficiencia_cartera <-eficiencia_carteras
  
  max_rentabilidad<-matriz_pesos[matriz_pesos$rentabilidad_cartera==max(matriz_pesos$rentabilidad_cartera),]
  min_riesgo<-matriz_pesos[matriz_pesos$riesgo_cartera==min(matriz_pesos$riesgo_cartera),]
  max_eficiencia<-matriz_pesos[matriz_pesos$eficiencia_cartera==max(matriz_pesos$eficiencia_cartera),]
  
  max_rentabilidad
  min_riesgo
  max_eficiencia
  
  • Tiempo de ejecución con 5 activos: 11 minutos
  • Tiempo de ejecución con 30 activos: 10 horas con 20 minutos
  • Tiempo de ejecución con 100 activos: aproximadamente 2 meses

Hay que tener presente que, a más activos, más simulaciones debemos realizar 

Podemos resolver el problema vectorizando todos los cálculos, eliminando los bucles y sacando todos los números aleatorios de una sola vez. Probablemente este enfoque resuelva el problema de tiempo de ejecución. Pongámoslo a prueba.

Cálculo de la frontera de Markowitz con 5 activos y 50.000 simulaciones (programación en R)
# Cargamos los datos
datos <- read.csv("indices.csv", sep=';',stringsAsFactors = FALSE) 
activos<- datos[,2:length(datos)] # Extraemos los activos quitando la primera.

# Calculamos la rentabilidad de los activos.
rent_activos<-as.data.frame(matrix(0,nrow=dim(activos)[1],ncol=length(activos),byrow=F)) 
names(rent_activos)<-names(activos) # Ponemos nombres a las columnas
rownames(rent_activos) <- datos[,1] # Ponemos nombres a las filas.
rent_activos[2:dim(activos)[1],1:length(activos)] <- log(activos[2:dim(activos)[1],1:length(activos)]/activos[2:dim(activos)[1]-1,1:length(activos)])
rent_activos<-rent_activos[2:dim(activos)[1],1:length(activos)] # Quitamos la primera línea dado que son ceros.

# Calculamos la matriz de correlaciones
matriz_correlaciones<-cor(rent_activos)

# Calculamos la matriz de varianzas / covarianzas.
matriz_var_covarianzas<-cov(rent_activos) # Obtenemos diréctamente la matriz de varianzas covarianzas

# Calculamos la rentabilidad diaria del periodo para cada activo: LN(precio final/ precio inicial)/nº de datos
rent_diaria <-as.data.frame(matrix(0,nrow=1,ncol=length(activos),byrow=F))
names(rent_diaria)<-names(activos) # Ponemos nombres a las columnas

rent_diaria<-t(rent_diaria)
activos<-t(activos) # rentabilidad vectorial: traspongo precios para que los activos sean filas y no columnas.
rent_diaria[1:dim(activos)[1]]<-log(activos[1:dim(activos)[1],dim(activos)[2]]/activos[1:dim(activos)[1],1])/dim(rent_activos)[1]
rent_diaria<-t(rent_diaria)
activos<-t(activos)

# Sacamos la matriz de pesos para cada cartera
num_simulaciones <- 50000
pesos <- runif(dim(activos)[2]*num_simulaciones, min = 0, max = 100) # pesos de todas simulaciones de una vez.
pesos <- matrix(pesos, nrow=num_simulaciones, ncol=dim(activos)[2], byrow=TRUE) 
pesos[,1:dim(activos)[2]]<-(pesos[,1:dim(activos)[2]]/rowSums(pesos)) # Escalamos los pesos a 1

# Calculamos la rentabilidad de la cartera en función de los pesos 
  # Para cada conjunto de pesos queremos hacer suma producto de pesos con rentabilidad diaria. 
rentabilidad_carteras <- c(1:num_simulaciones) # vector para guardar las rentabilidades de cada cartera.
matriz_intermedia <- matrix(0,nrow=dim(activos)[1],ncol=dim(activos)[2],byrow=F) 
matriz_intermedia<-t(as.vector(rent_diaria)*t(pesos)) # Sin as.vector da error al multiplicar dos matrices.
rentabilidad_carteras<-rowSums(matriz_intermedia) # Sumamos las filas para sacar rentabilidad de cada cartera.

# calculamos el riesgo de la cartera (desviación), en función de los pesos.
riesgo_carteras <- c(1:num_simulaciones) # Generamos un vector donde guardaremos el riesgo de cada cartera.

  # Multiplicamos la matriz de var/covar por cada fila de la matriz de pesos.
matriz_intermedia <- pesos[1:num_simulaciones,]%*%matriz_var_covarianzas 
 
 # Multiplicamos cada fila de la matriz intermedia * cada fila de la matriz pesos y el rdo lo elevamos a 0.5. 
  # Ojo, la multiplicación es matricial de dos vectores.
matriz_intermedia <- matriz_intermedia * pesos
riesgo_carteras <- rowSums(matriz_intermedia)
riesgo_carteras <- riesgo_carteras^0.5

# calculamos la eficiencia de la cartera (pendiente), en función de los pesos.
eficiencia_carteras <- c(1:num_simulaciones)
eficiencia_carteras[1:num_simulaciones] <- rentabilidad_carteras[1:num_simulaciones]/riesgo_carteras[1:num_simulaciones]

# Graficamos la frontera de Markowitz (riesgo y rentabilidad para cada vector de pesos)
plot(riesgo_carteras,rentabilidad_carteras, cex = .5, pch=19)

# Localizamos la cartera con mayor rentabilidad, menor riesgo y mayor eficiencia.
pesos<-as.data.frame(pesos)
names(pesos)<-names(rent_activos) # Ponemos nombres a las columnas 
pesos$rentabilidad_cartera <-rentabilidad_carteras # Unimos todos los datos en un único DF.
pesos$riesgo_cartera <-riesgo_carteras
pesos$eficiencia_cartera <-eficiencia_carteras

max_rentabilidad<-pesos[pesos$rentabilidad_cartera==max(pesos$rentabilidad_cartera),]
min_riesgo<-pesos[pesos$riesgo_cartera==min(pesos$riesgo_cartera),]
max_eficiencia<-pesos[pesos$eficiencia_cartera==max(pesos$eficiencia_cartera),]
  • Tiempo de ejecución con 5 activos: 0,44 segundos
  • Tiempo de ejecución con 30 activos: 1,67 segundos
  • Tiempo de ejecución con 100 activos: aproximadamente 5 segundos 

Sin duda hemos resuelto el problema del tiempo de ejecución. Pero si rascamos un poco la superficie, al centrarnos en la mejora de tiempo, hemos perdido el foco del problema y ya no estamos generando la frontera de Markowitz.

carteras 2

 

El procedimiento de generación de números aleatorios vectorizado asigna un porcentaje a cada uno de los activos. Posteriormente se transforma a una base 100 por lo que, en todas las simulaciones, todos los activos tienen un porcentaje asignado. De ahí que exista un núcleo central. Prácticamente todos los activos tienen, en todas las simulaciones, el mismo porcentaje invertido. Y este problema se agrava según vamos incrementando el número de activos con los que trabajamos. 

¿Qué os parece si tratamos de resolver un reto divertido y calculamos la frontera de Markowitz para 70.000 activos? 

Se me ocurren 6 maneras de afrontar este reto: 

  • Generando carteras aleatorias (enfoque clásico actualizado)
  • Aplicando una función cuadrática que aproxima la frontera
  • Usando algoritmos genéticos
  • Usando algoritmos enjambre
  • Aplicando grafos
  • Utilizando computación cuántica para la generación de aleatorios 

Vamos a tratar de resolver el problema de la cartera óptima empleando las aproximaciones primera y tercera (carteras aleatorias y algoritmos genéticos).

Generando carteras aleatorias (enfoque clásico actualizado en python)

Para resolver el problema se ha utilizado el siguiente ordenador

Alienware Aurora R11, I9-10900KF de 3.70GHz con 10 procesadores y 64 GB de RAM

Antes de empezar, debemos hacernos varias preguntas que tendremos que resolver:

  • Los activos no tienen los mismos días cotizados, ¿cómo los homogeneizamos?
  • Hay días en los que la mayoría de los activos no tienen cotización ¿qué hacemos?
  • ¿Con cuántos activos nos quedamos y por qué?
  • ¿Cuántas simulaciones vamos a realizar? trabajamos con muchos activos…
  • ¿Cómo generamos los números aleatorios y cuantos generamos? 70.000 activos * 50.000 simulaciones son 3.500 millones de números (más de 10 GB pickle) y 50.000 simulaciones no son suficientes para la cantidad de activos con la que estamos trabajando... tenemos un problema y serio
  • ¿Cuántos activos seleccionamos en cada simulación para darles peso?, ¿Cómo los seleccionamos?
  • ¿Cómo asignamos peso a los activos seleccionados?

Son preguntas muy importantes, que deberemos ir resolviendo.

Resolviendo el problema de la homogeneización
def homogeizar(datos):

    lista_isin = datos.iloc[:,2].unique()

    fechas_posibles = datos.date.unique()
    fechas_posibles.sort()
    fechas_posibles = pd.date_range(fechas_posibles[0], fechas_posibles[-1], freq=BDay())
    datos_ordenados = pd.DataFrame(columns=[""], index = fechas_posibles) 

    for isin in lista_isin:

        print(isin)
        isin_extraido = extraccion_datos(isin, datos)

        # La gran mayoría tiene más de 200 días, pero hay varios con menos de 100 datos
        # Debemos filtrar los activos para poder trabajar

        if (isin_extraido.shape[0] > fechas_posibles.shape[0]*0.7):

            datos_ordenados = pd.concat([datos_ordenados, isin_extraido], axis=1) # alternativa datos_ordenados = datos_ordenados.join(isin_extraido)

    datos_ordenados = datos_ordenados.drop(columns='')

    # Encontramos las filas donde un % elevado son Nan y las eliminamos
    dias_eliminar = ~(datos_ordenados.isna().sum(axis=1) > datos_ordenados.shape[1]*0.9)
    datos_ordenados = datos_ordenados.loc[dias_eliminar,:]

    # Completamos los Nan con los datos del día anterior
    datos_ordenados = datos_ordenados.fillna(method='ffill')

    # Aquellos valores que no tienen un primer día cotizado, el método anterior no sirve
    datos_ordenados = datos_ordenados.fillna(method='bfill')

    datos_ordenados.to_pickle("datos_ordenados.pkl")     
    
    return datos_ordenados
Resolviendo el problema de la generación de pesos
num_activos = datos_ordenados.shape[1]
simulaciones = 50000

# Este es el sistema más rápido para generar números aleatorios en python
aleatorios = np.random.randint(low=1, high=100, size=num_activos * simulaciones)
aleatorios = aleatorios.reshape((simulaciones, num_activos))

# Localizamos en cada fila aquellos números que superan un umbral
umbral = 98

aleatorios[aleatorios < umbral] = 0
aleatorios[aleatorios > = umbral] = 1

print(aleatorios.sum(axis=1)) # Sacando así los números aleatorios, cada fila tiene más de 1000 activos seleccionados (este sistema no nos vale)

def generador_pesos(datos_ordenados, simulaciones = 10000):

    num_activos = datos_ordenados.shape[1]
    aleatorios = np.random.random((simulaciones,num_activos))

    umbral = 0.9998
    aleatorios[aleatorios < umbral] = 0

    # Buscamos y eliminamos las simulaciones que no tienen ningún activo seleccionado
    seleccion = ~(aleatorios.sum(axis=1) == 0)
    aleatorios = aleatorios[seleccion,:]

    # Asignamos los pesos a cada cartera
    # Para poder usar los aleatorios, dado que todos los activos seleccionados superan un umbral, debo restar el umbral
    # Si reescalase el peso, ningún activo de una cartera de 3 elementos superaría el 50% - 60% de peso. No puedo asignar los pesos reescalando.
    aleatorios[aleatorios!=0] = (aleatorios[aleatorios!=0] - umbral) * 10000

    for sim in range(aleatorios.shape[0]):

        vector_pesos = aleatorios[sim,:]    
        posiciones = set(np.where(vector_pesos!=0)[0])

        # Obtenemos el órden en el que vamos a asignar los pesos
        posiciones_aleatorias = random.sample(posiciones, len(posiciones))

        peso = 0
        for pos in posiciones_aleatorias:
            if peso < 1:
                peso += aleatorios[sim, pos]

                if peso > 1:            
                    exceso = peso - 1
                    aleatorios[sim, pos] = aleatorios[sim, pos] - exceso
                    peso = 1            

            elif peso == 1:
                aleatorios[sim, pos] = 0
    
    # En las carteras con 1 o 2 activos, no van a sumar siempre un 100% invertido. 
    # De cada 10.000 simulaciones, con un umbral de 0.9999 ocurre 600 - 700 veces. 
    # Con un unbral de 0.9998 de 1 a 5 veces.
    # Lo damos por bueno.
    
    return aleatorios


Generando la frontera de Markowitz
  def frontera_markowitz(num_simulaciones=100000):

# Cargamos los datos
try:
    datos_ordenados = pd.read_pickle("datos_ordenados.pkl")

except:
    datos = cargar_ficheros()
    comprobacion = comprobar_homogeneizacion(datos)

    if comprobacion == False:
        datos_ordenados = homogeizar(datos)

# Inicializamos el valor de la cartera más eficiente y preparamos el gráfico de la frontera
eficiencia_inicial = -100
plt.figure(figsize=(20,15))    

# Calculamos la rentabilidad de los activos
rent_activos = np.log(datos_ordenados).diff() 
rent_activos = rent_activos.iloc[1:,:]

# Calculamos la matriz de correlaciones
try:
    matriz_correlaciones = pd.read_pickle("matriz_correlaciones.pkl")
except:
    matriz_correlaciones = rent_activos.corr()
    matriz_correlaciones.to_pickle("matriz_correlaciones.pkl") # EL pickle ocupa 25,4 GB

# Calculamos la matriz de varianzas / covarianzas
matriz_covarianzas = rent_activos.cov()
matriz_covarianzas = matriz_covarianzas.to_numpy(dtype='float') 

# Calculamos la rentabilidad diaria del periodo para cada activo: LN(precio final/ precio inicial)/nº de datos
precio_inicial = datos_ordenados.iloc[0,:]
precio_final = datos_ordenados.iloc[-1,:]

rentabilidad_diaria = np.log(precio_final / precio_inicial) / datos_ordenados.shape[0]

num_bloques = int(np.ceil(num_simulaciones/10000))
for bloque in range(num_bloques):

    # Sacamos la matriz de pesos para cada cartera
    matriz_pesos = generador_pesos(datos_ordenados, simulaciones = 10000)

    # Calculamos la rentabilidad de la cartera en función de los pesos 
    auxiliar = rentabilidad_diaria.values * matriz_pesos
    rentabilidad_carteras = auxiliar.sum(axis=1)

    # Calculamos el riesgo de la cartera (desviación), en función de los pesos
    auxiliar = np.dot(matriz_pesos, matriz_covarianzas) # Reutilizo la matriz auxiliar con otro propósito para no sobrecargar la RAM
    riesgo_carteras = pow((auxiliar * matriz_pesos).sum(axis=1), 0.5)

    # Hay que quitar outlayers: quantiles max de riesgo y quantiles minimos de rentabilidad
    filtro_riesgo = riesgo_carteras < np.percentile(riesgo_carteras, 99)
    riesgo_carteras = riesgo_carteras[filtro_riesgo]
    rentabilidad_carteras = rentabilidad_carteras[filtro_riesgo]

    filtro_rentabilidad = rentabilidad_carteras > np.percentile(rentabilidad_carteras, 1)
    riesgo_carteras = riesgo_carteras[filtro_rentabilidad]
    rentabilidad_carteras = rentabilidad_carteras[filtro_rentabilidad]

    # Calculamos la eficiencia de la cartera (pendiente), en función de los pesos
    # Existen posiciones que van a dar error, 0/0. Las localizo y hago que 0/1 = 0
    posiciones_0entre0 = (rentabilidad_carteras==0) * (riesgo_carteras==0)
    riesgo_carteras[posiciones_0entre0] = 0.0000001
    eficiencia_carteras = rentabilidad_carteras / riesgo_carteras

    # Localizamos la cartera con mayor eficiencia
    max_eficiencia = max(eficiencia_carteras)

    if max_eficiencia > eficiencia_inicial:

        posicion_cartera_eficiente = np.where(eficiencia_carteras == max_eficiencia)[0]
        cartera_eficiente = matriz_pesos[posicion_cartera_eficiente,:]    
        posicion_activos_eficientes = np.where(cartera_eficiente!=0)

        peso_activos_eficientes = cartera_eficiente[posicion_activos_eficientes]            
        isin_activos_eficientes = rent_activos.columns.values[posicion_activos_eficientes[1]]            
        eficiencia_inicial = max_eficiencia

    # Graficamos la frontera de Markowitz (riesgo y rentabilidad para cada vector de pesos)
    print(f'Iteracion {bloque+1} de {num_bloques}')
    plt.plot(riesgo_carteras,rentabilidad_carteras, 'o', color="b")

# Dejamos bonito el gráfico y ajustamos la escala, para ver bien la frontera.
plt.xlabel("Riesgo")
plt.ylabel("Rentabilidad")

scale_factor = 3
xmin, xmax = plt.xlim()
ymin, ymax = plt.ylim()
plt.xlim(xmin * scale_factor, xmax * scale_factor)
plt.ylim(ymin * scale_factor/2, ymax * scale_factor/2)
plt.savefig('frontera_markowitz.png')

return isin_activos_eficientes, peso_activos_eficientes, max_eficiencia
Image
ffrontera de markowitz

Hemos conseguido generar la frontera de Markowitz, pero para ello hemos necesitado un ordenador muy potente y casi 24 horas para generar 5 millones de carteras aleatorias, teniendo mucho cuidado en los procesos de selección de activos y asignación de pesos.

 De hecho, el cálculo de la matriz de covarianzas requiere de 25 GB de memoria RAM, por lo que no es un proceso que esté al alcance de todo el mundo. Ni tampoco parece la solución óptima.

 ¿Qué alternativas tenemos entonces?

Generando la cartera eficiente a través de algoritmos genéticos (python)

Recapitulando: queremos obtener la cartera óptima de un conjunto de 70.000 activos.

Vamos a intentar resolver el problema usando algoritmos genéticos. Para lo que tendremos que resolver distintos problemas:

Los inversores (cromosomas) deben poder generar carteras con un número variable de activos. Estableceremos el mínimo en 1 activo y 20 en el máximo. El número de activos debe poder modificarse entre generaciones, sin caer en extremos (que siempre salgan carteras de 1 activo o de 20). Debemos pensar un sistema coherente, para resolver este punto.

El método de selección de padres no parece, a priori, problemático, pero el cruce y la mutación sí. ¿Cómo va a heredar el hijo la información genética de los padres? Aquí tenemos un triple problema a resolver:

  • ¿Cuál es el número de activos que hereda un hijo de dos padres? Si un padre tiene 5 activos y el otro 7 (distintos entre sí), ¿cuántos activos tendrá el hijo? Debemos pensar en el punto primero. El hijo no puede tener un número de activos creciente en cada generación. El sistema tiene que ser dinámico, creciente y decreciente, respetando los límites de 1 y 20.
  • ¿Qué porcentaje de inversión hereda cada activo para que sume 100%? Debemos permitir la construcción de carteras donde unos pocos activos (1 o 2) se lleven un porcentaje muy elevado del capital disponible, para explorar todas las soluciones. Reescalar los pesos no es una solución aceptable.
  • La evolución, en este problema no puede limitarse a selección y cruzamiento o caeremos rápidamente en un mínimo local. Debemos encontrar un sistema que equilibre la selección con la exploración, si queremos encontrar la cartera óptima.

Manos a la obra:

Generamos la población inicial
def frontera_markowitz(num_simulaciones=100000):

# Cargamos los datos
try:
    datos_ordenados = pd.read_pickle("datos_ordenados.pkl")

except:
    datos = cargar_ficheros()
    comprobacion = comprobar_homogeneizacion(datos)

    if comprobacion == False:
        datos_ordenados = homogeizar(datos)

# Inicializamos el valor de la cartera más eficiente y preparamos el gráfico de la frontera
eficiencia_inicial = -100
plt.figure(figsize=(20,15))    

# Calculamos la rentabilidad de los activos
rent_activos = np.log(datos_ordenados).diff() 
rent_activos = rent_activos.iloc[1:,:]

# Calculamos la matriz de correlaciones
try:
    matriz_correlaciones = pd.read_pickle("matriz_correlaciones.pkl")
except:
    matriz_correlaciones = rent_activos.corr()
    matriz_correlaciones.to_pickle("matriz_correlaciones.pkl") # EL pickle ocupa 25,4 GB

# Calculamos la matriz de varianzas / covarianzas
matriz_covarianzas = rent_activos.cov()
matriz_covarianzas = matriz_covarianzas.to_numpy(dtype='float') 

# Calculamos la rentabilidad diaria del periodo para cada activo: LN(precio final/ precio inicial)/nº de datos
precio_inicial = datos_ordenados.iloc[0,:]
precio_final = datos_ordenados.iloc[-1,:]

rentabilidad_diaria = np.log(precio_final / precio_inicial) / datos_ordenados.shape[0]

num_bloques = int(np.ceil(num_simulaciones/10000))
for bloque in range(num_bloques):

    # Sacamos la matriz de pesos para cada cartera
    matriz_pesos = generador_pesos(datos_ordenados, simulaciones = 10000)

    # Calculamos la rentabilidad de la cartera en función de los pesos 
    auxiliar = rentabilidad_diaria.values * matriz_pesos
    rentabilidad_carteras = auxiliar.sum(axis=1)

    # Calculamos el riesgo de la cartera (desviación), en función de los pesos
    auxiliar = np.dot(matriz_pesos, matriz_covarianzas) # Reutilizo la matriz auxiliar con otro propósito para no sobrecargar la RAM
    riesgo_carteras = pow((auxiliar * matriz_pesos).sum(axis=1), 0.5)

    # Hay que quitar outlayers: quantiles max de riesgo y quantiles minimos de rentabilidad
    filtro_riesgo = riesgo_carteras < np.percentile(riesgo_carteras, 99)
    riesgo_carteras = riesgo_carteras[filtro_riesgo]
    rentabilidad_carteras = rentabilidad_carteras[filtro_riesgo]

    filtro_rentabilidad = rentabilidad_carteras > np.percentile(rentabilidad_carteras, 1)
    riesgo_carteras = riesgo_carteras[filtro_rentabilidad]
    rentabilidad_carteras = rentabilidad_carteras[filtro_rentabilidad]

    # Calculamos la eficiencia de la cartera (pendiente), en función de los pesos
    # Existen posiciones que van a dar error, 0/0. Las localizo y hago que 0/1 = 0
    posiciones_0entre0 = (rentabilidad_carteras==0) * (riesgo_carteras==0)
    riesgo_carteras[posiciones_0entre0] = 0.0000001
    eficiencia_carteras = rentabilidad_carteras / riesgo_carteras

    # Localizamos la cartera con mayor eficiencia
    max_eficiencia = max(eficiencia_carteras)

    if max_eficiencia > eficiencia_inicial:

        posicion_cartera_eficiente = np.where(eficiencia_carteras == max_eficiencia)[0]
        cartera_eficiente = matriz_pesos[posicion_cartera_eficiente,:]    
        posicion_activos_eficientes = np.where(cartera_eficiente!=0)

        peso_activos_eficientes = cartera_eficiente[posicion_activos_eficientes]            
        isin_activos_eficientes = rent_activos.columns.values[posicion_activos_eficientes[1]]            
        eficiencia_inicial = max_eficiencia

    # Graficamos la frontera de Markowitz (riesgo y rentabilidad para cada vector de pesos)
    print(f'Iteracion {bloque+1} de {num_bloques}')
    plt.plot(riesgo_carteras,rentabilidad_carteras, 'o', color="b")

# Dejamos bonito el gráfico y ajustamos la escala, para ver bien la frontera.
plt.xlabel("Riesgo")
plt.ylabel("Rentabilidad")

scale_factor = 3
xmin, xmax = plt.xlim()
ymin, ymax = plt.ylim()
plt.xlim(xmin * scale_factor, xmax * scale_factor)
plt.ylim(ymin * scale_factor/2, ymax * scale_factor/2)
plt.savefig('frontera_markowitz.png')

return isin_activos_eficientes, peso_activos_eficientes, max_eficiencia
    
Calculamos la función de fitness
def calcular_fitness(datos_ordenados, posiciones):
    
    # La función objetivo del AG es maximizar la eficiencia de la cartera (su relación rentabilidad / riesgo)
    
    datos = datos_ordenados.iloc[:,list(posiciones)]

    # Calculamos la rentabilidad de los activos
    rent_activos = np.log(datos).diff() 
    rent_activos = rent_activos.iloc[1:,:]

    # Calculamos la matriz de correlaciones
    matriz_correlaciones = rent_activos.corr()

    # Calculamos la matriz de varianzas / covarianzas
    matriz_covarianzas = rent_activos.cov()
    matriz_covarianzas = matriz_covarianzas.to_numpy(dtype='float') 

    # Calculamos la rentabilidad diaria del periodo para cada activo: LN(precio final/ precio inicial)/nº de datos
    precio_inicial = datos.iloc[0,:]
    precio_final = datos.iloc[-1,:]
    rentabilidad_diaria = np.log(precio_final / precio_inicial) / datos.shape[0]

    # Sacamos la matriz de pesos para cada cartera
    matriz_pesos = asignar_pesos(posiciones)
    
    # Calculamos la rentabilidad de la cartera en función de los pesos 
    auxiliar = rentabilidad_diaria.values * matriz_pesos
    rentabilidad_cartera = auxiliar.sum(axis=1)

    # Calculamos el riesgo de la cartera (desviación), en función de los pesos
    auxiliar = np.dot(matriz_pesos, matriz_covarianzas) # Reutilizo la matriz auxiliar con otro propósito para no sobrecargar la RAM
    riesgo_cartera = pow((auxiliar * matriz_pesos).sum(axis=1), 0.5)

    # Calculamos la eficiencia de la cartera (pendiente), en función de los pesos
    # Existen posiciones que van a dar error, 0/0. Las localizo y hago que 0/1 = 0
    posiciones_0entre0 = (rentabilidad_cartera==0) * (riesgo_cartera==0)    
    riesgo_cartera[posiciones_0entre0] = 0.0000001
    eficiencia_cartera = rentabilidad_cartera / riesgo_cartera
    
    # Localizo la cartera de mayor eficiencia y sus pesos correspondientes
    posicion_cartera_eficiente = np.where(eficiencia_cartera == eficiencia_cartera.max())
    eficiencia_cartera = eficiencia_cartera.max()
    pesos_cartera_eficiente = matriz_pesos[posicion_cartera_eficiente,:]
        
    return eficiencia_cartera, pesos_cartera_eficiente

    
Realizamos la selección de padres
def seleccion_padres(lista_fitness):

# Normalizamos con el método Min - Max para que los valores de la eficiencia estén entre 0 y 1
fitness_normalizado = np.asarray(lista_fitness)
fitness_normalizado = (fitness_normalizado - fitness_normalizado.min()) / (fitness_normalizado.max() - fitness_normalizado.min())

# El órden en el que se van a evaluar los cromosomas para ser seleccionados debe ser aleatorio
orden_aleatorio = random.sample(range(len(fitness_normalizado)), len(fitness_normalizado))

padres_seleccionados = list()
for candidato in orden_aleatorio:

    # Sacamos un número entre 0 y 1 y lo comparamos con el valor de fitness_normalizado del candidato
    # El valor de fitness actúa como un umbral
    # Si el aleatorio es menor al fitness, el candidato se convierte en uno de los dos padres
    # De esta manera todos los candidatos tienen posibilidades de ser padre
    # los que tienen mayor valor de fitness, son los que tienen más probabilidad de tener descendencia.

    aleatorio = np.random.random() 

    if fitness_normalizado[candidato] > aleatorio:

        padres_seleccionados.append(candidato)

        if len(padres_seleccionados) == 2:

            break

return padres_seleccionados
    
Cruzamiento y mutación
def cruzamiento(datos_ordenados, lista_posiciones, lista_pesos_carteras_fitness, padres_seleccionados, umbral = 0.4, num_act_max = 20):

'''
Las técnicas habituales de cruzamiento en algoritmos genéticos (N-puntos, segmentación, uniforme y aleatorio) no parecen apropiadas para resolver este problema
Queremos generar un sitema que cruce la información genética de los padres, permitiendo que el tamaño (número de activos) del hijo difiera del de los padres (sin tener que ser forzosamente mayor)
EL sistema de cruzamiento debe centrarse en la selección de activos y no en los pesos. Dado que los pesos serán calculados durante la función de fitness
Hemos usado en las funciones previas dos de los elementos más importantes para resolver el problema: pesos y eficiencia (relación rentabilidad riesgo)
Para hacer el cruzamiento, debemos usar el último elemento importante que queda: la correlación (medida normalizada de la covarianza)

Pasos a dar

    1) Nos quedamos con los activos de los padres que tengan peso (el resto los descartamos)
    2) Comprobamos que no existen activos repetidos
    3) Calculamos la matriz de correlaciones de los activos
    4) De aquel conjunto de activos que sean muy correlados, nos quedaremos únicamente con el activo más eficiente (rentabilidad / riesgo)
    5) Aplicamos la función de mutación para resolver el problema de la exploración (ver documentación de la función)
    6) Comprobamos que el número de activos no supera el máximo permitido y devolveremos las posiciones de los activos que hereda el hijo

'''

# Nos quedamos con los activos de los padres que tengan peso (el resto los descartamos)
posiciones_activos_con_peso_papa = np.where(lista_pesos_carteras_fitness[padres_seleccionados[0]] != 0)
posiciones_activos_con_peso_mama = np.where(lista_pesos_carteras_fitness[padres_seleccionados[1]] != 0)

activos_con_peso_papa = lista_posiciones[padres_seleccionados[0]][posiciones_activos_con_peso_papa[2]]
activos_con_peso_mama = lista_posiciones[padres_seleccionados[1]][posiciones_activos_con_peso_mama[2]]

# Comprobamos que no existen activos repetidos
activos_con_peso = np.unique(np.append(activos_con_peso_papa, activos_con_peso_mama))
activos_con_peso

# Calculamos la rentabilidad y riesgo para el periodo de los activos
datos = datos_ordenados.iloc[:,activos_con_peso]

precio_inicial = datos.iloc[0,:]
precio_final = datos.iloc[-1,:]
rent_global_activos = np.log(precio_final / precio_inicial) 

riesgo_global_activos = np.std(datos, axis=0)

eficiencia_global_activos = rent_global_activos / riesgo_global_activos

# Calculamos la matriz de correlaciones de los activos
rent_activos = np.log(datos).diff() 
rent_activos = rent_activos.iloc[1:,:]

matriz_correlaciones = rent_activos.corr()

# Calculamos un umbral, a partir del cual descartaremos activos por ser demasiado similares
# Cuanto más descorrelados estén los activos, más eficiente será la cartera, por lo que premiamos la descorrelación
# De aquel conjunto de activos que sean muy correlados, nos quedaremos únicamente con el activo más eficiente (rentabilidad / riesgo)

relaciones_a_analizar = matriz_correlaciones[np.logical_and(matriz_correlaciones > umbral, matriz_correlaciones != 1)]
relaciones_a_analizar = np.where(matriz_correlaciones == relaciones_a_analizar)

if len(relaciones_a_analizar[0]) > 0:

    # Recorro todas las relaciones a analizar y fabrico una lista de los activos a quitar (soy consciente de que las relaciones están duplicadas)
    activos_a_eliminar = list()

    for pos in range(len(relaciones_a_analizar[0])):

        if eficiencia_global_activos[relaciones_a_analizar[0][pos]] > eficiencia_global_activos[relaciones_a_analizar[1][pos]]:

            activos_a_eliminar.append(relaciones_a_analizar[1][pos])

        else:

            activos_a_eliminar.append(relaciones_a_analizar[0][pos])

    activos_a_eliminar = list(set(activos_a_eliminar))    
    activos_con_peso = np.delete(activos_con_peso, activos_a_eliminar)

# Aplicamos la función de mutación para resolver el problema de la exploración
nuevos_activos = mutacion(datos_ordenados, activos_con_peso, num_act_max = 20)

activos_heredados = np.unique(np.append(activos_con_peso, nuevos_activos))

# Comprobamos que el número de activos no supera el máximo permitido y devolveremos las posiciones de los activos que hereda el hijo
# Tal y como está programado no debería de ocurrir, pero por si acaso. Si ocurriese mucho, habría que modificar el umbral
if len(activos_heredados) > num_act_max:

    seleccion_aleatoria = random.sample(range(len(activos_heredados)), len(activos_heredados))[0:20]
    activos_heredados = activos_heredados[seleccion_aleatoria]    

return activos_heredados
def mutacion(datos_ordenados, activos_con_peso, num_act_max = 20):

'''
La evolución, en este problema no puede limitarse a selección y cruzamiento o caeremos rápidamente en un mínimo local
Debemos encontrar un sistema que equilibre la selección con la exploración, si queremos encontrar la cartera óptima
Una solución sería que un % del genoma se herede y el resto se dedique a la exploración
Un 20% sobre la cantidad máxima de activos parece un umbral máximo aceptable para combinar las tareas de evolución y exploración
Sacaremos un número aleatorio entre 1 y 4, que serán los nuevos activos para explorar el conjunto de soluciones posibles

'''

num_nuevos_activos = np.random.randint(low=1, high=4, size=1)

nuevos_activos = np.random.randint(low=0, high=datos_ordenados.shape[1]-1, size=num_nuevos_activos)

return nuevos_activos

    
Reemplazo generacional
def reemplazo_generacional(datos_ordenados, lista_posiciones, lista_fitness, lista_pesos_carteras_fitness, activos_cartera_mejor_individuo):

'''
La estrategia de reemplazo generacional que vamos a utilizar es reemplazo total. 
La generación de hijos reemplaza a sus padres, a excepción del mejor de la generación anterior.
'''

# Rellenamos la matriz de descendencia, la cual será una lista con la composición de activos de cada individuo
matriz_descendencia = list()
matriz_descendencia.append(activos_cartera_mejor_individuo)

for hijo in range(len(lista_posiciones)-1):

    # Seleccionamos los padres
    padres_seleccionados = seleccion_padres(lista_fitness)

    # Aplicamos el cruzamiento y la mutación
    activos_heredados = cruzamiento(datos_ordenados, lista_posiciones, lista_pesos_carteras_fitness, padres_seleccionados, umbral = 0.4, num_act_max = 20)

    # Añadimo el nuevo hijo a la matriz de descendencia
    matriz_descendencia.append(activos_heredados)

# Para que el algoritmo pueda ejecutarse de manera circular, la matriz de descendencia se ha de llamar lista_posiciones
lista_posiciones = matriz_descendencia

return lista_posiciones, fitness_mejor_individuo
    
Ejecutamos el algoritmo genético al completo
datos_ordenados = cargar_datos()

# Generamos la población inicial
lista_posiciones = generar_poblacion_inicial(datos_ordenados, num_act_min = 2, num_act_max = 20)
fitness_mejor_individuo = 0
generaciones_sin_mejora = 0
generacion = 0

while generaciones_sin_mejora < 100:

    generacion += 1

    # Calculamos la función de fitness a la generación actual
    lista_fitness = list()
    lista_pesos_carteras_fitness =  list()
    for cartera in range(len(lista_posiciones)):

        fitness, pesos_fitness = calcular_fitness(datos_ordenados = datos_ordenados, posiciones = lista_posiciones[cartera])
        lista_fitness.append(fitness)
        lista_pesos_carteras_fitness.append(pesos_fitness)

    # Guardamos el mejor individuo de la generación actual
    if fitness_mejor_individuo < max(lista_fitness):

        fitness_mejor_individuo = max(lista_fitness)            
        posicion_mejor_individuo = np.where(lista_fitness == fitness_mejor_individuo)
        pesos_cartera_mejor_individuo = lista_pesos_carteras_fitness[posicion_mejor_individuo[0][0]]
        activos_cartera_mejor_individuo = lista_posiciones[posicion_mejor_individuo[0][0]]
        print(f'Generación {generacion}. El mejor individuo tiene una eficiencia de {fitness_mejor_individuo}')
        generaciones_sin_mejora = 0

    else:

        print(f'Generación {generacion+1}. Sin mejora generacional respecto a la anterior')
        generaciones_sin_mejora += 1

    # Llevamos a cabo la selección de padres, cruzamiento, mutación y matriz de descendencia
    lista_posiciones, fitness_mejor_individuo = reemplazo_generacional(datos_ordenados, lista_posiciones, lista_fitness, lista_pesos_carteras_fitness, activos_cartera_mejor_individuo)

print(fitness_mejor_individuo)
print(activos_cartera_mejor_individuo)
print(pesos_cartera_mejor_individuo)    

    
¿Qué diferencia hay con el método anterior?
De necesitar 24 horas a emplear solo un minuto y medio
De una cartera con una eficiencia de 0.6226 a una de 1.5073
De necesitar un I9 con 64 GB de RAM a usar cualquier portátil doméstico

Y este es solo un ejemplo del aporte que realiza la Inteligencia Artificial a las finanzas