"use strict"; /* ========================================================================= FX PULSE — dashboard logic --------------------------------------------------------------------------- Sections: 1. Config & state 2. API layer (fetch with CDN fallback + caching) 3. Math/analysis helpers (MA, volatility, slope, patterns) 4. Rendering (current value, table, heatmap, charts, AI, patterns) 5. Calculator 6. Theme + events + init ========================================================================= */ /* -------------------- 1. CONFIG & STATE -------------------- */ const MAJORS = ["usd","eur","gbp","jpy","aud","cad","chf","cny","sgd","php"]; const FALLBACK_NAMES = {usd:"US Dollar",eur:"Euro",gbp:"British Pound",jpy:"Japanese Yen", aud:"Australian Dollar",cad:"Canadian Dollar",chf:"Swiss Franc",cny:"Chinese Yuan", sgd:"Singapore Dollar",php:"Philippine Peso",hkd:"Hong Kong Dollar",nzd:"New Zealand Dollar", inr:"Indian Rupee",krw:"South Korean Won",thb:"Thai Baht",myr:"Malaysian Ringgit"}; const state = { base:"usd", target:"php", range:30, names:{}, // code -> name latest:null, // latest base file: {date, base:{...}} yesterday:null, // most recent prior day base file series:[], // [{date, rate}] for selected target over range candles:[], // [{x,o,h,l,c}] table:[], // computed table rows sort:{key:"code",dir:1}, cache:new Map(), // url -> json }; /* -------------------- 2. API LAYER -------------------- */ // Build the candidate URLs (primary jsDelivr, then pages.dev fallback). function urlsLatest(base){ return [ `https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/${base}.json`, `https://latest.currency-api.pages.dev/v1/currencies/${base}.json` ]; } function urlsDated(base,date){ return [ `https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@${date}/v1/currencies/${base}.json`, `https://${date}.currency-api.pages.dev/v1/currencies/${base}.json` ]; } function urlsNames(){ return [ `https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies.json`, `https://latest.currency-api.pages.dev/v1/currencies.json` ]; } // Try each URL in order (with a hard timeout so a stalled request can't hang // the dashboard); first success wins. Throws if all fail. async function fetchJson(urls,timeoutMs=10000){ const key = urls[0]; if(state.cache.has(key)) return state.cache.get(key); let lastErr; for(const u of urls){ const ctrl=new AbortController(); const timer=setTimeout(()=>ctrl.abort(),timeoutMs); try{ const res = await fetch(u,{signal:ctrl.signal}); clearTimeout(timer); if(!res.ok) throw new Error("HTTP "+res.status); const json = await res.json(); state.cache.set(key,json); return json; }catch(e){ clearTimeout(timer); lastErr = e; } } throw lastErr || new Error("All endpoints failed"); } // Run async tasks with a concurrency cap (avoids jsDelivr burst throttling // when fetching many date-tagged files for the historical series). async function mapLimit(items,limit,fn){ const out=new Array(items.length); let idx=0; async function worker(){ while(idx a.reduce((s,x)=>s+x,0)/(a.length||1); function stdev(a){ const m=mean(a); return Math.sqrt(mean(a.map(x=>(x-m)**2))); } // Simple moving average series (window w) aligned to input length (null padded). function sma(arr,w){ return arr.map((_,i)=> ii), mx=mean(xs), my=mean(arr); let num=0,den=0; for(let i=0;ip.rate); const rets = returns(closes); const vol = stdev(rets); // typical daily move (fraction) const out=[]; for(let i=1;ip.rate); if(closes.length<5) return [{tag:"info",cls:"t-flat",text:"Not enough history to analyse patterns."}]; const out=[]; const rets=returns(closes); const vol=stdev(rets)*100; const slope=slopePct(closes); const last=closes[closes.length-1]; const sShort=sma(closes,5), sLong=sma(closes,Math.min(10,closes.length-1)); const maShort=sShort[sShort.length-1], maLong=sLong[sLong.length-1]; // Trend (via regression slope) if(slope>0.12) out.push({tag:"Uptrend",cls:"t-up",text:`Rate is rising ~${slope.toFixed(2)}% per day on a regression fit.`}); else if(slope<-0.12) out.push({tag:"Downtrend",cls:"t-down",text:`Rate is falling ~${Math.abs(slope).toFixed(2)}% per day on a regression fit.`}); else out.push({tag:"Sideways",cls:"t-flat",text:"No clear direction — rate is moving sideways."}); // Breakout: last value beyond recent range (exclude last point) const prior=closes.slice(0,-1); const hiP=Math.max(...prior), loP=Math.min(...prior); if(last>hiP) out.push({tag:"Breakout ↑",cls:"t-break",text:"Closed above its recent high — upside breakout."}); else if(last=8){ const half=Math.floor(rets.length/2); const recent=stdev(rets.slice(half))*100, early=stdev(rets.slice(0,half))*100; if(recent>early*1.6 && recent>0.25) out.push({tag:"Volatility spike",cls:"t-vol",text:`Recent swings (${recent.toFixed(2)}%) are well above the earlier ${early.toFixed(2)}% baseline.`}); } // MA crossover (golden/death) in the last step if(maShort!=null && maLong!=null){ const pShort=sShort[sShort.length-2], pLong=sLong[sLong.length-2]; if(pShort!=null && pLong!=null){ if(pShort<=pLong && maShort>maLong) out.push({tag:"Bullish crossover",cls:"t-up",text:"Short MA crossed above long MA — bullish signal."}); else if(pShort>=pLong && maShortp.rate); const out=[]; if(closes.length<3){ return [{ic:"ℹ️",t:"Limited history available for "+pair+".",s:""}]; } const slope=slopePct(closes); const totalChg=((closes[closes.length-1]-closes[0])/closes[0])*100; const vol=stdev(returns(closes))*100; const ma7=sma(closes,Math.min(7,closes.length))[closes.length-1]; const last=closes[closes.length-1]; // 1. Directional pressure if(slope>0.12) out.push({ic:"📈",t:`${pair} is showing upward pressure, up ${totalChg.toFixed(2)}% over the last ${closes.length} days.`,s:"Buyers currently in control."}); else if(slope<-0.12) out.push({ic:"📉",t:`${pair} is under downward pressure, down ${Math.abs(totalChg).toFixed(2)}% over the last ${closes.length} days.`,s:"Sellers currently in control."}); else out.push({ic:"➡️",t:`${pair} is broadly flat (${totalChg>=0?"+":""}${totalChg.toFixed(2)}%) — a consolidation phase.`,s:"Direction unclear; range-bound."}); // 2. MA momentum if(ma7!=null){ if(last>ma7*1.001) out.push({ic:"🟢",t:`Price is above its ${Math.min(7,closes.length)}-day moving average, suggesting bullish momentum.`,s:`Last ${last.toFixed(4)} vs MA ${ma7.toFixed(4)}.`}); else if(last0.6) out.push({ic:"⚡",t:"Volatility is higher than usual, so short-term movement may be unstable.",s:`Avg daily swing ≈ ${vol.toFixed(2)}%.`}); else out.push({ic:"🛡️",t:"Volatility is subdued — movements are relatively calm and orderly.",s:`Avg daily swing ≈ ${vol.toFixed(2)}%.`}); return out; } /* -------------------- 4. RENDERING -------------------- */ const $ = id => document.getElementById(id); const fmt = (v,d=4)=> v==null||isNaN(v) ? "—" : Number(v).toLocaleString(undefined,{maximumFractionDigits:d,minimumFractionDigits:Math.min(2,d)}); const nameOf = c => state.names[c] || FALLBACK_NAMES[c] || c.toUpperCase(); function trendClass(p){ return p>0.05?"up":p<-0.05?"down":"flat"; } function trendWord(p){ return p>0.05?"Uptrend":p<-0.05?"Downtrend":"Stable"; } // --- Current value card --- function renderCurrent(){ const {base,target,latest,yesterday,series}=state; const rate = latest?.[base]?.[target]; $("pairLabel").textContent = `${base.toUpperCase()}/${target.toUpperCase()}`; $("rateBig").textContent = fmt(rate,4); // daily change vs most recent prior day let chg=null; const prev = yesterday?.[base]?.[target]; if(prev) chg = ((rate-prev)/prev)*100; const cv=$("changeVal"); if(chg==null){ cv.textContent="n/a"; cv.className="change flat"; } else{ cv.textContent=`${chg>=0?"▲ +":"▼ "}${chg.toFixed(3)}%`; cv.className="change "+trendClass(chg); } const badge=$("trendBadge"); badge.textContent=trendWord(chg??0); badge.className="badge "+trendClass(chg??0); // 7d stats from series const closes=series.map(p=>p.rate); if(closes.length){ $("m7high").textContent=fmt(Math.max(...closes),4); $("m7low").textContent=fmt(Math.min(...closes),4); $("mvol").textContent=(stdev(returns(closes))*100).toFixed(2)+"%"; } } // --- Table --- function computeTable(){ const {base,latest,yesterday}=state; const cur=latest?.[base]||{}; const prev=yesterday?.[base]||{}; const rows=[]; for(const code in cur){ if(code===base) continue; const rate=cur[code]; const p=prev[code]; const change = (p!=null && p!==0) ? ((rate-p)/p)*100 : null; rows.push({code,name:nameOf(code),rate,change}); } // mark strongest / weakest by change among comparable rows const withChg=rows.filter(r=>r.change!=null); if(withChg.length){ let s=withChg[0], w=withChg[0]; for(const r of withChg){ if(r.change>s.change)s=r; if(r.changer.code.includes(q)||r.name.toLowerCase().includes(q)); rows.sort((a,b)=>{ let av=a[key], bv=b[key]; if(key==="change"){ av=av??-Infinity; bv=bv??-Infinity; } if(typeof av==="string") return av.localeCompare(bv)*dir; return (av-bv)*dir; }); const body=$("rateBody"); body.innerHTML = rows.map(r=>{ const cls = trendClass(r.change??0); const sign = r.change==null?"":(r.change>=0?"+":""); const lead = r.lead==="s"?`Strong`:r.lead==="w"?`Weak`:""; const rowCls = r.lead==="s"?"row-strong":r.lead==="w"?"row-weak":""; const arrow = r.change==null?"·":r.change>0.05?"▲":r.change<-0.05?"▼":"—"; return ` ${r.code.toUpperCase()}${lead} ${r.name} ${fmt(r.rate,r.rate<10?5:2)} ${r.change==null?"n/a":sign+r.change.toFixed(2)+"%"} ${arrow} ${trendWord(r.change??0)} `; }).join(""); $("tableCount").textContent=`${rows.length} currencies`; $("tableBaseLbl").textContent=`(1 ${state.base.toUpperCase()} = …)`; // header arrows document.querySelectorAll("thead th[data-sort]").forEach(th=>{ const a=th.querySelector(".arr"); if(!a) return; a.textContent = th.dataset.sort===key ? (dir>0?"▲":"▼") : ""; }); } // --- AI + patterns --- function renderAI(){ const ins=buildInsights(state.series,state.base,state.target); $("aiList").innerHTML = ins.map(i=>`
${i.ic}

${i.t}${i.s||""}

`).join(""); } function renderPatterns(){ const ps=detectPatterns(state.series); $("patternList").innerHTML = ps.map(p=>`
${p.tag}${p.text}
`).join(""); } // --- Heatmap (basket-weighted 7-day strength from USD cross rates) --- async function renderHeatmap(){ const grid=$("heatGrid"); grid.innerHTML = MAJORS.map(()=>`
`).join(""); // Cross rate C->O derived from USD file: rate = usd[O]/usd[C] const now = await getLatest("usd"); const past = await getRecentPast("usd",7,7); if(!now?.usd || !past?.usd){ grid.innerHTML=`
Heatmap data unavailable.
`; return; } const N=now.usd, P=past.usd; const valNow=c=> c==="usd"?1:N[c], valPast=c=> c==="usd"?1:P[c]; const strength={}; for(const c of MAJORS){ const diffs=[]; for(const o of MAJORS){ if(o===c) continue; const rNow=valNow(o)/valNow(c), rPast=valPast(o)/valPast(c); if(rPast) diffs.push(((rNow-rPast)/rPast)*100); } strength[c]=mean(diffs); } const vals=Object.values(strength); const lo=Math.min(...vals), hi=Math.max(...vals), span=(hi-lo)||1; // cool color ramp: muted navy (weak) -> bright mint/cyan (strong). No purple. const ramp=t=>{ const stops=[[11,34,51],[14,74,90],[19,138,138],[31,182,166],[127,240,200]]; const x=t*(stops.length-1), i=Math.min(stops.length-2,Math.floor(x)), f=x-i; const c=stops[i].map((v,k)=>Math.round(v+(stops[i+1][k]-v)*f)); return `rgb(${c[0]},${c[1]},${c[2]})`; }; const ordered=[...MAJORS].sort((a,b)=>strength[b]-strength[a]); grid.innerHTML = ordered.map((c,idx)=>{ const t=(strength[c]-lo)/span; const tag = idx===0?"Strongest":idx===ordered.length-1?"Weakest":(strength[c]>=0?"Firm":"Soft"); return `
${c.toUpperCase()}
${strength[c]>=0?"+":""}${strength[c].toFixed(2)}%
${tag}
`; }).join(""); } /* -------------------- CHARTS -------------------- */ let candleChart, lineChart; function themeColors(){ const cs=getComputedStyle(document.documentElement); return { text:cs.getPropertyValue("--text-dim").trim(), grid:cs.getPropertyValue("--grid").trim(), up:cs.getPropertyValue("--c-up").trim(), down:cs.getPropertyValue("--c-down").trim(), accent:cs.getPropertyValue("--accent").trim(), accent2:cs.getPropertyValue("--accent-2").trim(), slate:cs.getPropertyValue("--c-slate").trim(), }; } function renderCandles(){ const tc=themeColors(); state.candles=buildCandles(state.series); const ctx=$("candleChart"); if(candleChart) candleChart.destroy(); candleChart=new Chart(ctx,{ type:"candlestick", data:{datasets:[{ label:`${state.base.toUpperCase()}/${state.target.toUpperCase()} (est.)`, data:state.candles, // chartjs-chart-financial uses the *plural* keys; keeps candles on the // cool palette (teal = up, slate-blue = down) — no warm reds. backgroundColors:{up:tc.up,down:tc.down,unchanged:tc.slate}, borderColors:{up:tc.up,down:tc.down,unchanged:tc.slate}, }]}, options:{ responsive:true,maintainAspectRatio:false, plugins:{legend:{display:false},tooltip:{callbacks:{ label:c=>{const o=c.raw;return [`O ${fmt(o.o,4)}`,`H ${fmt(o.h,4)}`,`L ${fmt(o.l,4)}`,`C ${fmt(o.c,4)}`];} }}}, scales:{ x:{type:"time",time:{unit:state.range>30?"week":"day"},grid:{color:tc.grid},ticks:{color:tc.text,maxRotation:0,autoSkip:true,maxTicksLimit:8}}, y:{grid:{color:tc.grid},ticks:{color:tc.text}} } } }); } function renderLine(){ const tc=themeColors(); const labels=state.series.map(p=>p.date); const data=state.series.map(p=>p.rate); const ma=sma(data,7); const ctx=$("lineChart"); if(lineChart) lineChart.destroy(); // gradient fill under the line const g=ctx.getContext("2d").createLinearGradient(0,0,0,320); g.addColorStop(0,"rgba(34,211,238,.30)"); g.addColorStop(1,"rgba(34,211,238,0)"); lineChart=new Chart(ctx,{ type:"line", data:{labels,datasets:[ {label:"Rate",data,borderColor:tc.accent,backgroundColor:g,fill:true,tension:.3,pointRadius:0,borderWidth:2.4}, {label:"7-day MA",data:ma,borderColor:tc.accent2,borderDash:[6,5],fill:false,tension:.3,pointRadius:0,borderWidth:1.8} ]}, options:{ responsive:true,maintainAspectRatio:false, interaction:{mode:"index",intersect:false}, plugins:{legend:{display:true,labels:{color:tc.text,boxWidth:14,usePointStyle:true}}, tooltip:{callbacks:{label:c=>`${c.dataset.label}: ${fmt(c.parsed.y,5)}`}}}, scales:{ x:{grid:{color:tc.grid},ticks:{color:tc.text,maxRotation:0,autoSkip:true,maxTicksLimit:8}}, y:{grid:{color:tc.grid},ticks:{color:tc.text}} } } }); } /* -------------------- 5. CALCULATOR -------------------- */ async function runCalc(){ const amt=parseFloat($("calcAmount").value)||0; const from=$("calcFrom").value, to=$("calcTo").value; try{ const data=await getLatest(from); const rate = from===to ? 1 : data?.[from]?.[to]; if(rate==null){ $("calcOut").textContent="—"; $("calcRate").textContent="rate unavailable"; return; } const res=amt*rate; $("calcOut").textContent=`${fmt(res, res<10?4:2)} ${to.toUpperCase()}`; $("calcRate").textContent=`1 ${from.toUpperCase()} = ${fmt(rate,6)} ${to.toUpperCase()}`; }catch{ $("calcRate").textContent="rate unavailable"; } } /* -------------------- DATA FETCH ORCHESTRATION -------------------- */ // Fetch the rate series for the selected target across the chosen range. // Dates are counted back from the latest data date; requests are throttled. async function fetchSeries(){ const {base,target,range}=state; const ld=latestDate(); const step = range>60 ? 3 : 1; // sample longer ranges to limit requests const offsets=[]; for(let off=0;off<=range;off+=step) offsets.push(off); const results = await mapLimit(offsets,6, async off=>{ if(off===0){ const d=state.latest||await getLatest(base); return d?{date:d.date,rate:d[base]?.[target]}:null; } const d=await getDated(base, ymdMinus(ld,off)); return d && d[base]?.[target]!=null ? {date:d.date,rate:d[base][target]} : null; }); // dedupe by date + sort ascending const map=new Map(); results.filter(Boolean).forEach(p=>{ if(p.rate!=null) map.set(p.date,p.rate); }); state.series=[...map.entries()].map(([date,rate])=>({date,rate})).sort((a,b)=>a.datet.classList.remove("show"),7000); } function applyTheme(theme){ document.documentElement.setAttribute("data-theme",theme); $("themeIcon").textContent = theme==="dark"?"🌙":"☀️"; localStorage.setItem("fxpulse-theme",theme); // re-render charts so they pick up theme colors if(state.series.length){ renderLine(); renderCandles(); } } function fillSelect(sel,selected){ const codes=Object.keys(state.names).sort(); sel.innerHTML=codes.map(c=>``).join(""); } function bindEvents(){ $("baseSel").addEventListener("change",e=>{ state.base=e.target.value; refreshAll(); }); $("targetSel").addEventListener("change",e=>{ state.target=e.target.value; refreshSeries(); }); $("refreshBtn").addEventListener("click",refreshAll); $("themeBtn").addEventListener("click",()=>{ applyTheme(document.documentElement.getAttribute("data-theme")==="dark"?"light":"dark"); }); // range buttons $("rangeBtns").addEventListener("click",e=>{ const b=e.target.closest("button"); if(!b) return; state.range=+b.dataset.d; document.querySelectorAll("#rangeBtns button").forEach(x=>x.classList.toggle("active",x===b)); showLoading(true,"Loading "+state.range+"-day history…"); refreshSeries().finally(()=>showLoading(false)); }); // table search + sort $("tableSearch").addEventListener("input",renderTable); document.querySelectorAll("thead th[data-sort]").forEach(th=>{ th.addEventListener("click",()=>{ const k=th.dataset.sort; if(state.sort.key===k) state.sort.dir*=-1; else state.sort={key:k,dir:k==="code"||k==="name"?1:-1}; renderTable(); }); }); // calculator ["input","change"].forEach(ev=>{ $("calcAmount").addEventListener(ev,runCalc); $("calcFrom").addEventListener("change",runCalc); $("calcTo").addEventListener("change",runCalc); }); $("calcSwap").addEventListener("click",()=>{ const f=$("calcFrom").value; $("calcFrom").value=$("calcTo").value; $("calcTo").value=f; runCalc(); }); } async function init(){ // theme first (no flash) applyTheme(localStorage.getItem("fxpulse-theme")||"dark"); // currency names for selectors try{ state.names=await fetchJson(urlsNames()); } catch{ state.names={...FALLBACK_NAMES}; showError("Currency list endpoint failed — using a reduced fallback list."); } fillSelect($("baseSel"),state.base); fillSelect($("targetSel"),state.target); fillSelect($("calcFrom"),"usd"); fillSelect($("calcTo"),"eur"); bindEvents(); await refreshAll(); } // Wait for Chart.js (and financial plugin) then boot. window.addEventListener("DOMContentLoaded",init);