// Pantalla de detalle de acción — chart interactivo + sidebar + tabs
function StockDetail({ symbol, onBack, onOpenTicker, holdings, watchlist,
isWatching, onToggleWatch, accent, intensity }) {
const t = window.TICKERS[symbol];
const [tab, setTab] = React.useState('resumen');
if (!t) return null;
const up = t.change >= 0;
const holding = holdings.find((h) => h.symbol === symbol);
const news = (window.NEWS[symbol] || window.NEWS.NVRA).slice(0, 6);
const tabs = [
{ id: 'resumen', label: 'Resumen' },
{ id: 'noticias', label: 'Noticias' },
{ id: 'fund', label: 'Fundamentales' },
{ id: 'tecnico', label: 'Análisis técnico' },
{ id: 'perfil', label: 'Perfil de la empresa' },
];
const fmt = (n) => Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return (
{/* ── HEADER ── */}
{t.symbol.slice(0, 2)}
{t.name}
{t.symbol}
{t.market} · {t.sector} · {t.cap}
${fmt(t.price)}
{up ? '+' : ''}${fmt(Math.abs(t.change))}
{up ? '▲' : '▼'} {up ? '+' : ''}{t.pct.toFixed(2)}%
Hoy · 16:00 EDT · Mercado cerrado
{/* ── TABS ── */}
{tabs.map((tb) => (
))}
{/* ─── PANEL IZQUIERDO ─── */}
{/* Quick stats */}
Máx. diario
${fmt(t.high)}
Mín. diario
${fmt(t.low)}
{/* Chart */}
{/* Contenido por tab */}
{tab === 'resumen' && }
{tab === 'noticias' && }
{tab === 'fund' && }
{tab === 'tecnico' && }
{tab === 'perfil' && }
{/* ─── SIDEBAR DERECHO ─── */}
{/* Calculadora de análisis */}
{/* Mi posición */}
{holding && (
<>
Mi posición
Acciones
{holding.shares}
Coste medio
${fmt(holding.avgCost)}
Valor de mercado
${fmt(t.price * holding.shares)}
P&L
= holding.avgCost ? 'up' : 'dn'}`}>
{t.price >= holding.avgCost ? '+' : ''}${fmt((t.price - holding.avgCost) * holding.shares)}
{' · '}
{(((t.price - holding.avgCost) / holding.avgCost) * 100).toFixed(2)}%
>
)}
Métricas clave
Capitalización{t.mktcap}
Volumen hoy{t.volume}
P/E Ratio{t.pe}
EPS (TTM){t.eps}
Dividendo{t.div}
Margen neto{t.margin}
Beta (5 años){t.beta}
Rango 52 semanas
Mín. ${fmt(t.range52.min)}
${fmt(t.price)}
Máx. ${fmt(t.range52.max)}
Distancia al máx.
−{(((t.range52.max - t.price) / t.range52.max) * 100).toFixed(1)}%
Distancia al mín.
+{(((t.price - t.range52.min) / t.range52.min) * 100).toFixed(1)}%
);
}
// ─── Sub-tabs ─────────────────────────────────────────────────────────────
function ResumenTab({ t, news, accent }) {
return (
<>
Consenso de analistas
{[
{ label: 'Comprar fuerte', n: 18, pct: 60, cls: 'up' },
{ label: 'Comprar', n: 8, pct: 27, cls: 'up' },
{ label: 'Mantener', n: 3, pct: 10, cls: 'mid' },
{ label: 'Vender', n: 1, pct: 3, cls: 'dn' },
].map((r) => (
))}
{news.slice(0, 3).map((n, i) => )}
>
);
}
function NoticiasTab({ news }) {
return (
);
}
function FundTab({ t }) {
const rows = [
['Ingresos (TTM)', '$382.4B', '$391.0B', '+2.3%'],
['Beneficio neto', '$101.0B', '$99.8B', '−1.2%'],
['Margen operativo', '30.1%', '29.4%', '−70 pb'],
['Flujo de caja libre', '$96.4B', '$92.1B', '−4.5%'],
['Deuda total', '$108B', '$104B', '−3.7%'],
['ROE', '147%', '156%', '+900 pb'],
];
return (
Estado financiero · {t.name}
| Métrica | 2024 | 2023 | Var. |
{rows.map((r) => (
| {r[0]} |
{r[1]} |
{r[2]} |
{r[3]} |
))}
);
}
function TecnicoTab({ t }) {
const signals = [
{ label: 'RSI (14)', val: '58.2', tag: 'Neutral', cls: 'mid' },
{ label: 'MACD', val: '+1.42', tag: 'Compra', cls: 'up' },
{ label: 'Media móvil 50d', val: '$208.40', tag: 'Compra', cls: 'up' },
{ label: 'Media móvil 200d', val: '$192.10', tag: 'Compra fuerte', cls: 'up' },
{ label: 'Bandas de Bollinger', val: 'Centro', tag: 'Neutral', cls: 'mid' },
{ label: 'Estocástico', val: '72.1', tag: 'Sobrecomprado', cls: 'dn' },
];
return (
Indicadores técnicos
{signals.map((s) => (
{s.label}
{s.val}
{s.tag}
))}
);
}
function PerfilTab({ t }) {
return (
Datos clave
- CEO{'M. Vázquez'}
- Sede{'San Francisco, CA'}
- Empleados{'164,200'}
- Fundación{'1998'}
- Web{t.name.toLowerCase().replace(/\s+/g,'')}.com
);
}
function NewsCard({ n, accent }) {
const toneColors = { pos: 'var(--accent)', neg: 'var(--neg)', neutral: 'var(--c-cyan)' };
const c = toneColors[n.tone] || 'var(--accent)';
return (
{n.tag}
{n.headline}
{n.src} · {n.when}
);
}
// ─── Calculator ────────────────────────────────────────────────────────────
// Herramienta de análisis: dado un número de acciones (o monto a invertir),
// muestra el valor actual y permite proyectar valor a un precio objetivo.
function Calculator({ t, fmt }) {
const [shares, setShares] = React.useState(100);
const [target, setTarget] = React.useState(t.price);
const [editing, setEditing] = React.useState('shares'); // 'shares' | 'amount'
const [amount, setAmount] = React.useState(100 * t.price);
// Estado local para inputs en texto — evita reformateo en cada tecla
const [sharesText, setSharesText] = React.useState('100');
const [amountText, setAmountText] = React.useState('');
const [targetText, setTargetText] = React.useState('');
const [focusField, setFocusField] = React.useState(null);
// Cambio de ticker: resetear
React.useEffect(() => {
setShares(100);
setAmount(100 * t.price);
setTarget(t.price);
setEditing('shares');
setSharesText('100');
}, [t.symbol]);
// Sincronización bidireccional
React.useEffect(() => {
if (editing === 'shares') setAmount(shares * t.price);
}, [shares, t.price, editing]);
React.useEffect(() => {
if (editing === 'amount') setShares(amount / t.price);
}, [amount, t.price, editing]);
// Cuando NO está siendo editado, mostrar el valor formateado
const sharesDisplay = focusField === 'shares' ? sharesText
: Number.isInteger(shares) ? String(shares) : shares.toFixed(2);
const amountDisplay = focusField === 'amount' ? amountText : fmt(amount);
const targetDisplay = focusField === 'target' ? targetText : fmt(target);
const currentValue = shares * t.price;
const projectedValue = shares * target;
const diff = projectedValue - currentValue;
const diffPct = t.price > 0 ? ((target - t.price) / t.price) * 100 : 0;
const isUp = diff >= 0;
const presets = [
{ label: '−10%', pct: -0.10 },
{ label: 'Actual', pct: 0 },
{ label: '+10%', pct: 0.10 },
{ label: '+25%', pct: 0.25 },
{ label: 'Máx. 52s', value: t.range52.max },
];
const sliderMin = t.range52.min * 0.5;
const sliderMax = t.range52.max * 1.5;
const parseNum = (s) => {
const cleaned = String(s).replace(/[^\d.]/g, '');
const n = Number(cleaned);
return Number.isFinite(n) ? Math.max(0, n) : 0;
};
return (
Calculadora
${fmt(t.price)} actual
{/* Inputs: acciones <-> inversión */}
{/* Valor actual destacado */}
Valor actual de la posición
${fmt(currentValue)}
{/* Proyección */}
Precio objetivo
{diffPct >= 0 ? '+' : ''}{diffPct.toFixed(1)}%
$
{ setFocusField('target'); setTargetText(target.toFixed(2)); }}
onBlur={() => setFocusField(null)}
onChange={(e) => {
setTargetText(e.target.value);
setTarget(parseNum(e.target.value));
}}/>
setTarget(Number(e.target.value))}/>
${fmt(sliderMin)}
${fmt(t.price)}
${fmt(sliderMax)}
{presets.map((p) => {
const v = p.value != null ? p.value : t.price * (1 + p.pct);
const active = Math.abs(target - v) < 0.01;
return (
);
})}
Valor proyectado
${fmt(projectedValue)}
Diferencia
{isUp ? '+' : '−'}${fmt(Math.abs(diff))}
Rentabilidad
{diffPct >= 0 ? '+' : ''}{diffPct.toFixed(2)}%
);
}
window.StockDetail = StockDetail;