Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
logs/
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -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
|
||||
Generated
+143
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
+730
@@ -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();
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user