/* =========================================================
VALIDQ · TICKER DETAIL · PRO MODE
MRK · Merck & Co. · score 58 · HC SWEET SPOT
========================================================= */
/* === D1-C4e.4 engine + analysis labels === */
/* === D1-C4e.3 lifecycle dot fix + residuals === */
/* === D1-C4e.2 motor v3 signal patterns === */
/* === D1-C4e.1 signal verdict + pattern translation === */
/* === D1-C4e JSX runtime i18n (EN-native + T() + DICT EN-ES) === */
const { useState, useEffect, useRef } = React;
const T_DICT_EN_ES = {"Search another ticker — JNJ, KO, NESN.SW…": "Buscar otro ticker — JNJ, KO, NESN.SW…", "You need an account to analyse tickers. Free plan includes 5 analyses per month.": "Necesitas una cuenta para analizar tickers. El plan Gratuito incluye 5 análisis al mes.", "Intrinsic value vs current price": "Valor intrínseco vs precio actual", "Tactical & NLP signals": "Señales tácticas y NLP", "Sector sweet-spot": "Zona óptima del sector", "Composite score": "Puntuación compuesta", "Score breakdown": "Desglose de puntuación", "Sign in required": "Inicio de sesión requerido", "Current drivers": "Drivers actuales", "Intrinsic value": "Valor intrínseco", "Current price": "Precio actual", "Current score": "Puntuación actual", "Mapping error": "Error de mapeo", "Toggle theme": "Cambiar tema", "Back to home": "Volver al inicio", "Bad request": "Solicitud inválida", "Methodology": "Metodología", "Significance": "Significancia", "Agent hive": "Colmena de agentes", "Sweet spot": "Zona óptima", "Lifecycle": "Ciclo de vida", "Confidence": "Confianza", "Key stats": "Datos clave", "Analysing": "Analizando", "Sign out": "Cerrar sesión", "Sign in": "Iniciar sesión", "Account": "Cuenta", "Alerts": "Alertas", "Retry": "Reintentar", "Terms": "Términos", "Close": "Cerrar", "Upside": "Potencial", "Win rate": "Tasa de aciertos", "Agent": "Agente", "News": "Noticias", "Sell-side revisions 30d": "Revisiones del sell-side 30d", "Healthcare relative": "Relativo Sanidad", "Consensus rating": "Rating de consenso", "ETF inflows 30d": "Entradas ETF 30d", "Earnings momentum": "Momentum de beneficios", "Net Debt/EBITDA": "Deuda neta/EBITDA", "Moat (Damodaran)": "Foso (Damodaran)", "FY25 EPS guide": "Guía EPS FY25", "Operating margin": "Margen operativo", "Earnings yield": "Rentabilidad por beneficio", "Buyers/Sellers": "Compradores/Vendedores", "FCF yield TTM": "Rentabilidad FCF TTM", "Last 3 calls": "Últimas 3 calls", "Short interest": "Interés corto", "Switching cost": "Coste de cambio", "Sector NLP avg": "Media NLP del sector", "Brand equity": "Valor de marca", "Change vs Q1": "Cambio vs T1", "Yield curve": "Curva de tipos", "Sector cycle": "Ciclo del sector", "Avg maturity": "Vencimiento medio", "Avg target": "Precio objetivo medio", "Debt/Equity": "Deuda/Patrimonio", "CAPEX/Sales": "CAPEX/Ventas", "DCF upside": "Potencial DCF", "IP / patents": "PI / patentes", "Cost moat": "Foso de costes", "WC days": "Días WC", "Net 90d": "Neto 90d", "Threshold": "Umbral", "Prep tone": "Tono preparado", "Strong (Keytruda 2028)": "Fuerte (Keytruda 2028)", "Late-cycle defensive": "Defensivo de ciclo tardío", "Bullish-neutral": "Alcista-neutral", "+0.42 (bullish)": "+0.42 (alcista)", "4.2 (safe zone)": "4.2 (zona segura)", "−2.41 (clean)": "−2.41 (limpio)", "Buy (4.1/5)": "Compra (4.1/5)", "+0.05 (raise)": "+0.05 (sube)", "Medium-high": "Medio-alto", "0.04 (low)": "0.04 (bajo)", "Steepening": "Empinándose", "1.2% float": "1.2% del float", "8.4 years": "8.4 años", "Medium": "Medio", "Back to feed": "Volver al feed", "In watchlist": "En watchlist", "Add to watchlist": "Añadir a watchlist", "Re-analyse": "Reanalizar", "Analyse another": "Analizar otro", "analysed": "analizado", "cache 14m": "caché 14m", "calibrated against SSRN paper 6735820": "calibrado con paper SSRN 6735820", "vs SPX · 36 months": "vs SPX · 36 meses", "annualised": "anualizado", "positive cohorts": "cohortes positivas", "Backtest 2012–2014, 2017–2019 · 4 107 ticker-years": "Backtest 2012–2014, 2017–2019 · 4 107 años-ticker", "7 academic models · click for detail": "7 modelos académicos · clic para detalle", "7 agents · click for detail": "7 agentes · clic para detalle", "12-month horizon": "horizonte 12 meses", "Start-up": "Inicio", "Young Growth": "Crec. joven", "High Growth": "Crec. alto", "Mature G.": "Mad. crec.", "Mature Stable": "Madurez estable", "Decline": "Declive", "article": "artículo", "articles": "artículos", "active": "activas", "no alerts": "sin alertas", "No alerts yet.": "Aún no hay alertas.", "Earnings + score deltas will appear here.": "Variaciones de earnings y score aparecerán aquí.", "CAGR α": "TCAC α", "2-stage Mauboussin · WACC 7.2% · g 2.5%": "Mauboussin 2-etapas · WACC 7.2% · g 2.5%", "Accounting integrity": "Integridad contable", "Bankruptcy risk": "Riesgo de quiebra", "Business quality": "Calidad del negocio", "Valuation": "Valoración", "Debt structure": "Estructura de deuda", "Free Cash Flow Momentum": "Momentum de Free Cash Flow", "Guidance Reliability": "Fiabilidad de la guía", "Insider Activity": "Actividad de insiders", "ATLAS · Intangibles & Moat": "ATLAS · Intangibles y foso", "Sector timing": "Timing del sector", "CHRONOS · Cycle timing": "CHRONOS · Timing del ciclo", "CLIO · Business model": "CLIO · Modelo de negocio", "ARGOS · Consensus & flows": "ARGOS · Consenso y flujos", "HIGH": "ALTA", "MEDIUM": "MEDIA", "LOW": "BAJA", "Signal · quantitative verdict": "Señal · veredicto cuantitativo", "Alpha 3y": "Alfa 3a", "view detail": "ver detalle", "Valuation · reverse-DCF (Mauboussin)": "Valoración · reverse-DCF (Mauboussin)", "Valuation band": "Banda de valoración", "· overvalued": "· sobrevalorada", "· undervalued": "· infravalorada", "current": "actual", "fair value": "valor justo", "undervalued": "infravalorada", "fair": "justa", "expensive": "cara", "range": "rango", "inside": "dentro", "above range": "por encima", "below range": "por debajo", "EODHD · adjusted close": "EODHD · cierre ajustado", "My account": "Mi cuenta", "Profile & details": "Perfil y detalles", "Manage tickers": "Gestionar tickers", "History": "Historial", "Plan & billing": "Plan y facturación", "Help": "Ayuda", "Docs & support": "Docs y soporte", "this month": "este mes", "Layer": "Capa", "C1–C5 are the \"pure validation\" layers (SSRN paper 6735820). C6–C7 add NLP and intangibles signals. Recomputed after a new 10-K/10-Q or manual re-analysis.": "C1–C5 son las capas de \"validación pura\" (paper SSRN 6735820). C6–C7 añaden señales NLP e intangibles. Se recomputan tras un nuevo 10-K/10-Q o reanálisis manual.", "current score": "score actual", "Updated 14m ago · EODHD + SEC EDGAR data. Re-evaluated every 24h or after a new 10-Q/8-K.": "Actualizado hace 14m · Datos EODHD + SEC EDGAR. Re-evaluado cada 24h o tras nuevo 10-Q/8-K.", "confidence": "confianza", "Monthly quota reached": "Cuota mensual alcanzada", "You've used all your analyses this month. Upgrade your plan to continue.": "Has usado todos tus análisis del mes. Sube de plan para continuar.", "Plan upgrade required": "Mejora de plan requerida", "This feature requires a higher plan": "Esta función requiere un plan superior", "Ticker not found": "Ticker no encontrado", "Temporary error": "Error temporal", "Please try again": "Inténtalo de nuevo", "Watchlist error": "Error de watchlist", "removed from watchlist": "eliminado de watchlist", "added to watchlist": "añadido a watchlist", "AVOID": "EVITAR", "BUY": "COMPRAR", "WAIT": "ESPERAR", "REVIEW": "REVISAR", "QUALITY TRAP": "TRAMPA DE CALIDAD", "VALUE TRAP": "TRAMPA DE VALOR", "MOMENTUM TRAP": "TRAMPA DE MOMENTUM", "DEEP VALUE": "VALOR PROFUNDO", "GROWTH TRAP": "TRAMPA DE CRECIMIENTO", "QUALITY MISS": "CALIDAD INSUFICIENTE", "VALUE MISS": "INFRAVALORACIÓN AUSENTE", "HIGH QUALITY": "ALTA CALIDAD", "WIDE MOAT": "FOSO AMPLIO", "FAIR VALUE": "VALOR JUSTO", "CLEAR BUY": "COMPRA CLARA", "DEEP VALUE TRAP": "TRAMPA VALOR PROFUNDO", "PREMIUM PRICED": "PRECIO PREMIUM", "VALUE SWEET SPOT": "SWEET SPOT DE VALOR", "VALUE SWEET SPOT HC": "SWEET SPOT DE VALOR HC", "VALUE SWEET SPOT LITE": "SWEET SPOT DE VALOR LITE", "HC SWEET SPOT": "SWEET SPOT HC", "1 year · USD": "1 año · USD", "52w position": "Posición 52s", "GICS industry": "Industria GICS", "Engine": "Motor", "Analysis": "Análisis", "Engine v6.1 · 7 layers · 9 agents": "Motor v6.1 · 7 capas · 9 agentes", "Investment thesis": "Tesis de inversión", "Powered by": "Generado por", "Plan": "Plan"};
const T = (s) => {
if (typeof s !== "string") return s;
try {
const lang = (typeof localStorage !== "undefined" && localStorage.getItem("vq_lang")) || "es";
if (lang === "es" && T_DICT_EN_ES[s]) return T_DICT_EN_ES[s];
} catch (e) {}
return s;
};
/* ---------- Lucide-style icons (inline SVG, line-only) ---------- */
const I = ({ d, size = 16, sw = 1.6, children, ...rest }) =>
{children || }
;
const Icon = {
ArrowLeft: (p) => ,
Search: (p) => ,
Sun: (p) => ,
Moon: (p) => ,
Star: (p) => ,
Refresh: (p) => ,
Share: (p) => ,
Info: (p) => ,
Check: (p) => ,
X: (p) => ,
ChevronDown: (p) => ,
Flask: (p) => ,
Scale: (p) => ,
Gem: (p) => ,
Tag: (p) => ,
Landmark: (p) => ,
Home: (p) => ,
Wave: (p) => ,
Trending: (p) => ,
Cpu: (p) => ,
User: (p) => ,
Eye: (p) => ,
Mic: (p) => ,
Clock: (p) => ,
Layers: (p) => ,
Calendar: (p) => ,
Bell: (p) => ,
Newspaper: (p) => ,
Activity: (p) =>
};
/* ---------- DEMO DATA · MRK ---------- */
const TICKER = {
symbol: "MRK", market: "NYSE", flag: "🇺🇸",
name: "Merck & Co., Inc.",
sector: "Healthcare", sub: "Pharmaceuticals",
lifecycle: "Mature Stable", lifecycleConfidence: 79,
price: 111.38, change: 0.82, changePct: 0.74,
currency: "USD", asOf: "May 15, 2026 · 19:25 CET", lastAnalysis: "14m ago",
score_display: 58, score_c1c5: 58, score_with_agents: 63,
sectorSweetSpot: { lo: 45, hi: 58, label: "Healthcare · Mature stable" },
signal: {
verdict: "BUY",
pattern: "HC SWEET SPOT",
confidence: 3,
confLabel: T("HIGH"),
desc: "Healthcare mature-stable score 45–58. The engine identifies this pattern with empirical magnitude documented across 6 cohorts (2012–2014, 2017–2019).",
alpha3y: "+18.6pp",
cagrAlpha: "+6.20pp",
winRate: "6/6",
pValue: "p<0.001"
},
layers: [
{ code: "C1", name: T("Accounting integrity"), icon: "Flask", pass: true, score: null, max: null,
detail: "Beneish M-Score −2.41 · no manipulation detected",
methodology: "Beneish M-Score detects accounting manipulation from 8 ratios (DSRI, GMI, AQI, SGI, DEPI, SGAI, LVGI, TATA). Threshold −1.78. Validated across >70 SEC cases.",
drivers: [["M-Score", T("−2.41 (clean)")], ["DSRI", "1.04"], ["AQI", "0.98"], ["TATA", "−0.04"], [T("Threshold"), "−1.78"]] },
{ code: "C2", name: T("Bankruptcy risk"), icon: "Scale", pass: true, score: null, max: null,
detail: "Altman Z = 4.2 · Ohlson P = 0.04",
methodology: "Altman Z-Score (5 ratios) + Ohlson O-Score as cross-check. Z>3 = safe zone. P<0.5 = low probability of bankruptcy within 12 months.",
drivers: [["Altman Z", T("4.2 (safe zone)")], ["Ohlson P", T("0.04 (low)")], ["WC/TA", "0.18"], ["RE/TA", "0.41"], ["EBIT/TA", "0.16"]] },
{ code: "C3", name: T("Business quality"), icon: "Gem", pass: null, score: 28, max: 35,
detail: "Piotroski 7/9 · Moat 16/22 · ROIC 18.4%",
methodology: "Combines Piotroski F-Score (9 binary signals across profitability/leverage/efficiency), Damodaran moat scoring and ROIC vs WACC. Weighted by persistence.",
drivers: [["Piotroski F", "7/9"], [T("Moat (Damodaran)"), "16/22"], ["ROIC", "18.4%"], ["ROIC − WACC", "+11.2pp"], [T("Operating margin"), "31.5%"]] },
{ code: "C4", name: T("Valuation"), icon: "Tag", pass: null, score: 14, max: 25, warn: true,
detail: "P/E 18.4× · DCF +12% upside · EV/EBITDA 14×",
methodology: "Triangulation: relative multiples (P/E, EV/EBITDA, P/B) vs sector + 2-stage reverse DCF (Mauboussin). Adjusted for lifecycle stage.",
drivers: [["P/E TTM", "18.4× (sector 19.2×)"], ["EV/EBITDA", "14.1×"], [T("DCF upside"), "+12.0%"], ["P/B", "5.2×"], [T("Earnings yield"), "5.4%"]] },
{ code: "C5", name: T("Debt structure"), icon: "Landmark", pass: null, score: 18, max: 25,
detail: "EV/EBITDA 14× · Coverage 18× · ND/EBITDA 1.4×",
methodology: "Assesses leverage (ND/EBITDA), debt-service capacity (interest coverage) and maturity ladder. Penalises short-term maturity concentration.",
drivers: [[T("Net Debt/EBITDA"), "1.4×"], ["Interest coverage", "18×"], [T("Debt/Equity"), "0.62"], [T("Avg maturity"), T("8.4 years")], ["Credit rating", "AA-"]] },
{ code: "C6", name: "ATLAS · Intangibles", icon: "Home", pass: null, score: 12, max: 20,
detail: "5 signals · solid moat · strong IP",
methodology: "NLP + structured agent over 10-K filings. 5 dimensions: IP, switching costs, network effects, scale moat, brand. Same engine as the ATLAS agent, integrated as a layer.",
drivers: [[T("IP / patents"), T("Strong (Keytruda 2028)")], [T("Switching cost"), T("Medium")], [T("Brand equity"), "8.4 / top 25%"], ["Network effects", "Low"], [T("Cost moat"), T("Medium-high")]] },
{ code: "C7", name: "HERMES · NLP", icon: "Wave", pass: null, score: 14, max: 30, warn: true,
detail: "Management tone neutral-bullish · 3 earnings calls",
methodology: "NLP over transcripts (Loughran-McDonald + in-house finetune). Compares prepared vs Q&A tone, hedging, guidance clarity. Last 3 calls weighted.",
drivers: [[T("Prep tone"), T("+0.42 (bullish)")], ["Q&A tone", "+0.18 (neutral)"], ["Hedging", "low"], [T("Last 3 calls"), T("Bullish-neutral")], [T("Sector NLP avg"), "+0.12"]] }],
agents: [
{ name: "FCF-MOM", v: "+5", d: "/15", sign: "pos", tag: "CFO yield expansion",
full: T("Free Cash Flow Momentum"),
methodology: "Detects FCF / CFO yield acceleration over 4–8 quarters. Signals margin expansion and pricing power.",
drivers: [[T("FCF yield TTM"), "5.20%"], ["FCF yield (-1Y)", "4.45%"], [T("CAPEX/Sales"), "-12 bp QoQ"], [T("WC days"), "-3 d YoY"]] },
{ name: "GUIDANCE", v: "+3", d: "/15", sign: "pos", tag: "No FY25 cuts",
full: T("Guidance Reliability"),
methodology: "Evaluates the consistency of quarterly guidance and consensus revisions. Penalises recent cuts.",
drivers: [[T("FY25 EPS guide"), "$8.10-8.25"], [T("Change vs Q1"), T("+0.05 (raise)")], ["Beat rate (8Q)", "7/8"], [T("Sell-side revisions 30d"), "+0.4%"]] },
{ name: "INSIDER", v: "−2", d: "/12", sign: "neg", tag: "Moderate selling",
full: T("Insider Activity"),
methodology: "SEC Form 4. Net buying / selling weighted by management-signal vs stock-options-vesting noise.",
drivers: [[T("Net 90d"), "-$4.2M"], [T("Buyers/Sellers"), "1 / 4"], ["% executive signal", "38%"], ["Cluster activity", "No"]] },
{ name: "ATLAS", v: "+12", d: "/20", sign: "pos", tag: "Moat · 5 signals",
full: T("ATLAS · Intangibles & Moat"),
methodology: "NLP + structured over 10-K. Detects IP, switching costs, network effects, scale, brand. Damodaran moat scoring.",
drivers: [[T("IP / patents"), T("Strong (Keytruda 2028)")], [T("Switching cost"), T("Medium")], [T("Brand equity"), "8.4 / top 25%"], ["Network effects", "Low"], [T("Cost moat"), T("Medium-high")]] },
{ name: "HERMES", v: "+14", d: "/30", sign: "pos", tag: "Bullish neutral",
full: "HERMES · NLP earnings calls",
methodology: "Sentiment + management tone over earnings-call transcripts (Loughran-McDonald + in-house finetune). Compares Q&A vs prepared remarks.",
drivers: [[T("Prep tone"), T("+0.42 (bullish)")], ["Q&A tone", "+0.18 (neutral)"], ["Hedging frequency", "low"], [T("Last 3 calls"), T("Bullish-neutral")]] },
{ name: "CHRONOS", v: "+3", d: "/10", sign: "amb", tag: "Cycle neutral",
full: T("CHRONOS · Cycle timing"),
methodology: "Macro + sector cycle positioning (yield curve, PMI, sector rotation). Signals timing relative to the cycle.",
drivers: [[T("Sector cycle"), T("Late-cycle defensive")], [T("Yield curve"), T("Steepening")], [T("Healthcare relative"), "+0.3 sigma"], [T("Earnings momentum"), "Neutral"]] },
{ name: "ARGOS", v: "+4", d: "/8", sign: "pos", tag: "Consensus buy",
full: T("ARGOS · Consensus & flows"),
methodology: "Wisdom of crowds: sell-side consensus + ETF flows + short interest. Filters pure-momentum noise.",
drivers: [[T("Consensus rating"), T("Buy (4.1/5)")], [T("Avg target"), "$132 (+18%)"], [T("Short interest"), T("1.2% float")], [T("ETF inflows 30d"), "+$240M"]] }],
dcf: {
current: 111.38, intrinsic: 124.77, upside: 12.0,
method: T("2-stage Mauboussin · WACC 7.2% · g 2.5%"),
range: { lo: 90, hi: 150 }
},
stats: [
{ l: "Market cap", v: "$282.4B" },
{ l: "P/E (TTM)", v: "18.4×" },
{ l: "EV/EBITDA", v: "14.1×" },
{ l: "ROIC", v: "18.4%", g: true },
{ l: "FCF yield", v: "5.2%", g: true },
{ l: "Dividend yield", v: "2.94%" },
{ l: "Payout ratio", v: "42%" },
{ l: "Beta (5Y)", v: "0.41" },
{ l: T("GICS industry"), v: "Pharmaceuticals" },
{ l: T("Brand equity"), v: "8.4 · top 25%" },
{ l: T("Engine"), v: "v3.2" },
{ l: T("Analysis"), v: "248 ms" }],
news: [
{ src: "Reuters", time: "2h", title: "Trump says unaware of plan to fire FDA commissioner Marty Makary" },
{ src: "Bloomberg", time: "5h", title: "How Hantavirus made Moderna's mRNA platform a target for Merck partnership talks" },
{ src: "WSJ", time: "1d", title: "Merck faces Gardasil trial after plaintiffs allege concealed side effects" },
{ src: "FT", time: "2d", title: "Keytruda subcutaneous formulation cleared for late-2026 launch in EU" }],
alerts: [
{ kind: "up", text: "Score up +2 points vs previous analysis", meta: "14 days ago · 56 → 58" },
{ kind: "info", text: "Next earnings: July 18 · pre-market", meta: "Q2 2026 · estimated EPS $2.21" }]
};
/* ---------- TICKER SEARCH (autocomplete-ready) ---------- */
const TICKER_DB = [
{ sym: "MRK", name: "Merck & Co.", mkt: "NYSE" },
{ sym: "JNJ", name: "Johnson & Johnson", mkt: "NYSE" },
{ sym: "PFE", name: "Pfizer Inc.", mkt: "NYSE" },
{ sym: "LLY", name: "Eli Lilly", mkt: "NYSE" },
{ sym: "NVO", name: "Novo Nordisk", mkt: "NYSE" },
{ sym: "KO", name: "Coca-Cola", mkt: "NYSE" },
{ sym: "PEP", name: "PepsiCo", mkt: "NASDAQ" },
{ sym: "AAPL", name: "Apple Inc.", mkt: "NASDAQ" },
{ sym: "MSFT", name: "Microsoft", mkt: "NASDAQ" },
{ sym: "GOOGL", name: "Alphabet (A)", mkt: "NASDAQ" },
{ sym: "NESN.SW", name: "Nestlé", mkt: "SWX" },
{ sym: "ROG.SW", name: "Roche Holding", mkt: "SWX" },
{ sym: "NOVN.SW", name: "Novartis", mkt: "SWX" },
{ sym: "SAN.PA", name: "Sanofi", mkt: "EPA" },
{ sym: "ITX.MC", name: "Inditex", mkt: "BME" },
{ sym: "IBE.MC", name: "Iberdrola", mkt: "BME" },
{ sym: "BRK.B", name: "Berkshire Hathaway", mkt: "NYSE" },
{ sym: "V", name: "Visa", mkt: "NYSE" },
{ sym: "MA", name: "Mastercard", mkt: "NYSE" },
{ sym: "PG", name: "Procter & Gamble", mkt: "NYSE" }
];
// TODO: replace with /api/tickers/search?q= (DB)
async function searchTickers(q) {
const Q = q.trim().toUpperCase();
if (!Q) return [];
return TICKER_DB.filter(t => t.sym.includes(Q) || t.name.toUpperCase().includes(Q)).slice(0, 8);
}
function TickerSearch() {
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const [results, setResults] = useState([]);
const [active, setActive] = useState(0);
const wrapRef = useRef(null);
useEffect(() => {
let alive = true;
searchTickers(q).then(r => alive && setResults(r));
return () => { alive = false; };
}, [q]);
useEffect(() => {
const onDoc = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const showList = open && q.length > 0;
const onKey = (e) => {
if (!showList) return;
if (e.key === "ArrowDown") { e.preventDefault(); setActive((active + 1) % Math.max(1, results.length)); }
if (e.key === "ArrowUp") { e.preventDefault(); setActive((active - 1 + results.length) % Math.max(1, results.length)); }
if (e.key === "Enter" && results[active]) { setQ(results[active].sym); setOpen(false); }
if (e.key === "Escape") setOpen(false);
};
return (
{ setQ(e.target.value); setOpen(true); setActive(0); }}
onFocus={() => setOpen(true)}
onKeyDown={onKey}
/>
⌘K
{showList && (
{results.length === 0 &&
No matches in the database
}
{results.map((t, i) => (
setActive(i)}
onMouseDown={(e) => { e.preventDefault(); setQ(t.sym); setOpen(false); }}
type="button">
{t.sym}
{t.name}
{t.mkt}
))}
)}
);
}
/* ---------- USER MENU ---------- */
function UserMenu() {
const [open, setOpen] = useState(false);
const [me, setMe] = useState(null);
const ref = useRef(null);
useEffect(() => {
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
// Fetch real user info
useEffect(() => {
if (!window.vqApi || !vqApi.isLoggedIn()) return;
vqApi.me()
.then(u => setMe(u))
.catch(() => setMe(null));
}, []);
// If not logged → show Sign in button
if (!window.vqApi || !vqApi.isLoggedIn()){
return (
document.getElementById("modal-login").showModal()}>
{T("Sign in")}
);
}
const email = (me && me.email) || "...";
const userPart = email.split("@")[0].split(".")[0];
const displayName = userPart.charAt(0).toUpperCase() + userPart.slice(1);
const avatarLetter = (email[0] || "?").toUpperCase();
const plan = (me && me.plan) || "—";
const queriesUsed = (me && me.queries_this_month) || 0;
const queriesLimit = (me && me.queries_limit) || "∞";
const handleSignOut = () => {
if (window.vqApi) vqApi.logout("/");
};
const items = [
{ l: T("My account"), s: T("Profile & details"), href: "/cuenta.html" },
{ l: "Watchlist", s: T("Manage tickers"), href: "/home.html#watchlist" },
{ l: T("History"), s: `${queriesUsed} analyses this month`, href: "/home.html#history" },
{ l: T("Plan & billing"), s: `Plan ${plan}`, href: "/billing.html" },
{ l: T("Help"), s: T("Docs & support"), href: "/legal/metodologia.html" }
];
return (
setOpen(!open)}>
{avatarLetter}
{displayName}
{open && (
{avatarLetter}
{displayName}
{email}
Plan {plan} · {queriesUsed}/{queriesLimit} {T("this month")}
{T("Sign out")}
)}
);
}
/* ---------- NAV ---------- */
function Nav({ theme, setTheme }) {
return (
validq
v3 · analysis
setTheme(theme === "dark" ? "light" : "dark")}>
{theme === "dark" ? : }
);}
/* ---------- TICKER HEADER ---------- */
function TickerHeader({ t, watch, setWatch }) {
const pos = t.change >= 0;
return (
<>
{T("Back to feed")}
{t.flag}
{t.symbol}
{t.market}
· {t.name}
{t.sector} · {t.sub}
{t.lifecycle}
Engine v3.2
{T("cache 14m")}
${t.price.toFixed(2)}
{t.currency} · {t.asOf} · {T("analysed")} {t.lastAnalysis}
setWatch(!watch)}>
{watch ? T("In watchlist") : T("Add to watchlist")}
{
// Re-analyse: force a fresh analysis bypassing cache
const url = new URL(window.location.href);
url.searchParams.set("refresh", "1");
window.location.href = url.toString();
}}>
{T("Re-analyse")}
{
const dlg = document.getElementById("modal-analyse-another");
if (dlg) {
dlg.showModal();
setTimeout(() => {
const inp = document.getElementById("aa-ticker-input");
if (inp){ inp.value = ""; inp.focus(); }
}, 50);
}
}}>
{T("Analyse another")}
>);
}
/* ---------- SCORE HERO (Pro) ---------- */
function ScoreHero({ t }) {
const score = t.score_c1c5;
const tone = score >= 55 ? "" : score >= 40 ? "amber" : "red";
const ss = t.sectorSweetSpot;
// Sweet spot is calibrated on C1-C5 pure score, so the pin uses score_c1c5
const pinScore = t.score_c1c5;
const pinPct = pinScore; // axis 0..100
const ssLeft = ss.lo;
const ssWidth = ss.hi - ss.lo;
return (
{score}
/ 100
{T("Sweet spot")}
C1–C5 pure
{T("Composite score")}
{T("calibrated against SSRN paper 6735820")}
{T("Sector sweet-spot")}
{ss.label}
{T("range")} {ss.lo}–{ss.hi} · {ss.position === "in" ? "inside ✓" : ss.position === "above" ? "above range ⚠" : ss.position === "below" ? "below range ⚠" : "—"}
0 20 40 60 80 100
{/* S12_FIX: bloque sh-split eliminado (redundante con resto del hero) */}
{/* S12_MNEMO_UI_v1: MNEMO narrative block */}
{t.narrative && t.narrative.available && (t.narrative.textEs || t.narrative.textEn) && (
{T("Investment thesis")}
{'"' + t.narrative.oneLiner + '"'}
{t.narrative.textEs || t.narrative.textEn}
{T("Plan")} · {t.narrative.plan}
·
{t.narrative.textEs ? "ES" : "EN"}
·
{t.narrative.lifecycleStage}
{t.narrative.degraded && (
<>
·
FALLBACK
>
)}
{T("Powered by")}
MNEMO
)}
);
}
/* ---------- SIGNAL ---------- */
function SignalCard({ t }) {
const s = t.signal;
const dots = Array.from({ length: 3 }).map((_, i) =>
);
return (
{T("Signal · quantitative verdict")}
{T(s.verdict)}
·
{T(s.pattern)}
{T("confidence")}
{dots}
{s.confLabel}
{s.desc}
{T("Alpha 3y")}
{s.alpha3y}
{T("vs SPX · 36 months")}
{T("CAGR α")}
{s.cagrAlpha}
{T("annualised")}
{T("Win rate")}
{s.winRate}
{T("positive cohorts")}
{T("Significance")}
{s.pValue}
95% CI · t-test
);
}
/* ---------- LAYERS C1-C7 ---------- */
function LayersCard({ layers }) {
const [open, setOpen] = useState(null);
return (
Analysis · layers C1–C7
{T("Score breakdown")}
{T("7 academic models · click for detail")}
{layers.map((L) => {
const isPass = L.pass === true;
const pct = L.score != null ? Math.round(L.score / L.max * 100) : 100;
const cls = "layer layer-btn " + (isPass ? "pass " : "") + (L.warn ? "warn " : "");
const Ico = Icon[L.icon] || Icon.Layers;
return (
setOpen(L)}>
{L.code}
{L.name}
{L.detail}
{isPass ?
PASS :
<>
{L.score}/{L.max}
>
}
);
})}
{open &&
setOpen(null)} />}
);
}
function LayerModal({ layer, onClose }) {
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = ""; };
}, [onClose]);
const L = layer;
const isPass = L.pass === true;
const Ico = Icon[L.icon] || Icon.Layers;
const pct = L.score != null ? Math.round(L.score / L.max * 100) : 100;
return (
e.stopPropagation()} role="dialog" aria-modal="true">
{T("Layer")} {L.code}
{L.name}
{isPass ? PASS : <>{T("Current score")} {L.score}/{L.max} ({pct}%)>}
{T("Methodology")}
{L.methodology}
{T("Current drivers")}
{L.drivers.map(([k, v], i) => (
{k}
{v}
))}
{T("C1–C5 are the \"pure validation\" layers (SSRN paper 6735820). C6–C7 add NLP and intangibles signals. Recomputed after a new 10-K/10-Q or manual re-analysis.")}
);
}
/* ---------- AGENTS HIVE ---------- */
function AgentsCard({ agents }) {
const [open, setOpen] = useState(null);
return (
{T("Agent hive")}
{T("Tactical & NLP signals")}
{T("7 agents · click for detail")}
{agents.map((a) =>
setOpen(a)} type="button">
{a.name}
{a.sign === "pos" ? "▲" : a.sign === "neg" ? "▼" : "≈"}
{a.v}
{a.d}
{a.tag}
{T("view detail")}
)}
{open &&
setOpen(null)} />}
);
}
function AgentModal({ agent, onClose }) {
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = ""; };
}, [onClose]);
const a = agent;
return (
e.stopPropagation()} role="dialog" aria-modal="true">
{T("Agent")}
{a.full}
{a.name} · {T("current score")} {a.v}{a.d}
{T("Methodology")}
{a.methodology}
{T("Current drivers")}
{a.drivers.map(([k, v], i) => (
{k}
{v}
))}
{T("Updated 14m ago · EODHD + SEC EDGAR data. Re-evaluated every 24h or after a new 10-Q/8-K.")}
);
}
/* ---------- DCF ---------- */
function DcfCard({ d }) {
// Build a scale that ALWAYS includes current price + intrinsic + range
const allVals = [d.range.lo, d.range.hi, d.current, d.intrinsic].filter(v => v && v > 0);
const rawMin = Math.min(...allVals);
const rawMax = Math.max(...allVals);
// Add 8% padding on each side so pins never touch the edges
const padding = (rawMax - rawMin) * 0.08 || 1;
const scaleMin = rawMin - padding;
const scaleMax = rawMax + padding;
const pct = (v) => Math.max(2, Math.min(98, (v - scaleMin) / (scaleMax - scaleMin) * 100));
const lo = d.range.lo, hi = d.range.hi;
// Verdict: is the stock cheap or expensive vs intrinsic?
const isOvervalued = d.current > d.intrinsic;
return (
{T("Valuation · reverse-DCF (Mauboussin)")}
{T("Intrinsic value vs current price")}
{d.method}
{T("Current price")}
${d.current.toFixed(2)}
USD · spot
{T("Intrinsic value")}
${d.intrinsic.toFixed(2)}
2-stage · WACC 7.2%
{T("Upside")}
= 0 ? "g" : "neg")}>{d.upside >= 0 ? "+" : ""}{d.upside.toFixed(1)}%
{T("12-month horizon")}
{T("Valuation band")} {isOvervalued ? T("· overvalued") : T("· undervalued")}
bear ${lo.toFixed(0)} — bull ${hi.toFixed(0)}
{/* Range zone bear-bull as a shaded band */}
{/* Current price pin - label BELOW */}
${d.current.toFixed(2)}
{T("current")}
{/* Intrinsic pin - label ABOVE */}
${d.intrinsic.toFixed(2)}
{T("fair value")}
{T("undervalued")} {T("fair")} {T("expensive")}
);
}
/* ---------- PRICE CHART (interactive) ---------- */
function PriceChart({ ticker, currentPrice }) {
const ranges = ["1M", "3M", "6M", "1Y", "3Y", "5Y"];
const [active, setActive] = useState("1Y");
const [hover, setHover] = useState(null);
const wrapRef = useRef(null);
// Range config: days span + number of points
const rangeCfg = {
"1M": { days: 30, n: 30, vol: 0.6 },
"3M": { days: 90, n: 45, vol: 0.9 },
"6M": { days: 180, n: 60, vol: 1.1 },
"1Y": { days: 365, n: 80, vol: 1.4 },
"3Y": { days: 1095, n: 120, vol: 2.2 },
"5Y": { days: 1825, n: 150, vol: 3.0 }
};
const cfg = rangeCfg[active] || rangeCfg["1Y"];
// Deterministic series ending at currentPrice (so the chart "lands" on real price)
const endPrice = currentPrice || 100;
const pts = [];
let y = endPrice * 0.78; // start ~22% below current
for (let i = 0; i < cfg.n; i++) {
y += Math.sin(i * 0.42) * cfg.vol + Math.cos(i * 0.17) * (cfg.vol * 0.6) + (endPrice * 0.22 / cfg.n);
pts.push(y);
}
// Force last point to be the real current price
if (pts.length) pts[pts.length - 1] = endPrice;
const now = new Date();
const startDate = new Date(now.getTime() - cfg.days * 86400000);
const dayStep = cfg.days / Math.max(1, pts.length - 1);
const dates = pts.map((_, i) => new Date(startDate.getTime() + i * dayStep * 86400000));
const min = Math.min(...pts), max = Math.max(...pts);
const W = 800, H = 200, pad = 8;
const norm = (v) => H - pad - (v - min) / (max - min) * (H - pad * 2);
const stepX = (W - pad * 2) / (pts.length - 1);
const xAt = (i) => pad + i * stepX;
const path = pts.map((v, i) => (i === 0 ? "M" : "L") + xAt(i).toFixed(2) + " " + norm(v).toFixed(2)).join(" ");
const area = path + ` L ${xAt(pts.length - 1)} ${H} L ${pad} ${H} Z`;
const onMove = (e) => {
const wrap = wrapRef.current;
if (!wrap) return;
const r = wrap.getBoundingClientRect();
const x = e.clientX - r.left;
const ratio = Math.max(0, Math.min(1, x / r.width));
const i = Math.round(ratio * (pts.length - 1));
setHover({ i, xPct: (xAt(i) / W) * 100, yPct: (norm(pts[i]) / H) * 100 });
};
const onLeave = () => setHover(null);
const fmtDate = (d) => d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
const hovDate = hover ? dates[hover.i] : null;
const hovPx = hover ? pts[hover.i] : null;
return (
Price
{T("1 year · USD")}
{T("EODHD · adjusted close")}
{ranges.map((r) =>
setActive(r)}>
{r}
)}
{hover && (
{fmtDate(hovDate)}
${hovPx.toFixed(2)}
)}
{[0.25, 0.5, 0.75].map((g, i) =>
)}
{hover && (
)}
{hover && (
{fmtDate(hovDate)}
${hovPx.toFixed(2)}
)}
May 2025 Aug Nov Feb 2026 May 2026
);
}
/* ---------- SIDEBAR cards ---------- */
function LifecycleCard({ t }) {
const stages = [T("Start-up"), T("Young Growth"), T("High Growth"), T("Mature G."), T("Mature Stable"), T("Decline")];
const activeIdx = 4;
return (
{T("Lifecycle")}
Damodaran
Established company with stable growth, predictable dividend
and low operational volatility.
{T("Confidence")}
{t.lifecycleConfidence}% · {T("HIGH")}
);
}
function StatsCard({ stats }) {
return (
{T("Key stats")}
EODHD · TTM
{stats.map((s) =>
{s.l}
{s.v}
)}
);
}
function publisherFromUrl(url){
if (!url) return "";
try {
const host = new URL(url).hostname.replace(/^www\./, "");
// Map common domains to friendly publisher names
const map = {
"finance.yahoo.com": "Yahoo Finance",
"yahoo.com": "Yahoo Finance",
"reuters.com": "Reuters",
"bloomberg.com": "Bloomberg",
"cnbc.com": "CNBC",
"marketwatch.com": "MarketWatch",
"investing.com": "Investing.com",
"seekingalpha.com": "Seeking Alpha",
"fool.com": "Motley Fool",
"barrons.com": "Barron's",
"ft.com": "Financial Times",
"wsj.com": "WSJ",
"benzinga.com": "Benzinga",
"thestreet.com": "TheStreet",
"businesswire.com": "Business Wire",
"prnewswire.com": "PR Newswire",
"globenewswire.com": "GlobeNewswire"
};
return map[host] || host.split(".")[0].charAt(0).toUpperCase() + host.split(".")[0].slice(1);
} catch(e){ return ""; }
}
function newsDateStr(iso){
if (!iso) return "";
try {
const d = new Date(iso);
const ms = Date.now() - d.getTime();
const h = Math.floor(ms / 3600000);
if (h < 1) return Math.max(1, Math.floor(ms/60000)) + "m ago";
if (h < 24) return h + "h ago";
const days = Math.floor(h / 24);
if (days < 7) return days + "d ago";
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
} catch(e){ return ""; }
}
function NewsCard({ news }) {
return (
{T("News")}
{news.length} {news.length === 1 ? T("article") : T("articles")}
);
}
function AlertsCard({ alerts }) {
const count = alerts.length;
return (
{T("Alerts")}
{count > 0 ? `${count} ${T("active")}` : T("no alerts")}
{count === 0 ? (
{T("No alerts yet.")}
{T("Earnings + score deltas will appear here.")}
) : (
alerts.map((a, i) => {
const icon = a.kind === "up" ?
:
a.kind === "down" ?
:
;
const cls = "alert-item " + (a.kind === "up" ? "up" : a.kind === "down" ? "down" : "");
return (
{icon}
{a.text}
{a.meta && {a.meta} }
);
})
)}
);
}
/* ---------- DISCLAIMER ---------- */
function Disclaimer() {
return (
Validq is a quantitative analysis tool. It does not constitute personalised
financial advice or a buy/sell recommendation. Investment decisions are the
user's responsibility. MiFID II compliant. Data sourced from EODHD/SEC EDGAR.
Backtest results do not guarantee future outcomes.{" "}
{T("Methodology")} · {T("Terms")} .
);
}
/* ---------- APP ---------- */
/* =========================================================
MAPPING: backend /analyze/{ticker} → t (component shape)
========================================================= */
function flagForMarket(ticker){
const m = {".US":"🇺🇸",".SW":"🇨🇭",".MC":"🇪🇸",".PA":"🇫🇷",".DE":"🇩🇪",".L":"🇬🇧",".MI":"🇮🇹",".AS":"🇳🇱",".CO":"🇩🇰",".BR":"🇧🇪",".HE":"🇫🇮",".OL":"🇳🇴",".ST":"🇸🇪",".LS":"🇵🇹",".HK":"🇭🇰",".TO":"🇨🇦",".AX":"🇦🇺",".SA":"🇧🇷",".T":"🇯🇵",".NS":"🇮🇳"};
if (!ticker) return "🌐";
for (const k in m){ if (ticker.endsWith(k)) return m[k]; }
return "🌐";
}
function currencyFor(ticker){
const m = {".US":"USD",".SW":"CHF",".MC":"EUR",".PA":"EUR",".DE":"EUR",".L":"GBP",".MI":"EUR",".AS":"EUR",".CO":"DKK",".BR":"EUR",".HE":"EUR",".OL":"NOK",".ST":"SEK",".LS":"EUR",".HK":"HKD",".TO":"CAD",".AX":"AUD",".SA":"BRL",".T":"JPY",".NS":"INR"};
if (!ticker) return "USD";
for (const k in m){ if (ticker.endsWith(k)) return m[k]; }
return "USD";
}
function symbolFromTicker(t){ return t ? t.split(".")[0] : ""; }
function marketCodeFromTicker(t){
if (!t) return "";
const ext = t.split(".")[1] || "";
const map = {"US":"NYSE","SW":"SIX","MC":"BME","PA":"EPA","DE":"XETRA","L":"LSE","MI":"BIT","AS":"AMS","HK":"HKEX","TO":"TSX","AX":"ASX","T":"TSE","NS":"NSE"};
return map[ext] || ext;
}
function timeAgoStr(iso){
if (!iso) return "—";
try {
const ms = Date.now() - new Date(iso).getTime();
const mins = Math.floor(ms/60000);
if (mins<60) return mins + "m ago";
const h = Math.floor(mins/60);
if (h<24) return h + "h ago";
const d = Math.floor(h/24);
return d + "d ago";
} catch(e){ return "—"; }
}
function parseSignal(sig){
if (!sig) return {verdict:"REVIEW", pattern:""};
const parts = sig.replace(/_/g," ").trim().split(/ +/);
if (parts.length === 0) return {verdict:"REVIEW", pattern:""};
const verdictMap = {
"COMPRA":"BUY","COMPRA_CLARA":"BUY","BUY":"BUY",
"EVITAR":"AVOID","AVOID":"AVOID","NO_INVERTIR":"AVOID",
"ESPERAR":"WAIT","ESPERAR_MEJOR_PRECIO":"WAIT","WAIT":"WAIT",
"REVISAR":"REVIEW","REVIEW":"REVIEW"
};
const first = parts[0].toUpperCase();
return {
verdict: verdictMap[first] || first,
pattern: parts.slice(1).join(" ").toUpperCase()
};
}
// Constants for layer/agent metadata
const LAYER_META = {
"1": {code:"C1", name:T("Accounting integrity"), icon:"Flask", methodology:"Beneish M-Score detects accounting manipulation from 8 ratios (DSRI, GMI, AQI, SGI, DEPI, SGAI, LVGI, TATA). Threshold −1.78. Validated across >70 SEC cases."},
"2": {code:"C2", name:T("Bankruptcy risk"), icon:"Scale", methodology:"Altman Z-Score (5 ratios) + Ohlson O-Score as cross-check. Z>3 = safe zone. P<0.5 = low probability of bankruptcy within 12 months."},
"3": {code:"C3", name:T("Business quality"), icon:"Gem", methodology:"Combines Piotroski F-Score (9 binary signals across profitability/leverage/efficiency), Damodaran moat scoring and ROIC vs WACC. Weighted by persistence."},
"4": {code:"C4", name:T("Valuation"), icon:"Tag", methodology:"Triangulation: relative multiples (P/E, EV/EBITDA, P/B) vs sector + 2-stage reverse DCF (Mauboussin). Adjusted for lifecycle stage."},
"5": {code:"C5", name:T("Debt structure"), icon:"Landmark", methodology:"Assesses leverage (ND/EBITDA), debt-service capacity (interest coverage) and maturity ladder. Penalises short-term maturity concentration."}
};
const AGENT_META = {
fcf_momentum: {name:"FCF-MOM", full:T("Free Cash Flow Momentum"), max:15, methodology:"Detects FCF / CFO yield acceleration over 4–8 quarters. Signals margin expansion and pricing power."},
guidance_tracker:{name:"GUIDANCE", full:T("Guidance Reliability"), max:15, methodology:"Evaluates the consistency of quarterly guidance and consensus revisions. Penalises recent cuts."},
insider_flow: {name:"INSIDER", full:T("Insider Activity"), max:12, methodology:"SEC Form 4 + EODHD insider transactions. Net buying / selling weighted by management-signal vs stock-options-vesting noise."},
atlas: {name:"ATLAS", full:T("ATLAS · Intangibles & Moat"),max:20, methodology:"NLP + structured over 10-K. Detects IP, switching costs, network effects, scale, brand. Damodaran moat scoring."},
hermes: {name:"HERMES", full:"HERMES · NLP earnings calls",max:30, methodology:"Sentiment + management tone over earnings-call transcripts (Loughran-McDonald + in-house finetune). Compares Q&A vs prepared remarks."},
sector_agent: {name:"SECTOR", full:T("Sector timing"), max:10, methodology:"Sector cycle positioning + relative momentum. Adjusts for sector rotation flows."},
chronos: {name:"CHRONOS", full:T("CHRONOS · Cycle timing"), max:10, methodology:"Macro + sector cycle positioning (yield curve, PMI, sector rotation). Signals timing relative to the cycle."},
clio: {name:"CLIO", full:T("CLIO · Business model"), max:25, methodology:"LLM analysis of unit economics, customer concentration, scalability, competitive rivalry. CLIO Lite (Pro) vs CLIO Full (Trader+)."},
argos: {name:"ARGOS", full:T("ARGOS · Consensus & flows"), max:8, methodology:"Wisdom of crowds: sell-side consensus + ETF flows + short interest. Filters pure-momentum noise."}
};
function mapBackendToTicker(d){
if (!d) return null;
const sigParsed = parseSignal(d.action_signal && d.action_signal.signal);
const confMap = {"ALTA":3,"HIGH":3,"MEDIA":2,"MEDIUM":2,"BAJA":1,"LOW":1};
const conf = ((d.action_signal && d.action_signal.confidence) || "").toUpperCase();
const confidence = confMap[conf] || 2;
const confLabel = ({3:T("HIGH"),2:T("MEDIUM"),1:T("LOW")})[confidence] || T("MEDIUM");
const ticker = d.ticker || "";
const cur = currencyFor(ticker);
// Sweet spot
let sectorSweetSpot = d.sectorSweetSpot || null;
if (!sectorSweetSpot && d.score_c1c5 !== undefined){
const sc = d.score_c1c5;
sectorSweetSpot = {
lo: Math.max(0, Math.floor(sc * 0.85)),
hi: Math.min(100, Math.ceil(sc * 1.15)),
label: ((d.grupo||"").replace(/_/g," ") + " · " + (d.lifecycle_stage||"").replace(/_/g," ")) || "Sector range",
_derived: true
};
}
// Layers C1-C5
const layers = ["1","2","3","4","5"].map(k => {
const c = (d.capas || {})[k] || {};
const meta = LAYER_META[k];
const max = c.score_max;
const score = c.score;
return {
code: meta.code, name: meta.name, icon: meta.icon,
pass: c.passed === true,
score: (score !== null && score !== undefined) ? Math.round(score) : null,
max: max || null,
warn: (score !== null && score !== undefined && max && score < max * 0.5),
detail: (c.summary || "").substring(0, 90),
methodology: meta.methodology,
drivers: (c.corrections || []).slice(0,5).map(x => {
if (Array.isArray(x)) return x;
if (typeof x === "object" && x !== null){
const k = x.descripcion || x.description || x.label || x.name || x.key || JSON.stringify(x).substring(0,40);
const v = x.score !== undefined ? (x.score > 0 ? "+" : "") + x.score : (x.value || x.detail || "");
return [String(k).substring(0,60), String(v).substring(0,30)];
}
return [String(x).substring(0,40), ""];
})
};
});
// Layers C6/C7 from ATLAS/HERMES agents (paper exposes 7 layers)
if (d.agentes){
if (d.agentes.atlas){
layers.push({
code:"C6", name:"ATLAS · Intangibles", icon:"Home",
pass:null, score: Math.round(d.agentes.atlas.score || 0), max:20,
warn: (d.agentes.atlas.score || 0) < 10,
detail: (d.agentes.atlas.summary || "").substring(0,90),
methodology: AGENT_META.atlas.methodology,
drivers: []
});
}
if (d.agentes.hermes){
layers.push({
code:"C7", name:"HERMES · NLP", icon:"Wave",
pass:null, score: Math.round(d.agentes.hermes.score || 0), max:30,
warn: (d.agentes.hermes.score || 0) < 15,
detail: (d.agentes.hermes.summary || "").substring(0,90),
methodology: AGENT_META.hermes.methodology,
drivers: []
});
}
}
// Agents
const agentKeys = ["fcf_momentum","guidance_tracker","insider_flow","atlas","hermes","sector_agent","chronos","clio","argos"];
const agents = agentKeys.filter(k => d.agentes && d.agentes[k]).map(k => {
const a = d.agentes[k];
const meta = AGENT_META[k];
const sc = a.score;
const sign = sc > 0 ? "pos" : (sc < 0 ? "neg" : "amb");
return {
name: meta.name, full: meta.full,
v: (sc > 0 ? "+" : "") + ((sc !== null && sc !== undefined) ? Math.round(sc) : "—"),
d: "/" + meta.max,
sign: sign,
tag: (a.summary || "").substring(0,40),
methodology: meta.methodology,
drivers: (a.signals || []).slice(0,5).map(s => {
if (Array.isArray(s)) return s;
if (typeof s === "object" && s !== null){
const k = s.descripcion || s.description || s.label || s.name || s.key || s.signal || JSON.stringify(s).substring(0,40);
const v = s.score !== undefined ? (s.score > 0 ? "+" : "") + s.score : (s.value || s.detail || s.peso || "");
return [String(k).substring(0,60), String(v).substring(0,30)];
}
return [String(s).substring(0,40), ""];
})
};
});
// DCF
const dcf = d.dcf ? {
current: d.dcf.current_price || (d.precio && d.precio.price) || 0,
intrinsic: d.dcf.intrinsic_value_ps || 0,
upside: d.dcf.margin_of_safety_pct || 0,
method: "2-stage Mauboussin · WACC " + (d.dcf.wacc_pct ? d.dcf.wacc_pct.toFixed(1)+"%" : "—") + " · g " + (d.dcf.growth_rate_pct ? d.dcf.growth_rate_pct.toFixed(1)+"%" : "—"),
range: { lo: d.dcf.intrinsic_value_bear || 0, hi: d.dcf.intrinsic_value_bull || 0 }
} : null;
// Stats
const p = d.precio || {};
const stats = [];
if (p.pe_current) stats.push({l:"P/E (TTM)", v: p.pe_current.toFixed(1) + "×"});
if (p.pe_fwd) stats.push({l:"P/E forward", v: p.pe_fwd.toFixed(1) + "×"});
if (p.pffo_ttm) stats.push({l:"P/FFO (TTM)", v: p.pffo_ttm.toFixed(1) + "×"});
if (p.rsi_14) stats.push({l:"RSI (14)", v: Math.round(p.rsi_14).toString()});
if (p.range_52w_pos_pct !== undefined && p.range_52w_pos_pct !== null) {
// Backend already returns a percentage (0-100), not a fraction
let pos52 = Number(p.range_52w_pos_pct);
if (pos52 <= 1) pos52 = pos52 * 100; // safety: handle fraction format too
stats.push({l:T("52w position"), v: pos52.toFixed(1) + "%"});
}
if (p.epv_ratio) stats.push({l:"EPV ratio", v: p.epv_ratio.toFixed(2) + "×"});
if (d.gics_industry) stats.push({l:T("GICS industry"), v: d.gics_industry});
if (d.brand_equity){
const be = typeof d.brand_equity === "object" && d.brand_equity.score
? d.brand_equity.score.toFixed(1)
: (typeof d.brand_equity === "number" ? d.brand_equity.toFixed(1) : "—");
stats.push({l:T("Brand equity"), v: be, g: true});
}
stats.push({l:T("Engine"), v: "v6.1"});
if (d.analysis_ms) stats.push({l:T("Analysis"), v: Math.round(d.analysis_ms) + " ms"});
// Signal
const signal = {
verdict: sigParsed.verdict,
pattern: sigParsed.pattern,
confidence: confidence,
confLabel: confLabel,
desc: (d.action_signal && d.action_signal.rationale) || (d.action_signal && d.action_signal.headline) || (d.veredicto || ""),
alpha3y: (d.action_signal && d.action_signal.alpha_3y) || "—",
cagrAlpha: (d.action_signal && d.action_signal.cagr_alpha) || "—",
winRate: (d.action_signal && d.action_signal.win_rate) || "—",
pValue: (d.action_signal && d.action_signal.p_value) || "—"
};
return {
symbol: symbolFromTicker(ticker),
market: marketCodeFromTicker(ticker),
flag: flagForMarket(ticker),
name: d.company_name || ticker,
sector: (d.grupo || "").replace(/_/g," "),
sub: d.gics_industry || "",
lifecycle: (d.lifecycle_stage || "").replace(/_/g," "),
lifecycleConfidence: d.lifecycle_conf ? Math.round(d.lifecycle_conf * 100) : null,
price: (p.price !== undefined && p.price !== null) ? p.price : 0,
change: p.change || 0,
changePct: p.change_pct || 0,
currency: cur,
asOf: d.generated_at ? new Date(d.generated_at).toLocaleString("en-GB", {day:"numeric",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"}) : "—",
lastAnalysis: timeAgoStr(d.generated_at),
score_display: Math.round(d.score_display || d.score || 0),
score_c1c5: Math.round(d.score_c1c5 || 0),
score_with_agents: Math.round(d.score || 0),
sectorSweetSpot: sectorSweetSpot,
signal: signal,
layers: layers,
agents: agents,
dcf: dcf,
stats: stats,
news: d._news || [],
alerts: d._alerts || [],
// S12_MNEMO_UI_v1: MNEMO narrative
narrative: d.narrative ? {
available: d.narrative.data_available !== false,
oneLiner: d.narrative.summary_one_liner || "",
textEn: d.narrative.narrative_en || "",
textEs: d.narrative.narrative_es || "",
lifecycleStage: (d.narrative.lifecycle_focus_applied || "unknown").toUpperCase(),
depth: d.narrative.depth || "lite",
degraded: d.narrative.degraded === true,
plan: (d.narrative.plan || "basic")
} : null
};
}
/* =========================================================
APP · with real backend fetch + auth + login modal
========================================================= */
function App() {
const [theme, setTheme] = useState(localStorage.getItem("vq_theme") || "dark");
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [watch, setWatch] = useState(false);
const [tickerArg, setTickerArg] = useState("");
const [isDemo, setIsDemo] = useState(false);
// === D2-C-React polling ===
const [retryAttempt, setRetryAttempt] = useState(0);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("vq_theme", theme);
}, [theme]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const t = (params.get("t") || "").toUpperCase().trim();
const demo = params.get("demo") === "1";
setTickerArg(t);
setIsDemo(demo);
if (!t){
setLoading(false);
setError({status: 400, message: "Missing ?t=TICKER in URL"});
return;
}
if (!demo && !vqApi.isLoggedIn()){
setLoading(false);
setError({status: 401, message: T("Sign in required")});
setTimeout(() => {
const dlg = document.getElementById("modal-login");
if (dlg && !dlg.open) dlg.showModal();
}, 100);
return;
}
const forceRefresh = params.get("refresh") === "1";
let endpoint = demo ? "/demo/" + encodeURIComponent(t) : "/analyze/" + encodeURIComponent(t);
if (forceRefresh && !demo) endpoint += "?refresh=1";
let cancelled = false;
// === D2-C-React polling ===
// Reintenta si Cloudflare corta a 100s con 524 (motor lento).
// Cada reintento aprovecha caches intermedias del motor.
const REQ_TIMEOUT_MS = 95000; // <100s de Cloudflare
const POLL_DELAY_MS = 60000; // S12_FIX_COST: 60s entre retries (era 30s)
const MAX_RETRIES = 2; // S12_FIX_COST: 3 intentos max (era 7), reduce coste 4x
async function fetchWithTimeout(path, ms){
const ctl = new AbortController();
const tid = setTimeout(() => ctl.abort(), ms);
try { return await vqApi.get(path, {signal: ctl.signal}); }
finally { clearTimeout(tid); }
}
async function loadMainWithRetry(){
let lastErr;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++){
if (cancelled) throw new Error('cancelled');
if (attempt > 0) setRetryAttempt(attempt);
try {
return await fetchWithTimeout(endpoint, REQ_TIMEOUT_MS);
} catch(err){
lastErr = err;
const status = err && err.status;
const isRetriable = (
(err && (err.isAbort || err.isNetwork)) ||
status === 524 || status === 502 || status === 503 || status === 504
);
if (attempt >= MAX_RETRIES || !isRetriable) throw err;
console.info('[loadAll] retry', attempt+1, 'in', POLL_DELAY_MS/1000, 's:',
(err && err.message || '').slice(0, 80));
await new Promise(r => setTimeout(r, POLL_DELAY_MS));
}
}
throw lastErr;
}
async function loadAll(){
try {
const main = await loadMainWithRetry();
if (cancelled) return;
let news = [], alerts = [];
if (!demo){
try {
const newsRes = await vqApi.get("/news/" + encodeURIComponent(t));
news = newsRes.news || newsRes.items || [];
} catch(e){ /* silent */ }
try {
const alertsRes = await vqApi.get("/ticker/" + encodeURIComponent(t) + "/alerts");
alerts = alertsRes.alerts || alertsRes.items || [];
} catch(e){ /* silent */ }
}
main._news = news;
main._alerts = alerts;
if (!cancelled){
setData(main);
setLoading(false);
}
if (!demo && vqApi.isLoggedIn()){
try {
const w = await vqApi.get("/watchlist");
const items = w.watchlist || w.items || [];
if (!cancelled) setWatch(items.some(it => (it.ticker || it) === t));
} catch(e){ /* silent */ }
}
} catch(err){
if (cancelled) return;
setLoading(false);
setError({status: err.status || 500, message: err.message || "Network error"});
}
}
loadAll();
return () => { cancelled = true; };
}, []);
async function toggleWatch(){
if (!vqApi.isLoggedIn()){
const dlg = document.getElementById("modal-login");
if (dlg) dlg.showModal();
return;
}
try {
if (watch){
await vqApi.del("/watchlist/" + encodeURIComponent(tickerArg));
setWatch(false);
vqApi.toast(tickerArg + " " + T("removed from watchlist"), "info");
} else {
// Backend expects 'ticker' as a query param, not JSON body
await vqApi.post("/watchlist?ticker=" + encodeURIComponent(tickerArg), {});
setWatch(true);
vqApi.toast(tickerArg + " " + T("added to watchlist"), "ok");
}
} catch(err){
vqApi.toast(err.message || T("Watchlist error"), "err");
}
}
if (loading){
return (
<>
{T("Analysing")} {tickerArg}
{T("Engine v6.1 · 7 layers · 9 agents")}
{/* === D2-C-React polling === */}
{retryAttempt > 0 && (
Esto puede tardar 2–3 min para tickers nuevos.
Intento {retryAttempt + 1}/7 · siguientes consultas serán instantáneas (caché 24h).
)}
>
);
}
if (error || !data){
const msgMap = {
400: [T("Bad request"), "Missing ?t=TICKER in URL"],
401: [T("Sign in required"), T("You need an account to analyse tickers. Free plan includes 5 analyses per month.")],
403: [T("Plan upgrade required"), (error && error.message) || T("This feature requires a higher plan")],
404: [T("Ticker not found"), "We couldn't find analysis for \"" + tickerArg + "\". Check the symbol (e.g. MRK.US, ITX.MC, NESN.SW)."],
429: [T("Monthly quota reached"), T("You've used all your analyses this month. Upgrade your plan to continue.")],
500: [T("Temporary error"), (error && error.message) || T("Please try again")]
};
const arr = msgMap[(error && error.status) || 500] || msgMap[500];
const title = arr[0], msg = arr[1];
return (
<>
⚠
{title}
{msg}
{T("Back to home")}
{error && error.status === 401 ?
document.getElementById("modal-login").showModal()}>{T("Sign in")} :
location.reload()}>{T("Retry")}
}
>
);
}
const t = mapBackendToTicker(data);
if (!t) return {T("Mapping error")}
;
return (
<>
>
);
}
window.vqToggleLoginMode = (function(){
let mode = "login";
return function(){
mode = mode === "login" ? "register" : "login";
document.getElementById("login-title").textContent = mode === "login" ? T("Sign in") : "Create account";
document.getElementById("login-submit").textContent = mode === "login" ? T("Sign in") : "Create account";
document.getElementById("login-name-wrap").style.display = mode === "register" ? "block" : "none";
document.getElementById("login-toggle-text").textContent = mode === "login" ? "No account yet? " : "Already have one? ";
document.getElementById("login-toggle-btn").textContent = mode === "login" ? "Create account" : T("Sign in");
document.getElementById("login-err").style.display = "none";
return mode;
};
})();
document.addEventListener("DOMContentLoaded", () => {
const form = document.getElementById("login-form");
if (form){
form.addEventListener("submit", async (e) => {
e.preventDefault();
const btn = document.getElementById("login-submit");
const err = document.getElementById("login-err");
err.style.display = "none";
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = "...";
try {
const isReg = document.getElementById("login-name-wrap").style.display !== "none";
const email = form.email.value.trim();
const password = form.password.value;
if (isReg){
const name = form.name.value.trim();
await vqApi.register(email, password, name || null);
} else {
await vqApi.login(email, password);
}
vqApi.toast(isReg ? "Account created!" : "Welcome!", "ok");
document.getElementById("modal-login").close();
setTimeout(() => location.reload(), 400);
} catch(ex){
err.textContent = ex.message || "Authentication error";
err.style.display = "block";
btn.disabled = false;
btn.textContent = orig;
}
});
}
// Anti-autofill for ticker search
setTimeout(() => {
const inputs = document.querySelectorAll(".nav-search input, input[type='search']");
inputs.forEach(el => {
el.setAttribute("autocomplete", "off");
el.setAttribute("data-form-type", "other");
el.setAttribute("data-lpignore", "true");
el.setAttribute("data-1p-ignore", "true");
const clear = () => { if (el.value && document.activeElement !== el) el.value = ""; };
clear();
setTimeout(clear, 200);
setTimeout(clear, 500);
});
}, 300);
});
ReactDOM.createRoot(document.getElementById("root")).render( );