export default { async fetch(request, env) { const url = new URL(request.url); if (request.method === "OPTIONS") { return new Response(null, { status: 204, headers: corsHeaders() }); } if (url.pathname === "/" || url.pathname === "/index.html") { return new Response(buildHtml(), { headers: { "content-type": "text/html; charset=utf-8", ...corsHeaders() } }); } if (url.pathname === "/chart/snapshot") { try { const symbol = normalizeSymbol(url.searchParams.get("symbol") || "XAUUSD"); const tf = String(url.searchParams.get("tf") || "M15").trim().toUpperCase(); const cfg = resolveTF(tf); if (!env.CANDLES_BASE_URL) { return json({ ok: false, error: "Missing CANDLES_BASE_URL" }, 500); } const candlesUrl = `${stripSlash(env.CANDLES_BASE_URL)}/candles` + `?symbol=${encodeURIComponent(symbol)}` + `&tf=${encodeURIComponent(cfg.source)}` + `&limit=${encodeURIComponent(String(cfg.limit))}`; const candlesRes = await fetch(candlesUrl, { method: "GET", headers: { accept: "application/json" }, cf: { cacheTtl: 0, cacheEverything: false } }); const candlesText = await candlesRes.text(); if (!candlesRes.ok) { return json({ ok: false, error: `candles upstream failed: ${candlesRes.status}`, raw: candlesText.slice(0, 500) }, 500); } let candlesPayload; try { candlesPayload = JSON.parse(candlesText); } catch { return json({ ok: false, error: "invalid candles JSON" }, 500); } let candles = normalizeCandles(candlesPayload?.candles || []); if (cfg.mode === "aggregate") { candles = aggregateCandlesByBucket(candles, cfg.bucketSeconds, cfg.minCount); } let signal = null; if (env.GUARD_BASE_URL) { try { const guardUrl = `${stripSlash(env.GUARD_BASE_URL)}/check?symbol=${encodeURIComponent(symbol)}`; const sigRes = await fetch(guardUrl, { method: "GET", headers: { accept: "application/json" }, cf: { cacheTtl: 0, cacheEverything: false } }); const sigText = await sigRes.text(); try { signal = JSON.parse(sigText); } catch { signal = null; } } catch { signal = null; } } const last = candles[candles.length - 1] || null; const prev = candles[candles.length - 2] || null; const change = last && prev ? round(last.close - prev.close, 4) : null; const changePct = last && prev && prev.close ? round(((last.close - prev.close) / prev.close) * 100, 4) : null; return json({ ok: true, symbol, tf, source_tf: cfg.source, aggregated: cfg.mode === "aggregate", bucket_seconds: cfg.bucketSeconds, count: candles.length, change, change_pct: changePct, last_close: last ? last.close : null, candles, signal }); } catch (err) { return json({ ok: false, error: err?.message || String(err) }, 500); } } return json({ ok: false, error: "Not found" }, 404); } }; function corsHeaders() { return { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,OPTIONS", "access-control-allow-headers": "Content-Type, Authorization" }; } function json(data, status = 200) { return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8", ...corsHeaders() } }); } function stripSlash(v) { return String(v || "").replace(/\/+$/, ""); } function normalizeSymbol(symbol) { const s = String(symbol || "").trim().toUpperCase(); if (s === "XAU") return "XAUUSD"; if (s === "XAG") return "XAGUSD"; return s || "XAUUSD"; } function resolveTF(tf) { const map = { M1: { source: "1m", mode: "raw", limit: 1200, bucketSeconds: 60, minCount: 1 }, M5: { source: "5m", mode: "raw", limit: 1200, bucketSeconds: 300, minCount: 1 }, M15: { source: "15m", mode: "raw", limit: 1200, bucketSeconds: 900, minCount: 1 }, M30: { source: "15m", mode: "aggregate", limit: 1200, bucketSeconds: 1800, minCount: 2 }, H1: { source: "1h", mode: "raw", limit: 1500, bucketSeconds: 3600, minCount: 1 }, H4: { source: "1h", mode: "aggregate", limit: 1500, bucketSeconds: 14400, minCount: 4 }, D1: { source: "1h", mode: "aggregate", limit: 2000, bucketSeconds: 86400, minCount: 24 }, W1: { source: "1h", mode: "aggregate", limit: 4000, bucketSeconds: 604800, minCount: 24 } }; return map[tf] || map.M15; } function toNum(v) { const n = Number(v); return Number.isFinite(n) ? n : NaN; } function round(v, d = 4) { const n = Number(v); if (!Number.isFinite(n)) return null; const p = 10 ** d; return Math.round(n * p) / p; } function normalizeCandles(rows) { return (Array.isArray(rows) ? rows : []) .map((c) => { const tms = Number(c.bucket ?? c.time ?? c.ts ?? 0); const open = toNum(c.open ?? c.o); const high = toNum(c.high ?? c.h); const low = toNum(c.low ?? c.l); const close = toNum(c.close ?? c.c); const volume = Number.isFinite(Number(c.volume ?? c.v)) ? Number(c.volume ?? c.v) : 0; if (!Number.isFinite(tms) || !Number.isFinite(open) || !Number.isFinite(high) || !Number.isFinite(low) || !Number.isFinite(close)) { return null; } return { time: Math.floor(tms / 1000), open, high, low, close, volume }; }) .filter(Boolean) .sort((a, b) => a.time - b.time); } function aggregateCandlesByBucket(candles, bucketSeconds, minCount) { const map = new Map(); for (const c of candles) { const key = Math.floor(c.time / bucketSeconds) * bucketSeconds; if (!map.has(key)) { map.set(key, { time: key, open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume || 0, count: 1 }); continue; } const row = map.get(key); row.high = Math.max(row.high, c.high); row.low = Math.min(row.low, c.low); row.close = c.close; row.volume += c.volume || 0; row.count += 1; } return Array.from(map.values()) .sort((a, b) => a.time - b.time) .filter(x => x.count >= minCount) .map(({ count, ...rest }) => rest); } function buildHtml() { return `