commit 1b9535a9cba6411909a7f6a093faa042a64739a5 Author: FrondEnt Date: Thu Jan 29 22:13:37 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f877646 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +logs/ +.env +.env.* +.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/README.md b/README.md new file mode 100644 index 0000000..85d8129 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Polymarket BTC 15m Assistant + +A real-time console trading assistant for Polymarket **"Bitcoin Up or Down" 15-minute** markets. + +It combines: +- Polymarket market selection + UP/DOWN prices + liquidity +- Polymarket live WS **Chainlink BTC/USD CURRENT PRICE** (same feed shown on the Polymarket UI) +- Fallback to on-chain Chainlink (Polygon) via HTTP/WSS RPC +- Binance spot price for reference +- Short-term TA snapshot (Heiken Ashi, RSI, MACD, VWAP, Delta 1/3m) +- A simple live **Predict (LONG/SHORT %)** derived from the assistant’s current TA scoring + +## Requirements + +- Node.js **18+** (recommended: latest LTS) +- npm (comes with Node) + +## Install + +```bash +npm install +``` + +## Configuration + +This project reads configuration from environment variables. + +You can set them in your shell, or create a `.env` file and load it using your preferred method. + +### Polymarket + +- `POLYMARKET_AUTO_SELECT_LATEST` (default: `true`) + - When `true`, automatically picks the latest 15m market. +- `POLYMARKET_SERIES_ID` (default: `10192`) +- `POLYMARKET_SERIES_SLUG` (default: `btc-up-or-down-15m`) +- `POLYMARKET_SLUG` (optional) + - If set, the assistant will target a specific market slug. +- `POLYMARKET_LIVE_WS_URL` (default: `wss://ws-live-data.polymarket.com`) + +### Chainlink on Polygon (fallback) + +- `CHAINLINK_BTC_USD_AGGREGATOR` + - Default: `0xc907E116054Ad103354f2D350FD2514433D57F6f` + +HTTP RPC: +- `POLYGON_RPC_URL` (default: `https://polygon-rpc.com`) +- `POLYGON_RPC_URLS` (optional, comma-separated) + - Example: `https://polygon-rpc.com,https://rpc.ankr.com/polygon` + +WSS RPC (optional but recommended for more real-time fallback): +- `POLYGON_WSS_URL` (optional) +- `POLYGON_WSS_URLS` (optional, comma-separated) + +## Run + +```bash +npm start +``` + +## Notes / Troubleshooting + +- If you see no Chainlink updates: + - Polymarket WS might be temporarily unavailable. The bot falls back to Chainlink on-chain price via Polygon RPC. + - Ensure at least one working Polygon RPC URL is configured. +- If the console looks like it “spams” lines: + - The renderer uses `readline.cursorTo` + `clearScreenDown` for a stable, static screen, but some terminals may still behave differently. + +## Safety + +This is not financial advice. Use at your own risk. + +created by @krajekis diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..954b78a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,143 @@ +{ + "name": "polyassistent", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "polyassistent", + "version": "0.1.0", + "dependencies": { + "ethers": "^6.11.1", + "ws": "^8.18.0" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cd2dde5 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "polyassistent", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "start": "node src/index.js" + }, + "dependencies": { + "ethers": "^6.11.1", + "ws": "^8.18.0" + } +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..0b10597 --- /dev/null +++ b/src/config.js @@ -0,0 +1,35 @@ +export const CONFIG = { + symbol: "BTCUSDT", + binanceBaseUrl: "https://api.binance.com", + gammaBaseUrl: "https://gamma-api.polymarket.com", + clobBaseUrl: "https://clob.polymarket.com", + + pollIntervalMs: 1_000, + candleWindowMinutes: 15, + + vwapSlopeLookbackMinutes: 5, + rsiPeriod: 14, + rsiMaPeriod: 14, + + macdFast: 12, + macdSlow: 26, + macdSignal: 9, + + polymarket: { + marketSlug: process.env.POLYMARKET_SLUG || "", + seriesId: process.env.POLYMARKET_SERIES_ID || "10192", + seriesSlug: process.env.POLYMARKET_SERIES_SLUG || "btc-up-or-down-15m", + autoSelectLatest: (process.env.POLYMARKET_AUTO_SELECT_LATEST || "true").toLowerCase() === "true", + liveDataWsUrl: process.env.POLYMARKET_LIVE_WS_URL || "wss://ws-live-data.polymarket.com", + upOutcomeLabel: process.env.POLYMARKET_UP_LABEL || "Up", + downOutcomeLabel: process.env.POLYMARKET_DOWN_LABEL || "Down" + }, + + chainlink: { + polygonRpcUrls: (process.env.POLYGON_RPC_URLS || "").split(",").map((s) => s.trim()).filter(Boolean), + polygonRpcUrl: process.env.POLYGON_RPC_URL || "https://polygon-rpc.com", + polygonWssUrls: (process.env.POLYGON_WSS_URLS || "").split(",").map((s) => s.trim()).filter(Boolean), + polygonWssUrl: process.env.POLYGON_WSS_URL || "", + btcUsdAggregator: process.env.CHAINLINK_BTC_USD_AGGREGATOR || "0xc907E116054Ad103354f2D350FD2514433D57F6f" + } +}; diff --git a/src/data/binance.js b/src/data/binance.js new file mode 100644 index 0000000..f8d5d74 --- /dev/null +++ b/src/data/binance.js @@ -0,0 +1,40 @@ +import { CONFIG } from "../config.js"; + +function toNumber(x) { + const n = Number(x); + return Number.isFinite(n) ? n : null; +} + +export async function fetchKlines({ interval, limit }) { + const url = new URL("/api/v3/klines", CONFIG.binanceBaseUrl); + url.searchParams.set("symbol", CONFIG.symbol); + url.searchParams.set("interval", interval); + url.searchParams.set("limit", String(limit)); + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Binance klines error: ${res.status} ${await res.text()}`); + } + const data = await res.json(); + + return data.map((k) => ({ + openTime: Number(k[0]), + open: toNumber(k[1]), + high: toNumber(k[2]), + low: toNumber(k[3]), + close: toNumber(k[4]), + volume: toNumber(k[5]), + closeTime: Number(k[6]) + })); +} + +export async function fetchLastPrice() { + const url = new URL("/api/v3/ticker/price", CONFIG.binanceBaseUrl); + url.searchParams.set("symbol", CONFIG.symbol); + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Binance last price error: ${res.status} ${await res.text()}`); + } + const data = await res.json(); + return toNumber(data.price); +} diff --git a/src/data/binanceWs.js b/src/data/binanceWs.js new file mode 100644 index 0000000..3aee0a9 --- /dev/null +++ b/src/data/binanceWs.js @@ -0,0 +1,77 @@ +import WebSocket from "ws"; +import { CONFIG } from "../config.js"; + +function toNumber(x) { + const n = Number(x); + return Number.isFinite(n) ? n : null; +} + +function buildWsUrl(symbol) { + const s = String(symbol || "").toLowerCase(); + return `wss://stream.binance.com:9443/ws/${s}@trade`; +} + +export function startBinanceTradeStream({ symbol = CONFIG.symbol, onUpdate } = {}) { + let ws = null; + let closed = false; + let reconnectMs = 500; + let lastPrice = null; + let lastTs = null; + + const connect = () => { + if (closed) return; + + const url = buildWsUrl(symbol); + ws = new WebSocket(url); + + ws.on("open", () => { + reconnectMs = 500; + }); + + ws.on("message", (buf) => { + try { + const msg = JSON.parse(buf.toString()); + const p = toNumber(msg.p); + if (p === null) return; + lastPrice = p; + lastTs = Date.now(); + if (typeof onUpdate === "function") onUpdate({ price: lastPrice, ts: lastTs }); + } catch { + return; + } + }); + + const scheduleReconnect = () => { + if (closed) return; + try { + ws?.terminate(); + } catch { + // ignore + } + ws = null; + const wait = reconnectMs; + reconnectMs = Math.min(10_000, Math.floor(reconnectMs * 1.5)); + setTimeout(connect, wait); + }; + + ws.on("close", scheduleReconnect); + ws.on("error", scheduleReconnect); + }; + + connect(); + + return { + getLast() { + return { price: lastPrice, ts: lastTs }; + }, + close() { + closed = true; + try { + ws?.close(); + } catch { + // ignore + } + ws = null; + } + }; +} diff --git a/src/data/chainlink.js b/src/data/chainlink.js new file mode 100644 index 0000000..6ab8fe1 --- /dev/null +++ b/src/data/chainlink.js @@ -0,0 +1,130 @@ +import { ethers } from "ethers"; +import { CONFIG } from "../config.js"; + +const AGGREGATOR_ABI = [ + "function latestRoundData() view returns (uint80 roundId,int256 answer,uint256 startedAt,uint256 updatedAt,uint80 answeredInRound)", + "function decimals() view returns (uint8)" +]; + +const iface = new ethers.Interface(AGGREGATOR_ABI); + +let preferredRpcUrl = null; + +let cachedDecimals = null; +let cachedResult = { price: null, updatedAt: null, source: "chainlink" }; +let cachedFetchedAtMs = 0; +const MIN_FETCH_INTERVAL_MS = 2_000; +const RPC_TIMEOUT_MS = 1_500; + +function getRpcCandidates() { + const fromList = Array.isArray(CONFIG.chainlink.polygonRpcUrls) ? CONFIG.chainlink.polygonRpcUrls : []; + const single = CONFIG.chainlink.polygonRpcUrl ? [CONFIG.chainlink.polygonRpcUrl] : []; + const defaults = [ + "https://polygon-rpc.com", + "https://rpc.ankr.com/polygon", + "https://polygon.llamarpc.com" + ]; + + const all = [...fromList, ...single, ...defaults].map((s) => String(s).trim()).filter(Boolean); + return Array.from(new Set(all)); +} + +function getOrderedRpcs() { + const rpcs = getRpcCandidates(); + const pref = preferredRpcUrl; + if (pref && rpcs.includes(pref)) { + return [pref, ...rpcs.filter((x) => x !== pref)]; + } + return rpcs; +} + +async function jsonRpcRequest(rpcUrl, method, params) { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); + + try { + const res = await fetch(rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + signal: controller.signal + }); + + if (!res.ok) { + throw new Error(`rpc_http_${res.status}`); + } + + const data = await res.json(); + if (data.error) { + throw new Error(`rpc_error_${data.error.code}`); + } + return data.result; + } finally { + clearTimeout(t); + } +} + +async function ethCall(rpcUrl, to, data) { + return await jsonRpcRequest(rpcUrl, "eth_call", [{ to, data }, "latest"]); +} + +async function fetchDecimals(rpcUrl, aggregator) { + const data = iface.encodeFunctionData("decimals", []); + const result = await ethCall(rpcUrl, aggregator, data); + const [dec] = iface.decodeFunctionResult("decimals", result); + return Number(dec); +} + +async function fetchLatestRoundData(rpcUrl, aggregator) { + const data = iface.encodeFunctionData("latestRoundData", []); + const result = await ethCall(rpcUrl, aggregator, data); + const decoded = iface.decodeFunctionResult("latestRoundData", result); + return { + answer: decoded[1], + updatedAt: decoded[3] + }; +} + +export async function fetchChainlinkBtcUsd() { + if ((!CONFIG.chainlink.polygonRpcUrl && (!CONFIG.chainlink.polygonRpcUrls || CONFIG.chainlink.polygonRpcUrls.length === 0)) || !CONFIG.chainlink.btcUsdAggregator) { + return { price: null, updatedAt: null, source: "missing_config" }; + } + + const now = Date.now(); + if (cachedFetchedAtMs && now - cachedFetchedAtMs < MIN_FETCH_INTERVAL_MS) { + return cachedResult; + } + + const rpcs = getOrderedRpcs(); + if (rpcs.length === 0) return { price: null, updatedAt: null, source: "missing_config" }; + + const aggregator = CONFIG.chainlink.btcUsdAggregator; + + for (const rpc of rpcs) { + preferredRpcUrl = rpc; + try { + if (cachedDecimals === null) { + cachedDecimals = await fetchDecimals(rpc, aggregator); + } + + const round = await fetchLatestRoundData(rpc, aggregator); + const answer = Number(round.answer); + const scale = 10 ** Number(cachedDecimals); + const price = answer / scale; + + cachedResult = { + price, + updatedAt: Number(round.updatedAt) * 1000, + source: "chainlink" + }; + cachedFetchedAtMs = now; + preferredRpcUrl = rpc; + return cachedResult; + } catch { + cachedDecimals = null; + continue; + } + } + + return cachedResult; +} diff --git a/src/data/chainlinkWs.js b/src/data/chainlinkWs.js new file mode 100644 index 0000000..4413c49 --- /dev/null +++ b/src/data/chainlinkWs.js @@ -0,0 +1,165 @@ +import WebSocket from "ws"; +import { ethers } from "ethers"; +import { CONFIG } from "../config.js"; + +const ANSWER_UPDATED_TOPIC0 = ethers.id("AnswerUpdated(int256,uint256,uint256)"); + +function getWssCandidates() { + const fromList = Array.isArray(CONFIG.chainlink.polygonWssUrls) ? CONFIG.chainlink.polygonWssUrls : []; + const single = CONFIG.chainlink.polygonWssUrl ? [CONFIG.chainlink.polygonWssUrl] : []; + const all = [...fromList, ...single].map((s) => String(s).trim()).filter(Boolean); + return Array.from(new Set(all)); +} + +function hexToSignedBigInt(hex) { + const x = ethers.toBigInt(hex); + const TWO_255 = 1n << 255n; + const TWO_256 = 1n << 256n; + return x >= TWO_255 ? x - TWO_256 : x; +} + +function toNumber(x) { + const n = Number(x); + return Number.isFinite(n) ? n : null; +} + +export function startChainlinkPriceStream({ + aggregator = CONFIG.chainlink.btcUsdAggregator, + decimals = 8, + onUpdate +} = {}) { + const wssUrls = getWssCandidates(); + if (!aggregator || wssUrls.length === 0) { + return { + getLast() { + return { price: null, updatedAt: null, source: "chainlink_ws" }; + }, + close() {} + }; + } + + let ws = null; + let closed = false; + let reconnectMs = 500; + let urlIndex = 0; + + let lastPrice = null; + let lastUpdatedAt = null; + + let nextId = 1; + let subId = null; + + const connect = () => { + if (closed) return; + + const url = wssUrls[urlIndex % wssUrls.length]; + urlIndex += 1; + + ws = new WebSocket(url); + + const send = (obj) => { + try { + ws?.send(JSON.stringify(obj)); + } catch { + // ignore + } + }; + + const scheduleReconnect = () => { + if (closed) return; + try { + ws?.terminate(); + } catch { + // ignore + } + ws = null; + subId = null; + const wait = reconnectMs; + reconnectMs = Math.min(10_000, Math.floor(reconnectMs * 1.5)); + setTimeout(connect, wait); + }; + + ws.on("open", () => { + reconnectMs = 500; + const id = nextId++; + send({ + jsonrpc: "2.0", + id, + method: "eth_subscribe", + params: [ + "logs", + { + address: aggregator, + topics: [ANSWER_UPDATED_TOPIC0] + } + ] + }); + }); + + ws.on("message", (buf) => { + let msg; + try { + msg = JSON.parse(buf.toString()); + } catch { + return; + } + + if (msg.id && msg.result && typeof msg.result === "string" && !subId) { + subId = msg.result; + return; + } + + if (msg.method !== "eth_subscription") return; + const params = msg.params; + if (!params || !params.result) return; + + const log = params.result; + const topics = Array.isArray(log.topics) ? log.topics : []; + if (topics.length < 2) return; + + try { + const answer = hexToSignedBigInt(topics[1]); + const price = toNumber(answer) / 10 ** Number(decimals); + const updatedAtHex = typeof log.data === "string" ? log.data : null; + const updatedAt = updatedAtHex ? toNumber(ethers.toBigInt(updatedAtHex)) : null; + + lastPrice = Number.isFinite(price) ? price : lastPrice; + lastUpdatedAt = updatedAt ? updatedAt * 1000 : lastUpdatedAt; + + if (typeof onUpdate === "function") { + onUpdate({ price: lastPrice, updatedAt: lastUpdatedAt, source: "chainlink_ws" }); + } + } catch { + return; + } + }); + + ws.on("close", scheduleReconnect); + ws.on("error", scheduleReconnect); + }; + + connect(); + + return { + getLast() { + return { price: lastPrice, updatedAt: lastUpdatedAt, source: "chainlink_ws" }; + }, + close() { + closed = true; + try { + if (ws && subId) { + ws.send(JSON.stringify({ jsonrpc: "2.0", id: nextId++, method: "eth_unsubscribe", params: [subId] })); + } + } catch { + // ignore + } + try { + ws?.close(); + } catch { + // ignore + } + ws = null; + subId = null; + } + }; +} diff --git a/src/data/polymarket.js b/src/data/polymarket.js new file mode 100644 index 0000000..d104afe --- /dev/null +++ b/src/data/polymarket.js @@ -0,0 +1,201 @@ +import { CONFIG } from "../config.js"; + +function toNumber(x) { + const n = Number(x); + return Number.isFinite(n) ? n : null; +} + +export async function fetchMarketBySlug(slug) { + const url = new URL("/markets", CONFIG.gammaBaseUrl); + url.searchParams.set("slug", slug); + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Gamma markets error: ${res.status} ${await res.text()}`); + } + + const data = await res.json(); + const market = Array.isArray(data) ? data[0] : data; + if (!market) return null; + + return market; +} + +export async function fetchMarketsBySeriesSlug({ seriesSlug, limit = 50 }) { + const url = new URL("/markets", CONFIG.gammaBaseUrl); + url.searchParams.set("seriesSlug", seriesSlug); + url.searchParams.set("active", "true"); + url.searchParams.set("closed", "false"); + url.searchParams.set("enableOrderBook", "true"); + url.searchParams.set("limit", String(limit)); + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Gamma markets(series) error: ${res.status} ${await res.text()}`); + } + + const data = await res.json(); + return Array.isArray(data) ? data : []; +} + +export async function fetchLiveEventsBySeriesId({ seriesId, limit = 20 }) { + const url = new URL("/events", CONFIG.gammaBaseUrl); + url.searchParams.set("series_id", String(seriesId)); + url.searchParams.set("active", "true"); + url.searchParams.set("closed", "false"); + url.searchParams.set("limit", String(limit)); + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Gamma events(series_id) error: ${res.status} ${await res.text()}`); + } + + const data = await res.json(); + return Array.isArray(data) ? data : []; +} + +export function flattenEventMarkets(events) { + const out = []; + for (const e of Array.isArray(events) ? events : []) { + const markets = Array.isArray(e.markets) ? e.markets : []; + for (const m of markets) { + out.push(m); + } + } + return out; +} + +export async function fetchActiveMarkets({ limit = 200, offset = 0 } = {}) { + const url = new URL("/markets", CONFIG.gammaBaseUrl); + url.searchParams.set("active", "true"); + url.searchParams.set("closed", "false"); + url.searchParams.set("enableOrderBook", "true"); + url.searchParams.set("limit", String(limit)); + url.searchParams.set("offset", String(offset)); + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Gamma markets(active) error: ${res.status} ${await res.text()}`); + } + const data = await res.json(); + return Array.isArray(data) ? data : []; +} + +function safeTimeMs(x) { + if (!x) return null; + const t = new Date(x).getTime(); + return Number.isFinite(t) ? t : null; +} + +export function pickLatestLiveMarket(markets, nowMs = Date.now()) { + if (!Array.isArray(markets) || markets.length === 0) return null; + + const enriched = markets + .map((m) => { + const endMs = safeTimeMs(m.endDate); + const startMs = safeTimeMs(m.eventStartTime ?? m.startTime ?? m.startDate); + return { m, endMs, startMs }; + }) + .filter((x) => x.endMs !== null); + + const live = enriched + .filter((x) => { + const started = x.startMs === null ? true : x.startMs <= nowMs; + return started && nowMs < x.endMs; + }) + .sort((a, b) => a.endMs - b.endMs); + + if (live.length) return live[0].m; + + const upcoming = enriched + .filter((x) => nowMs < x.endMs) + .sort((a, b) => a.endMs - b.endMs); + + return upcoming.length ? upcoming[0].m : null; +} + +function marketHasSeriesSlug(market, seriesSlug) { + if (!market || !seriesSlug) return false; + + const events = Array.isArray(market.events) ? market.events : []; + for (const e of events) { + const series = Array.isArray(e.series) ? e.series : []; + for (const s of series) { + if (String(s.slug ?? "").toLowerCase() === String(seriesSlug).toLowerCase()) return true; + } + if (String(e.seriesSlug ?? "").toLowerCase() === String(seriesSlug).toLowerCase()) return true; + } + if (String(market.seriesSlug ?? "").toLowerCase() === String(seriesSlug).toLowerCase()) return true; + return false; +} + +export function filterBtcUpDown15mMarkets(markets, { seriesSlug, slugPrefix } = {}) { + const prefix = (slugPrefix ?? "").toLowerCase(); + const wantedSeries = (seriesSlug ?? "").toLowerCase(); + + return (Array.isArray(markets) ? markets : []).filter((m) => { + const slug = String(m.slug ?? "").toLowerCase(); + const matchesPrefix = prefix ? slug.startsWith(prefix) : false; + const matchesSeries = wantedSeries ? marketHasSeriesSlug(m, wantedSeries) : false; + return matchesPrefix || matchesSeries; + }); +} + +export async function fetchClobPrice({ tokenId, side }) { + const url = new URL("/price", CONFIG.clobBaseUrl); + url.searchParams.set("token_id", tokenId); + url.searchParams.set("side", side); + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`CLOB price error: ${res.status} ${await res.text()}`); + } + const data = await res.json(); + return toNumber(data.price); +} + +export async function fetchOrderBook({ tokenId }) { + const url = new URL("/book", CONFIG.clobBaseUrl); + url.searchParams.set("token_id", tokenId); + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`CLOB book error: ${res.status} ${await res.text()}`); + } + return await res.json(); +} + +export function summarizeOrderBook(book, depthLevels = 5) { + const bids = Array.isArray(book?.bids) ? book.bids : []; + const asks = Array.isArray(book?.asks) ? book.asks : []; + + const bestBid = bids.length + ? bids.reduce((best, lvl) => { + const p = toNumber(lvl.price); + if (p === null) return best; + if (best === null) return p; + return Math.max(best, p); + }, null) + : null; + + const bestAsk = asks.length + ? asks.reduce((best, lvl) => { + const p = toNumber(lvl.price); + if (p === null) return best; + if (best === null) return p; + return Math.min(best, p); + }, null) + : null; + const spread = bestBid !== null && bestAsk !== null ? bestAsk - bestBid : null; + + const bidLiquidity = bids.slice(0, depthLevels).reduce((acc, x) => acc + (toNumber(x.size) ?? 0), 0); + const askLiquidity = asks.slice(0, depthLevels).reduce((acc, x) => acc + (toNumber(x.size) ?? 0), 0); + + return { + bestBid, + bestAsk, + spread, + bidLiquidity, + askLiquidity + }; +} diff --git a/src/data/polymarketLiveWs.js b/src/data/polymarketLiveWs.js new file mode 100644 index 0000000..02bfec0 --- /dev/null +++ b/src/data/polymarketLiveWs.js @@ -0,0 +1,127 @@ +import WebSocket from "ws"; +import { CONFIG } from "../config.js"; + +function safeJsonParse(s) { + try { + return JSON.parse(s); + } catch { + return null; + } +} + +function normalizePayload(payload) { + if (!payload) return null; + if (typeof payload === "object") return payload; + if (typeof payload === "string") return safeJsonParse(payload); + return null; +} + +function toFiniteNumber(x) { + const n = typeof x === "string" ? Number(x) : typeof x === "number" ? x : NaN; + return Number.isFinite(n) ? n : null; +} + +export function startPolymarketChainlinkPriceStream({ + wsUrl = CONFIG.polymarket.liveDataWsUrl, + symbolIncludes = "btc", + onUpdate +} = {}) { + if (!wsUrl) { + return { + getLast() { + return { price: null, updatedAt: null, source: "polymarket_ws" }; + }, + close() {} + }; + } + + let ws = null; + let closed = false; + let reconnectMs = 500; + + let lastPrice = null; + let lastUpdatedAt = null; + + const connect = () => { + if (closed) return; + + ws = new WebSocket(wsUrl, { + handshakeTimeout: 10_000 + }); + + const scheduleReconnect = () => { + if (closed) return; + try { + ws?.terminate(); + } catch { + // ignore + } + ws = null; + const wait = reconnectMs; + reconnectMs = Math.min(10_000, Math.floor(reconnectMs * 1.5)); + setTimeout(connect, wait); + }; + + ws.on("open", () => { + reconnectMs = 500; + try { + ws.send( + JSON.stringify({ + action: "subscribe", + subscriptions: [{ topic: "crypto_prices_chainlink", type: "*", filters: "" }] + }) + ); + } catch { + scheduleReconnect(); + } + }); + + ws.on("message", (buf) => { + const msg = typeof buf === "string" ? buf : buf?.toString?.() ?? ""; + if (!msg || !msg.trim()) return; + + const data = safeJsonParse(msg); + if (!data || data.topic !== "crypto_prices_chainlink") return; + + const payload = normalizePayload(data.payload) || {}; + const symbol = String(payload.symbol || payload.pair || payload.ticker || "").toLowerCase(); + if (symbolIncludes && !symbol.includes(String(symbolIncludes).toLowerCase())) return; + + const price = toFiniteNumber(payload.value ?? payload.price ?? payload.current ?? payload.data); + if (price === null) return; + + const updatedAtMs = toFiniteNumber(payload.timestamp) + ? Math.floor(Number(payload.timestamp) * 1000) + : toFiniteNumber(payload.updatedAt) + ? Math.floor(Number(payload.updatedAt) * 1000) + : null; + + lastPrice = price; + lastUpdatedAt = updatedAtMs ?? lastUpdatedAt; + + if (typeof onUpdate === "function") { + onUpdate({ price: lastPrice, updatedAt: lastUpdatedAt, source: "polymarket_ws" }); + } + }); + + ws.on("close", scheduleReconnect); + ws.on("error", scheduleReconnect); + }; + + connect(); + + return { + getLast() { + return { price: lastPrice, updatedAt: lastUpdatedAt, source: "polymarket_ws" }; + }, + close() { + closed = true; + try { + ws?.close(); + } catch { + // ignore + } + ws = null; + } + }; +} diff --git a/src/engines/edge.js b/src/engines/edge.js new file mode 100644 index 0000000..f9b30b6 --- /dev/null +++ b/src/engines/edge.js @@ -0,0 +1,48 @@ +import { clamp } from "../utils.js"; + +export function computeEdge({ modelUp, modelDown, marketYes, marketNo }) { + if (marketYes === null || marketNo === null) { + return { marketUp: null, marketDown: null, edgeUp: null, edgeDown: null }; + } + + const sum = marketYes + marketNo; + const marketUp = sum > 0 ? marketYes / sum : null; + const marketDown = sum > 0 ? marketNo / sum : null; + + const edgeUp = marketUp === null ? null : modelUp - marketUp; + const edgeDown = marketDown === null ? null : modelDown - marketDown; + + return { + marketUp: marketUp === null ? null : clamp(marketUp, 0, 1), + marketDown: marketDown === null ? null : clamp(marketDown, 0, 1), + edgeUp, + edgeDown + }; +} + +export function decide({ remainingMinutes, edgeUp, edgeDown, modelUp = null, modelDown = null }) { + const phase = remainingMinutes > 10 ? "EARLY" : remainingMinutes > 5 ? "MID" : "LATE"; + + const threshold = phase === "EARLY" ? 0.05 : phase === "MID" ? 0.1 : 0.2; + + const minProb = phase === "EARLY" ? 0.55 : phase === "MID" ? 0.6 : 0.65; + + if (edgeUp === null || edgeDown === null) { + return { action: "NO_TRADE", side: null, phase, reason: "missing_market_data" }; + } + + const bestSide = edgeUp > edgeDown ? "UP" : "DOWN"; + const bestEdge = bestSide === "UP" ? edgeUp : edgeDown; + const bestModel = bestSide === "UP" ? modelUp : modelDown; + + if (bestEdge < threshold) { + return { action: "NO_TRADE", side: null, phase, reason: `edge_below_${threshold}` }; + } + + if (bestModel !== null && bestModel < minProb) { + return { action: "NO_TRADE", side: null, phase, reason: `prob_below_${minProb}` }; + } + + const strength = bestEdge >= 0.2 ? "STRONG" : bestEdge >= 0.1 ? "GOOD" : "OPTIONAL"; + return { action: "ENTER", side: bestSide, phase, strength, edge: bestEdge }; +} diff --git a/src/engines/probability.js b/src/engines/probability.js new file mode 100644 index 0000000..3e72971 --- /dev/null +++ b/src/engines/probability.js @@ -0,0 +1,59 @@ +import { clamp } from "../utils.js"; + +export function scoreDirection(inputs) { + const { + price, + vwap, + vwapSlope, + rsi, + rsiSlope, + macd, + heikenColor, + heikenCount, + failedVwapReclaim + } = inputs; + + let up = 1; + let down = 1; + + if (price !== null && vwap !== null) { + if (price > vwap) up += 2; + if (price < vwap) down += 2; + } + + if (vwapSlope !== null) { + if (vwapSlope > 0) up += 2; + if (vwapSlope < 0) down += 2; + } + + if (rsi !== null && rsiSlope !== null) { + if (rsi > 55 && rsiSlope > 0) up += 2; + if (rsi < 45 && rsiSlope < 0) down += 2; + } + + if (macd?.hist !== null && macd?.histDelta !== null) { + const expandingGreen = macd.hist > 0 && macd.histDelta > 0; + const expandingRed = macd.hist < 0 && macd.histDelta < 0; + if (expandingGreen) up += 2; + if (expandingRed) down += 2; + + if (macd.macd > 0) up += 1; + if (macd.macd < 0) down += 1; + } + + if (heikenColor) { + if (heikenColor === "green" && heikenCount >= 2) up += 1; + if (heikenColor === "red" && heikenCount >= 2) down += 1; + } + + if (failedVwapReclaim === true) down += 3; + + const rawUp = up / (up + down); + return { upScore: up, downScore: down, rawUp }; +} + +export function applyTimeAwareness(rawUp, remainingMinutes, windowMinutes) { + const timeDecay = clamp(remainingMinutes / windowMinutes, 0, 1); + const adjustedUp = clamp(0.5 + (rawUp - 0.5) * timeDecay, 0, 1); + return { timeDecay, adjustedUp, adjustedDown: 1 - adjustedUp }; +} diff --git a/src/engines/regime.js b/src/engines/regime.js new file mode 100644 index 0000000..a62cf53 --- /dev/null +++ b/src/engines/regime.js @@ -0,0 +1,24 @@ +export function detectRegime({ price, vwap, vwapSlope, vwapCrossCount, volumeRecent, volumeAvg }) { + if (price === null || vwap === null || vwapSlope === null) return { regime: "CHOP", reason: "missing_inputs" }; + + const above = price > vwap; + + const lowVolume = volumeRecent !== null && volumeAvg !== null ? volumeRecent < 0.6 * volumeAvg : false; + if (lowVolume && Math.abs((price - vwap) / vwap) < 0.001) { + return { regime: "CHOP", reason: "low_volume_flat" }; + } + + if (above && vwapSlope > 0) { + return { regime: "TREND_UP", reason: "price_above_vwap_slope_up" }; + } + + if (!above && vwapSlope < 0) { + return { regime: "TREND_DOWN", reason: "price_below_vwap_slope_down" }; + } + + if (vwapCrossCount !== null && vwapCrossCount >= 3) { + return { regime: "RANGE", reason: "frequent_vwap_cross" }; + } + + return { regime: "RANGE", reason: "default" }; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..cea8f07 --- /dev/null +++ b/src/index.js @@ -0,0 +1,730 @@ +import { CONFIG } from "./config.js"; +import { fetchKlines, fetchLastPrice } from "./data/binance.js"; +import { fetchChainlinkBtcUsd } from "./data/chainlink.js"; +import { startChainlinkPriceStream } from "./data/chainlinkWs.js"; +import { startPolymarketChainlinkPriceStream } from "./data/polymarketLiveWs.js"; +import { + fetchMarketBySlug, + fetchLiveEventsBySeriesId, + flattenEventMarkets, + pickLatestLiveMarket, + fetchClobPrice, + fetchOrderBook, + summarizeOrderBook +} from "./data/polymarket.js"; +import { computeSessionVwap, computeVwapSeries } from "./indicators/vwap.js"; +import { computeRsi, sma, slopeLast } from "./indicators/rsi.js"; +import { computeMacd } from "./indicators/macd.js"; +import { computeHeikenAshi, countConsecutive } from "./indicators/heikenAshi.js"; +import { detectRegime } from "./engines/regime.js"; +import { scoreDirection, applyTimeAwareness } from "./engines/probability.js"; +import { computeEdge, decide } from "./engines/edge.js"; +import { appendCsvRow, formatNumber, formatPct, getCandleWindowTiming, sleep } from "./utils.js"; +import { startBinanceTradeStream } from "./data/binanceWs.js"; +import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline"; + +function countVwapCrosses(closes, vwapSeries, lookback) { + if (closes.length < lookback || vwapSeries.length < lookback) return null; + let crosses = 0; + for (let i = closes.length - lookback + 1; i < closes.length; i += 1) { + const prev = closes[i - 1] - vwapSeries[i - 1]; + const cur = closes[i] - vwapSeries[i]; + if (prev === 0) continue; + if ((prev > 0 && cur < 0) || (prev < 0 && cur > 0)) crosses += 1; + } + return crosses; +} + +function fmtTimeLeft(mins) { + const totalSeconds = Math.max(0, Math.floor(mins * 60)); + const m = Math.floor(totalSeconds / 60); + const s = totalSeconds % 60; + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +const ANSI = { + reset: "\x1b[0m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + lightRed: "\x1b[91m", + gray: "\x1b[90m", + white: "\x1b[97m", + dim: "\x1b[2m" +}; + +function screenWidth() { + const w = Number(process.stdout?.columns); + return Number.isFinite(w) && w >= 40 ? w : 80; +} + +function sepLine(ch = "─") { + const w = screenWidth(); + return `${ANSI.white}${ch.repeat(w)}${ANSI.reset}`; +} + +function renderScreen(text) { + try { + readline.cursorTo(process.stdout, 0, 0); + readline.clearScreenDown(process.stdout); + } catch { + // ignore + } + process.stdout.write(text); +} + +function stripAnsi(s) { + return String(s).replace(/\x1b\[[0-9;]*m/g, ""); +} + +function padLabel(label, width) { + const visible = stripAnsi(label).length; + if (visible >= width) return label; + return label + " ".repeat(width - visible); +} + +function centerText(text, width) { + const visible = stripAnsi(text).length; + if (visible >= width) return text; + const left = Math.floor((width - visible) / 2); + const right = width - visible - left; + return " ".repeat(left) + text + " ".repeat(right); +} + +const LABEL_W = 16; +function kv(label, value) { + const l = padLabel(String(label), LABEL_W); + return `${l}${value}`; +} + +function section(title) { + return `${ANSI.white}${title}${ANSI.reset}`; +} + +function colorPriceLine({ label, price, prevPrice, decimals = 0, prefix = "" }) { + if (price === null || price === undefined) { + return `${label}: ${ANSI.gray}-${ANSI.reset}`; + } + + const p = Number(price); + const prev = prevPrice === null || prevPrice === undefined ? null : Number(prevPrice); + + let color = ANSI.reset; + let arrow = ""; + if (prev !== null && Number.isFinite(prev) && Number.isFinite(p) && p !== prev) { + if (p > prev) { + color = ANSI.green; + arrow = " ↑"; + } else { + color = ANSI.red; + arrow = " ↓"; + } + } + + const formatted = `${prefix}${formatNumber(p, decimals)}`; + return `${label}: ${color}${formatted}${arrow}${ANSI.reset}`; +} + +function formatSignedDelta(delta, base) { + if (delta === null || base === null || base === 0) return `${ANSI.gray}-${ANSI.reset}`; + const sign = delta > 0 ? "+" : delta < 0 ? "-" : ""; + const pct = (Math.abs(delta) / Math.abs(base)) * 100; + return `${sign}$${Math.abs(delta).toFixed(2)}, ${sign}${pct.toFixed(2)}%`; +} + +function colorByNarrative(text, narrative) { + if (narrative === "LONG") return `${ANSI.green}${text}${ANSI.reset}`; + if (narrative === "SHORT") return `${ANSI.red}${text}${ANSI.reset}`; + return `${ANSI.gray}${text}${ANSI.reset}`; +} + +function formatNarrativeValue(label, value, narrative) { + return `${label}: ${colorByNarrative(value, narrative)}`; +} + +function narrativeFromSign(x) { + if (x === null || x === undefined || !Number.isFinite(Number(x)) || Number(x) === 0) return "NEUTRAL"; + return Number(x) > 0 ? "LONG" : "SHORT"; +} + +function narrativeFromRsi(rsi) { + if (rsi === null || rsi === undefined || !Number.isFinite(Number(rsi))) return "NEUTRAL"; + const v = Number(rsi); + if (v >= 55) return "LONG"; + if (v <= 45) return "SHORT"; + return "NEUTRAL"; +} + +function narrativeFromSlope(slope) { + if (slope === null || slope === undefined || !Number.isFinite(Number(slope)) || Number(slope) === 0) return "NEUTRAL"; + return Number(slope) > 0 ? "LONG" : "SHORT"; +} + +function formatProbPct(p, digits = 0) { + if (p === null || p === undefined || !Number.isFinite(Number(p))) return "-"; + return `${(Number(p) * 100).toFixed(digits)}%`; +} + +function fmtEtTime(now = new Date()) { + try { + return new Intl.DateTimeFormat("en-US", { + timeZone: "America/New_York", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + }).format(now); + } catch { + return "-"; + } +} + +function getBtcSession(now = new Date()) { + const h = now.getUTCHours(); + const inAsia = h >= 0 && h < 8; + const inEurope = h >= 7 && h < 16; + const inUs = h >= 13 && h < 22; + + if (inEurope && inUs) return "Europe/US overlap"; + if (inAsia && inEurope) return "Asia/Europe overlap"; + if (inAsia) return "Asia"; + if (inEurope) return "Europe"; + if (inUs) return "US"; + return "Off-hours"; +} + +function parsePriceToBeat(market) { + const text = String(market?.question ?? market?.title ?? ""); + if (!text) return null; + const m = text.match(/price\s*to\s*beat[^\d$]*\$?\s*([0-9][0-9,]*(?:\.[0-9]+)?)/i); + if (!m) return null; + const raw = m[1].replace(/,/g, ""); + const n = Number(raw); + return Number.isFinite(n) ? n : null; +} + +const dumpedMarkets = new Set(); + +function safeFileSlug(x) { + return String(x ?? "") + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/(^-|-$)/g, "") + .slice(0, 120); +} + +function extractNumericFromMarket(market) { + const directKeys = [ + "priceToBeat", + "price_to_beat", + "strikePrice", + "strike_price", + "strike", + "threshold", + "thresholdPrice", + "threshold_price", + "targetPrice", + "target_price", + "referencePrice", + "reference_price" + ]; + + for (const k of directKeys) { + const v = market?.[k]; + const n = typeof v === "string" ? Number(v) : typeof v === "number" ? v : NaN; + if (Number.isFinite(n)) return n; + } + + const seen = new Set(); + const stack = [{ obj: market, depth: 0 }]; + + while (stack.length) { + const { obj, depth } = stack.pop(); + if (!obj || typeof obj !== "object") continue; + if (seen.has(obj) || depth > 6) continue; + seen.add(obj); + + const entries = Array.isArray(obj) ? obj.entries() : Object.entries(obj); + for (const [key, value] of entries) { + const k = String(key).toLowerCase(); + if (value && typeof value === "object") { + stack.push({ obj: value, depth: depth + 1 }); + continue; + } + + if (!/(price|strike|threshold|target|beat)/i.test(k)) continue; + + const n = typeof value === "string" ? Number(value) : typeof value === "number" ? value : NaN; + if (!Number.isFinite(n)) continue; + + if (n > 1000 && n < 2_000_000) return n; + } + } + + return null; +} + +function priceToBeatFromPolymarketMarket(market) { + const n = extractNumericFromMarket(market); + if (n !== null) return n; + return parsePriceToBeat(market); +} + +const marketCache = { + market: null, + fetchedAtMs: 0 +}; + +async function resolveCurrentBtc15mMarket() { + if (CONFIG.polymarket.marketSlug) { + return await fetchMarketBySlug(CONFIG.polymarket.marketSlug); + } + + if (!CONFIG.polymarket.autoSelectLatest) return null; + + const now = Date.now(); + if (marketCache.market && now - marketCache.fetchedAtMs < CONFIG.pollIntervalMs) { + return marketCache.market; + } + + const events = await fetchLiveEventsBySeriesId({ seriesId: CONFIG.polymarket.seriesId, limit: 25 }); + const markets = flattenEventMarkets(events); + const picked = pickLatestLiveMarket(markets); + + marketCache.market = picked; + marketCache.fetchedAtMs = now; + return picked; +} + +async function fetchPolymarketSnapshot() { + const market = await resolveCurrentBtc15mMarket(); + + if (!market) return { ok: false, reason: "market_not_found" }; + + const outcomes = Array.isArray(market.outcomes) ? market.outcomes : (typeof market.outcomes === "string" ? JSON.parse(market.outcomes) : []); + const outcomePrices = Array.isArray(market.outcomePrices) + ? market.outcomePrices + : (typeof market.outcomePrices === "string" ? JSON.parse(market.outcomePrices) : []); + + const clobTokenIds = Array.isArray(market.clobTokenIds) + ? market.clobTokenIds + : (typeof market.clobTokenIds === "string" ? JSON.parse(market.clobTokenIds) : []); + + let upTokenId = null; + let downTokenId = null; + for (let i = 0; i < outcomes.length; i += 1) { + const label = String(outcomes[i]); + const tokenId = clobTokenIds[i] ? String(clobTokenIds[i]) : null; + if (!tokenId) continue; + + if (label.toLowerCase() === CONFIG.polymarket.upOutcomeLabel.toLowerCase()) upTokenId = tokenId; + if (label.toLowerCase() === CONFIG.polymarket.downOutcomeLabel.toLowerCase()) downTokenId = tokenId; + } + + const upIndex = outcomes.findIndex((x) => String(x).toLowerCase() === CONFIG.polymarket.upOutcomeLabel.toLowerCase()); + const downIndex = outcomes.findIndex((x) => String(x).toLowerCase() === CONFIG.polymarket.downOutcomeLabel.toLowerCase()); + + const gammaYes = upIndex >= 0 ? Number(outcomePrices[upIndex]) : null; + const gammaNo = downIndex >= 0 ? Number(outcomePrices[downIndex]) : null; + + if (!upTokenId || !downTokenId) { + return { + ok: false, + reason: "missing_token_ids", + market, + outcomes, + clobTokenIds, + outcomePrices + }; + } + + let upBuy = null; + let downBuy = null; + let upBookSummary = { bestBid: null, bestAsk: null, spread: null, bidLiquidity: null, askLiquidity: null }; + let downBookSummary = { bestBid: null, bestAsk: null, spread: null, bidLiquidity: null, askLiquidity: null }; + + try { + const [yesBuy, noBuy, upBook, downBook] = await Promise.all([ + fetchClobPrice({ tokenId: upTokenId, side: "buy" }), + fetchClobPrice({ tokenId: downTokenId, side: "buy" }), + fetchOrderBook({ tokenId: upTokenId }), + fetchOrderBook({ tokenId: downTokenId }) + ]); + + upBuy = yesBuy; + downBuy = noBuy; + upBookSummary = summarizeOrderBook(upBook); + downBookSummary = summarizeOrderBook(downBook); + } catch { + upBuy = null; + downBuy = null; + upBookSummary = { + bestBid: Number(market.bestBid) || null, + bestAsk: Number(market.bestAsk) || null, + spread: Number(market.spread) || null, + bidLiquidity: null, + askLiquidity: null + }; + downBookSummary = { + bestBid: null, + bestAsk: null, + spread: Number(market.spread) || null, + bidLiquidity: null, + askLiquidity: null + }; + } + + return { + ok: true, + market, + tokens: { upTokenId, downTokenId }, + prices: { + up: upBuy ?? gammaYes, + down: downBuy ?? gammaNo + }, + orderbook: { + up: upBookSummary, + down: downBookSummary + } + }; +} + +async function main() { + const binanceStream = startBinanceTradeStream({ symbol: CONFIG.symbol }); + const polymarketLiveStream = startPolymarketChainlinkPriceStream({}); + const chainlinkStream = startChainlinkPriceStream({}); + + let prevSpotPrice = null; + let prevCurrentPrice = null; + let priceToBeatState = { slug: null, value: null, setAtMs: null }; + + const header = [ + "timestamp", + "entry_minute", + "time_left_min", + "regime", + "signal", + "model_up", + "model_down", + "mkt_up", + "mkt_down", + "edge_up", + "edge_down", + "recommendation" + ]; + + while (true) { + const timing = getCandleWindowTiming(CONFIG.candleWindowMinutes); + + const wsTick = binanceStream.getLast(); + const wsPrice = wsTick?.price ?? null; + + const polymarketWsTick = polymarketLiveStream.getLast(); + const polymarketWsPrice = polymarketWsTick?.price ?? null; + + const chainlinkWsTick = chainlinkStream.getLast(); + const chainlinkWsPrice = chainlinkWsTick?.price ?? null; + + try { + const chainlinkPromise = polymarketWsPrice !== null + ? Promise.resolve({ price: polymarketWsPrice, updatedAt: polymarketWsTick?.updatedAt ?? null, source: "polymarket_ws" }) + : chainlinkWsPrice !== null + ? Promise.resolve({ price: chainlinkWsPrice, updatedAt: chainlinkWsTick?.updatedAt ?? null, source: "chainlink_ws" }) + : fetchChainlinkBtcUsd(); + + const [klines1m, klines5m, lastPrice, chainlink, poly] = await Promise.all([ + fetchKlines({ interval: "1m", limit: 240 }), + fetchKlines({ interval: "5m", limit: 200 }), + fetchLastPrice(), + chainlinkPromise, + fetchPolymarketSnapshot() + ]); + + const settlementMs = poly.ok && poly.market?.endDate ? new Date(poly.market.endDate).getTime() : null; + const settlementLeftMin = settlementMs ? (settlementMs - Date.now()) / 60_000 : null; + + const timeLeftMin = settlementLeftMin ?? timing.remainingMinutes; + + const candles = klines1m; + const closes = candles.map((c) => c.close); + + const vwap = computeSessionVwap(candles); + const vwapSeries = computeVwapSeries(candles); + const vwapNow = vwapSeries[vwapSeries.length - 1]; + + const lookback = CONFIG.vwapSlopeLookbackMinutes; + const vwapSlope = vwapSeries.length >= lookback ? (vwapNow - vwapSeries[vwapSeries.length - lookback]) / lookback : null; + const vwapDist = vwapNow ? (lastPrice - vwapNow) / vwapNow : null; + + const rsiNow = computeRsi(closes, CONFIG.rsiPeriod); + const rsiSeries = []; + for (let i = 0; i < closes.length; i += 1) { + const sub = closes.slice(0, i + 1); + const r = computeRsi(sub, CONFIG.rsiPeriod); + if (r !== null) rsiSeries.push(r); + } + const rsiMa = sma(rsiSeries, CONFIG.rsiMaPeriod); + const rsiSlope = slopeLast(rsiSeries, 3); + + const macd = computeMacd(closes, CONFIG.macdFast, CONFIG.macdSlow, CONFIG.macdSignal); + + const ha = computeHeikenAshi(candles); + const consec = countConsecutive(ha); + + const vwapCrossCount = countVwapCrosses(closes, vwapSeries, 20); + const volumeRecent = candles.slice(-20).reduce((a, c) => a + c.volume, 0); + const volumeAvg = candles.slice(-120).reduce((a, c) => a + c.volume, 0) / 6; + + const failedVwapReclaim = vwapNow !== null && vwapSeries.length >= 3 + ? closes[closes.length - 1] < vwapNow && closes[closes.length - 2] > vwapSeries[vwapSeries.length - 2] + : false; + + const regimeInfo = detectRegime({ + price: lastPrice, + vwap: vwapNow, + vwapSlope, + vwapCrossCount, + volumeRecent, + volumeAvg + }); + + const scored = scoreDirection({ + price: lastPrice, + vwap: vwapNow, + vwapSlope, + rsi: rsiNow, + rsiSlope, + macd, + heikenColor: consec.color, + heikenCount: consec.count, + failedVwapReclaim + }); + + const timeAware = applyTimeAwareness(scored.rawUp, timeLeftMin, CONFIG.candleWindowMinutes); + + const marketUp = poly.ok ? poly.prices.up : null; + const marketDown = poly.ok ? poly.prices.down : null; + const edge = computeEdge({ modelUp: timeAware.adjustedUp, modelDown: timeAware.adjustedDown, marketYes: marketUp, marketNo: marketDown }); + + const rec = decide({ remainingMinutes: timeLeftMin, edgeUp: edge.edgeUp, edgeDown: edge.edgeDown, modelUp: timeAware.adjustedUp, modelDown: timeAware.adjustedDown }); + + const vwapSlopeLabel = vwapSlope === null ? "-" : vwapSlope > 0 ? "UP" : vwapSlope < 0 ? "DOWN" : "FLAT"; + + const macdLabel = macd === null + ? "-" + : macd.hist < 0 + ? (macd.histDelta !== null && macd.histDelta < 0 ? "bearish (expanding)" : "bearish") + : (macd.histDelta !== null && macd.histDelta > 0 ? "bullish (expanding)" : "bullish"); + + const lastCandle = klines1m.length ? klines1m[klines1m.length - 1] : null; + const lastClose = lastCandle?.close ?? null; + const close1mAgo = klines1m.length >= 2 ? klines1m[klines1m.length - 2]?.close ?? null : null; + const close3mAgo = klines1m.length >= 4 ? klines1m[klines1m.length - 4]?.close ?? null : null; + const delta1m = lastClose !== null && close1mAgo !== null ? lastClose - close1mAgo : null; + const delta3m = lastClose !== null && close3mAgo !== null ? lastClose - close3mAgo : null; + + const haNarrative = (consec.color ?? "").toLowerCase() === "green" ? "LONG" : (consec.color ?? "").toLowerCase() === "red" ? "SHORT" : "NEUTRAL"; + const rsiNarrative = narrativeFromSlope(rsiSlope); + const macdNarrative = narrativeFromSign(macd?.hist ?? null); + const vwapNarrative = narrativeFromSign(vwapDist); + + const pLong = timeAware?.adjustedUp ?? null; + const pShort = timeAware?.adjustedDown ?? null; + const predictNarrative = (pLong !== null && pShort !== null && Number.isFinite(pLong) && Number.isFinite(pShort)) + ? (pLong > pShort ? "LONG" : pShort > pLong ? "SHORT" : "NEUTRAL") + : "NEUTRAL"; + const predictValue = `${ANSI.green}LONG${ANSI.reset} ${ANSI.green}${formatProbPct(pLong, 0)}${ANSI.reset} / ${ANSI.red}SHORT${ANSI.reset} ${ANSI.red}${formatProbPct(pShort, 0)}${ANSI.reset}`; + const predictLine = `Predict: ${predictValue}`; + + const marketUpStr = `${marketUp ?? "-"}${marketUp === null || marketUp === undefined ? "" : "¢"}`; + const marketDownStr = `${marketDown ?? "-"}${marketDown === null || marketDown === undefined ? "" : "¢"}`; + const polyHeaderValue = `${ANSI.green}↑ UP${ANSI.reset} ${marketUpStr} | ${ANSI.red}↓ DOWN${ANSI.reset} ${marketDownStr}`; + + const heikenValue = `${consec.color ?? "-"} x${consec.count}`; + const heikenLine = formatNarrativeValue("Heiken Ashi", heikenValue, haNarrative); + + const rsiArrow = rsiSlope !== null && rsiSlope < 0 ? "↓" : rsiSlope !== null && rsiSlope > 0 ? "↑" : "-"; + const rsiValue = `${formatNumber(rsiNow, 1)} ${rsiArrow}`; + const rsiLine = formatNarrativeValue("RSI", rsiValue, rsiNarrative); + + const macdLine = formatNarrativeValue("MACD", macdLabel, macdNarrative); + + const delta1Narrative = narrativeFromSign(delta1m); + const delta3Narrative = narrativeFromSign(delta3m); + const deltaValue = `${colorByNarrative(formatSignedDelta(delta1m, lastClose), delta1Narrative)} | ${colorByNarrative(formatSignedDelta(delta3m, lastClose), delta3Narrative)}`; + const deltaLine = `Delta 1/3Min: ${deltaValue}`; + + const vwapValue = `${formatNumber(vwapNow, 0)} (${formatPct(vwapDist, 2)}) | slope: ${vwapSlopeLabel}`; + const vwapLine = formatNarrativeValue("VWAP", vwapValue, vwapNarrative); + + const signal = rec.action === "ENTER" ? (rec.side === "UP" ? "BUY UP" : "BUY DOWN") : "NO TRADE"; + + const actionLine = rec.action === "ENTER" + ? `${rec.action} NOW (${rec.phase} ENTRY)` + : `NO TRADE (${rec.phase})`; + + const spreadUp = poly.ok ? poly.orderbook.up.spread : null; + const spreadDown = poly.ok ? poly.orderbook.down.spread : null; + + const spread = spreadUp !== null && spreadDown !== null ? Math.max(spreadUp, spreadDown) : (spreadUp ?? spreadDown); + const liquidity = poly.ok + ? (Number(poly.market?.liquidityNum) || Number(poly.market?.liquidity) || null) + : null; + + const spotPrice = wsPrice ?? lastPrice; + const currentPrice = chainlink?.price ?? null; + const marketSlug = poly.ok ? String(poly.market?.slug ?? "") : ""; + const marketStartMs = poly.ok && poly.market?.eventStartTime ? new Date(poly.market.eventStartTime).getTime() : null; + + if (marketSlug && priceToBeatState.slug !== marketSlug) { + priceToBeatState = { slug: marketSlug, value: null, setAtMs: null }; + } + + if (priceToBeatState.slug && priceToBeatState.value === null && currentPrice !== null) { + const nowMs = Date.now(); + const okToLatch = marketStartMs === null ? true : nowMs >= marketStartMs; + if (okToLatch) { + priceToBeatState = { slug: priceToBeatState.slug, value: Number(currentPrice), setAtMs: nowMs }; + } + } + + const priceToBeat = priceToBeatState.slug === marketSlug ? priceToBeatState.value : null; + const currentPriceBaseLine = colorPriceLine({ + label: "CURRENT PRICE", + price: currentPrice, + prevPrice: prevCurrentPrice, + decimals: 2, + prefix: "$" + }); + + const ptbDelta = (currentPrice !== null && priceToBeat !== null && Number.isFinite(currentPrice) && Number.isFinite(priceToBeat)) + ? currentPrice - priceToBeat + : null; + const ptbDeltaColor = ptbDelta === null + ? ANSI.gray + : ptbDelta > 0 + ? ANSI.green + : ptbDelta < 0 + ? ANSI.red + : ANSI.gray; + const ptbDeltaText = ptbDelta === null + ? `${ANSI.gray}-${ANSI.reset}` + : `${ptbDeltaColor}${ptbDelta > 0 ? "+" : ptbDelta < 0 ? "-" : ""}$${Math.abs(ptbDelta).toFixed(2)}${ANSI.reset}`; + const currentPriceValue = currentPriceBaseLine.split(": ")[1] ?? currentPriceBaseLine; + const currentPriceLine = kv("CURRENT PRICE:", `${currentPriceValue} (${ptbDeltaText})`); + + if (poly.ok && poly.market && priceToBeatState.value === null) { + const slug = safeFileSlug(poly.market.slug || poly.market.id || "market"); + if (slug && !dumpedMarkets.has(slug)) { + dumpedMarkets.add(slug); + try { + fs.mkdirSync("./logs", { recursive: true }); + fs.writeFileSync(path.join("./logs", `polymarket_market_${slug}.json`), JSON.stringify(poly.market, null, 2), "utf8"); + } catch { + // ignore + } + } + } + + const binanceSpotBaseLine = colorPriceLine({ label: "BTC (Binance)", price: spotPrice, prevPrice: prevSpotPrice, decimals: 0, prefix: "$" }); + const diffLine = (spotPrice !== null && currentPrice !== null && Number.isFinite(spotPrice) && Number.isFinite(currentPrice) && currentPrice !== 0) + ? (() => { + const diffUsd = spotPrice - currentPrice; + const diffPct = (diffUsd / currentPrice) * 100; + const sign = diffUsd > 0 ? "+" : diffUsd < 0 ? "-" : ""; + return ` (${sign}$${Math.abs(diffUsd).toFixed(2)}, ${sign}${Math.abs(diffPct).toFixed(2)}%)`; + })() + : ""; + const binanceSpotLine = `${binanceSpotBaseLine}${diffLine}`; + const binanceSpotValue = binanceSpotLine.split(": ")[1] ?? binanceSpotLine; + const binanceSpotKvLine = kv("BTC (Binance):", binanceSpotValue); + + const titleLine = poly.ok ? `${poly.market?.question ?? "-"}` : "-"; + const marketLine = kv("Market:", poly.ok ? (poly.market?.slug ?? "-") : "-"); + + const timeColor = timeLeftMin >= 10 && timeLeftMin <= 15 + ? ANSI.green + : timeLeftMin >= 5 && timeLeftMin < 10 + ? ANSI.yellow + : timeLeftMin >= 0 && timeLeftMin < 5 + ? ANSI.red + : ANSI.reset; + const timeLeftLine = `⏱ Time left: ${timeColor}${fmtTimeLeft(timeLeftMin)}${ANSI.reset}`; + + const polyTimeLeftColor = settlementLeftMin !== null + ? (settlementLeftMin >= 10 && settlementLeftMin <= 15 + ? ANSI.green + : settlementLeftMin >= 5 && settlementLeftMin < 10 + ? ANSI.yellow + : settlementLeftMin >= 0 && settlementLeftMin < 5 + ? ANSI.red + : ANSI.reset) + : ANSI.reset; + + const lines = [ + titleLine, + marketLine, + kv("Time left:", `${timeColor}${fmtTimeLeft(timeLeftMin)}${ANSI.reset}`), + "", + sepLine(), + "", + kv("TA Predict:", predictValue), + kv("Heiken Ashi:", heikenLine.split(": ")[1] ?? heikenLine), + kv("RSI:", rsiLine.split(": ")[1] ?? rsiLine), + kv("MACD:", macdLine.split(": ")[1] ?? macdLine), + kv("Delta 1/3:", deltaLine.split(": ")[1] ?? deltaLine), + kv("VWAP:", vwapLine.split(": ")[1] ?? vwapLine), + "", + sepLine(), + "", + kv("POLYMARKET:", polyHeaderValue), + liquidity !== null ? kv("Liquidity:", formatNumber(liquidity, 0)) : null, + settlementLeftMin !== null ? kv("Time left:", `${polyTimeLeftColor}${fmtTimeLeft(settlementLeftMin)}${ANSI.reset}`) : null, + priceToBeat !== null ? kv("PRICE TO BEAT: ", `$${formatNumber(priceToBeat, 0)}`) : kv("PRICE TO BEAT: ", `${ANSI.gray}-${ANSI.reset}`), + currentPriceLine, + "", + sepLine(), + "", + binanceSpotKvLine, + "", + sepLine(), + "", + kv("ET | Session:", `${ANSI.white}${fmtEtTime(new Date())}${ANSI.reset} | ${ANSI.white}${getBtcSession(new Date())}${ANSI.reset}`), + "", + sepLine(), + centerText(`${ANSI.dim}${ANSI.gray}created by @krajekis${ANSI.reset}`, screenWidth()) + ].filter((x) => x !== null); + + renderScreen(lines.join("\n") + "\n"); + + prevSpotPrice = spotPrice ?? prevSpotPrice; + prevCurrentPrice = currentPrice ?? prevCurrentPrice; + + appendCsvRow("./logs/signals.csv", header, [ + new Date().toISOString(), + timing.elapsedMinutes.toFixed(3), + timeLeftMin.toFixed(3), + regimeInfo.regime, + signal, + timeAware.adjustedUp, + timeAware.adjustedDown, + marketUp, + marketDown, + edge.edgeUp, + edge.edgeDown, + rec.action === "ENTER" ? `${rec.side}:${rec.phase}:${rec.strength}` : "NO_TRADE" + ]); + } catch (err) { + console.log("────────────────────────────"); + console.log(`Error: ${err?.message ?? String(err)}`); + console.log("────────────────────────────"); + } + + await sleep(CONFIG.pollIntervalMs); + } +} + +main(); diff --git a/src/indicators/heikenAshi.js b/src/indicators/heikenAshi.js new file mode 100644 index 0000000..c123225 --- /dev/null +++ b/src/indicators/heikenAshi.js @@ -0,0 +1,42 @@ +export function computeHeikenAshi(candles) { + if (!Array.isArray(candles) || candles.length === 0) return []; + + const ha = []; + for (let i = 0; i < candles.length; i += 1) { + const c = candles[i]; + const haClose = (c.open + c.high + c.low + c.close) / 4; + + const prev = ha[i - 1]; + const haOpen = prev ? (prev.open + prev.close) / 2 : (c.open + c.close) / 2; + + const haHigh = Math.max(c.high, haOpen, haClose); + const haLow = Math.min(c.low, haOpen, haClose); + + ha.push({ + open: haOpen, + high: haHigh, + low: haLow, + close: haClose, + isGreen: haClose >= haOpen, + body: Math.abs(haClose - haOpen) + }); + } + return ha; +} + +export function countConsecutive(haCandles) { + if (!Array.isArray(haCandles) || haCandles.length === 0) return { color: null, count: 0 }; + + const last = haCandles[haCandles.length - 1]; + const target = last.isGreen ? "green" : "red"; + + let count = 0; + for (let i = haCandles.length - 1; i >= 0; i -= 1) { + const c = haCandles[i]; + const color = c.isGreen ? "green" : "red"; + if (color !== target) break; + count += 1; + } + + return { color: target, count }; +} diff --git a/src/indicators/macd.js b/src/indicators/macd.js new file mode 100644 index 0000000..7a309cc --- /dev/null +++ b/src/indicators/macd.js @@ -0,0 +1,44 @@ +function ema(values, period) { + if (!Array.isArray(values) || values.length < period) return null; + + const k = 2 / (period + 1); + let prev = values[0]; + for (let i = 1; i < values.length; i += 1) { + prev = values[i] * k + prev * (1 - k); + } + return prev; +} + +export function computeMacd(closes, fast, slow, signal) { + if (!Array.isArray(closes) || closes.length < slow + signal) return null; + + const fastEma = ema(closes, fast); + const slowEma = ema(closes, slow); + if (fastEma === null || slowEma === null) return null; + + const macdLine = fastEma - slowEma; + + const macdSeries = []; + for (let i = 0; i < closes.length; i += 1) { + const sub = closes.slice(0, i + 1); + const f = ema(sub, fast); + const s = ema(sub, slow); + if (f === null || s === null) continue; + macdSeries.push(f - s); + } + + const signalLine = ema(macdSeries, signal); + if (signalLine === null) return null; + + const hist = macdLine - signalLine; + + const lastHist = hist; + const prevHist = macdSeries.length >= signal + 1 ? (macdSeries[macdSeries.length - 2] - ema(macdSeries.slice(0, macdSeries.length - 1), signal)) : null; + + return { + macd: macdLine, + signal: signalLine, + hist, + histDelta: prevHist === null ? null : lastHist - prevHist + }; +} diff --git a/src/indicators/rsi.js b/src/indicators/rsi.js new file mode 100644 index 0000000..26e6c5e --- /dev/null +++ b/src/indicators/rsi.js @@ -0,0 +1,37 @@ +import { clamp } from "../utils.js"; + +export function computeRsi(closes, period) { + if (!Array.isArray(closes) || closes.length < period + 1) return null; + + let gains = 0; + let losses = 0; + for (let i = closes.length - period; i < closes.length; i += 1) { + const prev = closes[i - 1]; + const cur = closes[i]; + const diff = cur - prev; + if (diff > 0) gains += diff; + else losses += -diff; + } + + const avgGain = gains / period; + const avgLoss = losses / period; + if (avgLoss === 0) return 100; + const rs = avgGain / avgLoss; + const rsi = 100 - 100 / (1 + rs); + return clamp(rsi, 0, 100); +} + +export function sma(values, period) { + if (!Array.isArray(values) || values.length < period) return null; + const slice = values.slice(values.length - period); + const sum = slice.reduce((a, b) => a + b, 0); + return sum / period; +} + +export function slopeLast(values, points) { + if (!Array.isArray(values) || values.length < points) return null; + const slice = values.slice(values.length - points); + const first = slice[0]; + const last = slice[slice.length - 1]; + return (last - first) / (points - 1); +} diff --git a/src/indicators/vwap.js b/src/indicators/vwap.js new file mode 100644 index 0000000..0a9a0c2 --- /dev/null +++ b/src/indicators/vwap.js @@ -0,0 +1,22 @@ +export function computeSessionVwap(candles) { + if (!Array.isArray(candles) || candles.length === 0) return null; + + let pv = 0; + let v = 0; + for (const c of candles) { + const tp = (c.high + c.low + c.close) / 3; + pv += tp * c.volume; + v += c.volume; + } + if (v === 0) return null; + return pv / v; +} + +export function computeVwapSeries(candles) { + const series = []; + for (let i = 0; i < candles.length; i += 1) { + const sub = candles.slice(0, i + 1); + series.push(computeSessionVwap(sub)); + } + return series; +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..94c7878 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,66 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function clamp(x, min, max) { + return Math.max(min, Math.min(max, x)); +} + +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function formatNumber(x, digits = 0) { + if (x === null || x === undefined || Number.isNaN(x)) return "-"; + return new Intl.NumberFormat("en-US", { + minimumFractionDigits: digits, + maximumFractionDigits: digits + }).format(x); +} + +export function formatPct(x, digits = 2) { + if (x === null || x === undefined || Number.isNaN(x)) return "-"; + return `${(x * 100).toFixed(digits)}%`; +} + +export function getCandleWindowTiming(windowMinutes) { + const nowMs = Date.now(); + const windowMs = windowMinutes * 60_000; + const startMs = Math.floor(nowMs / windowMs) * windowMs; + const endMs = startMs + windowMs; + const elapsedMs = nowMs - startMs; + const remainingMs = endMs - nowMs; + return { + startMs, + endMs, + elapsedMs, + remainingMs, + elapsedMinutes: elapsedMs / 60_000, + remainingMinutes: remainingMs / 60_000 + }; +} + +export function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +export function appendCsvRow(filePath, header, row) { + ensureDir(path.dirname(filePath)); + const exists = fs.existsSync(filePath); + const line = row + .map((v) => { + if (v === null || v === undefined) return ""; + const s = String(v); + if (s.includes(",") || s.includes("\n") || s.includes('"')) { + return `"${s.replaceAll('"', '""')}"`; + } + return s; + }) + .join(","); + + if (!exists) { + fs.writeFileSync(filePath, `${header.join(",")}\n${line}\n`, "utf8"); + return; + } + + fs.appendFileSync(filePath, `${line}\n`, "utf8"); +}