| import streamlit as st |
| import numpy as np |
| import pandas as pd |
| import yfinance as yf |
| from scipy.signal import hilbert |
| from datetime import datetime |
| import matplotlib.pyplot as plt |
| import seaborn as sns |
| import warnings |
| warnings.filterwarnings('ignore') |
|
|
| st.set_page_config(page_title="Motor TFE", page_icon="🌊", layout="wide") |
|
|
| |
| |
| |
| class FrCalculator: |
| def __init__(self, W_macro=63, W_micro=21): |
| self.W_macro = W_macro |
| self.W_micro = W_micro |
|
|
| def compute_inertia_series(self, macro_returns): |
| rho = macro_returns.rolling(window=self.W_macro).apply( |
| lambda x: pd.Series(x).autocorr(lag=1) if len(x) > 1 else np.nan, raw=False) |
| rho_clipped = np.clip(rho, -0.99, 0.98) |
| tau_memory = 1.0 / (1.0 - rho_clipped) |
| var_movil = macro_returns.rolling(window=self.W_macro).var() |
| var_norm = var_movil / var_movil.expanding().mean() |
| return tau_memory * np.clip(var_norm, 1.0, 5.0) |
|
|
| def compute_coherence_series(self, micro_returns): |
| q_series = np.full(len(micro_returns), np.nan) |
| matriz = micro_returns.values |
| for i in range(self.W_micro, len(matriz)): |
| win = matriz[i - self.W_micro:i] |
| if np.min(np.std(win, axis=0)) > 1e-10: |
| corr = np.corrcoef(win, rowvar=False) |
| eigenvalues = np.linalg.eigvalsh(corr) |
| q_series[i] = eigenvalues[-1] / np.sum(eigenvalues) |
| return pd.Series(q_series, index=micro_returns.index) |
|
|
| def compute_fr_series(self, macro_returns, micro_returns): |
| tau = self.compute_inertia_series(macro_returns) |
| q = self.compute_coherence_series(micro_returns) |
| df = pd.DataFrame({'tau': tau, 'q': q}).dropna() |
| df['fr'] = df['tau'] * df['q'] |
| exp_mean = df['fr'].expanding(min_periods=252).mean() |
| exp_std = df['fr'].expanding(min_periods=252).std() |
| df['fr_normalized'] = (df['fr'] - exp_mean) / exp_std |
| return df.dropna() |
|
|
| class EMDDecomposer: |
| def __init__(self): |
| self.scales = {'short': (5, 21), 'medium': (21, 63), 'long': (63, 252)} |
|
|
| def decompose(self, signal_series): |
| signal = signal_series.fillna(method='ffill').fillna(0).values |
| N = len(signal) |
| if N < 100: |
| return None |
| fft_signal = np.fft.fft(signal) |
| freqs = np.fft.fftfreq(N, d=1.0) |
| components = {} |
| for name, (period_min, period_max) in self.scales.items(): |
| f_max = 1.0 / period_min |
| f_min = 1.0 / period_max |
| mask = ((np.abs(freqs) >= f_min) & (np.abs(freqs) <= f_max)) |
| fft_filtered = fft_signal * mask |
| component = np.real(np.fft.ifft(fft_filtered)) |
| components[name] = pd.Series(component, index=signal_series.index) |
| return components |
|
|
| class PhaseAnalyzer: |
| @staticmethod |
| def analyze(signal_component): |
| s = signal_component.fillna(method='ffill').fillna(0).values |
| if len(s) < 50: |
| return None |
| s_centered = s - np.mean(s) |
| analytic = hilbert(s_centered) |
| return { |
| 'phase': pd.Series(np.unwrap(np.angle(analytic)), index=signal_component.index), |
| 'amplitude': pd.Series(np.abs(analytic), index=signal_component.index), |
| } |
|
|
| class SyncAnalyzer: |
| @staticmethod |
| def kuramoto(phases_dict, amplitudes_dict=None): |
| df_phases = pd.DataFrame(phases_dict).dropna() |
| if df_phases.empty: |
| return pd.Series(dtype=float) |
| if amplitudes_dict is not None: |
| df_amp = pd.DataFrame(amplitudes_dict).reindex(df_phases.index).fillna(method='ffill') |
| complex_sum = (df_amp * np.exp(1j * df_phases)).sum(axis=1) |
| R = np.abs(complex_sum) / df_amp.sum(axis=1).replace(0, np.nan) |
| else: |
| R = np.abs(np.exp(1j * df_phases).mean(axis=1)) |
| return R.fillna(0) |
|
|
| @staticmethod |
| def plv(phase_i, phase_j, window=63): |
| df = pd.DataFrame({'i': phase_i, 'j': phase_j}).dropna() |
| if len(df) < window: |
| return pd.Series(dtype=float) |
| diff = df['i'] - df['j'] |
| plv = pd.Series(index=df.index, dtype=float) |
| for k in range(window, len(df)): |
| wd = diff.iloc[k - window:k].values |
| plv.iloc[k] = np.abs(np.mean(np.exp(1j * wd))) |
| return plv |
|
|
| TOPOLOGIA = { |
| 'CAPITAL_GLOBAL': {'macro': 'SPY', 'micro': ['XLK','XLF','XLE','XLV','XLI','XLY'], |
| 'W_macro': 63, 'W_micro': 21, 'desc': 'Mercado accionario EE.UU.'}, |
| 'GRAVEDAD_SISTEMICA': {'macro': '^TNX', 'micro': ['^IRX','^FVX','^TYX'], |
| 'W_macro': 63, 'W_micro': 63, 'desc': 'Curva de tasas Tesoro'}, |
| 'MATERIA_FISICA': {'macro': 'CL=F', 'micro': ['GC=F','ZS=F','HG=F'], |
| 'W_macro': 63, 'W_micro': 63, 'desc': 'Commodities'}, |
| 'DIGITAL_OCCIDENTE': {'macro': 'BTC-USD', 'micro': ['ETH-USD','SOL-USD','LINK-USD'], |
| 'W_macro': 30, 'W_micro': 21, 'desc': 'Cripto'}, |
| 'SOBERANO_ARGENTINA': {'macro': 'ARGT', 'micro': ['YPF','GGAL','PAM','CEPU'], |
| 'W_macro': 63, 'W_micro': 21, 'desc': 'Argentina'}, |
| } |
|
|
| @st.cache_data(ttl=3600) |
| def cargar_datos(macro_ticker, micro_tickers, period="20y"): |
| tickers = [macro_ticker] + list(micro_tickers) |
| data = yf.download(tickers, period=period, progress=False, auto_adjust=True)['Close'] |
| if isinstance(data, pd.Series): |
| data = data.to_frame() |
| data = data.ffill().bfill().dropna() |
| if len(data) < 252: |
| return None, None |
| returns = np.log(data / data.shift(1)).dropna() |
| if macro_ticker in returns.columns: |
| return returns[macro_ticker], returns[list(micro_tickers)] |
| return None, None |
|
|
| @st.cache_data(ttl=3600) |
| def procesar_subsistema(nombre): |
| config = TOPOLOGIA[nombre] |
| period = "max" if 'BTC' in config['macro'] else "20y" |
| macro, micro = cargar_datos(config['macro'], config['micro'], period) |
| if macro is None or len(macro) < 252: |
| return None |
| fr_calc = FrCalculator(W_macro=config['W_macro'], W_micro=config['W_micro']) |
| df_fr = fr_calc.compute_fr_series(macro, micro) |
| if df_fr is None or len(df_fr) < 252: |
| return None |
| emd = EMDDecomposer() |
| components = emd.decompose(df_fr['fr_normalized']) |
| if components is None: |
| return None |
| analyses = {} |
| for cn, cs in components.items(): |
| r = PhaseAnalyzer.analyze(cs) |
| if r is not None: |
| analyses[cn] = r |
| return {'fr_series': df_fr, 'wave_analyses': analyses, |
| 'first_date': df_fr.index[0], 'last_date': df_fr.index[-1], |
| 'desc': config['desc']} |
|
|
| def calcular_sync(processed, scale='medium'): |
| phases, amps = {}, {} |
| for n, d in processed.items(): |
| if d is None or scale not in d['wave_analyses']: |
| continue |
| w = d['wave_analyses'][scale] |
| phases[n] = w['phase'] |
| amps[n] = w['amplitude'] |
| if len(phases) < 2: |
| return None, None |
| R = SyncAnalyzer.kuramoto(phases, amps) |
| names = list(phases.keys()) |
| plv = pd.DataFrame(index=names, columns=names, dtype=float) |
| for i, n1 in enumerate(names): |
| for j, n2 in enumerate(names): |
| if i == j: |
| plv.loc[n1, n2] = 1.0 |
| elif i < j: |
| ps = SyncAnalyzer.plv(phases[n1], phases[n2], 63) |
| lp = float(ps.iloc[-1]) if len(ps) > 0 else np.nan |
| plv.loc[n1, n2] = lp |
| plv.loc[n2, n1] = lp |
| return R, plv |
|
|
| |
| |
| |
| st.title("🌊 Motor TFE — Sincronización Sistémica") |
| st.caption("Teoría de Fragilidad Espectral · Detección de coherencia entre subsistemas vía Kuramoto + Hilbert") |
|
|
| st.sidebar.header("Control") |
| if st.sidebar.button("🔄 Actualizar datos"): |
| st.cache_data.clear() |
| st.rerun() |
|
|
| st.sidebar.markdown("---") |
| st.sidebar.markdown("**Subsistemas activos:**") |
| for k, v in TOPOLOGIA.items(): |
| st.sidebar.text(f"• {k}") |
| st.sidebar.markdown("---") |
| st.sidebar.caption(f"Última carga: {datetime.now().strftime('%Y-%m-%d %H:%M')}") |
|
|
| with st.spinner("Procesando subsistemas (puede tardar 1-2 minutos la primera vez)..."): |
| processed = {} |
| progress = st.progress(0) |
| for i, nombre in enumerate(TOPOLOGIA): |
| processed[nombre] = procesar_subsistema(nombre) |
| progress.progress((i + 1) / len(TOPOLOGIA)) |
| progress.empty() |
|
|
| |
| st.subheader("📊 Estado actual por subsistema") |
| cols = st.columns(len(TOPOLOGIA)) |
| for col, (nombre, data) in zip(cols, processed.items()): |
| with col: |
| if data is None: |
| st.metric(nombre, "—", "sin datos") |
| continue |
| last = data['fr_series'].iloc[-1] |
| delta_color = "inverse" if last['fr_normalized'] > 1 else "normal" |
| st.metric(label=nombre, value=f"Q={last['q']:.3f}", |
| delta=f"τ={last['tau']:.2f} | Fr={last['fr_normalized']:+.2f}σ", |
| delta_color=delta_color) |
| st.caption(data['desc']) |
|
|
| |
| st.subheader("🔬 Sincronización Kuramoto multi-escala") |
| st.caption("R(t) = parámetro de orden. R→1 = ondas en fase (resonancia). R→0 = sistema disipado.") |
|
|
| cols = st.columns(3) |
| for col, scale in zip(cols, ['short', 'medium', 'long']): |
| with col: |
| R, _ = calcular_sync(processed, scale) |
| if R is None or len(R) == 0: |
| st.warning(f"{scale}: sin datos") |
| continue |
| recent = R.iloc[-21:] |
| R_now = recent.iloc[-1] |
| R_max = recent.max() |
| slope = np.polyfit(np.arange(len(recent)), recent.values, 1)[0] |
| label = {'short': '⚡ Corto (5-21d)', 'medium': '🌊 Medio (21-63d)', |
| 'long': '🏔️ Largo (63-252d)'}[scale] |
| st.metric(label, f"R = {R_now:.3f}", |
| delta=f"max 21d: {R_max:.3f} | dR/dt: {slope:+.4f}") |
|
|
| |
| st.subheader("📈 Evolución histórica de R(t)") |
| fig, axes = plt.subplots(3, 1, figsize=(14, 9), sharex=True) |
| labels = {'short': 'CORTO (5-21d) - Shocks', |
| 'medium': 'MEDIO (21-63d) - Incubaciones', |
| 'long': 'LARGO (63-252d) - Régimen'} |
| for ax, scale in zip(axes, ['short', 'medium', 'long']): |
| R, _ = calcular_sync(processed, scale) |
| if R is None or len(R) == 0: |
| continue |
| ax.plot(R.index, R.values, color='steelblue', lw=0.8) |
| ax.axhline(0.70, color='orange', ls='--', alpha=0.7, label='Aviso (0.70)') |
| ax.axhline(0.85, color='red', ls='--', alpha=0.7, label='Crítico (0.85)') |
| ax.fill_between(R.index, 0, R.values, alpha=0.15, color='steelblue') |
| ax.set_ylabel(f'R - {scale}') |
| ax.set_title(labels[scale]) |
| ax.set_ylim(0, 1.05) |
| ax.legend(loc='upper left', fontsize=8) |
| ax.grid(alpha=0.3) |
| plt.tight_layout() |
| st.pyplot(fig) |
|
|
| |
| st.subheader("🔗 Phase Locking Value entre subsistemas (escala media)") |
| _, plv = calcular_sync(processed, 'medium') |
| if plv is not None: |
| fig2, ax2 = plt.subplots(figsize=(9, 7)) |
| sns.heatmap(plv.astype(float), annot=True, fmt='.2f', |
| cmap='RdYlBu_r', center=0.5, vmin=0, vmax=1, |
| cbar_kws={'label': 'PLV'}, ax=ax2) |
| ax2.set_title('PLV > 0.80 = par fuertemente acoplado') |
| plt.tight_layout() |
| st.pyplot(fig2) |
|
|
| st.markdown("---") |
| st.caption("Motor TFE v2.2 · Datos: Yahoo Finance · Cache: 1 hora · " |
| "Fundamento: Kuramoto (1984), Scheffer (2009), Vicente (2012)") |
|
|