c264ba4d47
- npm start / watch:telegram: add --env-file=env so TELEGRAM_* and HTTPS_PROXY from nv reach process.env - watcher: import CONFIG.candleWindowMinutes from src/config.js so the per-window latch stays in sync with the bot - watcher: replace prevSide/cooldown latch with lastSentWindowId keyed on floor(ts/WINDOW_MS); one alert per window, NO_TRADE rows no longer reset state
133 lines
4.7 KiB
JavaScript
133 lines
4.7 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(__dirname, "..");
|
|
|
|
try {
|
|
const { applyGlobalProxyFromEnv } = await import(path.join(ROOT, "src/net/proxy.js"));
|
|
applyGlobalProxyFromEnv();
|
|
} catch {}
|
|
|
|
const { CONFIG } = await import(path.join(ROOT, "src/config.js"));
|
|
|
|
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
|
const CHAT_ID = process.env.TELEGRAM_CHAT_ID;
|
|
const CSV_PATH = process.env.SIGNALS_CSV || path.join(ROOT, "logs/signals.csv");
|
|
const DRY_RUN = process.env.DRY_RUN === "true";
|
|
const POLY_BASE = process.env.POLYMARKET_BASE_URL || "https://polymarket.com/zh/event";
|
|
const WINDOW_MS = CONFIG.candleWindowMinutes * 60_000;
|
|
|
|
if (!DRY_RUN && (!BOT_TOKEN || !CHAT_ID)) {
|
|
console.error("[watcher] Missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID");
|
|
console.error("[watcher] Or set DRY_RUN=true to log without sending.");
|
|
process.exit(1);
|
|
}
|
|
|
|
let lastSentWindowId = null;
|
|
let lastSentTs = null;
|
|
|
|
async function send(text) {
|
|
if (DRY_RUN) {
|
|
console.log("[watcher] DRY_RUN:", text.replace(/\n/g, " | "));
|
|
return;
|
|
}
|
|
try {
|
|
const r = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ chat_id: CHAT_ID, text, parse_mode: "HTML" })
|
|
});
|
|
if (!r.ok) console.error("[watcher] Telegram error:", await r.text());
|
|
else console.log(`[watcher] sent ${new Date().toISOString()}`);
|
|
} catch (e) {
|
|
console.error("[watcher] send failed:", e.message);
|
|
}
|
|
}
|
|
|
|
function fmtTime(min) {
|
|
const m = Math.floor(min);
|
|
const s = Math.floor((min - m) * 60);
|
|
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
}
|
|
|
|
function buildMessage(hdr, row, side, phase, strength, slug) {
|
|
const v = (k) => row[hdr.indexOf(k)];
|
|
const isUp = side === "UP";
|
|
const edge = Number(isUp ? v("edge_up") : v("edge_down"));
|
|
const model = Number(isUp ? v("model_up") : v("model_down"));
|
|
const mktYes = Number(isUp ? v("mkt_up") : v("mkt_down"));
|
|
const mktNo = Number(isUp ? v("mkt_down") : v("mkt_up"));
|
|
const mktSum = mktYes + mktNo;
|
|
const mkt = mktSum > 0 ? mktYes / mktSum : mktYes;
|
|
const lines = [
|
|
`🚨 ENTER <b>${side}</b> — ${phase} (${strength})`,
|
|
`Edge: ${edge >= 0 ? "+" : ""}${(edge * 100).toFixed(1)}%`,
|
|
`Model ${(model * 100).toFixed(1)}% vs Market ${(mkt * 100).toFixed(1)}%`,
|
|
`Time left: ${fmtTime(Number(v("time_left_min")))}`,
|
|
`Regime: ${v("regime")}`,
|
|
v("timestamp")
|
|
];
|
|
if (slug) lines.push(`🔗 <a href="${POLY_BASE}/${slug}">Open on Polymarket</a>`);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function getCurrentSlug() {
|
|
if (!fs.existsSync("./logs")) return null;
|
|
const files = fs.readdirSync("./logs")
|
|
.filter((f) => f.startsWith("polymarket_market_") && f.endsWith(".json"))
|
|
.map((f) => ({ f, mtime: fs.statSync(path.join("./logs", f)).mtimeMs }))
|
|
.sort((a, b) => b.mtime - a.mtime);
|
|
if (!files.length) return null;
|
|
try {
|
|
const m = JSON.parse(fs.readFileSync(path.join("./logs", files[0].f), "utf8"));
|
|
return m.slug || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readLast() {
|
|
if (!fs.existsSync(CSV_PATH)) return null;
|
|
const content = fs.readFileSync(CSV_PATH, "utf8");
|
|
const lines = content.trim().split("\n");
|
|
if (lines.length < 2) return null;
|
|
const hdr = lines[0].split(",");
|
|
const row = lines[lines.length - 1].split(",");
|
|
const ts = row[hdr.indexOf("timestamp")] || null;
|
|
const tsMs = ts ? Date.parse(ts) : NaN;
|
|
const windowId = Number.isFinite(tsMs) ? Math.floor(tsMs / WINDOW_MS) : null;
|
|
return { hdr, row, ts, windowId, rec: row[hdr.indexOf("recommendation")] || "" };
|
|
}
|
|
|
|
function watch() {
|
|
if (!fs.existsSync(CSV_PATH)) {
|
|
console.log(`[watcher] waiting for ${CSV_PATH} ...`);
|
|
setTimeout(watch, 1000);
|
|
return;
|
|
}
|
|
console.log(`[watcher] watching ${CSV_PATH} (window ${WINDOW_MS / 60_000}min${DRY_RUN ? ", DRY_RUN" : ""})`);
|
|
|
|
fs.watch(CSV_PATH, { persistent: true }, () => {
|
|
setTimeout(async () => {
|
|
try {
|
|
const data = readLast();
|
|
if (!data || data.windowId === null || !data.rec.includes(":")) return;
|
|
const [side, phase, strength] = data.rec.split(":");
|
|
if (data.windowId !== lastSentWindowId && data.ts !== lastSentTs) {
|
|
const slug = getCurrentSlug();
|
|
console.log("[watcher] slug for push:", slug);
|
|
const msg = buildMessage(data.hdr, data.row, side, phase, strength, slug);
|
|
await send(msg);
|
|
lastSentWindowId = data.windowId;
|
|
lastSentTs = data.ts;
|
|
}
|
|
} catch (e) {
|
|
console.error("[watcher] error:", e.message);
|
|
}
|
|
}, 300);
|
|
});
|
|
}
|
|
|
|
watch(); |