// Cloudflare Worker (single file) // // Required env: // ORIGIN_URL="https://.onrender.com/internal/webhook" // SIGNING_SECRET="..." (same as Flask) // SUPABASE_URL="https://.supabase.co" (or ".../rest/v1") // SUPABASE_SERVICE_ROLE_KEY="..." (SECRET) // // Optional: // MAX_SKEW_SECONDS="60" // WORKER_VERSION="2026-01-05-1" (any string, for /health) // // Routing/sizing env (supported): // DEFAULT_ROUTE="both" // hl|bybit|both|capital|all // DEFAULT_SPLIT="false" // DEFAULT_BYBIT_RATIO="0.5" // DEFAULT_CAPITAL_RATIO="0.5" // // Per-strategy: // ROUTE_="all" // SPLIT_="false" // BYBIT_RATIO_="0.5" // CAPITAL_RATIO_="0.5" // // Sizing (recommended): // DEFAULT_CRYPTO_FIXED_SIZE="100" // DEFAULT_STOCK_FIXED_SIZE="1000" // // Backward compatible: // DEFAULT_FIXED_SIZE="100" // fallback for crypto if DEFAULT_CRYPTO_FIXED_SIZE missing // DEFAULT_CAP_FIXED_SIZE="1000" // fallback for stock if DEFAULT_STOCK_FIXED_SIZE missing // CAP_SIZE_="1000" // SIZE_="100" export default { async fetch(request, env) { const url = new URL(request.url); // Normalize pathname: decode %0A and remove whitespace so we never get /webhook%0A let path = url.pathname || "/"; try { path = decodeURIComponent(path); } catch (_) {} path = path.replace(/\s+/g, ""); if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1); if (path === "/health") { return jsonResp( { ok: true, version: env.WORKER_VERSION || null, ts: new Date().toISOString(), }, 200 ); } if (path === "/exec") return handleExec(request, env); if (path === "/webhook" || path === "/") return handleWebhook(request, env); return jsonResp({ ok: false, error: "not_found", path }, 404); }, }; // -------------------- /webhook (TV -> Worker -> Flask) -------------------- async function handleWebhook(request, env) { const started = Date.now(); try { if (request.method !== "POST") { return jsonResp({ ok: false, error: "method_not_allowed" }, 405); } const rawIncoming = await request.text(); const trimmed = (rawIncoming || "").trim(); if (!trimmed) { return jsonResp({ ok: false, error: "empty_body" }, 400); } // Canonical JSON string forwarded to Flask + used for signal_uid. let payloadObj = null; let payloadJsonStr = ""; // Meta for DB (Metabase-friendly columns) let meta = { strategy_key: null, action: null, side: null, label: null, tv_symbol: null, timeframe: null, route: null, scope: null, group_alert: null, symbol: null, is_crypto: null, price: null, split: null, bybit_size_ratio: null, capital_size_ratio: null, fixed_quote_size: null, cap_fixed_quote_size: null, kv: null, }; // Backward compatible columns let dbTicker = null; let dbStrategy = null; let dbRoute = null; const parsedJsonAny = tryParseJsonAny(trimmed); if (parsedJsonAny !== null) { // Incoming JSON if (!parsedJsonAny || typeof parsedJsonAny !== "object" || Array.isArray(parsedJsonAny)) { return jsonResp({ ok: false, error: "json_must_be_object" }, 400); } let norm; try { norm = normalizeIncomingJsonAlert(parsedJsonAny, env); } catch (e) { return jsonResp({ ok: false, error: "invalid_json_alert", message: String(e) }, 400); } payloadObj = norm.payload; payloadJsonStr = JSON.stringify(payloadObj); meta.strategy_key = norm.meta.strategyKey || null; meta.action = payloadObj.action || null; meta.side = payloadObj.side || null; meta.route = payloadObj.route || null; meta.scope = norm.meta.scope || null; meta.group_alert = payloadObj.groupAlert === true; meta.tv_symbol = norm.meta.symbolTvForDb || null; meta.is_crypto = norm.meta.isCrypto === true; meta.symbol = payloadObj.symbol || null; meta.split = payloadObj.split === true; meta.bybit_size_ratio = isFiniteNumber(payloadObj.bybitSizeRatio) ? Number(payloadObj.bybitSizeRatio) : null; meta.capital_size_ratio = isFiniteNumber(payloadObj.capitalSizeRatio) ? Number(payloadObj.capitalSizeRatio) : null; meta.fixed_quote_size = isFiniteNumber(payloadObj.fixedQuoteSize) ? Number(payloadObj.fixedQuoteSize) : null; meta.cap_fixed_quote_size = isFiniteNumber(payloadObj.capFixedQuoteSize) ? Number(payloadObj.capFixedQuoteSize) : null; meta.timeframe = extractTimeframeFromJsonAlert(parsedJsonAny); meta.label = extractLabelFromJsonAlert(parsedJsonAny); meta.price = extractPriceFromJsonAlert(parsedJsonAny); meta.kv = extractKvFromJsonAlert(parsedJsonAny); dbTicker = cleanTickerForDb(meta.tv_symbol); dbStrategy = meta.strategy_key; dbRoute = meta.route; } else { // Legacy string: STRATEGY|ACTION|SIDE|LABEL|TV_SYMBOL|TF|PRICE|ROUTE|SCOPE(|k=v;...optional) const built = buildFromLegacyAlertString(trimmed, env); payloadObj = built.payload; payloadJsonStr = JSON.stringify(payloadObj); meta = built.meta; dbTicker = cleanTickerForDb(meta.tv_symbol); dbStrategy = meta.strategy_key; dbRoute = meta.route; } if (!payloadJsonStr || payloadObj == null) { return jsonResp({ ok: false, error: "payload_build_failed" }, 500); } // stable signal UID = sha256 of JSON forwarded to Flask (canonical string) const signalUid = await sha256Hex(payloadJsonStr); const nowIso = new Date().toISOString(); // Best-effort write to Supabase ledger.signals (DO NOT break trading) const symbolTvCompat = meta.tv_symbol || null; const symbolNormCompat = symbolTvCompat ? cleanTickerForDb(symbolTvCompat) : null; let coinCompat = null; if (meta.symbol != null && String(meta.symbol).trim() !== "") { coinCompat = String(meta.symbol).trim().split("-", 1)[0]; } else if (symbolNormCompat) { coinCompat = symbolNormCompat.replace(/(USDT|USDC|USD)$/i, ""); } const signalsRow = { signal_uid: signalUid, tv_time_utc: nowIso, received_at: nowIso, payload_raw: payloadJsonStr, strategy_key: dbStrategy, action: meta.action, side: meta.side, routing_code: dbRoute, ticker: dbTicker, symbol_tv: symbolTvCompat, coin: coinCompat, symbol_norm: symbolNormCompat, label: meta.label, tv_symbol: meta.tv_symbol, timeframe: meta.timeframe, route: meta.route, scope: meta.scope, group_alert: meta.group_alert, symbol: meta.symbol, is_crypto: meta.is_crypto, price: isFiniteNumber(meta.price) ? Number(meta.price) : null, split: meta.split === true, bybit_size_ratio: isFiniteNumber(meta.bybit_size_ratio) ? Number(meta.bybit_size_ratio) : null, capital_size_ratio: isFiniteNumber(meta.capital_size_ratio) ? Number(meta.capital_size_ratio) : null, fixed_quote_size: isFiniteNumber(meta.fixed_quote_size) ? Number(meta.fixed_quote_size) : null, cap_fixed_quote_size: isFiniteNumber(meta.cap_fixed_quote_size) ? Number(meta.cap_fixed_quote_size) : null, kv: meta.kv, payload_json: payloadObj, payload_text: payloadJsonStr, }; let ledgerSignalOk = false; let ledgerSignalErr = ""; try { await supabaseUpsertSmart(env, "signals", signalsRow, "signal_uid"); ledgerSignalOk = true; } catch (e) { ledgerSignalErr = String(e).slice(0, 200); console.log("ledger_signals_upsert_failed:", String(e)); } // Forward to Flask with signature + x-signal-uid const origin = String(env.ORIGIN_URL || "").trim(); if (!origin) return jsonResp({ ok: false, error: "ORIGIN_URL_not_set" }, 500); const ts = Math.floor(Date.now() / 1000).toString(); const signature = await hmacSha256Hex(env.SIGNING_SECRET, `${ts}\n${payloadJsonStr}`); const res = await fetch(origin, { method: "POST", headers: { "content-type": "application/json", "x-timestamp": ts, "x-signature": `sha256=${signature}`, "x-signal-uid": signalUid, }, body: payloadJsonStr, }); const text = await res.text(); console.log("tv_signal_uid:", signalUid); console.log("origin_status:", res.status); console.log("took_ms:", Date.now() - started); const hdrs = new Headers(); hdrs.set("content-type", res.headers.get("content-type") || "application/json"); hdrs.set("x-signal-uid", signalUid); hdrs.set("x-ledger-signal-ok", ledgerSignalOk ? "1" : "0"); if (!ledgerSignalOk && ledgerSignalErr) hdrs.set("x-ledger-signal-err", ledgerSignalErr); return new Response(text, { status: res.status, headers: hdrs }); } catch (e) { console.log("webhook_failed:", String(e)); return jsonResp({ ok: false, error: "webhook_failed", message: String(e) }, 502); } } // -------------------- /exec (Flask -> Worker -> Supabase) -------------------- async function handleExec(request, env) { try { if (request.method !== "POST") { return jsonResp({ ok: false, error: "method_not_allowed" }, 405); } const rawBody = await request.text(); if (rawBody == null || rawBody === "") { return jsonResp({ ok: false, error: "empty_body" }, 400); } await requireSignatureAsync(env, request, rawBody); let ev; try { ev = JSON.parse(rawBody); } catch { return jsonResp({ ok: false, error: "invalid_json" }, 400); } if (!ev || typeof ev !== "object" || Array.isArray(ev)) { return jsonResp({ ok: false, error: "json_must_be_object" }, 400); } const exchange = String(ev.exchange || "").trim().toLowerCase(); const eventType = String(ev.event_type || ev.eventType || "").trim().toLowerCase(); const market = String(ev.market || "").trim().toUpperCase(); const side = ev.side != null ? String(ev.side).trim().toUpperCase() : null; if (!exchange || !eventType || !market) { return jsonResp( { ok: false, error: "missing_required_fields", need: ["exchange", "event_type", "market"] }, 400 ); } const execUid = ev.exec_uid != null && String(ev.exec_uid).trim() !== "" ? String(ev.exec_uid).trim() : await sha256Hex( JSON.stringify({ exchange, eventType, signal_uid: ev.signal_uid || null, position_uid: ev.position_uid || null, strategy_key: ev.strategy_key || null, market, side: side || null, qty: ev.qty || null, price: ev.price || null, event_time_utc: ev.event_time_utc || null, }) ); const eventUid = ev.event_uid != null && String(ev.event_uid).trim() !== "" ? String(ev.event_uid).trim() : execUid; const nowIso = new Date().toISOString(); const row = { event_uid: eventUid, exec_uid: execUid, exchange, event_type: eventType, event_time_utc: ev.event_time_utc || nowIso, signal_uid: ev.signal_uid || null, position_uid: ev.position_uid || null, strategy_key: ev.strategy_key || null, market, side, qty: isFiniteNumber(ev.qty) ? Number(ev.qty) : null, price: isFiniteNumber(ev.price) ? Number(ev.price) : null, fee: isFiniteNumber(ev.fee) ? Number(ev.fee) : null, pnl: isFiniteNumber(ev.pnl) ? Number(ev.pnl) : isFiniteNumber(ev.pnl_realized_net) ? Number(ev.pnl_realized_net) : null, payload_raw: rawBody, received_at: nowIso, payload_json: ev, payload_text: rawBody, }; try { await supabaseUpsertSmart(env, "exec_events", row, "event_uid"); } catch (e1) { console.log("exec_upsert_event_uid_failed:", String(e1)); try { await supabaseUpsertSmart(env, "exec_events", row, "exec_uid"); } catch (e2) { console.log("exec_upsert_exec_uid_failed_fallback_to_insert:", String(e2)); await supabaseInsertSmart(env, "exec_events", row); } } return jsonResp({ ok: true, event_uid: eventUid, exec_uid: execUid }, 200); } catch (e) { console.log("exec_failed:", String(e)); return jsonResp({ ok: false, error: "exec_failed", message: String(e) }, 200); } } // -------------------- JSON normalize for TV alert() payload -------------------- function normalizeIncomingJsonAlert(obj, env) { const action = String(obj.action || "").trim().toUpperCase(); const side = String(obj.side || "").trim().toUpperCase(); if (!action || !side) throw new Error("missing action/side"); const label = extractLabelFromJsonAlert(obj); const labelU = label ? String(label).trim().toUpperCase() : ""; const strategyRaw = obj.strategy ?? obj.strategy_key ?? obj.strategyKey ?? obj.strategyKeyRaw ?? null; const strategyKey = strategyRaw != null && String(strategyRaw).trim() !== "" ? normalizeStrategyKey(String(strategyRaw)) : null; const symbolTv = obj.symbol_tv ?? obj.tvSymbol ?? obj.symbolTv ?? obj.ticker ?? obj.ticker_tv ?? null; const fallbackSymbol = obj.symbol ?? null; const symbolTvFinal = symbolTv != null && String(symbolTv).trim() !== "" ? String(symbolTv).trim() : (fallbackSymbol != null && String(fallbackSymbol).trim() !== "" ? String(fallbackSymbol).trim() : ""); if (!symbolTvFinal) throw new Error("missing symbol_tv/tvSymbol/symbol"); const cryptoFlag = isCryptoTvSymbol(symbolTvFinal); const routeRaw = obj.route ?? obj.exchange ?? obj.routing ?? "auto"; let route = String(routeRaw || "auto").trim().toLowerCase(); if (!route) route = "auto"; if (route === "auto") route = resolveAutoRoute(strategyKey, env); if (!["hl", "bybit", "both", "capital", "all"].includes(route)) { route = resolveAutoRoute(strategyKey, env); if (!["hl", "bybit", "both", "capital", "all"].includes(route)) route = "both"; } if (!cryptoFlag && (route === "hl" || route === "bybit" || route === "both")) { route = "capital"; } const scopeRaw = obj.scope ?? (obj.groupAlert === true ? "watchlist" : "individual"); const scope = normalizeScope(scopeRaw); const groupAlert = scope === "watchlist"; const symbol = obj.symbol != null && String(obj.symbol).trim() !== "" && (obj.symbol_tv == null && obj.tvSymbol == null && obj.symbolTv == null) ? String(obj.symbol).trim() : (cryptoFlag ? mapTvSymbolToUnifiedSymbol(symbolTvFinal) : mapTvSymbolToCapitalSymbol(symbolTvFinal)); const split = obj.split != null ? parseBool(obj.split, false) : resolveSplit(strategyKey, env); const bybitSizeRatio = obj.bybitSizeRatio != null ? clamp01(obj.bybitSizeRatio) : resolveBybitRatio(strategyKey, env); const capitalSizeRatio = obj.capitalSizeRatio != null ? clamp01(obj.capitalSizeRatio) : resolveCapitalRatio(strategyKey, env); const cryptoDefault = safeNumber(env.DEFAULT_CRYPTO_FIXED_SIZE ?? env.DEFAULT_FIXED_SIZE, 10); const stockDefault = safeNumber(env.DEFAULT_STOCK_FIXED_SIZE ?? env.DEFAULT_CAP_FIXED_SIZE, 1000); const fixedQuoteSize = obj.fixedQuoteSize != null ? safeNumber(obj.fixedQuoteSize, cryptoDefault) : resolveCryptoSize(strategyKey, env, cryptoDefault); const capFixedQuoteSize = obj.capFixedQuoteSize != null ? safeNumber(obj.capFixedQuoteSize, stockDefault) : resolveStockSize(strategyKey, env, stockDefault); const payload = { action, side, symbol, route, useFixedSize: obj.useFixedSize != null ? parseBool(obj.useFixedSize, true) : true, fixedQuoteSize, split, bybitSizeRatio, groupAlert, }; if (strategyKey) payload.strategy = strategyKey; if (label) payload.label = label; if (action === "OPEN") { payload.open_kind = labelU.endsWith("_RESCUE") ? "scale_in" : "entry"; } else if (action === "CLOSE") { const isRescueExit = labelU.endsWith("_EXIT_BE") || labelU.endsWith("_EXIT_HARDSTOP") || labelU.endsWith("_EXIT_TIMEOUT"); payload.close_kind = isRescueExit ? "close_all" : "close"; } if (route === "capital" || route === "all") payload.capFixedQuoteSize = capFixedQuoteSize; if (route === "all") payload.capitalSizeRatio = capitalSizeRatio; // ── forward price from alert to Flask (needed for tier=TEST paper trading) ── const alertPrice = extractPriceFromJsonAlert(obj); if (alertPrice != null) payload.price = alertPrice; // ── CHANGED: added "tier" to passthrough ── const passthroughAllow = ["targets", "strategyParams", "meta", "signalId", "symbol_tv", "tvSymbol", "tier"]; for (const k of passthroughAllow) { if (obj[k] != null && payload[k] == null) payload[k] = obj[k]; } // ── NEW: extract tier from kv bag if not already in payload ── if (!payload.tier) { const kvObj = extractKvFromJsonAlert(obj); if (kvObj && kvObj.tier != null) { payload.tier = String(kvObj.tier).trim().toUpperCase(); } } return { payload, meta: { strategyKey, route, scope, symbolTvForDb: symbolTvFinal, isCrypto: cryptoFlag, }, }; } // -------------------- Legacy string parsing (9 fields + optional kv bag) -------------------- function buildFromLegacyAlertString(text, env) { const parsed = parseLegacyAlertString(text); const strategyKey = normalizeStrategyKey(parsed.strategyKeyRaw); const cryptoFlag = isCryptoTvSymbol(parsed.tvSymbol); let route = normalizeRoute(parsed.routeRaw); if (!route || route === "auto") route = resolveAutoRoute(strategyKey, env); if (!["hl", "bybit", "both", "capital", "all"].includes(route)) { route = resolveAutoRoute(strategyKey, env); if (!["hl", "bybit", "both", "capital", "all"].includes(route)) route = "both"; } if (!cryptoFlag && (route === "hl" || route === "bybit" || route === "both")) route = "capital"; const scope = normalizeScope(parsed.scopeRaw); const groupAlert = scope === "watchlist"; const cryptoDefault = safeNumber(env.DEFAULT_CRYPTO_FIXED_SIZE ?? env.DEFAULT_FIXED_SIZE, 10); const stockDefault = safeNumber(env.DEFAULT_STOCK_FIXED_SIZE ?? env.DEFAULT_CAP_FIXED_SIZE, 1000); const fixedQuoteSize = resolveCryptoSize(strategyKey, env, cryptoDefault); const capFixedQuoteSize = resolveStockSize(strategyKey, env, stockDefault); const split = resolveSplit(strategyKey, env); const bybitSizeRatio = resolveBybitRatio(strategyKey, env); const capitalSizeRatio = resolveCapitalRatio(strategyKey, env); const unifiedSymbol = cryptoFlag ? mapTvSymbolToUnifiedSymbol(parsed.tvSymbol) : mapTvSymbolToCapitalSymbol(parsed.tvSymbol); const label = parsed.label ? String(parsed.label).trim() : null; const labelU = label ? label.toUpperCase() : ""; const payload = { action: parsed.action, strategy: strategyKey, symbol: unifiedSymbol, side: parsed.side, route, useFixedSize: true, fixedQuoteSize, split, bybitSizeRatio, groupAlert, }; if (label) payload.label = label; if (parsed.action === "OPEN") { payload.open_kind = labelU.endsWith("_RESCUE") ? "scale_in" : "entry"; } else if (parsed.action === "CLOSE") { const isRescueExit = labelU.endsWith("_EXIT_BE") || labelU.endsWith("_EXIT_HARDSTOP") || labelU.endsWith("_EXIT_TIMEOUT"); payload.close_kind = isRescueExit ? "close_all" : "close"; } if (route === "capital" || route === "all") payload.capFixedQuoteSize = capFixedQuoteSize; if (route === "all") payload.capitalSizeRatio = capitalSizeRatio; // ── forward price from alert to Flask (needed for tier=TEST paper trading) ── if (parsed.price != null) payload.price = parsed.price; // ── NEW: extract tier from kv bag and forward to Flask ── if (parsed.kv && parsed.kv.tier != null) { payload.tier = String(parsed.kv.tier).trim().toUpperCase(); } const meta = { strategy_key: strategyKey, action: parsed.action, side: parsed.side, label: parsed.label || null, tv_symbol: parsed.tvSymbol || null, timeframe: parsed.timeframe || null, route, scope, group_alert: groupAlert, symbol: unifiedSymbol, is_crypto: cryptoFlag, price: isFiniteNumber(parsed.price) ? Number(parsed.price) : null, split: split === true, bybit_size_ratio: isFiniteNumber(bybitSizeRatio) ? Number(bybitSizeRatio) : null, capital_size_ratio: route === "all" && isFiniteNumber(capitalSizeRatio) ? Number(capitalSizeRatio) : null, fixed_quote_size: isFiniteNumber(fixedQuoteSize) ? Number(fixedQuoteSize) : null, cap_fixed_quote_size: (route === "capital" || route === "all") && isFiniteNumber(capFixedQuoteSize) ? Number(capFixedQuoteSize) : null, kv: parsed.kv, }; return { payload, meta }; } function parseLegacyAlertString(text) { const parts = String(text).split("|"); if (parts.length < 5) { throw new Error(`invalid_alert_string: expected >=5 parts, got ${parts.length}`); } const strategyKeyRaw = String(parts[0] || "").trim(); const action = String(parts[1] || "").trim().toUpperCase(); const side = String(parts[2] || "").trim().toUpperCase(); const label = parts.length >= 4 ? String(parts[3] || "").trim() : ""; const tvSymbol = String(parts[4] || "").trim(); if (!strategyKeyRaw || !action || !side || !tvSymbol) { throw new Error("invalid_alert_string: missing required (strategy/action/side/tv_symbol)"); } const timeframe = parts.length >= 6 ? String(parts[5] || "").trim().toUpperCase() : ""; const priceStr = parts.length >= 7 ? String(parts[6] || "").trim() : ""; const routeRaw = parts.length >= 8 ? String(parts[7] || "").trim() : ""; const scopeRaw = parts.length >= 9 ? String(parts[8] || "").trim() : ""; const kvBagRaw = parts.length >= 10 ? parts.slice(9).join("|").trim() : ""; const kv = kvBagRaw ? parseKvBag(kvBagRaw) : null; const price = parseNumberOrNull(priceStr); return { strategyKeyRaw, action, side, label: label || null, tvSymbol, timeframe: timeframe || null, price, routeRaw: routeRaw || null, scopeRaw: scopeRaw || null, kv, }; } function parseKvBag(bagText) { const s = String(bagText || "").trim(); if (!s) return null; const out = {}; const chunks = s.split(";"); for (const chunk of chunks) { const c = String(chunk || "").trim(); if (!c) continue; const eqIdx = c.indexOf("="); if (eqIdx === -1) { const k = normalizeKvKey(c); if (k) out[k] = true; continue; } const kRaw = c.slice(0, eqIdx).trim(); const vRaw = c.slice(eqIdx + 1).trim(); const k = normalizeKvKey(kRaw); if (!k) continue; out[k] = coerceKvValue(vRaw); } return Object.keys(out).length ? out : null; } function normalizeKvKey(k) { const s = String(k || "").trim(); if (!s) return ""; return s .replace(/\s+/g, "_") .replace(/[^a-zA-Z0-9_]+/g, "_") .replace(/^_+|_+$/g, "") .toLowerCase(); } function coerceKvValue(v) { const s = String(v || "").trim(); if (s === "") return ""; const low = s.toLowerCase(); if (low === "true") return true; if (low === "false") return false; if (low === "null") return null; const n = Number(s); if (Number.isFinite(n) && String(n) === String(Number(s))) return n; return s; } function parseNumberOrNull(x) { if (x == null) return null; const s = String(x).trim(); if (!s) return null; const n = Number(s); return Number.isFinite(n) ? n : null; } function normalizeRoute(routeRaw) { const r = String(routeRaw || "").trim().toLowerCase(); return r || ""; } function normalizeScope(scopeRaw) { const s = String(scopeRaw || "").trim().toLowerCase(); if (!s) return "individual"; if (s === "watchlist" || s === "watch" || s === "group") return "watchlist"; if (s === "individual" || s === "single") return "individual"; return "individual"; } // -------------------- Extractors for JSON alerts -------------------- function extractTimeframeFromJsonAlert(obj) { const keys = ["tf", "timeframe", "timeframe_period", "timeframePeriod", "interval"]; for (const k of keys) { if (obj != null && obj[k] != null) { const v = String(obj[k]).trim(); if (v) return v.toUpperCase(); } } return null; } function extractLabelFromJsonAlert(obj) { const keys = ["label", "sigLabel", "signal_label", "signalLabel"]; for (const k of keys) { if (obj != null && obj[k] != null) { const v = String(obj[k]).trim(); if (v) return v; } } return null; } function extractPriceFromJsonAlert(obj) { const keys = ["price", "close", "entry_price", "entryPrice"]; for (const k of keys) { if (obj != null && obj[k] != null) { const n = Number(obj[k]); if (Number.isFinite(n)) return n; const s = String(obj[k]).trim(); const n2 = Number(s); if (Number.isFinite(n2)) return n2; } } return null; } function extractKvFromJsonAlert(obj) { if (!obj || typeof obj !== "object") return null; const raw = obj.kv ?? obj.kv_bag ?? obj.kvBag ?? obj.tags ?? null; if (raw == null) return null; if (typeof raw === "object" && !Array.isArray(raw)) return raw; const s = String(raw).trim(); if (!s) return null; return parseKvBag(s); } // -------------------- Routing/sizing resolvers -------------------- function resolveAutoRoute(strategyKey, env) { const def = String(env.DEFAULT_ROUTE ?? "both").trim().toLowerCase() || "both"; if (!strategyKey) return def; const routeEnvName = `ROUTE_${strategyKey}`; const raw = env[routeEnvName]; const r = raw != null && String(raw).trim() !== "" ? String(raw).trim().toLowerCase() : def; return r; } function resolveSplit(strategyKey, env) { const defRaw = env.DEFAULT_SPLIT ?? "false"; if (!strategyKey) return parseBool(defRaw, false); const k = `SPLIT_${strategyKey}`; const raw = env[k] != null ? env[k] : defRaw; return parseBool(raw, false); } function resolveBybitRatio(strategyKey, env) { const def = env.DEFAULT_BYBIT_RATIO ?? "0.5"; if (!strategyKey) return clamp01(def); const k = `BYBIT_RATIO_${strategyKey}`; const raw = env[k] != null ? env[k] : def; return clamp01(raw); } function resolveCapitalRatio(strategyKey, env) { const def = env.DEFAULT_CAPITAL_RATIO ?? "0.5"; if (!strategyKey) return clamp01(def); const k = `CAPITAL_RATIO_${strategyKey}`; const raw = env[k] != null ? env[k] : def; return clamp01(raw); } function resolveCryptoSize(strategyKey, env, fallback) { if (!strategyKey) return fallback; const k = `SIZE_${strategyKey}`; const raw = env[k]; if (raw == null || String(raw).trim() === "") return fallback; return safeNumber(raw, fallback); } function resolveStockSize(strategyKey, env, fallback) { if (!strategyKey) return fallback; const k = `CAP_SIZE_${strategyKey}`; const raw = env[k]; if (raw == null || String(raw).trim() === "") return fallback; return safeNumber(raw, fallback); } // -------------------- Supabase (schema=ledger) -------------------- function supabaseRestBase(env) { let base = String(env.SUPABASE_URL || "").trim(); if (!base) throw new Error("SUPABASE_URL_not_set"); base = base.replace(/\/+$/, ""); if (base.endsWith("/rest/v1")) return base; return `${base}/rest/v1`; } function supabaseKey(env) { const key = String(env.SUPABASE_SERVICE_ROLE_KEY || "").trim(); if (!key) throw new Error("SUPABASE_SERVICE_ROLE_KEY_not_set"); return key; } async function supabaseInsertSmart(env, table, row, maxTries = 10) { const base = supabaseRestBase(env); const key = supabaseKey(env); let cur = { ...row }; for (let i = 0; i < maxTries; i++) { const url = `${base}/${table}`; const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json", apikey: key, Authorization: `Bearer ${key}`, "Content-Profile": "ledger", "Accept-Profile": "ledger", Prefer: "return=representation", }, body: JSON.stringify([cur]), }); const text = await res.text(); if (!res.ok && res.status === 409) { const isDuplicate = text.includes("duplicate key value violates unique constraint") || text.includes('"code":"23505"') || text.includes("'23505'"); if (isDuplicate) return []; } if (res.ok) return text ? safeJson(text) : []; if (tryDropUnknownColumn(cur, text)) continue; throw new Error(`supabase_insert_${res.status}:${text}`); } throw new Error("supabase_insert_failed_after_retries"); } async function supabaseUpsertSmart(env, table, row, onConflict, maxTries = 10) { const base = supabaseRestBase(env); const key = supabaseKey(env); let cur = { ...row }; const conflict = String(onConflict || "").trim(); if (!conflict) throw new Error("on_conflict_empty"); for (let i = 0; i < maxTries; i++) { const url = `${base}/${table}?on_conflict=${encodeURIComponent(conflict)}`; const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json", apikey: key, Authorization: `Bearer ${key}`, "Content-Profile": "ledger", "Accept-Profile": "ledger", Prefer: "resolution=merge-duplicates,return=representation", }, body: JSON.stringify([cur]), }); const text = await res.text(); if (res.ok) return text ? safeJson(text) : []; if (tryDropUnknownColumn(cur, text)) continue; throw new Error(`supabase_upsert_${res.status}:${text}`); } throw new Error("supabase_upsert_failed_after_retries"); } function tryDropUnknownColumn(obj, errorText) { const t = String(errorText || ""); const patterns = [ /Could not find the '([^']+)' column/, /column \"([^\"]+)\" of relation/, /column \"([^\"]+)\" does not exist/, /unknown field \"([^\"]+)\"/, ]; let col = null; for (const p of patterns) { const m = t.match(p); if (m && m[1]) { col = m[1]; break; } } if (col && Object.prototype.hasOwnProperty.call(obj, col)) { delete obj[col]; return true; } return false; } function safeJson(t) { try { return JSON.parse(t); } catch { return t; } } // -------------------- Signature -------------------- async function requireSignatureAsync(env, request, bodyTextExact) { const secret = String(env.SIGNING_SECRET || "").trim(); if (!secret) throw new Error("SIGNING_SECRET_not_set"); const ts = request.headers.get("x-timestamp"); const sig = request.headers.get("x-signature"); if (!ts || !sig) throw new Error("missing_signature_headers"); const sigHex = sig.startsWith("sha256=") ? sig.split("=", 2)[1].trim() : sig.trim(); const tsInt = Number(ts); if (!Number.isFinite(tsInt)) throw new Error("bad_timestamp"); const now = Math.floor(Date.now() / 1000); const maxSkew = Number(env.MAX_SKEW_SECONDS || 60); if (Math.abs(now - tsInt) > maxSkew) throw new Error("timestamp_skew"); const expected = await hmacSha256Hex(secret, `${ts}\n${String(bodyTextExact ?? "")}`); if (expected !== sigHex) throw new Error("bad_signature"); } // -------------------- utils -------------------- function jsonResp(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" }, }); } function tryParseJsonAny(text) { if (!text) return null; const first = text[0]; if ( first !== "{" && first !== "[" && first !== '"' && first !== "t" && first !== "f" && first !== "n" && first !== "-" && (first < "0" || first > "9") ) { return null; } try { return JSON.parse(text); } catch { return null; } } function isFiniteNumber(x) { const n = Number(x); return Number.isFinite(n); } async function sha256Hex(text) { const enc = new TextEncoder(); const buf = await crypto.subtle.digest("SHA-256", enc.encode(String(text))); return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); } async function hmacSha256Hex(secret, data) { const enc = new TextEncoder(); const key = await crypto.subtle.importKey( "raw", enc.encode(String(secret || "")), { name: "HMAC", hash: "SHA-256" }, false, ["sign"] ); const sig = await crypto.subtle.sign("HMAC", key, enc.encode(String(data || ""))); return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); } function cleanTickerForDb(sym) { if (sym == null) return null; let s = String(sym).trim().toUpperCase(); if (!s) return null; const c = s.indexOf(":"); if (c !== -1) s = s.slice(c + 1); if (s.endsWith(".P")) s = s.slice(0, -2); if (s.endsWith(".PERP")) s = s.slice(0, -5); if (s.endsWith("-PERP")) s = s.slice(0, -5); if (s.endsWith("-PERPETUAL")) s = s.slice(0, -10); s = s.replace(/[\s\-_\/]/g, ""); return s || null; } // -------------------- Trading helpers -------------------- function normalizeStrategyKey(s) { return String(s || "") .trim() .toUpperCase() .replace(/[^A-Z0-9_]+/g, "_") .replace(/^_+|_+$/g, ""); } function stripTvPrefixAndPerpSuffix(tvSymbol) { let s = String(tvSymbol || "").trim(); const colonIdx = s.indexOf(":"); if (colonIdx !== -1) s = s.slice(colonIdx + 1); s = s.trim(); const up = s.toUpperCase(); if (up.endsWith(".P")) s = s.slice(0, -2); else if (up.endsWith(".PERP")) s = s.slice(0, -5); else if (up.endsWith("-PERP")) s = s.slice(0, -5); else if (up.endsWith("-PERPETUAL")) s = s.slice(0, -10); return s; } function isCryptoTvSymbol(tvSymbol) { const cleaned = stripTvPrefixAndPerpSuffix(tvSymbol); const s = cleaned.toUpperCase(); return s.includes("USDT") || s.includes("USDC") || s.endsWith(".P") || s.includes("PERP") || s.includes("SWAP"); } function mapTvSymbolToUnifiedSymbol(tvSymbol) { let s = String(tvSymbol).trim().toUpperCase(); const colonIdx = s.indexOf(":"); if (colonIdx !== -1) s = s.slice(colonIdx + 1); if (s.endsWith(".P")) s = s.slice(0, -2); if (s.endsWith(".PERP")) s = s.slice(0, -5); if (s.endsWith("-PERP")) s = s.slice(0, -5); if (s.endsWith("-PERPETUAL")) s = s.slice(0, -10); const dashQuotes = ["-USDT", "-USD", "-USDC"]; for (const q of dashQuotes) { if (s.endsWith(q)) { s = s.slice(0, -q.length); break; } } if (s.endsWith("/USDT")) s = s.slice(0, -5); else if (s.endsWith("/USD")) s = s.slice(0, -4); const plainQuotes = ["USDT", "USD", "USDC"]; for (const q of plainQuotes) { if (s.endsWith(q)) { s = s.slice(0, -q.length); break; } } const base = s; return `${base}-USDT`; } function mapTvSymbolToCapitalSymbol(tvSymbol) { let s = String(tvSymbol).trim().toUpperCase(); const colonIdx = s.indexOf(":"); if (colonIdx !== -1) s = s.slice(colonIdx + 1); if (s.endsWith(".P")) s = s.slice(0, -2); if (s.endsWith(".PERP")) s = s.slice(0, -5); if (s.endsWith("-PERP")) s = s.slice(0, -5); if (s.endsWith("-PERPETUAL")) s = s.slice(0, -10); s = s.replace(/\s+/g, ""); return s; } function safeNumber(value, def) { if (value === undefined || value === null) return def; const n = Number(value); return Number.isFinite(n) ? n : def; } function clamp01(x) { let n = Number(x); if (!Number.isFinite(n)) n = 0.5; if (n < 0) n = 0; if (n > 1) n = 1; return n; } function parseBool(value, def) { if (value === undefined || value === null) return def; const v = String(value).trim().toLowerCase(); if (["true", "1", "yes", "y"].includes(v)) return true; if (["false", "0", "no", "n"].includes(v)) return false; return def; }