Spaces:
Sleeping
Sleeping
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // BitPredict AI β script.js | |
| // Dataset : Kaggle novandraanugrah BTC Binance 2018-01-01 β 2026-03-26 | |
| // Link : https://www.kaggle.com/datasets/novandraanugrah/ | |
| // bitcoin-historical-datasets-2018-2024 | |
| // Model : 3-Layer LSTM (100β100β50) | 60-day lookback | 80/20 split | |
| // Test set: Aug 2024 β Mar 2026 (602 days) | |
| // Metrics : MAPE=2.74% | RΒ²=0.9476 | Accuracy=97.26% | |
| // Live : CoinGecko public API (no key needed) | refresh 30s | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // DYNAMIC FORECAST HORIZON β label & hint berubah per type | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function updateHorizonLabel() { | |
| const type = document.getElementById('predType')?.value || 'daily'; | |
| const label = document.getElementById('horizonLabel'); | |
| const unit = document.getElementById('horizonUnit'); | |
| const hint = document.getElementById('horizonHint'); | |
| const input = document.getElementById('numDays'); | |
| const cfg = { | |
| daily: { lbl:'Forecast Horizon (Days)', unit:'days', hint:'Each step = 1 day Β· range 1β60', max:60, def:7 }, | |
| weekly: { lbl:'Forecast Horizon (Weeks)', unit:'weeks', hint:'Each step = 7 days Β· range 1β8 weeks', max:8, def:4 }, | |
| monthly: { lbl:'Forecast Horizon (Months)', unit:'months', hint:'Each step = 30 days Β· range 1β2 months', max:2, def:1 }, | |
| }; | |
| const c = cfg[type] || cfg.daily; | |
| if (label) label.textContent = c.lbl; | |
| if (unit) unit.textContent = c.unit; | |
| if (hint) hint.textContent = c.hint; | |
| if (input) { input.max = c.max; input.placeholder = 'e.g. ' + c.def; | |
| if (parseInt(input.value) > c.max) input.value = c.def; } | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // PREDICTION TYPE CARDS β click to select | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function selectPredType(val) { | |
| document.getElementById('predType').value = val; | |
| document.querySelectorAll('.ptcard').forEach(c => { | |
| c.classList.toggle('ptcard-active', c.dataset.val === val); | |
| }); | |
| updateHorizonLabel(); | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // LIVE DATA β fetched from backend (CoinGecko proxy) | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function fetchHistory(days = 60) { | |
| const res = await fetch(`/api/history?days=${days}`, { cache: 'no-cache' }); | |
| if (!res.ok) throw new Error('HTTP ' + res.status); | |
| return res.json(); | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // NAVBAR | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const hamburger = document.getElementById('hamburger'); | |
| const navLinks = document.getElementById('navLinks'); | |
| hamburger?.addEventListener('click', () => navLinks?.classList.toggle('open')); | |
| navLinks?.querySelectorAll('a').forEach(a => | |
| a.addEventListener('click', () => navLinks.classList.remove('open')) | |
| ); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // DARK / LIGHT MODE TOGGLE (Moon π | Sun βοΈ) | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| (function initTheme() { | |
| const sw = document.getElementById('themeSwitch'); | |
| const body = document.body; | |
| const saved = localStorage.getItem('cp-theme') || 'dark'; | |
| if (saved === 'light') body.classList.add('light'); | |
| sw?.addEventListener('click', () => { | |
| const isLight = body.classList.toggle('light'); | |
| localStorage.setItem('cp-theme', isLight ? 'light' : 'dark'); | |
| }); | |
| })(); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // SCROLL REVEAL | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| (function initReveal() { | |
| const obs = new IntersectionObserver(entries => { | |
| entries.forEach(e => { | |
| if (e.isIntersecting) { e.target.classList.add('revealed'); obs.unobserve(e.target); } | |
| }); | |
| }, { threshold: 0.10 }); | |
| document.querySelectorAll('.reveal').forEach(el => obs.observe(el)); | |
| })(); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // ACTIVE NAV LINK β highlight saat section aktif atau diklik | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| (function initActiveNav() { | |
| const links = document.querySelectorAll('.nav-links a'); | |
| const sections = ['home','about','technology','features','forecast','compare']; // includes Prediction=#forecast | |
| function setActive(id) { | |
| links.forEach(a => { | |
| const href = a.getAttribute('href'); | |
| if (href === '#' + id) { | |
| a.classList.add('nav-active'); | |
| } else { | |
| a.classList.remove('nav-active'); | |
| } | |
| }); | |
| } | |
| // Set active on click immediately | |
| links.forEach(a => { | |
| a.addEventListener('click', () => { | |
| const id = a.getAttribute('href').slice(1); | |
| setActive(id); | |
| }); | |
| }); | |
| // Set active on scroll using IntersectionObserver | |
| const obs = new IntersectionObserver(entries => { | |
| entries.forEach(e => { | |
| if (e.isIntersecting) setActive(e.target.id); | |
| }); | |
| }, { threshold: 0.35 }); | |
| sections.forEach(id => { | |
| const el = document.getElementById(id); | |
| if (el) obs.observe(el); | |
| }); | |
| // Set home as default | |
| setActive('home'); | |
| })(); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // UTILS | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const fmt = v => '$' + (+v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); | |
| const fmtShort = v => v >= 1e12 ? '$' + (v/1e12).toFixed(2) + 'T' : v >= 1e9 ? '$' + (v/1e9).toFixed(2) + 'B' : v >= 1e6 ? '$' + (v/1e6).toFixed(0) + 'M' : fmt(v); | |
| const avgArr = arr => arr.reduce((a, b) => a + b, 0) / arr.length; | |
| const stdArr = arr => { const m = avgArr(arr); return Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length); }; | |
| function sparseLabels(arr, maxTicks = 20) { | |
| const step = Math.ceil(arr.length / maxTicks); | |
| return arr.map((v, i) => i % step === 0 ? v : ''); | |
| } | |
| function baseChartOpts() { | |
| return { | |
| responsive: true, maintainAspectRatio: true, | |
| interaction: { mode: 'index', intersect: false }, | |
| plugins: { | |
| legend: { display: false }, | |
| tooltip: { | |
| backgroundColor: '#1d2132', borderColor: 'rgba(255,255,255,0.09)', borderWidth: 1, | |
| titleColor: '#8b90ab', bodyColor: '#eef0f8', | |
| titleFont: { family: 'JetBrains Mono', size: 11 }, | |
| bodyFont: { family: 'Sora', size: 12 }, | |
| padding: 14, | |
| callbacks: { label: ctx => ` ${ctx.dataset.label}: ${fmt(ctx.parsed.y)}` } | |
| } | |
| }, | |
| scales: { | |
| x: { | |
| ticks: { color: '#484d68', font: { family: 'JetBrains Mono', size: 10 }, maxRotation: 45, autoSkip: false }, | |
| grid: { color: 'rgba(255,255,255,0.04)' }, border: { color: 'transparent' } | |
| }, | |
| y: { | |
| ticks: { color: '#484d68', font: { family: 'JetBrains Mono', size: 10 }, callback: v => v >= 1000 ? '$' + (v/1000).toFixed(0) + 'K' : '$' + v }, | |
| grid: { color: 'rgba(255,255,255,0.05)' }, border: { color: 'transparent', dash: [3, 4] } | |
| } | |
| } | |
| }; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // π΄ LIVE PRICE + CHART β CoinGecko Public API | |
| // Timeframes: 1D (hourly) | 1W | 1M | 1Y (daily) | |
| // Refresh : every 30 seconds automatically | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let livePrice = null; | |
| let chartInst = null; | |
| let currentTf = '1D'; | |
| let cachedSparkPrices = null; // 7d hourly from /markets | |
| let isUpGlobal = true; | |
| // ββ CoinGecko endpoints ββ | |
| // NOTE: 15m/1h/4h use free endpoint (returns finest grain available per days param) | |
| function tfUrl(tf) { | |
| const base = 'https://api.coingecko.com/api/v3/coins/bitcoin/market_chart'; | |
| const map = { | |
| '15m': base + '?vs_currency=usd&days=1', // ~hourly; finest free tier = 5min per 1 day | |
| '1h' : base + '?vs_currency=usd&days=2', // 2 days β hourly granularity | |
| '4h' : base + '?vs_currency=usd&days=7', // 7 days β hourly, sample every 4 | |
| '1D' : base + '?vs_currency=usd&days=30', // 30 days β daily | |
| '1W' : base + '?vs_currency=usd&days=90', // 90 days β daily | |
| '1M' : base + '?vs_currency=usd&days=365&interval=daily', | |
| '1Y' : base + '?vs_currency=usd&days=365&interval=daily', | |
| '4Y' : base + '?vs_currency=usd&days=1461&interval=daily', | |
| }; | |
| return map[tf] || map['1D']; | |
| } | |
| // ββ For 4h: sample every 4th datapoint from hourly data ββ | |
| function sampleEvery(arr, n) { | |
| return arr.filter((_, i) => i % n === 0); | |
| } | |
| // ββ Fetch & render chart for a given timeframe ββ | |
| async function fetchAndRenderChart(tf) { | |
| currentTf = tf; | |
| document.querySelectorAll('.ld-tab').forEach(btn => { | |
| btn.classList.toggle('active', btn.dataset.tf === tf); | |
| }); | |
| try { | |
| const res = await fetch(tfUrl(tf), { cache: 'no-cache' }); | |
| if (!res.ok) throw new Error('HTTP ' + res.status); | |
| const data = await res.json(); | |
| let rawPrices = data.prices.map(p => p[1]); | |
| let rawTimes = data.prices.map(p => { | |
| const d = new Date(p[0]); | |
| switch(tf) { | |
| case '15m': | |
| return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); | |
| case '1h': | |
| return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); | |
| case '4h': | |
| return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); | |
| case '1D': | |
| return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); | |
| case '1W': | |
| return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); | |
| case '1M': | |
| return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); | |
| case '1Y': | |
| return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); | |
| case '4Y': | |
| return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); | |
| default: | |
| return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); | |
| } | |
| }); | |
| // 4h: sample every 4th hourly point | |
| if (tf === '4h') { | |
| rawPrices = sampleEvery(rawPrices, 4); | |
| rawTimes = sampleEvery(rawTimes, 4); | |
| } | |
| const isUp = rawPrices[rawPrices.length-1] >= rawPrices[0]; | |
| buildDashChart(rawPrices, rawTimes, isUp); | |
| } catch(e) { | |
| console.warn('[CryptoPedia] Chart fetch failed, trying /api/history:', e.message); | |
| try { | |
| const slices = { '15m':1, '1h':2, '4h':7, '1D':30, '1W':90, '1M':365, '1Y':365, '4Y':365 }; | |
| const hist = await fetchHistory(slices[tf] || 30); | |
| const pts = hist.prices; | |
| const labels = hist.dates.map(d => { | |
| const dt = new Date(d + 'T00:00:00'); | |
| return tf === '15m' || tf === '1h' || tf === '4h' | |
| ? dt.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) | |
| : dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); | |
| }); | |
| const isUp = pts[pts.length - 1] >= pts[0]; | |
| buildDashChart(pts, labels, isUp); | |
| } catch (err) { | |
| console.warn('[CryptoPedia] History fallback also failed:', err.message); | |
| } | |
| } | |
| } | |
| // ββ Build / refresh the dashboard chart ββ | |
| function buildDashChart(prices, labels, isUp) { | |
| const canvas = document.getElementById('ldChart'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| const color = isUp ? '#22c55e' : '#ff6b6b'; | |
| if (chartInst) { chartInst.destroy(); chartInst = null; } | |
| const g = ctx.createLinearGradient(0, 0, 0, 110); | |
| g.addColorStop(0, isUp ? 'rgba(34,197,94,0.22)' : 'rgba(255,107,107,0.22)'); | |
| g.addColorStop(1, 'rgba(0,0,0,0)'); | |
| // Sparse labels β show max 8 | |
| const step = Math.ceil(labels.length / 8); | |
| const sparseL = labels.map((l, i) => i % step === 0 ? l : ''); | |
| chartInst = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels: sparseL, | |
| datasets: [{ data: prices, borderColor: color, backgroundColor: g, | |
| borderWidth: 1.8, pointRadius: 0, tension: 0.3, fill: true }] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| animation: { duration: 500 }, | |
| interaction: { mode: 'index', intersect: false }, | |
| plugins: { | |
| legend: { display: false }, | |
| tooltip: { | |
| backgroundColor: '#1d2132', borderColor: 'rgba(255,255,255,.09)', | |
| borderWidth: 1, titleColor: '#8b90ab', bodyColor: '#eef0f8', | |
| callbacks: { | |
| title: (items) => { | |
| // Show actual label (time/date) as tooltip title | |
| const lbl = labels[items[0].dataIndex]; | |
| return lbl || ''; | |
| }, | |
| label: ctx => ' BTC: ' + fmt(ctx.parsed.y) | |
| } | |
| } | |
| }, | |
| scales: { | |
| x: { ticks: { color: '#484d68', font: { size: 9 }, maxRotation: 0 }, | |
| grid: { color: 'rgba(255,255,255,0.04)' }, border: { color: 'transparent' } }, | |
| y: { ticks: { color: '#484d68', font: { size: 9 }, | |
| callback: v => '$' + (v/1000).toFixed(0) + 'K' }, | |
| grid: { color: 'rgba(255,255,255,0.05)' }, border: { color: 'transparent' } } | |
| } | |
| } | |
| }); | |
| // ββ Tampilkan range waktu di bawah chart ββ | |
| const timeEl = document.getElementById('ldChartTime'); | |
| if (timeEl && labels.length > 0) { | |
| const first = labels.find(l => l !== ''); | |
| const last = [...labels].reverse().find(l => l !== ''); | |
| if (first && last && first !== last) { | |
| timeEl.textContent = first + ' β ' + last; | |
| } else { | |
| timeEl.textContent = ''; | |
| } | |
| } | |
| } | |
| // ββ Tab click handlers ββ | |
| document.querySelectorAll('.ld-tab').forEach(btn => { | |
| btn.addEventListener('click', () => fetchAndRenderChart(btn.dataset.tf)); | |
| }); | |
| // ββ Main live price fetch ββ | |
| async function fetchLivePrice() { | |
| try { | |
| const res = await fetch('/api/live', { cache: 'no-cache' }); | |
| if (!res.ok) throw new Error('HTTP ' + res.status); | |
| const data = await res.json(); | |
| livePrice = data.price; | |
| const chg = data.change; | |
| isUpGlobal = chg >= 0; | |
| // Big price display | |
| const ldP = document.getElementById('ldPrice'); | |
| if (ldP) { ldP.textContent = fmt(livePrice); ldP.className = 'ld-price ' + (isUpGlobal ? 'up' : 'down'); } | |
| // Pill | |
| const pill = document.getElementById('ldPill'); | |
| if (pill) { | |
| pill.className = 'ld-pill ' + (isUpGlobal ? 'up' : 'down'); | |
| document.getElementById('ldArrow').textContent = isUpGlobal ? 'β²' : 'βΌ'; | |
| document.getElementById('ldPct').textContent = Math.abs(chg).toFixed(2) + '%'; | |
| } | |
| // Meta | |
| const setEl = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; | |
| setEl('ldHigh', fmt(data.high)); | |
| setEl('ldLow', fmt(data.low)); | |
| setEl('ldMcap', fmtShort(data.mcap)); | |
| setEl('ldVol', fmtShort(data.volume)); | |
| setEl('ldRank', '#' + data.rank); | |
| setEl('ldAth', fmt(data.ath)); | |
| // Navbar pill | |
| const navP = document.getElementById('navPrice'); | |
| const navC = document.getElementById('navChg'); | |
| if (navP) navP.textContent = fmt(livePrice); | |
| if (navC) { navC.textContent = (isUpGlobal ? 'β²' : 'βΌ') + Math.abs(chg).toFixed(2) + '%'; | |
| navC.className = 'nlp-chg ' + (isUpGlobal ? 'up' : 'down'); } | |
| // Hero stat | |
| const hp = document.getElementById('heroPrice'); | |
| if (hp) hp.textContent = fmt(livePrice); | |
| // Timestamp | |
| const upd = document.getElementById('ldUpdated'); | |
| if (upd) upd.textContent = new Date().toLocaleTimeString('en-US', { hour12: false }); | |
| } catch (err) { | |
| console.warn('[CryptoPedia] Live API unavailable, using fallback.', err.message); | |
| fallbackTick(); | |
| } | |
| } | |
| function fallbackTick() { | |
| if (!livePrice) return; | |
| livePrice *= (1 + (Math.random() - 0.499) * 0.0006); | |
| ['ldPrice','heroPrice','navPrice'].forEach(id => { | |
| const el = document.getElementById(id); if (el) el.textContent = fmt(livePrice); | |
| }); | |
| const upd = document.getElementById('ldUpdated'); | |
| if (upd) upd.textContent = new Date().toLocaleTimeString('en-US', { hour12: false }) + ' (offline)'; | |
| } | |
| // ββ Init: fetch price then chart ββ | |
| updateHorizonLabel(); | |
| fetchLivePrice(); | |
| fetchAndRenderChart('15m'); // load default 15m chart | |
| setInterval(fetchLivePrice, 30000); | |
| setInterval(() => fetchAndRenderChart(currentTf), 60000); // refresh chart every 60s | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // COMPARE CHART β Actual vs Predicted (live rolling LSTM) | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function buildCompareChart() { | |
| const canvas = document.getElementById('compareChart'); | |
| if (!canvas) return; | |
| try { | |
| const res = await fetch('/api/compare?days=90', { cache: 'no-cache' }); | |
| if (!res.ok) throw new Error('HTTP ' + res.status); | |
| const { dates, actual, predicted } = await res.json(); | |
| const s = actual[0]; | |
| const e = actual[actual.length - 1]; | |
| const ch = ((e - s) / s) * 100; | |
| const av = avgArr(actual); | |
| const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; | |
| set('cmpStart', fmt(s)); set('cmpEnd', fmt(e)); set('cmpAvg', fmt(av)); | |
| const chEl = document.getElementById('cmpChange'); | |
| if (chEl) { | |
| chEl.textContent = (ch >= 0 ? '+' : '') + ch.toFixed(2) + '%'; | |
| chEl.classList.add(ch >= 0 ? 'pos' : 'neg'); | |
| } | |
| const ctx = canvas.getContext('2d'); | |
| const gA = ctx.createLinearGradient(0, 0, 0, 420); | |
| gA.addColorStop(0, 'rgba(240,185,11,0.22)'); gA.addColorStop(1, 'rgba(240,185,11,0)'); | |
| const gP = ctx.createLinearGradient(0, 0, 0, 420); | |
| gP.addColorStop(0, 'rgba(78,205,196,0.15)'); gP.addColorStop(1, 'rgba(78,205,196,0)'); | |
| new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels: sparseLabels(dates, 22), | |
| datasets: [ | |
| { label: 'Actual Price', data: actual, borderColor: '#F0B90B', backgroundColor: gA, borderWidth: 2, pointRadius: 0, pointHoverRadius: 5, tension: 0.3, fill: true }, | |
| { label: 'Predicted Price', data: predicted, borderColor: '#4ecdc4', backgroundColor: gP, borderWidth: 2, borderDash: [5, 4], pointRadius: 0, pointHoverRadius: 5, tension: 0.3, fill: true, spanGaps: false } | |
| ] | |
| }, | |
| options: baseChartOpts() | |
| }); | |
| } catch (err) { | |
| console.warn('[CryptoPedia] Compare chart fetch failed:', err.message); | |
| } | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // FORECAST GENERATOR | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let forecastInst = null; | |
| document.getElementById('generateBtn')?.addEventListener('click', function () { | |
| const btn = this; | |
| btn.innerHTML = '<span>β³</span> Generatingβ¦'; | |
| btn.disabled = true; | |
| runForecast().finally(() => { | |
| btn.innerHTML = 'Generate Prediction'; | |
| btn.disabled = false; | |
| }); | |
| }); | |
| async function runForecast() { | |
| const rawN = parseInt(document.getElementById('numDays').value) || 7; | |
| const pType = document.getElementById('predType')?.value || 'daily'; | |
| const mult = pType === 'monthly' ? 30 : pType === 'weekly' ? 7 : 1; | |
| const maxAllowed = pType === 'monthly' ? 2 : pType === 'weekly' ? 8 : 60; | |
| const n = Math.min(rawN * mult, 60); | |
| if (rawN < 1 || rawN > maxAllowed) { | |
| alert(`Please enter a value between 1 and ${maxAllowed} ${pType === 'daily' ? 'days' : pType === 'weekly' ? 'weeks' : 'months'}.`); | |
| return; | |
| } | |
| let recentPrices = []; | |
| try { | |
| const hist = await fetchHistory(60); | |
| recentPrices = hist.prices.slice(-60); | |
| console.log('[BitPredict] Live 60-day input loaded. Latest close:', recentPrices.at(-1)); | |
| } catch (e) { | |
| console.warn('[BitPredict] History fetch failed:', e.message); | |
| } | |
| let fDates, fPrices; | |
| try { | |
| const res = await fetch('/api/forecast', { | |
| method : 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body : JSON.stringify({ days: n, prices: recentPrices }) | |
| }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const data = await res.json(); | |
| fDates = data.dates; | |
| fPrices = data.prices; | |
| console.log(`[BitPredict] Forecast source: ${data.source}`); | |
| } catch (err) { | |
| console.warn('[BitPredict] API forecast failed, using local fallback.', err); | |
| if (!livePrice || recentPrices.length < 2) { | |
| alert('Unable to generate forecast β live price data is unavailable.'); | |
| return; | |
| } | |
| fDates = []; | |
| fPrices = []; | |
| const base = livePrice; | |
| const slice = recentPrices.slice(-30); | |
| const mom = (slice[slice.length - 1] - slice[0]) / slice.length; | |
| const vol = stdArr(recentPrices.slice(-60)) / base; | |
| let p = base; | |
| const baseDate = new Date(); | |
| for (let i = 1; i <= n; i++) { | |
| const d = new Date(baseDate); d.setDate(baseDate.getDate() + i); | |
| fDates.push(d.toISOString().slice(0, 10)); | |
| p += mom * 0.38 + p * (Math.random() - 0.48) * vol * 1.6; | |
| p = Math.max(p, 10_000); | |
| fPrices.push(Math.round(p * 100) / 100); | |
| } | |
| } | |
| const sp = fPrices[0], ep = fPrices[fPrices.length - 1]; | |
| const ch = ((ep - sp) / sp) * 100; | |
| const av = avgArr(fPrices); | |
| const lo = Math.min(...fPrices), hi = Math.max(...fPrices); | |
| const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; | |
| set('predStart', fmt(sp)); set('predEnd', fmt(ep)); set('predAvg', fmt(av)); | |
| const chEl = document.getElementById('predChange'); | |
| if (chEl) { chEl.textContent = (ch >= 0 ? '+' : '') + ch.toFixed(2) + '%'; chEl.style.color = ch >= 0 ? '#22c55e' : '#ff6b6b'; } | |
| ['predSummary', 'forecastChartBox', 'aiPanel'].forEach(id => document.getElementById(id)?.classList.remove('hidden')); | |
| if (forecastInst) { forecastInst.destroy(); forecastInst = null; } | |
| const ctx2 = document.getElementById('forecastChart').getContext('2d'); | |
| const gF = ctx2.createLinearGradient(0, 0, 0, 380); | |
| gF.addColorStop(0, 'rgba(240,185,11,0.30)'); gF.addColorStop(1, 'rgba(240,185,11,0)'); | |
| forecastInst = new Chart(ctx2, { | |
| type: 'line', | |
| data: { labels: fDates, datasets: [{ label: 'LSTM Prediction', data: fPrices, borderColor: '#F0B90B', backgroundColor: gF, borderWidth: 2.5, pointRadius: 5, pointBackgroundColor: '#F0B90B', pointBorderColor: '#0d0e12', pointBorderWidth: 2, tension: 0.35, fill: true }] }, | |
| options: baseChartOpts() | |
| }); | |
| // AI text | |
| const base = livePrice; | |
| const dir = ch < 0 ? 'penurunan' : 'kenaikan'; | |
| const pct = Math.abs(ch).toFixed(2); | |
| const aiEl = document.getElementById('aiText'); | |
| if (aiEl) aiEl.textContent = | |
| `Harga Bitcoin diprediksi mengalami ${dir} sebesar ${pct}% dari ${fDates[0]} sampai ${fDates[fDates.length - 1]}, ` + | |
| `dengan harga rata-rata ${fmt(av)}. Kisaran prediksi antara ${fmt(lo)} hingga ${fmt(hi)}. ` + | |
| `Berdasarkan harga live saat ini (${fmt(base)}) dan volatilitas historis pasar terkini, ` + | |
| (ch < -5 ? 'tekanan jual masih dominan β waspadai penurunan lebih lanjut.' | |
| : ch > 5 ? 'momentum bullish terdeteksi β potensi kenaikan signifikan.' | |
| : 'pasar dalam fase konsolidasi β tunggu konfirmasi arah breakout.'); | |
| const rb = document.getElementById('recBadge'); | |
| if (rb) { | |
| if (ch < -5) { rb.textContent = 'Sell'; rb.className = 'rec-text sell'; } | |
| else if (ch > 5) { rb.textContent = 'Buy'; rb.className = 'rec-text buy'; } | |
| else { rb.textContent = 'Hold'; rb.className = 'rec-text hold'; } | |
| } | |
| document.getElementById('predSummary')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // INIT | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| buildCompareChart(); |