File size: 22,163 Bytes
8f074bc 119d3b4 8f074bc 119d3b4 8f074bc 119d3b4 8f074bc 119d3b4 8f074bc 119d3b4 8f074bc 119d3b4 8f074bc 119d3b4 8f074bc 119d3b4 8f074bc 119d3b4 8f074bc 119d3b4 8f074bc |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 |
import os
import numpy as np
import pandas as pd
from sklearn.neighbors import NearestNeighbors
from sklearn.preprocessing import QuantileTransformer
from scipy.stats import gamma
import json
class FrontDatasetHandler:
def __init__(self, maestro: pd.DataFrame=None, precios_cierre: pd.DataFrame=None, app_dataset: pd.DataFrame=None,
json_path: str = None, pickle_path: str = None, ignore_columns: list = None, numeric_columns: list = None):
self.maestro = maestro
self.app_dataset = app_dataset # Dataframe preprocesado para la app
self.pickle_path = pickle_path
# Extraemos los ficheros JSON para la creación del dataset de la app si no se ha pasado como argumento:
if self.app_dataset is None and json_path is not None:
with open(os.path.join(json_path, "ignore_columns.json"), "r") as f:
self.ignore_columns = json.load(f)['ignore_columns']
print(f"ignore_columns: {self.ignore_columns}")
with open(os.path.join(json_path, "numeric_columns.json"), "r") as f:
self.numeric_columns = json.load(f)['numeric_columns']
print(f"numeric_columns: {self.numeric_columns}")
with open(os.path.join(json_path, "app_column_config.json"), "rb") as f:
self.app_dataset_cols = json.load(f)['app_dataset_cols']
print(f"app_dataset_cols: {self.app_dataset_cols}")
with open(os.path.join(json_path, "cat_to_num_maps.json"), "r") as f:
num_maps = json.load(f)
self.sector_num_map = num_maps['sector_num_map']
self.industry_num_map = num_maps['industry_num_map']
else:
self.ignore_columns = ignore_columns
self.numeric_columns = numeric_columns
print(f"ignore_columns: {self.ignore_columns}")
print(f"numeric_columns: {self.numeric_columns}")
self.norm_columns = None
if maestro is not None:
maestro.drop(columns=self.ignore_columns, inplace=True, errors='ignore')
self.precios_cierre = precios_cierre # Sólo necesario cuando se requiere preprocesar el dataset para la app
self.rend_diario_log = None
self.precios_cierre_fh = None
self.rendimientos_y_volatilidad = None
self.mapeos_var_categoricas = None
self.activos_descartados = []
self.quantile_scaler = None
def filtra_y_homogeneiza(self, n_dias=366, n_dias_descartar=1, min_dias=100):
if self.precios_cierre.index.name != 'date':
self.precios_cierre.set_index('date', inplace=True)
self.precios_cierre.columns.name = 'ticker'
end_date = self.precios_cierre.index.max()
start_date = end_date - pd.Timedelta(days=n_dias)
# If start_date is not in the index, find the nearest earlier date
if start_date not in self.precios_cierre.index:
earlier_dates = self.precios_cierre.index[self.precios_cierre.index < start_date]
if len(earlier_dates) > 0:
start_date = earlier_dates.max()
else:
start_date = self.precios_cierre.index.min()
# Filtrar datos dentro del rango de fechas
precios_cierre_fh = self.precios_cierre.loc[start_date:end_date].copy()
# Descartar los últimos n_dias_descartar
if n_dias_descartar > 0:
dates_to_drop = precios_cierre_fh.index.sort_values(ascending=False)[:n_dias_descartar]
precios_cierre_fh.drop(dates_to_drop, inplace=True)
precios_cierre_fh.ffill(axis=0, inplace=True) # Se rellenan los datos vacíos con el dato del día anterior
self.activos_descartados = precios_cierre_fh.columns[precios_cierre_fh.notna().sum(axis=0) < min_dias].tolist()
precios_cierre_fh.drop(columns=self.activos_descartados, inplace=True)
self.precios_cierre = precios_cierre_fh
return
def calcula_rendimientos_y_volatilidad(self, n_dias=365, umbral_max=0.3, umbral_min=-0.3):
end_date = self.precios_cierre.index.max()
print(f"Última fecha: {end_date}")
print("Primera fecha: ",self.precios_cierre.index.min())
start_date = end_date - pd.Timedelta(days=n_dias)
# Si no hay cotizaciones para la fecha de inicio calculada (ej. fin de semana), se cambia por la fecha más cercana
if start_date not in self.precios_cierre.index:
earlier_dates = self.precios_cierre.index[self.precios_cierre.index < start_date]
if len(earlier_dates) > 0:
start_date = earlier_dates.max()
else:
start_date = self.precios_cierre.index.min()
_df_rend_y_vol = self.precios_cierre.loc[start_date:end_date].copy()
_df_rend_y_vol.dropna(how='all', inplace=True) #####
# Reemplazar valores cero y negativos (errores de formato) por el siguiente valor más pequeño positivo
_df_rend_y_vol[_df_rend_y_vol <= 0] = np.nextafter(0, 1)
if self.activos_descartados:
_df_rend_y_vol = _df_rend_y_vol.drop(columns=[col for col in self.activos_descartados if col in _df_rend_y_vol.columns])
if len(_df_rend_y_vol) == 0:
raise ValueError(f"No hay datos disponibles en el rango de {n_dias} días")
_rend_diario_log = np.log(_df_rend_y_vol).diff()
_rend_diario_log = _rend_diario_log.iloc[1:] # Eliminar la primera fila
# _rend_diario_log.dropna(how='all', inplace=True)
print(f'Datos rentabilidad ({n_dias} días) con outliers: {_rend_diario_log.shape}')
# Identificar activos a descartar (outliers)
_activos_outliers = _rend_diario_log.columns[((_rend_diario_log > umbral_max) | (_rend_diario_log < umbral_min)).any()].tolist()
self.activos_descartados.extend([asset for asset in _activos_outliers if asset not in self.activos_descartados])
# Descartar activos con rentabilidades atípicas
_rend_diario_log = _rend_diario_log.loc[:, ~((_rend_diario_log > umbral_max) | (_rend_diario_log < umbral_min)).any()]
print(f'Datos rentabilidad sin outliers: {_rend_diario_log.shape}')
self.rend_diario_log = _rend_diario_log.copy()
# Inicializar rendimientos_y_volatilidad si no existe
if self.rendimientos_y_volatilidad is None:
self.rendimientos_y_volatilidad = pd.DataFrame(columns=_rend_diario_log.columns)
# print(f'INIT: Tabla rendimientos {n_dias}: {self.rendimientos_y_volatilidad.shape}')
else:
# Mantener solo los activos que están en _rend_diario_log
self.rendimientos_y_volatilidad = self.rendimientos_y_volatilidad.loc[:, _rend_diario_log.columns]
# print(f'Tabla rendimientos {n_dias}: {self.rendimientos_y_volatilidad.shape}')
# Añadir nuevas columnas para el n_dias actual
self.rendimientos_y_volatilidad.loc[f'ret_log_{n_dias}'] = np.sum(_rend_diario_log, axis=0)
self.rendimientos_y_volatilidad.loc[f'ret_{n_dias}'] = (_df_rend_y_vol.ffill().bfill().iloc[-1] / _df_rend_y_vol.ffill().bfill().iloc[0]) - 1
self.rendimientos_y_volatilidad.loc[f'vol_{n_dias}'] = _rend_diario_log.var()**0.5
return
def cruza_maestro(self):
_rets_y_vol_maestro = self.rendimientos_y_volatilidad.T.reset_index().copy()
_columns_to_merge = [col for col in _rets_y_vol_maestro.columns if col not in self.maestro.columns]
if len(_columns_to_merge) > 0:
_maestro_v2 = self.maestro.merge(_rets_y_vol_maestro, left_on='ticker', right_on='ticker')
_maestro_v2 = _maestro_v2.replace([float('inf'), float('-inf')], np.nan)
self.maestro = _maestro_v2
else:
raise ValueError("No hay nuevas columnas para cruzar con el dataframe maestro")
return
def _cat_to_num_(self, df, cat, pre_map=None):
"""
Transforma una columna categórica en un DataFrame a valores numéricos asignando un número entero a cada categoría.
Si no se proporciona un mapeo (`pre_map`), asigna 0 a la categoría más frecuente, 1 a la siguiente más frecuente, y así sucesivamente.
Si se proporciona un mapeo (`pre_map`), utiliza ese mapeo para la conversión.
Parámetros
----------
df : pandas.DataFrame
DataFrame que contiene la columna categórica a transformar.
cat : str
Nombre de la columna categórica a transformar.
pre_map : dict, opcional
Diccionario que mapea cada categoría a un valor numérico. Si no se proporciona, el mapeo se genera automáticamente.
Devuelve
--------
pandas.DataFrame
DataFrame con dos columnas: la columna categórica original y una columna con los valores numéricos asignados.
"""
if not pre_map:
pivot = pd.pivot_table(df, index=[cat], aggfunc='size')
df_sorted = pivot.sort_values(ascending=False).reset_index(name='count')
df_sorted[cat + '_num'] = range(len(df_sorted))
else:
df_sorted = pd.DataFrame({cat: list(pre_map.keys()), cat + '_num': list(pre_map.values())})
return df_sorted
def var_categorica_a_numerica(self, cat_cols):
for col in cat_cols:
if col == 'sectorDisp':
globals()[f"pt_{col}"] = self._cat_to_num_(self.maestro, col, self.sector_num_map)
elif col == 'industryDisp':
globals()[f"pt_{col}"] = self._cat_to_num_(self.maestro, col, self.industry_num_map)
else:
globals()[f"pt_{col}"] = self._cat_to_num_(self.maestro, col) # Creamos un dataframe con el mapeo de cada variable categórica por frecuencia
self.mapeos_var_categoricas = [globals()[f"pt_{col}"] for col in cat_cols] # Lista de dataframes con los mapeos de cada una de las variables categóricas
_maestro = self.maestro.copy()
for col, pt in zip(cat_cols, self.mapeos_var_categoricas):
_maestro[col] = _maestro[col].astype(str)
pt[col] = pt[col].astype(str)
# Creamos un diccionario con cada variable categórica y su equivalente numérico
mapping_dict = dict(zip(pt[col], pt[col + '_num']))
_maestro[col + '_num'] = _maestro[col].map(mapping_dict)
_maestro[col + '_num'] = pd.to_numeric(_maestro[col + '_num'], errors='coerce')
self.maestro = _maestro
return
def normaliza_por_cuantiles(self):
maestro_copy = self.maestro.copy()
numeric_columns = maestro_copy.select_dtypes(include=np.number).columns
self.quantile_scaler = QuantileTransformer(output_distribution='uniform')
variables_numericas = [col for col in numeric_columns if not col.endswith('_norm')]
all_na_cols = [col for col in variables_numericas if maestro_copy[col].isna().all()]
variables_numericas = [col for col in variables_numericas if col not in all_na_cols]
self.norm_columns = ['{}_norm'.format(var) for var in variables_numericas]
maestro_copy[self.norm_columns] = self.quantile_scaler.fit_transform(maestro_copy[variables_numericas])
maestro_copy[self.norm_columns] = maestro_copy[self.norm_columns].clip(0, 1)
self.maestro = maestro_copy
return
def var_estandar_z(self):
maestro_copy = self.maestro.copy()
numeric_columns = maestro_copy.select_dtypes(include=np.number).columns
variables_numericas = [col for col in numeric_columns if not col.endswith('_std')]
variables_num_std = ['{}_std'.format(var) for var in variables_numericas]
def estandarizar(x):
# Estandariza el valor z, restando la media y dividiendo por la desviación estándar
mean_val = x.mean()
std_val = x.std()
if pd.isna(std_val) or std_val == 0:
return pd.Series(0.0, index=x.index, name=x.name)
else:
normalized_series = (x - mean_val) / std_val
return normalized_series.fillna(0.0)
normalized_data = maestro_copy[variables_numericas].apply(estandarizar, axis=0)
maestro_copy[variables_num_std] = normalized_data
self.maestro = maestro_copy
return
def configura_distr_prob(self, shape, loc, scale, max_dist, precision_cdf):
x = np.linspace(0, max_dist, num=precision_cdf)
y_pdf = gamma.pdf(x, shape, loc, scale )
y_cdf = gamma.cdf(x, shape, loc, scale )
return y_pdf, y_cdf
def calculos_y_ajustes_dataset_activos(self):
maestro_copy = self.maestro.copy()
# Conversiones a formato numérico de columnas que dan problemas
for column in self.numeric_columns:
if column in maestro_copy.columns:
maestro_copy[column] = pd.to_numeric(maestro_copy[column], errors='coerce')
# print(f"Columna {column} convertida a {maestro_copy[column].dtype}")
# Estandarización de los diferentes tipos de NaN
# maestro_copy = maestro_copy.replace([None, np.nan, np.inf, -np.inf], pd.NA)
# Antigüedad del fondo en años:
if self.precios_cierre is not None and not self.precios_cierre.index.empty:
_most_recent_date = self.precios_cierre.index.max().date()
else:
_most_recent_date = pd.Timestamp.today().date()
# maestro_copy['firstTradeDateMilliseconds'] = pd.to_datetime(maestro_copy['firstTradeDateMilliseconds']).dt.date
maestro_copy['firstTradeDateMilliseconds'] = pd.to_datetime(maestro_copy['firstTradeDateMilliseconds'], unit='ms', errors='coerce').dt.date
maestro_copy['asset_age'] = maestro_copy['firstTradeDateMilliseconds'].apply(
lambda x: ((_most_recent_date - x).days / 365) if pd.notnull(x) and hasattr(x, 'day') else 0
).astype(int)
outlier_thresholds = {
'beta': (-100, 100),
'dividendYield': (-1,100),
'fiveYearAvgDividendYield': (-1,100),
'trailingAnnualDividendYield': (-1,100),
'quickRatio': (-1, 500),
'currentRatio': (-1, 500),
'ebitda': (-1e12, 1e12),
'grossProfits': (-1e12, 1e12),
}
for column, (lower_bound, upper_bound) in outlier_thresholds.items():
maestro_copy.loc[(maestro_copy[column] < lower_bound) | (maestro_copy[column] > upper_bound), column] = pd.NA
self.maestro = maestro_copy.copy()
return
def filtra_df_activos(self, df, isin_target, filtros, debug=False):
'''
LEGACY
Devuelve un dataframe filtrado, sin alterar el orden, eliminando características no deseadas, para usar en aplicación de búsqueda de activos sustitutivos.
Las características y valores a filtrar son las de un fondo objetivo dado por su isin.
Por ejemplo, si clean_share es False en filtros, el dataframe final no incluirá más activos con el mismo valor de clean_share que el ISIN objetivo
Argumentos:
df (pandas.core.frame.DataFrame): Dataframe maestro de activos
isin_target (str): ISIN del fondo objetivo
# caracteristicas (list): Lista de str con los nombres de las características
filtros (dict): Diccionario donde las claves son las características y los valores son True si se quiere conservar
debug (bool, optional): Muestra información de depuración. Por defecto False.
Resultado:
df_filt (pandas.core.frame.DataFrame): Dataframe filtrado
'''
# fondo_target = df[df['isin'] == isin_target].iloc[0]
fondo_target = df[df['ticker'] == isin_target].iloc[0]
if debug: print(f'Tamaño inicial: {df.shape}')
car_numericas = ['ret_365', 'vol_365', 'marketCap', 'asset_age']
# for feature in caracteristicas[2:]:
for feature in list(filtros.keys()):
value = fondo_target[feature]
if debug: print(f'{feature} = {value}')
# Verificar si esta característica debe ser filtrada
if feature in filtros and not filtros[feature]:
if debug: print(f'FILTRO: {feature} != {value}')
df = df[df[feature] != value]
# Aplicar filtros adicionales para variables numéricas
if feature in car_numericas:
if feature == 'ret_365':
if debug: print(f'FILTRO NUMÉRICO: {feature} > {value}')
df = df[df[feature] > value]
elif feature == 'vol_365':
if debug: print(f'FILTRO NUMÉRICO: {feature} < {value}')
df = df[df[feature] < value]
elif feature == 'asset_age':
if debug: print(f'FILTRO NUMÉRICO: {feature} > {value}')
df = df[df[feature] > value]
elif feature == 'marketCap':
if debug: print(f'FILTRO NUMÉRICO: {feature} > {value}')
df = df[df[feature] < value]
df_filt = df
if debug: print(f'Tamaño final: {df_filt.shape}')
return df_filt
def calcula_ind_sust (self, dist, y_cdf, precision_cdf, max_dist):
try:
idx = int((precision_cdf / max_dist) * dist)
idx = min(idx, len(y_cdf) - 1)
norm_dist = y_cdf[idx]
ind_sust = max(0.0, 1.0 - norm_dist)
except IndexError:
ind_sust = 0
return ind_sust
def vecinos_cercanos(self, df, variables_busq, caracteristicas, target_ticker, y_cdf, precision_cdf, max_dist, n_neighbors, filtros):
if target_ticker not in df['ticker'].values:
return f"Error: '{target_ticker}' no encontrado en la base de datos"
target_row = df[df['ticker'] == target_ticker]
if ~target_row.index.isin(df.index):
df = pd.concat([df, target_row], ignore_index=True)
# print(f'DF original: {df.shape}')
X = df[variables_busq]
model = NearestNeighbors(n_neighbors=n_neighbors) ##### probar con más y filtrar después #######
model.fit(X)
target_row = df[df['ticker'] == target_ticker][variables_busq]
# model.kneighbors devuelve dos arrays bidimensionales con los vecinos más cercanos y sus distancias:
distances, indices = model.kneighbors(target_row)
# combined_columns = list(set(caracteristicas + variables_busq))
neighbors_df = df.iloc[indices[0]][caracteristicas]
neighbors_df['distance'] = distances[0]
ind_sust = np.array([self.calcula_ind_sust(dist, y_cdf, precision_cdf, max_dist) for dist in distances[0]])
neighbors_df['ind_sust'] = ind_sust
neighbors_df = neighbors_df.sort_values(by='distance', ascending=True)
target_row = neighbors_df[neighbors_df['ticker'] == target_ticker]
# Aplicamos los filtros de exclusión:
### Código pendiente de eliminar/modificar (legado de la aplicación de fondos)
neighbors_df = self.filtra_df_activos (df = neighbors_df, isin_target = target_ticker, filtros = filtros)
####################
# Recupera el activo seleccionado en caso de haber hecho filtros, devolviéndolo a la primera posición del dataframe:
if ~target_row.index.isin(neighbors_df.index):
neighbors_df = pd.concat([pd.DataFrame(target_row), neighbors_df], ignore_index=True)
# print(f'DF filtrado: {neighbors_df.shape}')
# Ponemos el ticker como índice:
neighbors_df.set_index('ticker', inplace = True)
return neighbors_df
def format_large_number(self, n, decimals=2):
if n >= 1e12:
return f'{n / 1e12:.{decimals}f} T'
elif n >= 1e9:
return f'{n / 1e9:.{decimals}f} B'
elif n >= 1e6:
return f'{n / 1e6:.{decimals}f} M'
else:
return str(n)
def trae_embeddings_desde_pkl(self, embeddings_df_file_name='df_with_embeddings.pkl', embeddings_col_name='embeddings'):
embeddings_df = pd.read_pickle(os.path.join(self.pickle_path, embeddings_df_file_name))
self.maestro = self.maestro.merge(
embeddings_df[['ticker', embeddings_col_name]],
on='ticker',
how='left'
)
print(f"Agregados embeddings {self.maestro.shape}")
return
def procesa_app_dataset(self, periodo=366, n_dias_descartar=1, min_dias=250, umbrales_rend=(-0.3, +0.3), periodos_metricas=[60, 365],
cat_cols = ['industryDisp', 'sectorDisp', 'country', 'city', 'exchange', 'financialCurrency', 'quoteType'],
embeddings_df_file_name='df_with_embeddings.pkl', embeddings_col_name='embeddings'):
if self.app_dataset is not None:
print("app_dataset already exists, skipping processing")
return
self.filtra_y_homogeneiza(n_dias=periodo, n_dias_descartar=n_dias_descartar, min_dias=min_dias)
for periodo_metricas in periodos_metricas:
self.calcula_rendimientos_y_volatilidad(n_dias=periodo_metricas, umbral_max=umbrales_rend[1], umbral_min=umbrales_rend[0])
self.cruza_maestro()
self.var_categorica_a_numerica(cat_cols)
self.calculos_y_ajustes_dataset_activos()
self.normaliza_por_cuantiles()
self.trae_embeddings_desde_pkl(embeddings_df_file_name=embeddings_df_file_name, embeddings_col_name=embeddings_col_name)
app_dataset = self.maestro.copy()
app_dataset = app_dataset.fillna({col: 0.5 for col in self.norm_columns})
# Filtrado final de columnas para reducir el dataset:
self.app_dataset = app_dataset[self.app_dataset_cols].copy()
print(f"app_dataset preparado: {self.app_dataset.shape}")
return |