Files
kennis-Markets/kennis_streamlit.py
T
2026-02-22 13:03:37 -05:00

509 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# kennis_streamlit.py
import streamlit as st
import numpy as np
import pandas as pd
from io import BytesIO
import plotly.graph_objects as go
# ------------------ Configuración de la página ------------------
st.set_page_config(page_title="Kennis FX Markets", layout="wide", initial_sidebar_state="expanded")
st.markdown(
"""
<style>
.stApp { background-color: #f4f6f9; color: #1a1f2b; font-family: 'Segoe UI', Roboto, Arial; }
.header { color: #0f2742; font-weight:700; font-size:22px; }
.subtitle { color: #4a6578; margin-top:-6px; }
.card { background:#ffffff; padding:14px; border-radius:10px; box-shadow: 0 2px 8px rgba(20,30,40,0.06); }
.muted { color:#5b6b75; font-size:13px; }
.decision { font-weight:700; font-size:18px; color:#0f2742; }
.explain { color:#22303a; font-size:14px; line-height:1.45; }
.small { font-size:12px; color:#6f8190; }
hr { border:none; border-top: 1px solid #e6eef8; }
.warning { color:#8a6d3b; }
</style>
""",
unsafe_allow_html=True,
)
# ------------------ Header / Hero ------------------
col1, col2 = st.columns([4,1])
with col1:
st.markdown("<div class='header'>Kennis FX Markets</div>", unsafe_allow_html=True)
st.markdown("<div class='subtitle'>Institutional Bayesian Intelligence Engine</div>", unsafe_allow_html=True)
st.markdown("<div class='muted'>Detection and strategic allocation guidance. (Edición táctica — swing / corto plazo)</div>", unsafe_allow_html=True)
with col2:
st.markdown("<div class='card'><div class='small'>Kennis FX — Tactical edition</div></div>", unsafe_allow_html=True)
st.markdown("---")
# ------------------ Sidebar: Inputs & opciones ------------------
st.sidebar.header("Entradas (táctica / swing)")
uploaded = st.sidebar.file_uploader(
"Sube CSV histórico (opcional). Columnas requeridas: date,cot_long,cot_short,fed_prob,retail_pct,target_up",
type=["csv"]
)
st.sidebar.markdown("O ingresa la fila live abajo:")
par = st.sidebar.selectbox(
"Par de divisas",
[
# Majors
"USD/JPY", "EUR/USD", "GBP/USD", "AUD/USD", "USD/CHF", "USD/CAD", "NZD/USD",
"EUR/GBP", "EUR/JPY", "GBP/JPY",
# Principales exóticas / emergentes y crosses relevantes
"USD/SGD", "USD/MXN", "USD/BRL", "USD/ZAR", "USD/TRY", "USD/HKD", "EUR/TRY", "GBP/TRY"
],
index=0
)
cot_long = st.sidebar.number_input("COT No-Commercial — Long", value=13662, step=1)
cot_short = st.sidebar.number_input("COT No-Commercial — Short", value=13546, step=1)
fed_prob = st.sidebar.number_input("FedWatch Prob (%) para bin actual", min_value=0.0, max_value=100.0, value=96.0, step=0.1)
retail_pct = st.sidebar.number_input("Retail % Buy (0-100)", min_value=0.0, max_value=100.0, value=47.0, step=0.1)
st.sidebar.markdown("---")
st.sidebar.header("Modelo y normalización")
s_cot = st.sidebar.number_input("S_cot (escala para COT)", value=50000, step=1000)
prior_var = st.sidebar.number_input("Varianza prior gaussiano", value=0.5, step=0.1)
retrain = st.sidebar.button("Reentrenar (si subes CSV)")
# ------------------ Utilidades matemáticas ------------------
def sigmoid(x):
return 1.0 / (1.0 + np.exp(-x))
def fit_bayesian_logit(X, y, prior_var=0.5, maxiter=200, tol=1e-8):
n, p = X.shape
beta = np.zeros(p)
prior_prec = np.eye(p) / prior_var
H = np.eye(p) * 1e-6
for i in range(maxiter):
eta = X.dot(beta)
p_vec = sigmoid(eta)
W = p_vec * (1.0 - p_vec)
W = np.clip(W, 1e-8, None)
H = X.T.dot(W[:, None] * X) + prior_prec
grad = X.T.dot(y - p_vec) - prior_prec.dot(beta)
try:
delta = np.linalg.solve(H, grad)
except np.linalg.LinAlgError:
H += np.eye(p) * 1e-8
delta = np.linalg.solve(H, grad)
beta = beta + delta
if np.linalg.norm(delta) < tol:
break
try:
cov = np.linalg.inv(H)
except np.linalg.LinAlgError:
cov = np.linalg.pinv(H)
return beta, cov
def to_excel_bytes(df):
out = BytesIO()
with pd.ExcelWriter(out, engine="openpyxl") as writer:
df.to_excel(writer, index=False, sheet_name="signal")
return out.getvalue()
# ------------------ Ingesta de datos ------------------
using_demo = False
if uploaded is not None:
try:
df = pd.read_csv(uploaded)
st.info(f"CSV cargado: {uploaded.name}{len(df)} filas")
except Exception as e:
st.error("Error leyendo CSV: " + str(e))
df = pd.DataFrame()
using_demo = True
else:
using_demo = True
np.random.seed(42)
N = 420
cot_long_demo = np.random.normal(12000, 3000, size=N).astype(int)
cot_short_demo = np.random.normal(11000, 3000, size=N).astype(int)
fed_demo = np.clip(np.random.normal(60, 12, size=N), 0, 100)
retail_demo = np.clip(np.random.normal(50, 12, size=N), 0, 100)
net_demo = cot_long_demo - cot_short_demo
z_net_demo = (net_demo - net_demo.mean()) / (net_demo.std() if net_demo.std() > 0 else 1)
true_score = 0.8 * z_net_demo + 1.5 * ((fed_demo - 50) / 50) - 0.5 * ((retail_demo - 50) / 50)
prob_demo = sigmoid(true_score)
target_demo = (np.random.rand(N) < prob_demo).astype(int)
df = pd.DataFrame({
"date": pd.date_range(end=pd.Timestamp.today(), periods=N),
"cot_long": cot_long_demo, "cot_short": cot_short_demo,
"fed_prob": fed_demo, "retail_pct": retail_demo,
"target_up": target_demo
})
st.info("Usando datos demo. Suba CSV histórico para calibración con datos reales.")
# ------------------ Feature engineering ------------------
def build_features(df_in, s_cot_local=50000):
df = df_in.copy()
df["net"] = df["cot_long"] - df["cot_short"]
df["net_scaled"] = df["net"] / float(s_cot_local)
df["z_net"] = (df["net"] - df["net"].rolling(252, min_periods=1).mean()) / df["net"].rolling(252, min_periods=1).std().replace(0,1)
df["delta_1w"] = df["net"].diff(5).fillna(0)
df["delta_4w"] = df["net"].diff(20).fillna(0)
df["z_delta_1w"] = (df["delta_1w"] - df["delta_1w"].rolling(252, min_periods=1).mean()) / df["delta_1w"].rolling(252, min_periods=1).std().replace(0,1)
df["z_delta_4w"] = (df["delta_4w"] - df["delta_4w"].rolling(252, min_periods=1).mean()) / df["delta_4w"].rolling(252, min_periods=1).std().replace(0,1)
df["signal_fed"] = (df["fed_prob"] - 50.0) / 50.0
df["signal_retail"] = (df["retail_pct"] - 50.0) / 50.0
return df
df_feat = build_features(df, s_cot_local=s_cot)
# Features used in el modelo táctico
feature_cols = ["z_net", "z_delta_1w", "z_delta_4w", "signal_fed", "signal_retail"]
if "target_up" in df_feat.columns:
train_df = df_feat.copy()
X_train = train_df[feature_cols].fillna(0).values
y_train = train_df["target_up"].astype(int).values
else:
# Should not happen because demo includes target_up, but keep safe fallback
train_df = df_feat.copy()
X_train = train_df[feature_cols].fillna(0).values
y_train = df_feat.get("target_up", pd.Series(np.zeros(len(train_df)))).astype(int).values
if retrain and uploaded is None:
st.warning("Para reentrenar debe subir un CSV histórico con etiqueta 'target_up'.")
with st.spinner("Calibrando modelo bayesiano táctico (MAP + Laplace)..."):
try:
beta_map, cov_post = fit_bayesian_logit(X_train, y_train, prior_var=prior_var)
except Exception as e:
st.error("Fallo en calibración del modelo: " + str(e))
beta_map = np.zeros(len(feature_cols))
cov_post = np.eye(len(feature_cols)) * 1.0
# ------------------ Fila en vivo y predicción ------------------
live = {"cot_long": float(cot_long), "cot_short": float(cot_short), "fed_prob": float(fed_prob), "retail_pct": float(retail_pct)}
aug = pd.concat([df.head(1).copy(), pd.DataFrame([live])], ignore_index=True)
live_feat = build_features(aug, s_cot_local=s_cot).iloc[-1]
x_live = live_feat[feature_cols].fillna(0).values
net = float(live_feat["net"])
# Posterior sampling + predicción (defensiva) + calibración contra sesgo histórico
sample_probs_adj = None
calibration_applied = False
try:
cov_post = np.array(cov_post)
cov_post = 0.5 * (cov_post + cov_post.T)
jitter = 1e-8 * np.eye(cov_post.shape[0])
samples = np.random.multivariate_normal(beta_map, cov_post + jitter, size=4000)
sample_probs = sigmoid(samples.dot(x_live))
# Calibración: centramos la predicción para neutralizar el sesgo medio en entrenamiento
try:
train_eta = X_train.dot(beta_map)
train_mean_p = float(sigmoid(train_eta).mean())
except Exception:
train_mean_p = float(sigmoid(X_train.dot(beta_map)).mean()) if X_train.size else 0.5
bias_offset = train_mean_p - 0.5 # positivo => modelo históricamente overpredicts up
# ajustar sample probs y recortar entre 0 y 1
sample_probs_adj = np.clip(sample_probs - bias_offset, 0.0, 1.0)
p_mean = float(sample_probs_adj.mean())
p_low = float(np.percentile(sample_probs_adj, 2.5))
p_high = float(np.percentile(sample_probs_adj, 97.5))
calibration_applied = abs(bias_offset) > 1e-6
except Exception:
eta = float(x_live.dot(beta_map))
p_mean = float(sigmoid(eta))
p_low, p_high = p_mean, p_mean
sample_probs_adj = np.array([p_mean])
calibration_applied = False
# Índice de convicción (usando distribución calibrada si existe)
dispersion = max(1e-9, float(np.std(sample_probs_adj)))
conviction = min(100.0, max(0.0, 100.0 * (abs(p_mean - 0.5) * 2.0) * (1.0 / (1.0 + 5.0 * dispersion))))
conviction = round(conviction, 1)
# Decisión táctica profesional (probabilidad + convicción)
if conviction < 35:
decision = "ESPERAR (NO OPERAR)"
decision_flag = "wait"
elif p_mean >= 0.60:
decision = "COMPRAR (LONG)"
decision_flag = "buy"
elif p_mean <= 0.40:
decision = "VENDER (SHORT)"
decision_flag = "sell"
else:
decision = "ESPERAR (NO OPERAR)"
decision_flag = "wait"
# Descomposición de contribuciones
contrib_raw = np.array(beta_map) * np.array(x_live)
if np.sum(np.abs(contrib_raw)) > 0:
contrib_pct = 100.0 * contrib_raw / np.sum(np.abs(contrib_raw))
else:
contrib_pct = np.zeros_like(contrib_raw)
# ------------------ Módulo Intradía (Híbrido Profesional) ------------------
st.markdown("---")
st.markdown("## Intraday Execution Engine")
activate_intraday = st.checkbox("Activar módulo intradía (requiere bias macro activo)")
if activate_intraday:
# Solo permitir intradía si bias macro activo y convicción suficiente
if decision_flag == "wait" or conviction < 35:
st.warning("Bias macro insuficiente o convicción baja. Intradía desactivado hasta nueva señal.")
intraday_signal = "NO TRADE"
intraday_info = {}
else:
# Parámetros de la demo (reemplazar por feed real en producción)
st.markdown("### Parámetros intradía (demo)")
# Simulación de datos intradía (1m-like series) — sustituir por feed real
np.random.seed(7)
M = 300
intraday_returns = np.random.normal(0, 0.0008, M) # retornos pequeños
intraday_prices = 100.0 + np.cumsum(intraday_returns)
intraday_volume = np.random.randint(80, 200, M)
df_intraday = pd.DataFrame({
"price": intraday_prices,
"volume": intraday_volume
})
# VWAP
df_intraday["cum_vol"] = df_intraday["volume"].cumsum()
df_intraday["cum_pv"] = (df_intraday["price"] * df_intraday["volume"]).cumsum()
# evitar división por cero
df_intraday["vwap"] = df_intraday["cum_pv"] / df_intraday["cum_vol"].replace(0, np.nan)
df_intraday["vwap"].fillna(method="ffill", inplace=True)
current_price = float(df_intraday["price"].iloc[-1])
current_vwap = float(df_intraday["vwap"].iloc[-1])
# ATR proxy (rolling mean absolute diff)
df_intraday["returns_abs"] = df_intraday["price"].diff().abs()
atr = float(df_intraday["returns_abs"].rolling(14, min_periods=1).mean().iloc[-1])
# Session high / low
session_high = float(df_intraday["price"].max())
session_low = float(df_intraday["price"].min())
# Tick imbalance proxy
up_ticks = int((df_intraday["price"].diff() > 0).sum())
down_ticks = int((df_intraday["price"].diff() < 0).sum())
denom = max(1, up_ticks + down_ticks)
imbalance = float((up_ticks - down_ticks) / denom)
# Confirmación estructural simple
intraday_signal = "NO TRADE"
if decision_flag == "buy":
# criterio: precio por encima de VWAP y imbalance positivo
if (current_price > current_vwap) and (imbalance > 0):
intraday_signal = "LONG CONFIRMADO"
elif decision_flag == "sell":
if (current_price < current_vwap) and (imbalance < 0):
intraday_signal = "SHORT CONFIRMADO"
intraday_info = {
"current_price": current_price,
"current_vwap": current_vwap,
"atr": atr,
"session_high": session_high,
"session_low": session_low,
"imbalance": imbalance,
"signal": intraday_signal
}
# Mostrar resultados intradía
st.markdown("### Estado intradía (demo)")
colA, colB, colC = st.columns(3)
with colA:
st.metric("Precio actual", f"{current_price:.5f}")
st.metric("VWAP", f"{current_vwap:.5f}")
with colB:
st.metric("ATR (proxy)", f"{atr:.6f}")
st.metric("Imbalance", f"{imbalance:.3f}")
with colC:
st.metric("Session High", f"{session_high:.5f}")
st.metric("Session Low", f"{session_low:.5f}")
if intraday_signal == "LONG CONFIRMADO":
st.success("Señal Intradía: LONG confirmado con VWAP + Imbalance")
elif intraday_signal == "SHORT CONFIRMADO":
st.error("Señal Intradía: SHORT confirmado con VWAP + Imbalance")
else:
st.info("No hay confirmación intradía aún. Esperar estructura o mayor desequilibrio.")
# Plan de ejecución sugerido (demo)
st.markdown("### Plan de Ejecución (demo)")
if intraday_signal in ["LONG CONFIRMADO", "SHORT CONFIRMADO"]:
st.markdown(f"""
- Entrada en dirección del bias macro ({decision}).
- Stop técnico ≈ 1.2 × ATR (≈ {1.2*atr:.6f}).
- Target inicial ≥ 1.5 × riesgo.
- Tamaño sugerido: 0.51% del capital.
""")
else:
st.markdown("Esperar ruptura estructural o mayor desequilibrio de flujo.")
else:
intraday_signal = "NO TRADE"
intraday_info = {}
# ------------------ Narrativa simplificada + técnica (en español) ------------------
def narrative_tactical_simplificada(p_mean, p_low, p_high, conviction, decision_flag, x_live, contrib_pct, feature_cols, par, calibration_applied, using_demo):
"""
Versión legible para usuario promedio + explicación técnica desplegable.
Devuelve (texto_simple, texto_tecnico).
"""
prob_text = f"P({par} ↑) ≈ {p_mean*100:.1f}% (IC95%: {p_low*100:.1f}%{p_high*100:.1f}%). Convicción: {conviction}/100."
if decision_flag == "buy":
simple = (
"Recomendación: **Comprar / Abrir posición larga (táctica)**.\n\n"
"Por qué (simple): la probabilidad de subida es alta y hay soporte estructural. "
"Se sugiere entrar de forma gradual y controlar el riesgo con stops."
)
elif decision_flag == "sell":
simple = (
"Recomendación: **Vender / Abrir posición corta (táctica)**.\n\n"
"Por qué (simple): la probabilidad favorece la baja y la convicción es suficiente para una operación táctica. "
"Usa stops ajustados y tamaño reducido."
)
else:
simple = (
"Recomendación: **No operar (esperar)**.\n\n"
"Por qué (simple): la probabilidad está cerca de equilibrio o la convicción es baja; mejor esperar confirmación."
)
# Texto técnico
fed_s = x_live[3] if len(x_live) > 3 else 0.0
cot_s = x_live[0] if len(x_live) > 0 else 0.0
retail_s = x_live[4] if len(x_live) > 4 else 0.0
tech_lines = []
tech_lines.append(f"Resumen técnico — activo: {par}")
tech_lines.append(f"- Probabilidad bayesiana P({par} ↑) = {p_mean*100:.1f}% (IC95%: {p_low*100:.1f}%{p_high*100:.1f}%). Convicción: {conviction}/100.")
if calibration_applied:
tech_lines.append("- Nota técnica: la predicción ha sido calibrada contra el sesgo medio del histórico para mejorar neutralidad.")
if using_demo:
tech_lines.append("- Advertencia: se están usando datos demo para calibración. Suba su CSV histórico para calibración real y backtests.")
# Macro
if fed_s > 0.4:
tech_lines.append("- Macro: FedWatch con sesgo hawkish → favorece USD fuerte en el corto plazo.")
elif fed_s < -0.4:
tech_lines.append("- Macro: FedWatch dovish → presión a la baja para USD.")
else:
tech_lines.append("- Macro: FedWatch neutral/moderado → macro no es el driver dominante ahora.")
# COT
if abs(cot_s) < 0.15:
tech_lines.append("- Posicionamiento (COT): cercano a neutral — riesgo de sobreacumulación limitado.")
elif cot_s >= 0.15:
tech_lines.append("- Posicionamiento (COT): sesgo long entre no-commercials — soporte estructural pero atención a toma de ganancias.")
else:
tech_lines.append("- Posicionamiento (COT): sesgo short — riesgo de short-squeeze si macro gira hawkish.")
# Retail
if retail_s > 0.15:
tech_lines.append("- Retail: minoristas inclinados a comprar — posible señal contraria a corto plazo.")
elif retail_s < -0.15:
tech_lines.append("- Retail: minoristas inclinados a vender — puede reforzar momentum bajista si lo institucional confirma.")
else:
tech_lines.append("- Retail: posicionamiento minorista balanceado — no prevalece contrarian ahora.")
# Contribuciones
tech_lines.append("- Descomposición de contribuciones (valores y % firmadas):")
for name, pct, val in zip(feature_cols, contrib_pct, x_live):
sign = "+" if pct >= 0 else "-"
tech_lines.append(f" • {name}: {sign}{abs(pct):.1f}% (valor {val:.3f})")
# Razonamiento
if decision_flag == "buy":
tech_lines.append("- Recomendación táctica: acumulación gradual LONG. Ejecución: escala en 3 tramos; tamaño inicial 0.51% notional; stop 1.01.5×ATR; R:R ≥ 1:1.5.")
elif decision_flag == "sell":
tech_lines.append("- Recomendación táctica: considerar SHORT táctico o reducir largos. Ejecución: tamaño reducido; stops ajustados; vigilar noticias.")
else:
tech_lines.append("- Recomendación táctica: no operar. Esperar confirmación de flujos o ruptura técnica.")
tech_lines.append("- Nota: COT es semanal (retardo); combine con order-flow intradía y skew de opciones para ejecución.")
texto_tecnico = "\n".join(tech_lines)
texto_simple = prob_text + "\n\n" + simple
return texto_simple, texto_tecnico
# Obtener textos simple y técnico
texto_simple, texto_tecnico = narrative_tactical_simplificada(
p_mean, p_low, p_high, conviction, decision_flag, x_live, contrib_pct, feature_cols, par, calibration_applied, using_demo
)
# ------------------ Interfaz: resultado y narrativa ------------------
left, right = st.columns([2,3])
with left:
st.markdown("<div class='card'>", unsafe_allow_html=True)
st.markdown(f"<div class='decision'>{decision}</div>", unsafe_allow_html=True)
st.markdown(
f"<div class='muted'>Probabilidad (media): <strong>{p_mean*100:.1f}%</strong> &nbsp;&nbsp; 95% IC: <strong>{p_low*100:.1f}%{p_high*100:.1f}%</strong></div>",
unsafe_allow_html=True
)
st.markdown(f"<div class='muted'>Convicción: <strong>{conviction}/100</strong></div>", unsafe_allow_html=True)
if calibration_applied:
st.markdown("<div class='small warning'>Se aplicó calibración contra el sesgo histórico del modelo para favorecer neutralidad.</div>", unsafe_allow_html=True)
if using_demo:
st.markdown("<div class='small warning'>ATENCIÓN: usando datos demo. Suba CSV histórico para calibración real.</div>", unsafe_allow_html=True)
st.markdown("<hr/>", unsafe_allow_html=True)
st.markdown("### Plan táctico (resumen)", unsafe_allow_html=True)
if decision_flag == "buy":
st.markdown("- Escala en 3 tramos. Tramo inicial 0.51% notional.", unsafe_allow_html=True)
st.markdown("- Stop: 1.01.5×ATR o por debajo de soporte técnico.", unsafe_allow_html=True)
st.markdown("- Target: R:R ≥ 1:1.5.", unsafe_allow_html=True)
elif decision_flag == "sell":
st.markdown("- Reducir exposiciones largas o iniciar cortos pequeños (0.51%).", unsafe_allow_html=True)
st.markdown("- Stops estrictos; vigila comunicados macro.", unsafe_allow_html=True)
else:
st.markdown("- No operar. Esperar señal clara o confirmación de flujos.", unsafe_allow_html=True)
st.markdown("</div>", unsafe_allow_html=True)
with right:
fig = go.Figure(go.Indicator(
mode="gauge+number",
value=p_mean*100,
number={'suffix': '%'},
domain={'x': [0,1], 'y': [0,1]},
title={'text': f"P({par} ↑)"},
gauge={
'axis': {'range': [0,100]},
'bar': {'color': "#0f2742"},
'steps': [
{'range':[0,40], 'color':'#d9534f'},
{'range':[40,60], 'color':'#f0ad4e'},
{'range':[60,100], 'color':'#2a9d8f'}
],
}
))
st.plotly_chart(fig, use_container_width=True)
st.markdown("### Resumen contextual — versión simple", unsafe_allow_html=True)
st.markdown(f"<div class='card'><div class='explain'>{texto_simple.replace(chr(10), '<br/>')}</div></div>", unsafe_allow_html=True)
with st.expander("Ver explicación técnica (detalle para analistas)"):
st.markdown(f"<div class='card'><div class='explain'>{texto_tecnico.replace(chr(10), '<br/>')}</div></div>", unsafe_allow_html=True)
# ------------------ Tabla de componentes y coeficientes ------------------
st.markdown("### Componentes (valores normalizados) y coeficientes MAP")
comp_df = pd.DataFrame({
"Componente": feature_cols,
"Valor (normalizado)": [f"{v:.4f}" for v in x_live],
"beta_MAP": [f"{float(b):.4f}" for b in beta_map],
"Contribución firmada (%)": [f"{float(c):.1f}%" for c in contrib_pct]
})
st.table(comp_df)
# ------------------ Exportar resultado ------------------
out_df = pd.DataFrame([{
"par": par, "cot_long": cot_long, "cot_short": cot_short, "net": net,
"fed_prob": fed_prob, "retail_pct": retail_pct,
"p_mean": p_mean, "p_2.5": p_low, "p_97.5": p_high,
"conviction": conviction, "decision": decision,
"intraday_signal": intraday_signal
}])
try:
excel_bytes = to_excel_bytes(out_df)
st.download_button("Exportar resultado (Excel)", data=excel_bytes, file_name=f"kennis_{par.replace('/','')}_signal.xlsx")
except Exception as e:
st.error("Error exportando Excel: " + str(e))
st.markdown("---")
st.markdown("<div class='small'>Implementación: Logistic bayesiano táctico (MAP + Laplace). Suba CSV con 'target_up' para calibración real y backtests. Uso profesional: combine con price feed y motor de ejecución.</div>", unsafe_allow_html=True)
st.markdown("© Kennis FX Markets — Institutional Bayesian Intelligence Engine", unsafe_allow_html=True)