// 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;