// Dashboard view — chart + forecast cards rail // ── Landscape touch-coordinate transformer ────────────────────────────────── // CSS rotate(90deg) trasforma i pixel visivi ma NON le coordinate dei touch event. // Questo componente intercetta i touch, ruota le coordinate di 90° CW inverso, // e le ridispatcha come PointerEvent al canvas di LightweightCharts. function LandscapeEventFix({ active, children }) { const ref = useRef(null); useEffect(() => { if (!active) return; const el = ref.current; if (!el) return; // Per rotate(90deg) CW: screen(sx,sy) → chart(sy, innerWidth-sx) const fire = (type, sx, sy, pid) => { const canvas = el.querySelector('canvas'); if (!canvas) return; canvas.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, clientX: sy, clientY: window.innerWidth - sx, pointerId: pid, pointerType: 'touch', isPrimary: pid === 1, pressure: type === 'pointerup' ? 0 : 0.5, })); }; const onStart = e => { e.preventDefault(); Array.from(e.changedTouches).forEach((t,i) => fire('pointerdown', t.clientX, t.clientY, i+1)); }; const onMove = e => { e.preventDefault(); Array.from(e.changedTouches).forEach((t,i) => fire('pointermove', t.clientX, t.clientY, i+1)); }; const onEnd = e => { e.preventDefault(); Array.from(e.changedTouches).forEach((t,i) => fire('pointerup', t.clientX, t.clientY, i+1)); }; el.addEventListener('touchstart', onStart, { passive: false }); el.addEventListener('touchmove', onMove, { passive: false }); el.addEventListener('touchend', onEnd, { passive: false }); return () => { el.removeEventListener('touchstart', onStart); el.removeEventListener('touchmove', onMove); el.removeEventListener('touchend', onEnd); }; }, [active]); return (
{children}
); } // Default info vuota — usata quando un tool non ha dati per il simbolo const _EMPTY_T2 = { signal:"—", confidence:0, confidence_real:0, trend_1h:"—", expected_1h:0, expected_3h:0, expected_6h:0, expected_24h:0, expected_2w:0, timestamp:"", mode:"—" }; const _EMPTY_T3 = { signal:"—", score_total:0, method:"—", sample_size:0, h01:0, h03:0, h06:0, h24:0, h2w:0, timestamp:"" }; const _EMPTY_T4 = { signal:"—", news_impact:"—", sentiment:0, fc_1h:0, fc_3h:0, fc_6h:0, fc_24h:0, confidence:0, timestamp:"" }; // Earnings breakdown card (Integrazione 3) — pesca /api/earnings-breakdown function EarningsCard({ sym }) { const [bd, setBd] = useState(null); useEffect(() => { let on = true; fetch("/api/earnings-breakdown") .then(r => r.json()) .then(d => { if (on) setBd(d.breakdowns || {}); }) .catch(() => {}); return () => { on = false; }; }, []); const e = bd && bd[sym]; if (!e) return null; const hist = e.eps_history || []; if (!hist.length && e.next_earnings_days == null && e.pe_ttm == null) return null; const ned = e.next_earnings_days; const f = (v, suf = "") => (v == null ? "n/d" : `${Math.round(v * 10) / 10}${suf}`); return (
Earnings
{ned != null && ( tra {ned}g{e.beats_streak ? ` · ${e.beats_streak}/4 beat` : ""} )}
P/E {f(e.pe_ttm)} Crescita {f(e.rev_growth_yoy, "%")} Margine {f(e.net_margin, "%")}
{hist.length > 0 && (
{hist.slice(0, 4).map((h, i) => { const sp = h.surprise_pct; return (
{h.period} {h.estimate != null ? h.estimate : "?"}→{h.actual != null ? h.actual : "?"} = 0 ? "earn-pos" : "earn-neg"}`}> {sp != null ? `${sp >= 0 ? "+" : ""}${Math.round(sp)}%` : "n/d"}
); })}
)}
segmenti/opzioni: n/d (free)
); } function Dashboard({ refreshTick, initSym: initSymProp }) { const defaultSym = initSymProp || (window.WATCHLIST || [])[0]?.symbol || "AMZN"; const [sym, setSym] = useState(defaultSym); const [interval, setInterval] = useState("5m"); const data = (window.ALL_DATA || {})[sym]; const meta = (window.WATCHLIST || []).find(w => w.symbol === sym) || { name: sym, price: 0, deltaPct: 0 }; if (!data) { return (
Nessun dato disponibile per {sym}. Avvia il daemon e attendi il primo ciclo.
); } // Merge con default vuoto per evitare crash se un tool non ha ancora dati const t2i = (data.tool2_info && data.tool2_info.signal) ? data.tool2_info : _EMPTY_T2; const t3i = (data.tool3_info && data.tool3_info.signal) ? data.tool3_info : _EMPTY_T3; const t4i = (data.tool4_info && data.tool4_info.signal) ? data.tool4_info : _EMPTY_T4; const show = (typeof window !== "undefined" && window.DASH_SHOW) || {}; const [fullscreen, setFullscreen] = useState(false); const [landscape, setLandscape] = useState(false); return (
{/* ── Fullscreen overlay — portrait o landscape in base allo stato ── */} {fullscreen && (
{sym} ${meta.price.toFixed(2)} =0 ? "delta-pos" : "delta-neg"}`}> {meta.deltaPct>=0 ? "+" : ""}{meta.deltaPct.toFixed(2)}%
AI Tecnico News
{/* Toggle portrait/landscape — solo mobile */}
)}
setFullscreen(true)} interval={interval} onIntervalChange={setInterval}/>
{sym} ${meta.price.toFixed(2)} =0 ? "delta-pos" : "delta-neg"}`}> {meta.deltaPct>=0 ? "+" : ""}{meta.deltaPct.toFixed(2)}%
{meta.name} · 5m · NYSE
AI Tecnico News
); } function DashToolbar({ sym, setSym, onFullscreen, interval, onIntervalChange }) { const wl = window.WATCHLIST || []; const [refreshing, setRefreshing] = useState(false); const handleRefresh = async () => { if (refreshing) return; setRefreshing(true); try { await (window.refreshDashboard?.() || Promise.resolve()); } finally { setRefreshing(false); } }; return (
{wl.map(w => ( ))}
{["5m","1h","1d"].map(iv => ( ))}
); } function ForecastCard({ tool, name, info, keys, meta, confidenceKey="confidence" }) { const conf = info[confidenceKey] || 0; return (
{name}
{info.sample_size != null && info.sample_size < 50 && ( bassa affid. )} {info.signal}
{confidenceKey === "score_total" ? "Score" : "Conf."}
{conf}{confidenceKey === "score_total" ? "" : "%"}
{keys.map(({k, l}) => { const v = typeof info[k] === "number" ? info[k] : 0; return (
+{l} =0 ? "pos":"neg"}`}> {v>=0?"+":""}{v.toFixed(2)}%
); })}
{meta && (
{meta.map(m => ( {m.l}: {m.v} ))}
)}
); } function ConvergenceCard({ t2, t3, t4 }) { const signals = [t2.signal, t3.signal, t4.signal]; const buy = signals.filter(s=>s==="BUY").length; const sell = signals.filter(s=>s==="SELL").length; const hold = signals.filter(s=>s==="HOLD").length; const max = Math.max(buy, sell, hold); const consensus = buy===max ? "BUY" : sell===max ? "SELL" : "HOLD"; const agreement = Math.round((max/3)*100); // average % for 24h const avg = (((t2.expected_24h || 0) + (t3.h24 || 0) + (t4.fc_24h || 0)) / 3); return (
Convergenza
{consensus} · {agreement}%
{buy} BUY {hold} HOLD {sell} SELL
Media 24h: =0?"var(--green)":"var(--red)"}}>{avg>=0?"+":""}{avg.toFixed(2)}%
); } // ── Lightweight Charts wrapper ── function LWChart({ symbol, interval = "5m", refreshTick }) { const containerRef = useRef(null); const chartRef = useRef(null); const seriesRef = useRef({}); const cacheRef = useRef({}); // chiave: `${symbol}|${interval}` const [loading, setLoading] = useState(false); // Crea il grafico una volta sola al mount useEffect(() => { if (!containerRef.current || !window.LightweightCharts) return; const container = containerRef.current; let chart; try { chart = LightweightCharts.createChart(container, { width: Math.max(100, container.clientWidth), height: Math.max(100, container.clientHeight), layout: { background: { color: 'transparent' }, textColor: '#8892a4', fontFamily: 'JetBrains Mono, monospace', }, grid: { vertLines: { color: 'rgba(255,255,255,0.022)' }, horzLines: { color: 'rgba(255,255,255,0.05)' }, }, crosshair: { mode: LightweightCharts.CrosshairMode.Normal, vertLine: { color: 'rgba(150,164,196,0.35)', width: 1, style: LightweightCharts.LineStyle.Dashed, labelBackgroundColor: '#2b3442' }, horzLine: { color: 'rgba(150,164,196,0.35)', width: 1, style: LightweightCharts.LineStyle.Dashed, labelBackgroundColor: '#2b3442' }, }, rightPriceScale: { borderColor: 'rgba(255,255,255,0.07)' }, timeScale: { borderColor: 'rgba(255,255,255,0.07)', timeVisible: true, secondsVisible: false }, }); } catch(e) { console.warn('Dashboard chart init failed:', e); return; } chart.priceScale('right').applyOptions({ scaleMargins: { top: 0.06, bottom: 0.06 } }); const candles = chart.addCandlestickSeries({ upColor: '#22c55e', downColor: '#ef4444', borderUpColor: '#22c55e', borderDownColor: '#ef4444', wickUpColor: '#22c55e', wickDownColor: '#ef4444', lastValueVisible: false, priceLineVisible: false, }); const t2 = chart.addLineSeries({ color: '#6ea8fe', lineWidth: 2, lineStyle: LightweightCharts.LineStyle.Dotted, priceLineVisible:false, lastValueVisible:false }); const t3 = chart.addLineSeries({ color: '#f59e0b', lineWidth: 2, lineStyle: LightweightCharts.LineStyle.Dashed, priceLineVisible:false, lastValueVisible:false }); const t4 = chart.addLineSeries({ color: '#a78bfa', lineWidth: 2, lineStyle: LightweightCharts.LineStyle.SparseDotted, priceLineVisible:false, lastValueVisible:false }); chartRef.current = chart; seriesRef.current = { candles, t2, t3, t4 }; const ro = new ResizeObserver(() => { chart.applyOptions({ width: container.clientWidth, height: container.clientHeight }); }); ro.observe(container); return () => { ro.disconnect(); chart.remove(); chartRef.current = null; }; }, []); // Aggiorna i dati quando cambiano symbol / interval / refreshTick useEffect(() => { if (!chartRef.current) return; const _apply = (candles) => { if (!candles || !candles.length) return; chartRef.current.priceScale('right').applyOptions({ autoScale: true }); seriesRef.current.candles.setData(candles); const showForecast = interval === "5m"; const d = (window.ALL_DATA || {})[symbol] || {}; if (showForecast) { const milestonePct = (anchor, val) => { if (!anchor) return ''; const pct = (val - anchor) / anchor * 100; return (pct >= 0 ? '+' : '') + pct.toFixed(1) + '%'; }; // Prezzo della candela più vicina a un dato istante (entro 30 min). const nearestClose = (t) => { let best = null, bd = Infinity; for (const c of candles) { const dd = Math.abs(c.time - t); if (dd < bd) { bd = dd; best = c; } } return (best && bd <= 1800) ? best.close : null; }; // Ancora il forecast all'ORA in cui il tool ha girato (dai punti del backend, // pts[0].time) — NON all'ultima candela — col prezzo della candela a quell'ora. // Così la previsione appare quando è stata calcolata (es. 21:31), non alle 01:55. const buildLineAt = (pts, info, keys) => { if (!Array.isArray(pts) || pts.length < 2) return []; // tool nascosto/assente → niente linea const baseT = pts[0].time; const baseP = nearestClose(baseT) || pts[0].value; const deltas = [3600, 3*3600, 6*3600, 24*3600]; const out = [{ time: baseT, value: baseP }]; deltas.forEach((delta, i) => { const pct = info[keys[i]] || 0; out.push({ time: baseT + delta, value: +(baseP * (1 + pct/100)).toFixed(4) }); }); return out; }; // Etichetta % SOLO sul punto finale (24h) per non accavallare i marker. const _isMobile = (typeof window !== 'undefined') && window.innerWidth < 760; const addMarkers = (series, pts, color, position) => { if (!pts.length) { series.setMarkers([]); return; } const fpts = pts.slice(1); // Pallini piccoli e discreti per i passi intermedi; il punto finale (24h) // resta leggermente più visibile perché porta l'etichetta %. series.setMarkers(fpts.map((pt, i) => { const isLast = i === fpts.length - 1; return { time: pt.time, position, color, shape: 'circle', text: isLast ? milestonePct(pts[0].value, pt.value) : '', size: _isMobile ? (isLast ? 0.45 : 0.3) : (isLast ? 0.6 : 0.4), }; })); }; const t2pts = buildLineAt(d.tool2, d.tool2_info || {}, ["expected_1h","expected_3h","expected_6h","expected_24h"]); const t3pts = buildLineAt(d.tool3, d.tool3_info || {}, ["h01","h03","h06","h24"]); const t4pts = buildLineAt(d.tool4, d.tool4_info || {}, ["fc_1h","fc_3h","fc_6h","fc_24h"]); seriesRef.current.t2.setData(t2pts); seriesRef.current.t3.setData(t3pts); seriesRef.current.t4.setData(t4pts); addMarkers(seriesRef.current.t2, t2pts, '#6ea8fe', 'inBar'); addMarkers(seriesRef.current.t3, t3pts, '#f59e0b', 'aboveBar'); addMarkers(seriesRef.current.t4, t4pts, '#a78bfa', 'belowBar'); // Range visibile: dall'ancora del forecast (ora del run) a +25h, con un po' di // contesto prima. Fallback all'ultimo blocco contiguo di candele se non c'è forecast. const fAnchor = t3pts[0] || t2pts[0] || t4pts[0] || null; if (fAnchor) { chartRef.current.timeScale().setVisibleRange({ from: fAnchor.time - 2 * 3600, to: fAnchor.time + 25 * 3600, }); } else { let fromIdx = candles.length - 1; const _GAP = 40 * 60, _MAXBACK = 24; while (fromIdx > 0 && (candles.length - fromIdx) < _MAXBACK && (candles[fromIdx].time - candles[fromIdx - 1].time) <= _GAP) { fromIdx--; } chartRef.current.timeScale().setVisibleRange({ from: candles[fromIdx].time, to: candles[candles.length-1].time, }); } } else { // Nascondi forecast lines per 1h/1d seriesRef.current.t2.setData([]); seriesRef.current.t3.setData([]); seriesRef.current.t4.setData([]); seriesRef.current.t2.setMarkers([]); seriesRef.current.t3.setMarkers([]); seriesRef.current.t4.setMarkers([]); // Range visibile: 1h → ~2gg (48 candele), 1d → ~3 mesi (90 candele) const tail = interval === "1h" ? 48 : 90; const fromIdx = Math.max(0, candles.length - tail); chartRef.current.timeScale().setVisibleRange({ from: candles[fromIdx].time, to: candles[candles.length-1].time, }); } }; const cacheKey = `${symbol}|${interval}`; if (interval === "5m") { // 5m: usa direttamente window.ALL_DATA (già in memoria) const d = (window.ALL_DATA || {})[symbol]; _apply(d?.candles || []); } else if (cacheRef.current[cacheKey]) { // Hit cache _apply(cacheRef.current[cacheKey]); } else { // Fetch 1h/1d dall'API setLoading(true); fetch(`/api/candles/${encodeURIComponent(symbol)}?interval=${interval}`) .then(r => r.json()) .then(data => { const candles = data.candles || []; cacheRef.current[cacheKey] = candles; _apply(candles); }) .catch(e => console.warn(`LWChart fetch ${symbol} ${interval}:`, e)) .finally(() => setLoading(false)); } }, [symbol, interval, refreshTick]); return (
{loading && (
Caricamento {interval}…
)}
); } window.Dashboard = Dashboard;