Files
2026-05-31 13:49:36 +08:00

6560 lines
284 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* BTC 5-minute up/down order book monitor - standalone server
* Start: npx tsx server.ts
*/
import express from "express";
import { createServer } from "http";
import { WebSocketServer, WebSocket } from "ws";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, readdirSync, unlinkSync, statSync, renameSync } from "fs";
import { ethers } from "ethers";
import dotenv from "dotenv";
import { ClobClient, Side, OrderType, Chain, SignatureTypeV2 as SignatureType, AssetType, getContractConfig } from "@polymarket/clob-client-v2";
import { getAllStrategies, getStrategy, getAllDescriptions, initStrategies } from "./strategies/registry.js";
import type { StrategyNumber, StrategyDirection, StrategyLifecycleState, StrategyKey, TunableParam } from "./strategies/types.js";
import { ALL_STRATEGY_KEYS } from "./strategies/types.js";
import { getFairProb, hasFairProbTable, setActiveMarket as setFairProbMarket } from "./strategies/_core/fair-prob.js";
import { setActiveMarket as setDiffExtremesMarket } from "./strategies/_core/diff-extremes.js";
import { getRealFillFromTx } from "./chain-watcher.js";
import { PmPnlManager } from "./polymarket-pnl.js";
import { loadTgConfig, saveTgConfig, sendTgMessage, autoDetectChatId, type TgConfig } from "./tg-push.js";
import { MARKETS, DEFAULT_KEY, isValidKey, isLegacySymbol, getBinanceWsUrl, priceDecimals, ALL_PERIODS, ALL_SYMBOLS, type MarketKey, type MarketConfig, type MarketSymbol } from "./market-configs.js";
import { Agent, setGlobalDispatcher } from "undici";
import * as httpMod from "http";
import * as httpsMod from "https";
// Project version - keep in sync with package.json when changed
export const APP_VERSION = "5.0.0";
// -- HTTP Keep-Alive connection optimization -----------------------------
// Measured: Polymarket server closes idle connections between 30-60 seconds
// - cold start: 689 ms
// - connection reuse: 350 ms (saves 340 ms)
// - reconnect: 1586 ms (actually slower!)
// Use keep-alive + 20s heartbeat to keep the connection alive, stabilizing order latency
//
// The two HTTP channels each have an independent connection pool, configured separately:
// 1) undici (Node built-in fetch) - fetchBookTopOfBook, Gamma API, TG push
setGlobalDispatcher(new Agent({
keepAliveTimeout: 60000, // client timeout 60s (server closes first)
keepAliveMaxTimeout: 600000,
connections: 10,
pipelining: 1,
}));
// 2) axios (clob-client place order / query open orders / cancel) uses Node native http/https globalAgent
// Node 19+ globalAgent.keepAlive defaults to true, but keepAliveMsecs defaults to only 1000ms,
// when creating a socket the Agent reads the internal options object, so options.keepAliveMsecs must be changed.
// 30000 = send a TCP keep-alive probe every 30s, combined with the 20s application-layer heartbeat keeps the connection stable.
{
type AgentInternals = { keepAlive: boolean; options: { keepAlive: boolean; keepAliveMsecs: number } };
const httpAg = httpMod.globalAgent as unknown as AgentInternals;
const httpsAg = httpsMod.globalAgent as unknown as AgentInternals;
httpAg.keepAlive = true;
httpAg.options.keepAlive = true;
httpAg.options.keepAliveMsecs = 30000;
httpsAg.keepAlive = true;
httpsAg.options.keepAlive = true;
httpsAg.options.keepAliveMsecs = 30000;
}
const __dirname = dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: resolve(__dirname, ".env") });
// All logs automatically get a UTC+8 time prefix, for easier sequencing
//
// Three outputs:
// 1. Terminal: clean version (filters out lines marked [RAW])
// 2. logs/trade-YYYY-MM-DD.log: clean version (same as terminal)
// 3. logs/trade-raw-YYYY-MM-DD.log: full version (includes full raw data, for debugging)
//
// Example of a line with [RAW] prefix: console.log("[RAW]", "[Trade.Market] postOrder raw result:", JSON.stringify(result))
// Lines without the prefix (summary/progress/warning) go to terminal + clean log.
const LOG_DIR = resolve(__dirname, "logs");
const LOG_RETENTION_DAYS = 1;
const CLEAN_LOG_PREFIX = "trade-";
const RAW_LOG_PREFIX = "trade-raw-";
const LOG_FILE_SUFFIX = ".log";
/**
* US stock market pause status: Fri 20:00 ET after-hours wrap-up ~ Mon 4:00 ET pre-market start is paused
* (includes pre-market/after-hours liquidity periods, only avoids the truly low-liquidity weekend window, 56 hours total)
* Returns { paused, secondsUntilNext }, secondsUntilNext is the seconds until the next status switch
*/
const WK_MAP: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
const PAUSE_START_SEC = 20 * 3600; // pause starts Fri 20:00 ET
const PAUSE_END_SEC = 4 * 3600; // pause ends Mon 4:00 ET
function getUsMarketStatus(d = new Date()): { paused: boolean; secondsUntilNext: number } {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(d);
let wk = "", hour = 0, minute = 0, second = 0;
for (const p of parts) {
if (p.type === "weekday") wk = p.value;
else if (p.type === "hour") hour = parseInt(p.value, 10) || 0;
else if (p.type === "minute") minute = parseInt(p.value, 10) || 0;
else if (p.type === "second") second = parseInt(p.value, 10) || 0;
}
if (hour === 24) hour = 0;
const idx = WK_MAP[wk];
if (idx == null) return { paused: false, secondsUntilNext: 0 };
const hms = hour * 3600 + minute * 60 + second;
const paused =
(idx === 5 || idx === 6) ||
(idx === 4 && hms >= PAUSE_START_SEC) ||
(idx === 0 && hms < PAUSE_END_SEC);
let secondsUntilNext: number;
if (paused) {
// compute to next Mon 4:00:00 ET
if (idx === 4) secondsUntilNext = (86400 - hms) + 2 * 86400 + PAUSE_END_SEC; // Fri ≥ 20:00
else if (idx === 5) secondsUntilNext = (86400 - hms) + 1 * 86400 + PAUSE_END_SEC; // Sat
else if (idx === 6) secondsUntilNext = (86400 - hms) + PAUSE_END_SEC; // Sun
else secondsUntilNext = PAUSE_END_SEC - hms; // Mon < 4:00
} else {
// compute to next Fri 20:00:00 ET
if (idx === 0) secondsUntilNext = (86400 - hms) + 3 * 86400 + PAUSE_START_SEC; // Mon ≥ 4:00
else if (idx === 1) secondsUntilNext = (86400 - hms) + 2 * 86400 + PAUSE_START_SEC; // Tue
else if (idx === 2) secondsUntilNext = (86400 - hms) + 1 * 86400 + PAUSE_START_SEC; // Wed
else if (idx === 3) secondsUntilNext = (86400 - hms) + PAUSE_START_SEC; // Thu
else secondsUntilNext = PAUSE_START_SEC - hms; // Fri < 20:00
}
return { paused, secondsUntilNext: Math.max(0, secondsUntilNext) };
} catch {
return { paused: false, secondsUntilNext: 0 };
}
}
function isUsWeekend(d = new Date()): boolean {
return getUsMarketStatus(d).paused;
}
// throttle: weekend pause log prints at most once every 30 minutes (avoids 250ms tick flooding)
let _weekendPauseLogAt = 0;
function logWeekendPauseOnce(): void {
const now = Date.now();
if (now - _weekendPauseLogAt < 30 * 60 * 1000) return;
_weekendPauseLogAt = now;
console.log("[Strategy.AntiManip] US stock low-liquidity period (Fri 20:00 ET ~ Mon 4:00 ET), pausing all new entries (positions/TP/SL run normally)");
}
function getCnDateString(d = new Date()): string {
// compute date by UTC+8 to avoid midnight-switch errors
const cn = new Date(d.getTime() + 8 * 3600 * 1000);
return cn.toISOString().slice(0, 10); // YYYY-MM-DD
}
function getCleanLogFile(): string {
return resolve(LOG_DIR, `${CLEAN_LOG_PREFIX}${getCnDateString()}${LOG_FILE_SUFFIX}`);
}
function getRawLogFile(): string {
return resolve(LOG_DIR, `${RAW_LOG_PREFIX}${getCnDateString()}${LOG_FILE_SUFFIX}`);
}
function cleanupOldLogs(): void {
try {
if (!existsSync(LOG_DIR)) return;
const files = readdirSync(LOG_DIR);
const today = getCnDateString();
const todayMs = Date.parse(today + "T00:00:00+08:00");
const cutoffMs = todayMs - LOG_RETENTION_DAYS * 24 * 3600 * 1000;
for (const f of files) {
// handle both .log and rotated backup .log.1
let base = f;
if (base.endsWith(".1")) base = base.slice(0, -2);
if (!base.endsWith(LOG_FILE_SUFFIX)) continue;
let dateStr = "";
if (base.startsWith(RAW_LOG_PREFIX)) dateStr = base.slice(RAW_LOG_PREFIX.length, base.length - LOG_FILE_SUFFIX.length);
else if (base.startsWith(CLEAN_LOG_PREFIX)) dateStr = base.slice(CLEAN_LOG_PREFIX.length, base.length - LOG_FILE_SUFFIX.length);
else continue;
const fileMs = Date.parse(dateStr + "T00:00:00+08:00");
if (!Number.isFinite(fileMs)) continue;
if (fileMs < cutoffMs) {
try { unlinkSync(resolve(LOG_DIR, f)); } catch { /* ignore */ }
}
}
} catch { /* ignore */ }
}
try { mkdirSync(LOG_DIR, { recursive: true }); } catch { /* ignore */ }
cleanupOldLogs();
// at 0:01 switch date file + clean up old files (extra 1 minute tolerance for cross-day timezone jitter)
function scheduleDailyLogRotate(): void {
const now = new Date();
const cnNow = now.getTime() + 8 * 3600 * 1000;
const next = new Date(cnNow);
next.setUTCHours(0, 1, 0, 0);
next.setUTCDate(next.getUTCDate() + 1);
const delayMs = next.getTime() - cnNow;
setTimeout(() => {
cleanupOldLogs();
scheduleDailyLogRotate();
}, Math.max(60_000, delayMs)).unref?.();
}
scheduleDailyLogRotate();
// rotate when a single log file exceeds this size (rename to .1, overwriting old .1; new writes start from 0)
const LOG_MAX_BYTES = 10 * 1024 * 1024;
// RAW log switch (detailed raw data, large size): off by default, set RAW_LOG_ENABLED=true in .env when debugging
const RAW_LOG_ENABLED = String(process.env.RAW_LOG_ENABLED ?? "").toLowerCase() === "true";
function rotateIfTooLarge(file: string): void {
try {
if (!existsSync(file)) return;
const sz = statSync(file).size;
if (sz < LOG_MAX_BYTES) return;
const rotated = file + ".1";
try { if (existsSync(rotated)) unlinkSync(rotated); } catch { /* ignore */ }
renameSync(file, rotated);
} catch { /* ignore */ }
}
function appendCleanLog(line: string): void {
try {
const f = getCleanLogFile();
rotateIfTooLarge(f);
appendFileSync(f, line + "\n");
} catch { /* ignore */ }
}
function appendRawLog(line: string): void {
if (!RAW_LOG_ENABLED) return;
try {
const f = getRawLogFile();
rotateIfTooLarge(f);
appendFileSync(f, line + "\n");
} catch { /* ignore */ }
}
(["log", "warn", "error"] as const).forEach(method => {
const orig = console[method].bind(console);
console[method] = (...args: unknown[]) => {
const ts = new Date().toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false });
// detect whether the first arg is the "[RAW]" marker
const isRaw = args.length > 0 && args[0] === "[RAW]";
let realArgs = isRaw ? args.slice(1) : args;
// The SDK's "[CLOB Client] request error" log dumps the entire axios request/response into an extremely long JSON, flooding badly
// after intercepting: terminal only shows a short summary (status + url + error), the full object goes to raw log
let isClobErrorDigest = false;
if (!isRaw && realArgs.length >= 2 && realArgs[0] === "[CLOB Client] request error") {
try {
const obj = typeof realArgs[1] === "string" ? JSON.parse(realArgs[1] as string) : realArgs[1];
const status = (obj as any)?.status ?? "?";
const errMsg = (obj as any)?.data?.error ?? (obj as any)?.error ?? "?";
const url = (obj as any)?.config?.url ?? "?";
// generate a short summary for terminal + clean log; full body goes to raw log
appendRawLog(`[${ts}] [${method.toUpperCase()}] [CLOB Client] request error full: ${typeof realArgs[1] === "string" ? realArgs[1] : JSON.stringify(obj)}`);
realArgs = [`[CLOB Client] ${status} ${url.split("?")[0]} - ${errMsg}`];
isClobErrorDigest = true;
} catch { /* on parse failure pass through as-is */ }
}
const text = realArgs.map(a => {
if (typeof a === "string") return a;
if (a instanceof Error) return a.stack || a.message;
try { return typeof a === "object" ? JSON.stringify(a) : String(a); } catch { return String(a); }
}).join(" ");
if (!isRaw) {
// clean: terminal + clean log
orig(`[${ts}]`, ...realArgs);
appendCleanLog(`[${ts}] [${method.toUpperCase()}] ${text}`);
}
// raw always goes to raw log (clean lines are mirrored too, to keep raw log context complete)
// if the full CLOB body was already written to raw log above, skip the corresponding line here to avoid duplication
if (!isClobErrorDigest) {
appendRawLog(`[${ts}] [${method.toUpperCase()}]${isRaw ? " [RAW]" : ""} ${text}`);
}
};
});
// prevent uncaught Promise rejections (e.g. RPC timeouts) from killing the process
process.on('unhandledRejection', (reason) => {
console.error('[System.Exception]', reason instanceof Error ? reason.message : reason);
});
type AppMode = "full" | "headless";
type ClientDataMode = "full" | "low";
interface StrategyConfig {
enabled: Record<StrategyKey, boolean>;
amount: Record<StrategyKey, number>;
shares: Record<StrategyKey, number>; // share config for limit strategies (minimum 5)
/** strategy tunable params (e.g. l1's tpDelta/slDiff), indexed by strategyKey -> paramKey -> value */
params: Record<StrategyKey, Record<string, number>>;
slippage: number;
autoClaimEnabled: boolean;
maxRoundEntries: number;
marketHoursOnly: boolean; // momentum strategies only enter during US market hours
weekendPause: boolean; // US Eastern weekend (Sat/Sun) pauses all strategy entries
}
interface StrategyConfigUpdate {
enabled?: Partial<Record<StrategyKey, unknown>>;
amount?: Partial<Record<StrategyKey, unknown>>;
shares?: Partial<Record<StrategyKey, unknown>>;
params?: Partial<Record<StrategyKey, unknown>>;
slippage?: unknown;
autoClaimEnabled?: unknown;
maxRoundEntries?: unknown;
marketHoursOnly?: unknown;
weekendPause?: unknown;
}
interface StrategyRuntimeState {
state: StrategyLifecycleState;
activeStrategy: StrategyKey | null;
direction: StrategyDirection | null;
buyAmount: number;
posBeforeBuy: number;
posBeforeSell: number;
waitVerifyAfterSell: boolean;
cleanupAfterVerify: boolean;
actionTs: number;
prevUpPct: number | null;
buyLockUntil: number;
positionsReady: boolean;
roundEntryCount: number;
}
interface Kline {
openTime: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
closed: boolean;
}
interface PendingTradeMeta {
key: string;
orderId?: string;
ts: number;
windowStart: number;
side: "buy" | "sell";
direction: StrategyDirection;
amount: number;
worstPrice: number;
source: string;
exitReason?: string;
roundEntry?: string;
// only on buy: may carry these; after fill, create conditional orders based on these params
stopProfit?: { pctDelta?: number; targetPrice?: number };
stopLoss?: { pctDelta?: number; diffValue?: number; slippage?: number };
}
interface ClientSession {
dataMode: ClientDataMode;
lastStateSentAt: number;
stateTimer: NodeJS.Timeout | null;
stateDirty: boolean;
stateIncludeHistory: boolean;
}
interface StatePayloadOptions {
includeHistory?: boolean;
simple?: boolean;
}
function parseBooleanEnv(name: string, fallback: boolean): boolean {
const raw = process.env[name]?.trim().toLowerCase();
if (!raw) return fallback;
if (["1", "true", "yes", "on"].includes(raw)) return true;
if (["0", "false", "no", "off"].includes(raw)) return false;
return fallback;
}
function parseNumberEnv(name: string, fallback: number, minimum?: number): number {
const raw = process.env[name]?.trim();
if (!raw) return fallback;
const value = Number(raw);
if (!Number.isFinite(value)) return fallback;
if (minimum != null && value < minimum) return fallback;
return value;
}
function parseBooleanLike(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
}
return null;
}
function parseNumberLike(value: unknown, minimum: number): number | null {
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (!Number.isFinite(parsed) || parsed < minimum) return null;
return parsed;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value != null && !Array.isArray(value);
}
const PORT_BASE = Number(process.env.PORT) || 3456;
const PORT_MAX_TRIES = 10;
let PORT = PORT_BASE;
const MARKET_WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market";
const CHAINLINK_WS_URL = "wss://ws-live-data.polymarket.com";
const USER_WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/user";
const COINBASE_WS_URL = "wss://ws-feed.exchange.coinbase.com";
// Note: Binance WS URL, Coinbase product, slug prefix all depend on the current activeMarket, see market-configs.ts below
const GAMMA_URL = "https://gamma-api.polymarket.com";
const CLOB_URL = "https://clob.polymarket.com";
const HISTORY_RETENTION_MS = 130000;
const MAX_CHAINLINK_HISTORY_POINTS = 2000;
const MAX_BINANCE_HISTORY_POINTS = 4000;
const MAX_COINBASE_HISTORY_POINTS = 4000;
const MAX_KLINE_1M = 200; // keep 200 1-minute klines
const MAX_KLINE_5M = 50; // keep 50 5-minute klines
const MAX_CONFIRMED_TRADE_IDS = 2000;
const CLAIM_CYCLE_DELAY_MS = 15000; // query loop interval 15s (frontend amount refresh + on-chain balance check)
const CLAIM_COOLDOWN_MS = 5 * 60 * 1000; // Claim execution cooldown: can only claim again 5 minutes after success/failure
const UNVERIFIED_SELL_BUFFER = 0.05;
const POST_TRADE_CALIBRATION_MS = 18000; // post-order calibration wait duration; the buy lock also uses this value
const STRAT_BUY_LOCK_MS = POST_TRADE_CALIBRATION_MS;
const STRATEGY_TICK_MS = 250;
const WAIT_FILL_TIMEOUT_MS = 10000;
const FILL_RECONCILE_TIMEOUT_MS = POST_TRADE_CALIBRATION_MS + 2000; // wait 2 more seconds after calibration completes to confirm
const BINANCE_ALIGN_WINDOW_MS = 60000;
const BINANCE_ALIGN_MIN_SPAN_MS = 10000;
const BINANCE_ALIGN_BUCKET_MS = 500;
const BINANCE_ALIGN_REFRESH_MS = 30000;
const BINANCE_OFFSET_EPSILON = 0.01;
const FULL_DATA_STATE_INTERVAL_MS = 200;
const LOW_DATA_STATE_INTERVAL_MS = 2000;
const MAX_WS_BUFFERED_BYTES = 512 * 1024;
const STRATEGY_CONFIG_FILE = resolve(__dirname, ".strategy-config.json");
const ACTIVE_MARKET_FILE = resolve(__dirname, ".active-market.json");
const BACKTEST_DATA_DIR = resolve(__dirname, "backtest-data");
function loadActiveMarketKey(): MarketKey {
try {
if (existsSync(ACTIVE_MARKET_FILE)) {
const data = JSON.parse(readFileSync(ACTIVE_MARKET_FILE, "utf-8"));
// new format: { key: "btc-5m" }
if (data && typeof data.key === "string" && isValidKey(data.key)) return data.key;
// compat with old format: { symbol: "btc" } -> default upgrade to 5m
if (data && typeof data.symbol === "string" && isLegacySymbol(data.symbol)) {
return `${data.symbol}-5m` as MarketKey;
}
}
} catch { /* ignore */ }
return DEFAULT_KEY;
}
function saveActiveMarketKey(key: MarketKey): void {
try {
writeFileSync(ACTIVE_MARKET_FILE, JSON.stringify({ key }, null, 2) + "\n", "utf-8");
} catch (err) {
console.warn(`[System.Market] failed to save current market: ${(err as Error).message}`);
}
}
let activeMarket: MarketConfig = MARKETS[loadActiveMarketKey()];
setFairProbMarket(activeMarket.symbol, activeMarket.period);
setDiffExtremesMarket(activeMarket.symbol, activeMarket.period);
console.log(`[System.Market] current market: ${activeMarket.displayName} (key=${activeMarket.key})`);
const PENDING_TRADE_META_MAX_AGE_MS = 15 * 60 * 1000;
const PRIVATE_KEY = process.env.POLYMARKET_PRIVATE_KEY || "";
const PROXY_ADDRESS = process.env.POLYMARKET_PROXY_ADDRESS || "";
let accountName = "";
async function fetchAccountNameOnce(): Promise<boolean> {
try {
const res = await fetch(
`https://polymarket.com/api/profile/userData?address=${PROXY_ADDRESS.toLowerCase()}`,
{ signal: AbortSignal.timeout(5000) }
);
if (!res.ok) return false;
const data = await res.json() as { name?: string; pseudonym?: string };
const name = data.name || data.pseudonym || "";
if (!name) return false;
accountName = name;
saveAccountNameToCreds(name);
console.log(`[System.Account] ${name} (${PROXY_ADDRESS.slice(0, 6)}...${PROXY_ADDRESS.slice(-4)})`);
return true;
} catch (e) {
console.log(`[System.Account] failed to get username:`, (e as Error).message);
return false;
}
}
async function ensureAccountName(): Promise<void> {
if (accountName || !PROXY_ADDRESS) return;
// backoff: 5s / 30s / 5min, give up after all fail, retry on next startup
const delays = [5_000, 30_000, 300_000];
if (await fetchAccountNameOnce()) return;
for (const ms of delays) {
await new Promise(r => setTimeout(r, ms));
if (accountName) return; // filled by another path in the meantime
if (await fetchAccountNameOnce()) return;
}
}
const APP_MODE: AppMode = process.env.APP_MODE === "headless" ? "headless" : "full";
const IS_FULL_MODE = APP_MODE === "full";
function createEnvStrategyConfig(): StrategyConfig {
const enabled = {} as Record<StrategyKey, boolean>;
const amount = {} as Record<StrategyKey, number>;
const shares = {} as Record<StrategyKey, number>;
const params = {} as Record<StrategyKey, Record<string, number>>;
for (const key of ALL_STRATEGY_KEYS) {
const upper = key.toUpperCase();
enabled[key] = parseBooleanEnv(`STRATEGY_${upper}_ENABLED`, false);
amount[key] = parseNumberEnv(`STRATEGY_${upper}_AMOUNT`, 1, 0.01);
shares[key] = parseNumberEnv(`STRATEGY_${upper}_SHARES`, 5, 5);
params[key] = {};
}
return {
enabled,
amount,
shares,
params,
slippage: parseNumberEnv("ORDER_DEFAULT_SLIPPAGE", 0.06, 0),
autoClaimEnabled: parseBooleanEnv("AUTO_CLAIM_ENABLED", true),
maxRoundEntries: parseNumberEnv("MAX_ROUND_ENTRIES", 1, 1),
marketHoursOnly: parseBooleanEnv("MARKET_HOURS_ONLY", false),
weekendPause: parseBooleanEnv("WEEKEND_PAUSE", false),
};
}
function cloneStrategyConfig(config: StrategyConfig): StrategyConfig {
const params = {} as Record<StrategyKey, Record<string, number>>;
for (const key of Object.keys(config.params)) {
params[key] = { ...config.params[key] };
}
return {
enabled: { ...config.enabled },
amount: { ...config.amount },
shares: { ...config.shares },
params,
slippage: config.slippage,
autoClaimEnabled: config.autoClaimEnabled,
maxRoundEntries: config.maxRoundEntries,
marketHoursOnly: config.marketHoursOnly,
weekendPause: config.weekendPause,
};
}
function loadPersistedStrategyConfig(config: StrategyConfig): void {
if (!existsSync(STRATEGY_CONFIG_FILE)) return;
try {
const raw = JSON.parse(readFileSync(STRATEGY_CONFIG_FILE, "utf-8"));
if (typeof raw.maxRoundEntries === "number" && raw.maxRoundEntries >= 1) {
config.maxRoundEntries = Math.floor(raw.maxRoundEntries);
}
if (typeof raw.marketHoursOnly === "boolean") {
config.marketHoursOnly = raw.marketHoursOnly;
}
if (typeof raw.weekendPause === "boolean") {
config.weekendPause = raw.weekendPause;
}
if (typeof raw.autoClaimEnabled === "boolean") {
config.autoClaimEnabled = raw.autoClaimEnabled;
}
if (isRecord(raw.enabled)) {
for (const key of Object.keys(raw.enabled)) {
if (typeof raw.enabled[key] === "boolean") {
config.enabled[key] = raw.enabled[key] as boolean;
}
}
}
if (isRecord(raw.amount)) {
for (const key of Object.keys(raw.amount)) {
const v = raw.amount[key];
if (typeof v === "number" && v >= 0.01) {
config.amount[key] = v;
}
}
}
if (isRecord(raw.shares)) {
for (const key of Object.keys(raw.shares)) {
const v = raw.shares[key];
if (typeof v === "number" && v >= 5) {
config.shares[key] = v;
}
}
}
if (isRecord(raw.params)) {
for (const key of Object.keys(raw.params)) {
const sub = raw.params[key];
if (!isRecord(sub)) continue;
if (config.params[key] == null) config.params[key] = {};
for (const pk of Object.keys(sub)) {
const v = sub[pk];
if (typeof v === "number" && Number.isFinite(v)) {
config.params[key][pk] = v;
}
}
}
}
} catch {
// ignore
}
}
function savePersistedStrategyConfig(config: StrategyConfig): void {
try {
writeFileSync(STRATEGY_CONFIG_FILE, JSON.stringify({
maxRoundEntries: config.maxRoundEntries,
marketHoursOnly: config.marketHoursOnly,
weekendPause: config.weekendPause,
autoClaimEnabled: config.autoClaimEnabled,
enabled: config.enabled,
amount: config.amount,
shares: config.shares,
params: config.params,
}, null, 2));
} catch (err) {
console.warn(`[Strategy.Config] persist save failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
function getStrategyTunableParams(key: StrategyKey): TunableParam[] {
const s = getStrategy(key);
if (!s) return [];
try {
return s.getDescription().tunableParams ?? [];
} catch {
return [];
}
}
/** inject the current value of strategyConfig.params[key] into the strategy instance field (call before each tick / at startup) */
function applyTunableParamsToStrategy(s: { key: StrategyKey }): void {
const defs = getStrategyTunableParams(s.key);
if (!defs.length) return;
const stored = strategyConfig.params[s.key];
for (const def of defs) {
const v = stored?.[def.key];
if (typeof v === "number" && Number.isFinite(v)) {
(s as any)[def.key] = v;
}
}
}
function applyStrategyConfigUpdate(current: StrategyConfig, rawUpdate: unknown): { config?: StrategyConfig; error?: string } {
if (!isRecord(rawUpdate)) return { error: "invalid config format" };
const next = cloneStrategyConfig(current);
if ("enabled" in rawUpdate) {
if (!isRecord(rawUpdate.enabled)) return { error: "invalid enabled config format" };
for (const key of ALL_STRATEGY_KEYS) {
if (!(key in rawUpdate.enabled)) continue;
const parsed = parseBooleanLike(rawUpdate.enabled[key]);
if (parsed == null) return { error: `${key} switch must be a boolean` };
next.enabled[key] = parsed;
}
}
if ("amount" in rawUpdate) {
if (!isRecord(rawUpdate.amount)) return { error: "invalid amount config format" };
for (const key of ALL_STRATEGY_KEYS) {
if (!(key in rawUpdate.amount)) continue;
const parsed = parseNumberLike(rawUpdate.amount[key], 0.01);
if (parsed == null) return { error: `${key} amount must be >= 0.01` };
next.amount[key] = parsed;
}
}
if ("shares" in rawUpdate) {
if (!isRecord(rawUpdate.shares)) return { error: "invalid shares config format" };
for (const key of ALL_STRATEGY_KEYS) {
if (!(key in rawUpdate.shares)) continue;
const parsed = parseNumberLike(rawUpdate.shares[key], 5);
if (parsed == null) return { error: `${key} shares must be >= 5` };
next.shares[key] = parsed;
}
}
if ("params" in rawUpdate) {
if (!isRecord(rawUpdate.params)) return { error: "invalid params config format" };
for (const key of ALL_STRATEGY_KEYS) {
if (!(key in rawUpdate.params)) continue;
const sub = rawUpdate.params[key];
if (!isRecord(sub)) return { error: `${key} invalid params format` };
const desc = getStrategyTunableParams(key);
if (next.params[key] == null) next.params[key] = {};
for (const pk of Object.keys(sub)) {
const def = desc.find((d: TunableParam) => d.key === pk);
const min = def?.min ?? -Infinity;
const parsed = parseNumberLike(sub[pk], min === -Infinity ? -Number.MAX_SAFE_INTEGER : min);
if (parsed == null) return { error: `${key}.${pk} must be >= ${min}` };
if (def?.max != null && parsed > def.max) return { error: `${key}.${pk} must be <= ${def.max}` };
next.params[key][pk] = parsed;
}
// L1 cross-param constraints
if (key === "l1") {
const p = next.params.l1 ?? {};
const defaults: Record<string, number> = {};
for (const d of desc) defaults[d.key] = d.defaultValue;
const entryDiff = p.entryDiff ?? defaults.entryDiff ?? 40;
const cancelDiff = p.cancelDiff ?? defaults.cancelDiff ?? 30;
const limitPrice = p.limitPrice ?? defaults.limitPrice ?? 0.5;
const tpDelta = p.tpDelta ?? defaults.tpDelta ?? 0.15;
const remMax = p.remMax ?? defaults.remMax ?? 300;
const remMin = p.remMin ?? defaults.remMin ?? 10;
if (cancelDiff >= entryDiff) return { error: `L1 cancel threshold (${cancelDiff}) must be less than entry threshold (${entryDiff})` };
if (limitPrice + tpDelta > 0.99 + 1e-9) return { error: `L1 entry price + TP delta (${(limitPrice + tpDelta).toFixed(2)}) must be <= 0.99` };
if (remMin >= remMax) return { error: `L1 entry time-remaining lower bound (${remMin}) must be less than upper bound (${remMax})` };
}
}
}
if ("slippage" in rawUpdate) {
const parsed = parseNumberLike(rawUpdate.slippage, 0);
if (parsed == null) return { error: "slippage must be >= 0" };
next.slippage = parsed;
}
if ("autoClaimEnabled" in rawUpdate) {
const parsed = parseBooleanLike(rawUpdate.autoClaimEnabled);
if (parsed == null) return { error: "autoClaimEnabled must be a boolean" };
next.autoClaimEnabled = parsed;
}
if ("maxRoundEntries" in rawUpdate) {
const parsed = parseNumberLike(rawUpdate.maxRoundEntries, 1);
if (parsed == null || !Number.isInteger(parsed)) return { error: "maxRoundEntries must be an integer >= 1" };
next.maxRoundEntries = parsed;
}
if ("marketHoursOnly" in rawUpdate) {
const parsed = parseBooleanLike(rawUpdate.marketHoursOnly);
if (parsed == null) return { error: "marketHoursOnly must be a boolean" };
next.marketHoursOnly = parsed;
}
if ("weekendPause" in rawUpdate) {
const parsed = parseBooleanLike(rawUpdate.weekendPause);
if (parsed == null) return { error: "weekendPause must be a boolean" };
next.weekendPause = parsed;
}
return { config: next };
}
// strategyConfig is first initialized as an empty shell; the strategy loader fills it after (after initStrategies)
let strategyConfig: StrategyConfig = {
enabled: {}, amount: {}, shares: {}, params: {},
slippage: parseNumberEnv("ORDER_DEFAULT_SLIPPAGE", 0.06, 0),
autoClaimEnabled: parseBooleanEnv("AUTO_CLAIM_ENABLED", true),
maxRoundEntries: parseNumberEnv("MAX_ROUND_ENTRIES", 1, 1),
marketHoursOnly: parseBooleanEnv("MARKET_HOURS_ONLY", false),
weekendPause: parseBooleanEnv("WEEKEND_PAUSE", false),
};
function initStrategyConfig(): void {
strategyConfig = createEnvStrategyConfig();
loadPersistedStrategyConfig(strategyConfig);
}
const pendingTradeMeta = new Map<string, PendingTradeMeta>();
// -- Manual order config (amount / slippage / TP-SL) persisted to .manual-config.json --
const MANUAL_CONFIG_FILE = resolve(__dirname, ".manual-config.json");
interface ManualConfig {
amount: number; // USDC
slippage: number; // 0-0.99 (percentage / 100)
cond: {
tpEnabled: boolean;
tpPct: number; // 0-99 (percentage points)
slEnabled: boolean;
slMode: "price" | "diff"; // price=by fill-price percentage points / diff=by diff crossing threshold
slPct: number; // used when mode=price, 0-99 (percentage points)
slDiff: number; // used when mode=diff, positive number (e.g. 5 means: when buying up triggers at diff<=-5 / when buying down triggers at diff>=+5)
slSlippage: number; // 0-99
collapsed: boolean;
};
}
const MANUAL_DEFAULT: ManualConfig = {
amount: 1,
slippage: 0.06,
cond: { tpEnabled: false, tpPct: 20, slEnabled: false, slMode: "price", slPct: 20, slDiff: 5, slSlippage: 15, collapsed: true },
};
let manualConfig: ManualConfig = structuredClone(MANUAL_DEFAULT);
function loadManualConfig(): void {
if (!existsSync(MANUAL_CONFIG_FILE)) return;
try {
const raw = JSON.parse(readFileSync(MANUAL_CONFIG_FILE, "utf-8")) as Partial<ManualConfig>;
if (typeof raw.amount === "number" && raw.amount >= 0) manualConfig.amount = raw.amount;
if (typeof raw.slippage === "number" && raw.slippage >= 0 && raw.slippage <= 1) manualConfig.slippage = raw.slippage;
if (raw.cond && typeof raw.cond === "object") {
const c = raw.cond as Partial<ManualConfig["cond"]>;
if (typeof c.tpEnabled === "boolean") manualConfig.cond.tpEnabled = c.tpEnabled;
if (typeof c.tpPct === "number" && c.tpPct >= 0 && c.tpPct <= 99) manualConfig.cond.tpPct = c.tpPct;
if (typeof c.slEnabled === "boolean") manualConfig.cond.slEnabled = c.slEnabled;
if (c.slMode === "price" || c.slMode === "diff") manualConfig.cond.slMode = c.slMode;
if (typeof c.slPct === "number" && c.slPct >= 0 && c.slPct <= 99) manualConfig.cond.slPct = c.slPct;
if (typeof c.slDiff === "number" && c.slDiff >= -200 && c.slDiff <= 200) manualConfig.cond.slDiff = c.slDiff;
if (typeof c.slSlippage === "number" && c.slSlippage >= 0 && c.slSlippage <= 99) manualConfig.cond.slSlippage = c.slSlippage;
if (typeof c.collapsed === "boolean") manualConfig.cond.collapsed = c.collapsed;
}
} catch (err) {
console.warn(`[Manual.Config] load failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
function saveManualConfig(): void {
try {
writeFileSync(MANUAL_CONFIG_FILE, JSON.stringify(manualConfig, null, 2));
} catch (err) {
console.warn(`[Manual.Config] save failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
loadManualConfig();
function applyManualConfigUpdate(raw: unknown): string | null {
if (!isRecord(raw)) return "invalid config format";
const next = structuredClone(manualConfig);
if ("amount" in raw) {
const v = Number(raw.amount);
if (!Number.isFinite(v) || v < 0) return "amount invalid";
next.amount = v;
}
if ("slippage" in raw) {
const v = Number(raw.slippage);
if (!Number.isFinite(v) || v < 0 || v > 1) return "slippage must be within 0-1";
next.slippage = v;
}
if ("cond" in raw && isRecord(raw.cond)) {
const c = raw.cond;
if ("tpEnabled" in c) next.cond.tpEnabled = !!c.tpEnabled;
if ("tpPct" in c) { const v = Number(c.tpPct); if (Number.isFinite(v) && v >= 0 && v <= 99) next.cond.tpPct = v; }
if ("slEnabled" in c) next.cond.slEnabled = !!c.slEnabled;
if ("slMode" in c && (c.slMode === "price" || c.slMode === "diff")) next.cond.slMode = c.slMode;
if ("slPct" in c) { const v = Number(c.slPct); if (Number.isFinite(v) && v >= 0 && v <= 99) next.cond.slPct = v; }
if ("slDiff" in c) { const v = Number(c.slDiff); if (Number.isFinite(v) && v >= -200 && v <= 200) next.cond.slDiff = v; }
if ("slSlippage" in c) { const v = Number(c.slSlippage); if (Number.isFinite(v) && v >= 0 && v <= 99) next.cond.slSlippage = v; }
if ("collapsed" in c) next.cond.collapsed = !!c.collapsed;
}
manualConfig = next;
saveManualConfig();
return null;
}
// -- Polymarket real PnL manager -----------------------------------------
const pmPnlManager = new PmPnlManager(PROXY_ADDRESS);
/** after a fill lands, only record the strategy source (PnL stats rely on the full refresh every 5 minutes, no longer incremental sync) */
function notifyTradeForPnl(txHash: string | undefined, source: string | undefined): void {
if (txHash && source) {
pmPnlManager.recordStrategySource(txHash, source);
}
}
// -- HTTP heartbeat status ---------------------------------------
// the two channels are timed independently, reflecting the real connection-reuse status of the order path:
// - undici: fetch() related (order book query / Gamma API / TG)
// - axios: clob-client place order / query and cancel open orders
const httpHeartbeat = {
latencyMs: -1, // undici (fetch) last heartbeat latency (-1 = not tested/failed)
lastAt: 0,
ok: false,
};
const axiosHeartbeat = {
latencyMs: -1, // axios (Node native https) last heartbeat latency
lastAt: 0,
ok: false,
};
function broadcastHttpHeartbeat(): void {
broadcast("httpHeartbeat", {
latencyMs: httpHeartbeat.latencyMs,
lastAt: httpHeartbeat.lastAt,
ok: httpHeartbeat.ok,
});
}
function broadcastAxiosHeartbeat(): void {
broadcast("axiosHeartbeat", {
latencyMs: axiosHeartbeat.latencyMs,
lastAt: axiosHeartbeat.lastAt,
ok: axiosHeartbeat.ok,
});
}
function sendHttpHeartbeatToClient(ws: WebSocket): void {
send(ws, "httpHeartbeat", {
latencyMs: httpHeartbeat.latencyMs,
lastAt: httpHeartbeat.lastAt,
ok: httpHeartbeat.ok,
});
send(ws, "axiosHeartbeat", {
latencyMs: axiosHeartbeat.latencyMs,
lastAt: axiosHeartbeat.lastAt,
ok: axiosHeartbeat.ok,
});
}
// -- Order latency monitoring -----------------------------------------
// the only metric: the moment this machine initiates postOrder -> the moment UserWS receives the MATCHED event for that order
// reflects the real elapsed time from order to match receipt (network uplink + match decision + WS downlink)
//
// difficulty: WS MATCHED sometimes arrives before the HTTP response (WS is faster than HTTP), at which point the orderID is not yet known
// solution: bidirectional cache. Whichever of HTTP and WS arrives first is recorded first, when the other arrives later it is matched and settled
const ORDER_LATENCY_HISTORY_MAX = 10;
const ORDER_LATENCY_TIMEOUT_MS = 15000;
interface PendingByOrderId {
t0: number; // moment postOrder was initiated
timeoutTimer: ReturnType<typeof setTimeout>;
}
interface EarlyWsEntry {
orderId: string;
tWs: number; // moment WS MATCHED arrived
}
interface LatencySample {
ts: number; // moment of completion
latencyMs: number; // order -> WS MATCHED total elapsed time; -1 = timeout
}
// in-flight timings with known orderID (registered after postOrder returns)
const pendingByOrderId = new Map<string, PendingByOrderId>();
// WS events that arrived before the HTTP response (cached, looked up by orderID after HTTP returns)
const earlyWsCache = new Map<string, EarlyWsEntry>();
const latencyHistory: LatencySample[] = [];
const orderLatencyStatus = {
lastMs: -1, // last latency; -1 = not tested
lastAt: 0,
timeouts: 0, // cumulative WS timeouts
wsDisconnects: 0,
};
function pushLatencySample(sample: LatencySample): void {
latencyHistory.unshift(sample);
if (latencyHistory.length > ORDER_LATENCY_HISTORY_MAX) latencyHistory.length = ORDER_LATENCY_HISTORY_MAX;
}
function computeAvg(): number {
const valid = latencyHistory.filter(s => s.latencyMs >= 0);
if (!valid.length) return -1;
return Math.round(valid.reduce((a, b) => a + b.latencyMs, 0) / valid.length);
}
function buildOrderLatencyPayload(): Record<string, unknown> {
return {
lastMs: orderLatencyStatus.lastMs,
lastAt: orderLatencyStatus.lastAt,
avgMs: computeAvg(),
timeouts: orderLatencyStatus.timeouts,
wsDisconnects: orderLatencyStatus.wsDisconnects,
history: latencyHistory.slice(0, ORDER_LATENCY_HISTORY_MAX),
};
}
function broadcastOrderLatency(): void {
broadcast("orderLatency", buildOrderLatencyPayload());
}
function sendOrderLatencyToClient(ws: WebSocket): void {
send(ws, "orderLatency", buildOrderLatencyPayload());
}
function recordLatencySuccess(latencyMs: number, orderId: string): void {
orderLatencyStatus.lastMs = latencyMs;
orderLatencyStatus.lastAt = Date.now();
pushLatencySample({ ts: Date.now(), latencyMs });
console.log(`[Trade.Latency] order latency ${latencyMs}ms orderID:${fmtOid(orderId)}`);
broadcastOrderLatency();
}
/**
* called after postOrder returns; registers orderID and checks earlyWsCache
* if WS already arrived -> settle immediately; otherwise start a 15s timeout waiting for WS
*/
function registerOrderLatencyStart(orderId: string, t0: number): void {
if (!orderId) return;
// WS already arrived? settle directly
const early = earlyWsCache.get(orderId);
if (early) {
earlyWsCache.delete(orderId);
recordLatencySuccess(early.tWs - t0, orderId);
return;
}
// set timeout
const timeoutTimer = setTimeout(() => {
if (!pendingByOrderId.has(orderId)) return;
pendingByOrderId.delete(orderId);
orderLatencyStatus.timeouts++;
orderLatencyStatus.lastMs = -1;
orderLatencyStatus.lastAt = Date.now();
pushLatencySample({ ts: Date.now(), latencyMs: -1 });
console.warn(`[Trade.Latency] orderID=${fmtOid(orderId)} WS MATCHED timeout (>${ORDER_LATENCY_TIMEOUT_MS}ms)`);
broadcastOrderLatency();
}, ORDER_LATENCY_TIMEOUT_MS);
pendingByOrderId.set(orderId, { t0, timeoutTimer });
}
/**
* called when UserWS receives a MATCHED event
* if HTTP already returned and registered -> settle; otherwise cache in earlyWsCache waiting for HTTP return
*/
function onWsMatched(orderId: string | undefined): void {
if (!orderId) return;
const pending = pendingByOrderId.get(orderId);
if (pending) {
clearTimeout(pending.timeoutTimer);
pendingByOrderId.delete(orderId);
recordLatencySuccess(Date.now() - pending.t0, orderId);
return;
}
// WS arrived before HTTP, cache it (EarlyWsCache also set to expire in 15s)
earlyWsCache.set(orderId, { orderId, tWs: Date.now() });
setTimeout(() => earlyWsCache.delete(orderId), ORDER_LATENCY_TIMEOUT_MS);
}
function invalidatePendingLatencyOnWsDisconnect(): void {
const total = pendingByOrderId.size + earlyWsCache.size;
if (!total) return;
for (const p of pendingByOrderId.values()) clearTimeout(p.timeoutTimer);
pendingByOrderId.clear();
earlyWsCache.clear();
orderLatencyStatus.wsDisconnects += total;
console.warn(`[Trade.Latency] UserWS disconnected, discarding ${total} in-flight timings`);
broadcastOrderLatency();
}
// -- Conditional orders (TP GTC limit resting order + SL local monitoring) --------------
// all state lives in memory; lost on service restart - GTC resting orders stay on Polymarket's side,
// after restart the frontend list will be empty (invisible), but it does not affect the fill of the real resting orders.
// on window switch all GTC are automatically cancelled and the SL list is cleared.
type CondKind = "tp" | "sl" | "sl-diff";
type CondStatus = "open" | "filled" | "canceled" | "triggered" | "failed";
interface ConditionOrder {
id: string; // internal ID (uuid-ish)
groupId: string; // shared by the TP+SL produced by the same buy; used to clean up the same group when one triggers
kind: CondKind; // tp=TP limit resting order sl=SL local monitoring
direction: StrategyDirection; // position direction
assetId: string; // the asset of this position
windowStart: number; // the window it belongs to
createdAt: number;
size: number; // original shares
remainingSize: number; // match-layer unfilled shares (decremented by MATCHED push; display only, not used for fill decision)
chainFilledSize?: number; // on-chain confirmed filled shares (accumulated by MINED push; only counts as a real fill when it reaches zero)
entryPrice: number; // buy fill price (the baseline relative to delta)
triggerPrice: number; // trigger price (TP=resting price; SL=the probability that triggers closing)
slippage?: number; // SL only
status: CondStatus;
// TP only: the real Polymarket orderID (filled after resting order succeeds)
polymarketOrderId?: string;
pendingGtc?: boolean; // GTC resting order request in progress, local trigger forbidden during tick
failReason?: string;
}
const activeConditionOrders = new Map<string, ConditionOrder>();
function makeCondId(): string {
return `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
}
function clampPrice(p: number): number {
return Math.min(0.99, Math.max(0.01, p));
}
function serializeConditionOrders(): ConditionOrder[] {
return [...activeConditionOrders.values()].sort((a, b) => a.createdAt - b.createdAt);
}
function broadcastConditionOrders(): void {
broadcast("conditionOrders", { list: serializeConditionOrders() });
}
function sendConditionOrdersToClient(ws: WebSocket): void {
send(ws, "conditionOrders", { list: serializeConditionOrders() });
}
/**
* place a GTC limit sell order on Polymarket as take-profit
* returns: { orderID } on success | { error: string } on failure (passes through the Polymarket error message directly)
*/
async function placeTakeProfitOrder(
assetId: string,
sellSize: number,
targetPrice: number,
): Promise<{ orderID?: string; error?: string }> {
if (!clobClient) return { error: "Clob not initialized" };
try {
const tickSize = getCachedTickSize(assetId);
const priceDecimals = getDecimalPlaces(tickSize);
const normalizedSize = floorToDecimals(sellSize, 2);
const normalizedPrice = floorToDecimals(targetPrice, priceDecimals);
if (normalizedSize <= 0 || normalizedPrice <= 0 || normalizedPrice >= 1) {
return { error: `invalid params size=${normalizedSize} price=${normalizedPrice}` };
}
const tpArgs = { tokenID: assetId, side: Side.SELL, price: normalizedPrice, size: normalizedSize };
const tpOpts = { tickSize, negRisk: false };
console.log("[RAW]", `[Cond.TP] createOrder args:`, JSON.stringify(tpArgs), "opts:", JSON.stringify(tpOpts), "ctx:", JSON.stringify({ assetId, sellSize, targetPrice, normalizedSize, normalizedPrice, tickSize, priceDecimals }));
const signed = await clobClient.createOrder(tpArgs, tpOpts);
console.log("[RAW]", `[Cond.TP] signedOrder:`, JSON.stringify(signed));
const tpT0 = Date.now();
const result = await clobClient.postOrder(signed, OrderType.GTC);
const tpHttpMs = Date.now() - tpT0;
console.log("[RAW]", `[Cond.TP] postOrder GTC raw result:`, JSON.stringify(result), `HTTP:${tpHttpMs}ms`);
const orderID = typeof result?.orderID === "string" ? result.orderID : "";
if (!orderID) {
const errMsg = typeof result?.error === "string" ? result.error : "response has no orderID";
return { error: errMsg };
}
console.log(`[Cond.TP] resting order sell ${normalizedSize}@${normalizedPrice} orderID=${fmtOid(orderID)}`);
registerOrderConfirm(orderID, "cond-tp", { side: "sell", size: normalizedSize, price: normalizedPrice });
return { orderID };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
/**
* cancel an already-placed take-profit GTC order
*/
async function cancelTakeProfitOrder(polymarketOrderId: string): Promise<boolean> {
if (!clobClient || !polymarketOrderId) return false;
registerCancelConfirm(polymarketOrderId, "cond-tp");
for (let attempt = 1; attempt <= 2; attempt++) {
const t0 = Date.now();
console.log("[RAW]", `[Cond.TP] cancelOrder args:`, JSON.stringify({ orderID: polymarketOrderId }), `attempt:${attempt}/2`);
try {
const result = await clobClient.cancelOrder({ orderID: polymarketOrderId });
const dt = Date.now() - t0;
console.log("[RAW]", `[Cond.TP] cancelOrder raw result:`, JSON.stringify(result), `HTTP:${dt}ms attempt:${attempt}`);
const canceledList = Array.isArray((result as any)?.canceled) ? (result as any).canceled as string[] : [];
const notCanceled = (result as any)?.not_canceled;
const ncReason = notCanceled && typeof notCanceled === "object" ? String((notCanceled as Record<string, unknown>)[polymarketOrderId] ?? "") : "";
if (canceledList.includes(polymarketOrderId)) {
console.log(`[Cond.TP] cancel resting order orderID=${fmtOid(polymarketOrderId)}${attempt > 1 ? ` (retry succeeded)` : ""}`);
return true;
}
if (ncReason) {
if (/filled|matched|not.?found|does not exist|already/i.test(ncReason)) {
console.log(`[Cond.TP] orderID=${fmtOid(polymarketOrderId)} not_canceled treated as handled: ${ncReason}`);
resolveCancelConfirm(polymarketOrderId, "HTTP not_canceled soft success");
return true;
}
console.warn(`[Cond.TP] not_canceled retry (attempt ${attempt}/2): ${ncReason}`);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const dt = Date.now() - t0;
console.log("[RAW]", `[Cond.TP] cancelOrder threw attempt:${attempt} HTTP:${dt}ms err:`, msg);
if (/not found|does not exist|already/i.test(msg)) {
console.log(`[Cond.TP] cancel orderID=${fmtOid(polymarketOrderId)} order no longer exists, treated as handled`);
resolveCancelConfirm(polymarketOrderId, "HTTP threw not found");
return true;
}
console.warn(`[Cond.TP] cancel failed (attempt ${attempt}/2):`, msg);
}
if (attempt < 2) await new Promise(r => setTimeout(r, 500));
}
return false;
}
/** clean up all active conditional orders for the given direction (called after a sell fills) */
async function clearConditionOrdersForDirection(
assetId: string,
direction: StrategyDirection,
reason: string,
): Promise<void> {
const toClean: ConditionOrder[] = [];
for (const cond of activeConditionOrders.values()) {
if (cond.direction !== direction) continue;
if (cond.assetId !== assetId) continue; // do not clean same-direction in a different window (different assetId)
toClean.push(cond);
}
for (const cond of toClean) {
if (cond.kind === "tp" && cond.polymarketOrderId) {
// asynchronously cancel the Polymarket GTC, non-blocking
void cancelTakeProfitOrder(cond.polymarketOrderId);
}
activeConditionOrders.delete(cond.id);
console.log(`[Cond] cleaned up ${cond.kind === "tp" ? "TP" : "SL"} ${direction} (${reason})`);
}
broadcastConditionOrders();
}
/** clean up the other conditional orders in the same group (the TP+SL from one buy share a groupId).
* when TP triggers, use excludeId=the triggered TP, to avoid cancelling itself (a filled one cannot be cancelled). */
async function clearConditionOrdersByGroup(
groupId: string,
excludeId: string | null,
reason: string,
): Promise<void> {
if (!groupId) {
broadcastConditionOrders();
return;
}
const toClean: ConditionOrder[] = [];
for (const cond of activeConditionOrders.values()) {
if (cond.groupId !== groupId) continue;
if (excludeId && cond.id === excludeId) continue;
toClean.push(cond);
}
for (const cond of toClean) {
if (cond.kind === "tp" && cond.polymarketOrderId) {
void cancelTakeProfitOrder(cond.polymarketOrderId);
}
activeConditionOrders.delete(cond.id);
console.log(`[Cond] cleaned up same group ${cond.kind === "tp" ? "TP" : "SL"} ${cond.direction} (${reason})`);
}
broadcastConditionOrders();
}
/** after buy MINED, create conditional orders (TP GTC + SL local record) */
async function createConditionOrdersAfterFill(opts: {
direction: StrategyDirection;
assetId: string;
entryPrice: number;
filledSize: number;
windowStart: number;
stopProfit?: { pctDelta?: number; targetPrice?: number };
stopLoss?: { pctDelta?: number; diffValue?: number; slippage?: number };
}): Promise<void> {
const { direction, assetId, entryPrice, filledSize, windowStart, stopProfit, stopLoss } = opts;
if (!stopProfit && !stopLoss) return;
// shared slippage for market orders (SL + fallback): prefer the SL setting, otherwise default 15%
const marketSlippage = stopLoss?.slippage ?? 0.15;
// the TP+SL produced by the same buy share one groupId; when either triggers only the same group is cleaned up
const groupId = makeCondId();
// TP GTC: supports two modes - pctDelta (relative to entry price) / targetPrice (absolute price 0~1)
const tpTargetPrice = stopProfit
? (stopProfit.targetPrice != null && stopProfit.targetPrice > 0
? clampPrice(stopProfit.targetPrice)
: (stopProfit.pctDelta != null && stopProfit.pctDelta > 0
? clampPrice(entryPrice + stopProfit.pctDelta)
: null))
: null;
if (tpTargetPrice != null) {
const targetPrice = tpTargetPrice;
const cond: ConditionOrder = {
id: makeCondId(),
groupId,
kind: "tp",
direction,
assetId,
windowStart,
createdAt: Date.now(),
size: filledSize,
remainingSize: filledSize,
entryPrice,
triggerPrice: targetPrice,
status: "open",
};
activeConditionOrders.set(cond.id, cond);
broadcastConditionOrders();
// GTC minimum size is 5 shares; if insufficient, go straight to local monitoring
const gtcSize = floorToDecimals(filledSize, 2);
if (gtcSize < 5) {
console.log(`[Cond.TP] size ${gtcSize} < 5 (GTC minimum), skip limit and go straight to local monitoring`);
cond.failReason = `size below 5 shares -> local monitoring`;
broadcastConditionOrders();
} else {
// async resting order: first mark pendingGtc, to prevent tick from falsely triggering local market TP during resting
cond.pendingGtc = true;
void (async () => {
const r = await placeTakeProfitOrder(assetId, filledSize, targetPrice);
const latest = activeConditionOrders.get(cond.id);
if (!latest) return; // already cancelled
latest.pendingGtc = false;
if (r.orderID) {
latest.polymarketOrderId = r.orderID;
// write pendingTradeMeta: used for MINED side=sell reverse lookup of source=cond-tp, tracking the real on-chain fill
rememberPendingTradeMeta({
orderId: r.orderID,
ts: Date.now(),
windowStart: latest.windowStart,
side: "sell",
direction: latest.direction,
amount: filledSize,
worstPrice: targetPrice,
source: "cond-tp",
});
// placeTakeProfitOrder already logged " resting order sell" internally, do not repeat here
broadcastConditionOrders();
} else {
// GTC resting order failed -> downgrade to local-monitoring market TP
console.warn(`[Cond.TP] TP resting order failed, downgrading to local monitoring (size=${filledSize} price=${targetPrice}): ${r.error}`);
latest.failReason = `limit failed -> local monitoring: ${r.error || "resting order failed"}`;
broadcastConditionOrders();
}
})();
} // end else (size >= 5)
}
// SL (local monitoring)
// the two modes are mutually exclusive:
// - price mode: by fill-price percentage points (pctDelta), monitor odds dropping below entryPrice - pctDelta to trigger
// - diff mode: by diff crossing (diffValue positive X), when buying up trigger at diff <= -X, when buying down trigger at diff >= +X
if (stopLoss) {
if (typeof stopLoss.pctDelta === "number" && stopLoss.pctDelta > 0) {
const triggerPrice = clampPrice(entryPrice - stopLoss.pctDelta);
const cond: ConditionOrder = {
id: makeCondId(),
groupId,
kind: "sl",
direction, assetId, windowStart,
createdAt: Date.now(),
size: filledSize, remainingSize: filledSize,
entryPrice, triggerPrice,
slippage: marketSlippage,
status: "open",
};
activeConditionOrders.set(cond.id, cond);
broadcastConditionOrders();
} else if (typeof stopLoss.diffValue === "number" && Number.isFinite(stopLoss.diffValue)) {
// diffValue is the absolute threshold entered by the user (can be positive or negative)
// buying up: trigger at diff <= triggerPrice; buying down: trigger at diff >= -triggerPrice (symmetric)
const cond: ConditionOrder = {
id: makeCondId(),
groupId,
kind: "sl-diff",
direction, assetId, windowStart,
createdAt: Date.now(),
size: filledSize, remainingSize: filledSize,
entryPrice,
triggerPrice: stopLoss.diffValue,
slippage: marketSlippage,
status: "open",
};
activeConditionOrders.set(cond.id, cond);
broadcastConditionOrders();
}
}
}
// -- Conditional order tick monitoring --------------------------------------
// TP: when there is no polymarketOrderId (GTC failed, downgraded) monitor locally, trigger a market sell when the probability reaches the target price
// SL: always monitored locally, market sell when the probability drops below the trigger price
let condTriggerInFlight = false; // prevent concurrent triggers
async function checkConditionOrdersTick(upPct: number, dnPct: number, diff: number | null): Promise<void> {
if (condTriggerInFlight) return;
const toTrigger: ConditionOrder[] = [];
for (const cond of activeConditionOrders.values()) {
if (cond.status !== "open") continue;
// TP: only monitor downgraded TP orders (no GTC resting order and not in the middle of resting)
if (cond.kind === "tp" && !cond.polymarketOrderId && !cond.pendingGtc) {
const currentPct = (cond.direction === "up" ? upPct : dnPct) / 100;
if (currentPct >= cond.triggerPrice) toTrigger.push(cond);
}
// SL: always monitored locally
if (cond.kind === "sl") {
const currentPct = (cond.direction === "up" ? upPct : dnPct) / 100;
if (currentPct <= cond.triggerPrice) toTrigger.push(cond);
}
// SL-DIFF: based on the diff absolute threshold (triggerPrice stores the user-entered value, can be positive or negative)
// buying up: trigger at diff <= triggerPrice
// buying down: trigger at diff >= -triggerPrice (symmetric)
if (cond.kind === "sl-diff" && diff != null) {
const T = cond.triggerPrice;
const triggered = cond.direction === "up" ? diff <= T : diff >= -T;
if (triggered) toTrigger.push(cond);
}
}
if (!toTrigger.length) return;
condTriggerInFlight = true;
try {
for (const cond of toTrigger) {
const latest = activeConditionOrders.get(cond.id);
if (!latest || latest.status !== "open") continue;
const kindZh = cond.kind === "tp" ? "TP" : "SL";
const currentPct = (cond.direction === "up" ? upPct : dnPct) / 100;
if (cond.kind === "sl-diff") {
const T = cond.triggerPrice;
const want = cond.direction === "up" ? `≤${T}` : `≥${-T}`;
console.log(`[Cond.SL] SL triggered ${cond.direction} diff=${diff?.toFixed(1) ?? "?"} threshold ${want} size=${cond.size}`);
} else {
console.log(`[Cond.${cond.kind}] ${kindZh} triggered ${cond.direction} pct=${(currentPct * 100).toFixed(1)}% triggerPrice=${(cond.triggerPrice * 100).toFixed(1)}% size=${cond.size}`);
}
latest.status = "triggered";
broadcastConditionOrders();
// sell shares:
// sl-diff (crossing SL) -> fully clear all positions in that direction (reverse hard signal, leave nothing)
// tp / sl -> use this order's remainingSize (and take the min with actual position, to prevent overselling)
const actualSize = getDirectionLocalSize(cond.direction);
let sellSize: number;
if (cond.kind === "sl-diff") {
sellSize = actualSize;
console.log(`[Cond.SL] fully clear ${cond.direction} position size=${actualSize.toFixed(4)}`);
} else {
sellSize = actualSize > 0.01 && actualSize < cond.remainingSize
? actualSize
: cond.remainingSize;
if (sellSize !== cond.remainingSize) {
console.log(`[Cond.${cond.kind}] actual position ${actualSize.toFixed(4)} < remainingSize ${cond.remainingSize.toFixed(4)}, using actual position`);
}
}
if (sellSize <= 0.01) {
console.log(`[Cond.${cond.kind}] actual position ${actualSize.toFixed(4)} is empty, skip`);
activeConditionOrders.delete(cond.id);
broadcastConditionOrders();
continue;
}
const result = await placeOrder({
direction: cond.direction,
side: "sell",
amount: sellSize,
slippage: cond.slippage ?? marketSlippageDefault(),
source: `cond-${cond.kind}-trigger`,
exitReason: cond.kind === "sl-diff"
? `crossing SL triggered diff${cond.direction === "up" ? "<=" : ">="}${cond.direction === "up" ? cond.triggerPrice : -cond.triggerPrice}`
: `${kindZh} triggered target ${(cond.triggerPrice * 100).toFixed(1)}%`,
});
if (result.success) {
console.log(`[Cond.${cond.kind}] ${kindZh} market sell succeeded size=${sellSize.toFixed(4)}`);
activeConditionOrders.delete(cond.id);
if (cond.kind === "sl-diff") {
// crossing SL fully cleared all positions in that direction, other same-direction conditional orders (tp/sl) must also be cleared
void clearConditionOrdersForDirection(cond.assetId, cond.direction, "crossing SL full clear");
} else {
// clean up the other side of the same group (when SL triggers clear TP, when downgraded TP triggers clear SL)
void clearConditionOrdersByGroup(cond.groupId, cond.id, `${kindZh} triggered`);
}
// broadcast a "filled" notification to the frontend (same message type as TP GTC fill)
broadcast("condFilled", { kind: cond.kind, direction: cond.direction, price: cond.triggerPrice });
} else {
latest.status = "failed";
latest.failReason = `${kindZh} market sell failed: ${result.errorMessage}`;
console.error(`[Cond.${cond.kind}] ${kindZh} market sell failed: ${result.errorMessage}`);
}
broadcastConditionOrders();
}
} finally {
condTriggerInFlight = false;
}
}
function marketSlippageDefault(): number {
return 0.15;
}
// -- Telegram push ---------------------------------------
let tgConfig: TgConfig = loadTgConfig();
let tgPushTimer: ReturnType<typeof setInterval> | null = null;
/** returns the timestamp of midnight UTC+8 today (milliseconds) */
function getCstDayStartMs(now = Date.now()): number {
const CST_OFFSET_MS = 8 * 60 * 60 * 1000;
const msSinceCstMidnight = (now + CST_OFFSET_MS) % (24 * 60 * 60 * 1000);
return now - msSinceCstMidnight;
}
/** returns the UTC+8 date string, format YYYY-MM-DD */
function getCstDateStr(now = Date.now()): string {
const CST_OFFSET_MS = 8 * 60 * 60 * 1000;
return new Date(now + CST_OFFSET_MS).toISOString().slice(0, 10);
}
function buildTgMessage(): string {
const now = new Date();
const ts = now.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false });
// balance
const usdcBalance = positions.usdc != null ? `$${positions.usdc.toFixed(2)}` : "—";
// all history + today (uniformly via computeSnapshot, ensuring consistency with frontend panel/monitor page/TG)
const today = pmPnlManager.getTotalPnl(0); // grand total (all history, only used for summary info display)
const todaySec = Math.floor(getCstDayStartMs() / 1000);
const todaySnap = pmPnlManager.computeSnapshot(todaySec);
const todayNet = todaySnap.netPnl;
const todayCount = todaySnap.positions;
const wins = todaySnap.wins;
const winRate = todaySnap.closedPositions > 0
? `${(wins / todaySnap.closedPositions * 100).toFixed(1)}%`
: "—";
// strategy status
const stratLines = ALL_STRATEGY_KEYS
.map(k => `${k}${strategyConfig.enabled[k] ? "✅" : "❌"}`)
.filter(s => s.endsWith("✅"))
.join(" ") || "all off";
// HTTP latency
const httpStatus = httpHeartbeat.ok
? `${httpHeartbeat.latencyMs}ms ${httpHeartbeat.latencyMs < 500 ? "✅" : httpHeartbeat.latencyMs < 1000 ? "⚠️" : "❌"}`
: "down";
// last 5 (excluding buys: only settlement-type events, PnL is more meaningful)
const recent = pmPnlManager.getEvents({ sinceDays: 7, limit: 20 })
.filter(e => e.kind !== "BUY")
.slice(0, 5);
const recentLines = recent.map(e => {
const t = new Date(e.ts * 1000).toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false, hour: "2-digit", minute: "2-digit" });
const kindZh = e.kind === "SELL" ? "sell" : e.kind === "REDEEM" ? "claim" : "zeroed";
const outZh = e.outcome === "Up" ? "Up▲" : "Down▼";
// source display: manual -> "manual"; strategyp3 -> "P3"; others keep original
const src = !e.strategySource
? "—"
: /^manual$/i.test(e.strategySource)
? "manual"
: e.strategySource.replace(/^strategy/i, "").toUpperCase();
const posPnl = e.positionPnl != null
? `${e.positionPnl >= 0 ? "+" : ""}$${e.positionPnl.toFixed(2)}`
: "—";
return `${t} ${kindZh} ${outZh} ${src} ${posPnl}`;
}).join("\n");
return [
`📊 ${activeMarket.displayName}${accountName ? ` · 👤 ${accountName}` : ""} (${ts})`,
`─────────────────`,
`💰 Balance: ${usdcBalance} USDC`,
`📈 Today: ${todayNet >= 0 ? "+" : ""}$${todayNet.toFixed(2)} (${todayCount} trades, win ${winRate})`,
`💸 Fees: $${todaySnap.totalFee.toFixed(2)}`,
`⚙️ Strategies: ${stratLines}`,
`🌐 HTTP: ${httpStatus}`,
``,
`━━━ Last 5 ━━━`,
recentLines || "(none)",
].join("\n");
}
function startTgPushLoop(): void {
if (tgPushTimer) {
clearInterval(tgPushTimer);
tgPushTimer = null;
}
if (!tgConfig.enabled || !tgConfig.scheduledEnabled || !tgConfig.botToken || !tgConfig.chatId) return;
const intervalMs = Math.max(5, tgConfig.intervalMinutes) * 60 * 1000;
console.log(`[Push] scheduled push started, every ${tgConfig.intervalMinutes} minutes`);
tgPushTimer = setInterval(async () => {
const text = buildTgMessage();
const result = await sendTgMessage(tgConfig, text);
if (result.ok) {
console.log(`[Push] scheduled push succeeded`);
} else {
console.warn(`[Push] scheduled push failed: ${result.error}`);
}
}, intervalMs);
}
// -- Post-trade delayed push (10 minutes after the last buy MINED) ----------------
// single slot: each new buy cancels the previously queued push and restarts the timer.
// purpose: when placing multiple orders in a row, push one summary only at "the last order"+10 minutes.
const POST_TRADE_PUSH_DELAY_MS = 10 * 60 * 1000;
let postTradePushTimer: ReturnType<typeof setTimeout> | null = null;
let postTradePushCount = 0; // cumulative order count this window (used for the push header display)
let postTradePushFirstTs = 0;
function schedulePostTradePush(meta: { direction: StrategyDirection; size: number; price: number; source: string }): void {
if (!tgConfig.enabled || !tgConfig.postTradeEnabled || !tgConfig.botToken || !tgConfig.chatId) return;
const dirZh = meta.direction === "up" ? "up" : "down";
// cancel the previously queued push (if any), restart the timer
const replaced = postTradePushTimer != null;
if (postTradePushTimer) {
clearTimeout(postTradePushTimer);
postTradePushTimer = null;
} else {
postTradePushCount = 0;
postTradePushFirstTs = Date.now();
}
postTradePushCount++;
console.log(`[Push] post-trade push ${replaced ? "rescheduled" : "queued"} (in ${POST_TRADE_PUSH_DELAY_MS / 60000} minutes): ${meta.source} buy ${dirZh} ${meta.size}@${meta.price} (cumulative ${postTradePushCount} trades)`);
const snapshotCount = postTradePushCount;
const snapshotFirstTs = postTradePushFirstTs;
postTradePushTimer = setTimeout(async () => {
postTradePushTimer = null;
const finalCount = postTradePushCount;
postTradePushCount = 0;
postTradePushFirstTs = 0;
if (!tgConfig.enabled || !tgConfig.postTradeEnabled || !tgConfig.botToken || !tgConfig.chatId) return;
const minsSinceFirst = Math.round((Date.now() - snapshotFirstTs) / 60000);
const header = finalCount > 1
? `📌 Post-trade ${POST_TRADE_PUSH_DELAY_MS / 60000}-minute review (last ${meta.source} buy ${dirZh} ${meta.size}@${meta.price.toFixed(3)}, ${finalCount} trades total, span ${minsSinceFirst} minutes)\n\n`
: `📌 Post-trade ${POST_TRADE_PUSH_DELAY_MS / 60000}-minute review (${meta.source} buy ${dirZh} ${meta.size}@${meta.price.toFixed(3)})\n\n`;
const text = header + buildTgMessage();
const result = await sendTgMessage(tgConfig, text);
if (result.ok) {
console.log(`[Push] post-trade push succeeded (${finalCount} trades, snapshot=${snapshotCount})`);
} else {
console.warn(`[Push] post-trade push failed: ${result.error}`);
}
}, POST_TRADE_PUSH_DELAY_MS);
}
function broadcastTgConfig(): void {
// when sending config to the frontend, hide the second half of botToken (to avoid leakage via screenshots)
const masked = tgConfig.botToken
? tgConfig.botToken.slice(0, 10) + "***" + tgConfig.botToken.slice(-4)
: "";
broadcast("tgConfig", {
enabled: tgConfig.enabled,
botToken: masked,
botTokenSet: !!tgConfig.botToken,
chatId: tgConfig.chatId,
intervalMinutes: tgConfig.intervalMinutes,
scheduledEnabled: tgConfig.scheduledEnabled,
postTradeEnabled: tgConfig.postTradeEnabled,
});
}
function sendTgConfigToClient(ws: WebSocket): void {
const masked = tgConfig.botToken
? tgConfig.botToken.slice(0, 10) + "***" + tgConfig.botToken.slice(-4)
: "";
send(ws, "tgConfig", {
enabled: tgConfig.enabled,
botToken: masked,
botTokenSet: !!tgConfig.botToken,
chatId: tgConfig.chatId,
intervalMinutes: tgConfig.intervalMinutes,
scheduledEnabled: tgConfig.scheduledEnabled,
postTradeEnabled: tgConfig.postTradeEnabled,
});
}
// full refresh cadence: once every 5 minutes
const PMPNL_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
let pmPnlNextRefreshAt = 0;
function buildPmPnlPayload(): Record<string, unknown> {
const events = pmPnlManager.getEvents();
const total = pmPnlManager.getTotalPnl();
// today's snapshot (CST cutoff) -- same convention as server.ts, monitor page, TG
const todaySec = Math.floor(getCstDayStartMs() / 1000);
const today = pmPnlManager.computeSnapshot(todaySec);
return {
events, total, today,
initialized: pmPnlManager.isInitialized(),
lastRefreshAt: pmPnlManager.getLastRefreshAt(),
nextRefreshAt: pmPnlNextRefreshAt,
};
}
function broadcastPmPnl(): void {
broadcast("pmPnl", buildPmPnlPayload());
}
function sendPmPnlToClient(ws: WebSocket): void {
send(ws, "pmPnl", buildPmPnlPayload());
}
function cleanupPendingTradeMeta(now = Date.now()): void {
for (const [key, meta] of pendingTradeMeta) {
if (now - meta.ts > PENDING_TRADE_META_MAX_AGE_MS) {
pendingTradeMeta.delete(key);
}
}
}
function rememberPendingTradeMeta(meta: Omit<PendingTradeMeta, "key">): void {
cleanupPendingTradeMeta(meta.ts);
const key = meta.orderId || `pending-${meta.ts}-${Math.random().toString(36).slice(2, 8)}`;
pendingTradeMeta.set(key, { key, ...meta });
}
/** called when a stratOrder is deleted, cleans up the corresponding pendingTradeMeta (fix A no longer "consume once", needs to be cleared at the end of the order lifecycle) */
function clearPendingTradeMetaByOrderId(orderId: string | undefined): void {
if (orderId) pendingTradeMeta.delete(orderId);
}
function normalizeTradeSide(value: unknown): "buy" | "sell" | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (normalized === "buy") return "buy";
if (normalized === "sell") return "sell";
return null;
}
function getDirectionByAssetId(assetId: string): StrategyDirection | null {
if (assetId === state.upTokenId) return "up";
if (assetId === state.downTokenId) return "down";
return null;
}
// token_id -> symbol reverse lookup (used in logs to show which symbol's fill, also accurate across symbol switches)
const tokenSymbolMap = new Map<string, MarketSymbol>();
function rememberTokenSymbol(tokenId: string, sym: MarketSymbol): void {
if (tokenId) tokenSymbolMap.set(tokenId, sym);
}
function getSymbolByAssetId(assetId: string): string {
return tokenSymbolMap.get(assetId) || activeMarket.symbol;
}
function parseTradeEventTimestamp(evt: Record<string, unknown>): number {
const raw = typeof evt.match_time === "string"
? evt.match_time
: typeof evt.last_update === "string"
? evt.last_update
: "";
const parsed = raw ? Date.parse(raw) : NaN;
return Number.isFinite(parsed) ? parsed : Date.now();
}
function consumePendingTradeMeta(evt: Record<string, unknown>): PendingTradeMeta | null {
cleanupPendingTradeMeta();
const candidateIds: string[] = [];
if (typeof evt.taker_order_id === "string" && evt.taker_order_id) {
candidateIds.push(evt.taker_order_id);
}
if (Array.isArray(evt.maker_orders)) {
for (const makerOrder of evt.maker_orders) {
if (!isRecord(makerOrder) || typeof makerOrder.order_id !== "string" || !makerOrder.order_id) continue;
candidateIds.push(makerOrder.order_id);
}
}
for (const id of candidateIds) {
const meta = pendingTradeMeta.get(id);
if (!meta) continue;
pendingTradeMeta.delete(id);
return meta;
}
const side = normalizeTradeSide(evt.side);
const assetId = typeof evt.asset_id === "string" ? evt.asset_id : "";
const size = typeof evt.size === "number" ? evt.size : Number(evt.size);
if (!side || !assetId || !Number.isFinite(size)) return null;
// fuzzy match: only consider it our order if the size diff is within 10% and the time diff is within 30 seconds
const FUZZY_MAX_AGE_MS = 30_000;
const FUZZY_MAX_SIZE_RATIO = 0.10;
let bestKey: string | null = null;
let bestScore = Number.POSITIVE_INFINITY;
const now = Date.now();
for (const [key, meta] of pendingTradeMeta) {
const directionTokenId = meta.direction === "up" ? state.upTokenId : state.downTokenId;
if (directionTokenId !== assetId || meta.side !== side) continue;
const ageDiff = now - meta.ts;
if (ageDiff > FUZZY_MAX_AGE_MS) continue;
const sizeRatio = size > 0 ? Math.abs(meta.amount - size) / size : Math.abs(meta.amount - size);
if (sizeRatio > FUZZY_MAX_SIZE_RATIO) continue;
const score = sizeRatio * 1000 + ageDiff / 1000;
if (score < bestScore) {
bestScore = score;
bestKey = key;
}
}
if (!bestKey) return null;
const meta = pendingTradeMeta.get(bestKey) || null;
if (meta) pendingTradeMeta.delete(bestKey);
return meta;
}
// -- Polymarket authentication -----------------------------------------
const CREDS_FILE = resolve(__dirname, ".polymarket-creds.json");
interface PolymarketCreds {
key: string; secret: string; passphrase: string; address: string;
accountName?: string;
walletType?: "safe" | "deposit"; // safe=old Gnosis Safe (POLY_GNOSIS_SAFE), deposit=new Deposit Wallet (POLY_1271)
proxyAddress?: string; // the PROXY_ADDRESS the creds were created for; re-detect if env changed
}
/** detect via EIP-1967 implementation slot whether PROXY is a new deposit wallet or an old Gnosis Safe
* old Safe: no EIP-1967 proxy structure -> slot all 0
* new Deposit: ERC-1967 proxy -> slot stores the implementation address
* on network failure / exception default to "deposit" (new wallet - PM's main direction going forward; misjudging an old wallet will immediately fail orders so it is detectable)
*/
async function detectWalletType(addr: string): Promise<"safe" | "deposit"> {
if (!addr) return "deposit";
const RPC_URLS = ["https://polygon.drpc.org", "https://polygon-bor-rpc.publicnode.com"];
const IMPL_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
for (const url of RPC_URLS) {
try {
const r = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getStorageAt", params: [addr, IMPL_SLOT, "latest"] }),
});
const j = await r.json() as any;
if (j.error) throw new Error(j.error.message);
const slot = String(j.result || "");
const implIsZero = /^0x0+$/.test(slot);
return implIsZero ? "safe" : "deposit";
} catch {
// try the next RPC
}
}
console.warn("[System.Auth] wallet type detection: all RPCs failed, defaulting to deposit (new wallet)");
return "deposit";
}
function adaptSigner(wallet: ethers.Wallet) {
return {
_signTypedData: (
domain: Record<string, unknown>,
types: Record<string, unknown[]>,
value: Record<string, unknown>
) => wallet.signTypedData(
domain as ethers.TypedDataDomain,
types as Record<string, ethers.TypedDataField[]>,
value
),
getAddress: () => Promise.resolve(wallet.address),
};
}
function loadCreds(): PolymarketCreds | null {
if (!existsSync(CREDS_FILE)) return null;
try {
const creds: PolymarketCreds = JSON.parse(readFileSync(CREDS_FILE, "utf-8"));
if (creds.key && creds.secret && creds.passphrase) {
if (creds.accountName) accountName = creds.accountName;
return creds;
}
} catch { /* ignore */ }
return null;
}
function saveAccountNameToCreds(name: string): void {
if (!name || !existsSync(CREDS_FILE)) return;
try {
const creds = JSON.parse(readFileSync(CREDS_FILE, "utf-8")) as PolymarketCreds;
if (creds.accountName === name) return;
creds.accountName = name;
writeFileSync(CREDS_FILE, JSON.stringify(creds, null, 2));
} catch { /* ignore */ }
}
function walletTypeToSigType(walletType: "safe" | "deposit" | undefined): SignatureType {
if (!PROXY_ADDRESS) return SignatureType.EOA;
if (walletType === "deposit") return SignatureType.POLY_1271;
return SignatureType.POLY_GNOSIS_SAFE; // safe or undefined (fallback to old logic)
}
/** decide which wallet type to use for this startup:
* 1. creds has walletType and proxyAddress matches the current PROXY_ADDRESS -> use directly
* 2. otherwise call on-chain RPC detection -> write into creds
* on failure default to "deposit" (new wallet, PM's main direction)
*/
async function resolveWalletType(saved: PolymarketCreds | null): Promise<"safe" | "deposit"> {
if (!PROXY_ADDRESS) return "deposit"; // not actually used in practice (EOA mode)
if (saved?.walletType && saved.proxyAddress?.toLowerCase() === PROXY_ADDRESS.toLowerCase()) {
return saved.walletType;
}
console.log("[System.Auth] detecting wallet type...");
const wt = await detectWalletType(PROXY_ADDRESS);
console.log(`[System.Auth] PROXY=${PROXY_ADDRESS} type: ${wt === "deposit" ? "new deposit wallet (POLY_1271)" : "old Gnosis Safe (POLY_GNOSIS_SAFE)"}`);
return wt;
}
function persistWalletType(saved: PolymarketCreds, walletType: "safe" | "deposit"): void {
// persist the detected walletType + current PROXY_ADDRESS into creds, skip detection on next startup
if (saved.walletType === walletType && saved.proxyAddress?.toLowerCase() === PROXY_ADDRESS.toLowerCase()) return;
try {
saved.walletType = walletType;
saved.proxyAddress = PROXY_ADDRESS;
writeFileSync(CREDS_FILE, JSON.stringify(saved, null, 2));
} catch { /* ignore */ }
}
async function createClobClient(): Promise<ClobClient | null> {
const funderAddress = PROXY_ADDRESS || undefined;
const saved = loadCreds();
if (saved) {
// verify creds matches the current .env; on mismatch only log an error, do not auto re-derive (avoids infinite retries / mis-operation)
if (PRIVATE_KEY) {
const currentEoa = new ethers.Wallet(PRIVATE_KEY).address;
if (saved.address && saved.address.toLowerCase() !== currentEoa.toLowerCase()) {
console.error(
`[System.Auth] ❌ detected .env PRIVATE_KEY does not match .polymarket-creds.json:\n` +
` creds.address: ${saved.address}\n` +
` .env EOA: ${currentEoa}\n` +
` fix: delete .polymarket-creds.json then restart, it will auto re-derive`
);
}
}
if (saved.proxyAddress && PROXY_ADDRESS && saved.proxyAddress.toLowerCase() !== PROXY_ADDRESS.toLowerCase()) {
console.error(
`[System.Auth] ❌ detected .env PROXY_ADDRESS does not match .polymarket-creds.json:\n` +
` creds.proxyAddress: ${saved.proxyAddress}\n` +
` .env PROXY: ${PROXY_ADDRESS}\n` +
` fix: delete .polymarket-creds.json then restart, it will auto re-derive`
);
}
const walletType = await resolveWalletType(saved);
const sigType = walletTypeToSigType(walletType);
persistWalletType(saved, walletType);
const creds = { key: saved.key, secret: saved.secret, passphrase: saved.passphrase };
if (PRIVATE_KEY) {
const signer = adaptSigner(new ethers.Wallet(PRIVATE_KEY)) as any;
return new ClobClient({ host: CLOB_URL, chain: Chain.POLYGON, signer, creds, signatureType: sigType, funderAddress });
}
return new ClobClient({ host: CLOB_URL, chain: Chain.POLYGON, creds, signatureType: sigType, funderAddress });
}
if (!PRIVATE_KEY) {
console.warn("[System.Auth] POLYMARKET_PRIVATE_KEY not configured, ordering is unavailable");
return null;
}
console.log("[System.Auth] first use, generating Polymarket API credentials from private key...");
const walletType = await resolveWalletType(null);
const sigType = walletTypeToSigType(walletType);
const wallet = new ethers.Wallet(PRIVATE_KEY);
const signer = adaptSigner(wallet) as any;
const client = new ClobClient({ host: CLOB_URL, chain: Chain.POLYGON, signer, signatureType: sigType, funderAddress });
const creds = await client.deriveApiKey(0);
writeFileSync(CREDS_FILE, JSON.stringify({
key: creds.key, secret: creds.secret, passphrase: creds.passphrase,
address: wallet.address,
walletType,
proxyAddress: PROXY_ADDRESS || undefined,
}, null, 2));
console.log("[System.Auth] credentials saved to .polymarket-creds.json");
return new ClobClient({ host: CLOB_URL, chain: Chain.POLYGON, signer, creds, signatureType: sigType, funderAddress });
}
// -- HTTP server ---------------------------------------------------
const app = express();
app.use(express.json());
if (IS_FULL_MODE) {
app.use(express.static(__dirname));
app.get("/", (_req, res) => {
res.sendFile(resolve(__dirname, "index.html"));
});
} else {
app.get("/", (_req, res) => {
res.json({
name: "btc5m-web",
mode: APP_MODE,
stateUrl: "/api/state",
});
});
}
const server = createServer(app);
const wss = IS_FULL_MODE ? new WebSocketServer({ server }) : null;
const clientSessions = new Map<WebSocket, ClientSession>();
// -- CLOB Client (for ordering) ----------------------------------------
let clobClient: ClobClient | null = null;
async function ensureClobClient(): Promise<boolean> {
if (clobClient) return true;
try { clobClient = await createClobClient(); return clobClient != null; }
catch (err) { console.error("[System.CLOB] init failed:", err); return false; }
}
// -- Order book state ----------------------------------------------
const state = {
windowStart: 0,
windowEnd: 0,
upTokenId: "",
downTokenId: "",
conditionId: "",
slug: "",
bids: new Map<string, string>(),
asks: new Map<string, string>(),
bestBid: "-",
bestAsk: "-",
lastPrice: "-",
lastSide: "",
updatedAt: 0,
priceToBeat: null as number | null,
currentPrice: null as number | null,
binanceOffset: null as number | null,
coinbaseOffset: null as number | null,
// BTC amplitude percentage within a 30-second rolling window = (max - min) / min * 100
// updated by binance aggTrade; strategies can read ctx.volPct for decisions like "pause entry on high volatility"
volPct: null as number | null,
priceHistory: [] as Array<{ t: number; price: number }>,
binanceHistory: [] as Array<{ t: number; price: number }>,
coinbaseHistory: [] as Array<{ t: number; price: number }>,
kline1m: [] as Array<Kline>,
kline5m: [] as Array<Kline>,
};
const strategyRuntime: StrategyRuntimeState = {
state: "IDLE",
activeStrategy: null,
direction: null,
buyAmount: 0,
posBeforeBuy: 0,
posBeforeSell: 0,
waitVerifyAfterSell: false,
cleanupAfterVerify: false,
actionTs: 0,
prevUpPct: null,
buyLockUntil: 0,
positionsReady: !PROXY_ADDRESS,
roundEntryCount: 0,
};
// -- Position state ----------------------------------------------
const wsStatus = { market: false, chainlink: false, user: false, binance: false, coinbase: false };
function broadcastWsStatus() { broadcast("wsStatus", wsStatus as unknown as Record<string, unknown>); }
const positions = {
usdc: null as number | null,
usdcAllowanceStatus: "NotApproved" as "Approved" | "NotFullyApproved" | "NotApproved",
usdcAllowanceMin: null as number | null,
usdcAllowanceDetails: [] as Array<{ spender: string; amount: number | null }>,
localSize: {} as Record<string, number>,
apiSize: {} as Record<string, number>,
apiVerified: {} as Record<string, boolean>,
confirmedIds: new Set<string>(),
confirmedIdOrder: [] as string[],
lastTradeAt: null as number | null,
lastApiSyncAt: null as number | null,
};
// -- Broadcast --------------------------------------------------
function send(ws: WebSocket, type: string, data: Record<string, unknown>): void {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type, ...data }));
}
function createClientSession(dataMode: ClientDataMode): ClientSession {
return {
dataMode,
lastStateSentAt: 0,
stateTimer: null,
stateDirty: false,
stateIncludeHistory: false,
};
}
function normalizeClientDataMode(value: unknown): ClientDataMode {
return value === "low" ? "low" : "full";
}
function resolveClientDataModeFromUrl(urlValue: string | undefined): ClientDataMode {
if (!urlValue) return "full";
try {
const url = new URL(urlValue, `http://localhost:${PORT}`);
return normalizeClientDataMode(url.searchParams.get("dataMode"));
} catch {
return "full";
}
}
function getClientSession(ws: WebSocket): ClientSession {
let session = clientSessions.get(ws);
if (!session) {
session = createClientSession("full");
clientSessions.set(ws, session);
}
return session;
}
function clearStateTimer(session: ClientSession): void {
if (session.stateTimer) {
clearTimeout(session.stateTimer);
session.stateTimer = null;
}
}
function getStateIntervalMs(session: ClientSession): number {
return session.dataMode === "low" ? LOW_DATA_STATE_INTERVAL_MS : FULL_DATA_STATE_INTERVAL_MS;
}
function shouldSendRealtimeEvent(type: string, ws: WebSocket, session: ClientSession): boolean {
if (session.dataMode === "low" && (type === "chainlinkPrice" || type === "binancePrice")) {
return false;
}
if ((type === "chainlinkPrice" || type === "binancePrice") && ws.bufferedAmount > MAX_WS_BUFFERED_BYTES) {
return false;
}
return true;
}
function broadcast(type: string, data: Record<string, unknown>): void {
if (!wss) return;
const msg = JSON.stringify({ type, ...data });
for (const client of wss.clients) {
if (client.readyState !== WebSocket.OPEN) continue;
const session = getClientSession(client);
if (!shouldSendRealtimeEvent(type, client, session)) continue;
client.send(msg);
}
}
function trimHistory<T extends { t: number }>(points: T[], cutoff: number, maxPoints: number): void {
while (points.length > 0 && points[0].t < cutoff) points.shift();
if (points.length > maxPoints) points.splice(0, points.length - maxPoints);
}
function rememberBounded(set: Set<string>, order: string[], key: string, maxSize: number): boolean {
if (set.has(key)) return false;
set.add(key);
order.push(key);
while (order.length > maxSize) {
const oldest = order.shift();
if (oldest !== undefined) set.delete(oldest);
}
return true;
}
function prunePositionCaches(activeTokenIds: string[]): void {
const keep = new Set(activeTokenIds.filter(Boolean));
for (const store of [positions.localSize, positions.apiSize, positions.apiVerified]) {
for (const key of Object.keys(store)) {
if (!keep.has(key)) delete store[key];
}
}
}
function getDirectionTokenId(direction: StrategyDirection | null): string {
if (direction === "up") return state.upTokenId;
if (direction === "down") return state.downTokenId;
return "";
}
function getDirectionLocalSize(direction: StrategyDirection | null): number {
const tokenId = getDirectionTokenId(direction);
return tokenId ? (positions.localSize[tokenId] ?? 0) : 0;
}
function getDirectionApiSize(direction: StrategyDirection | null): number {
const tokenId = getDirectionTokenId(direction);
return tokenId ? (positions.apiSize[tokenId] ?? 0) : 0;
}
function isDirectionVerified(direction: StrategyDirection | null): boolean {
const tokenId = getDirectionTokenId(direction);
return tokenId ? (positions.apiVerified[tokenId] ?? false) : false;
}
function hasOpenPosition(): boolean {
return getDirectionLocalSize("up") > 0.01 || getDirectionLocalSize("down") > 0.01;
}
function hasEnoughUsdcForBuy(amount: number): boolean {
if (positions.usdc == null || !Number.isFinite(amount)) return true;
return positions.usdc + 1e-6 >= amount;
}
function hasPendingStrategyBuyLock(now = Date.now()): boolean {
return now < strategyRuntime.buyLockUntil;
}
function getSellableShares(direction: StrategyDirection | null): number {
const localSize = getDirectionLocalSize(direction);
if (localSize <= 0) return 0;
if (isDirectionVerified(direction)) return localSize;
return Math.max(0, localSize - UNVERIFIED_SELL_BUFFER);
}
function getLatestBinancePrice(): number | null {
const point = state.binanceHistory[state.binanceHistory.length - 1];
return point?.price ?? null;
}
// 30-second rolling amplitude = (max - min) / min * 100; null when points < 2 or time is less than 30s
const VOL_WINDOW_MS = 30_000;
function recomputeVolPct(now: number): void {
const cutoff = now - VOL_WINDOW_MS;
const pts = state.binanceHistory.filter(p => p.t >= cutoff);
if (pts.length < 2) { state.volPct = null; return; }
// also check whether the earliest point is really 30+ seconds ago (the window is insufficient in the early startup phase)
const span = pts[pts.length - 1].t - pts[0].t;
if (span < VOL_WINDOW_MS - 1000) { state.volPct = null; return; }
let hi = -Infinity, lo = Infinity;
for (const p of pts) { if (p.price > hi) hi = p.price; if (p.price < lo) lo = p.price; }
if (lo <= 0) { state.volPct = null; return; }
state.volPct = (hi - lo) / lo * 100;
}
function getProbabilitySnapshot(): { upPct: number; dnPct: number } | null {
if (!isProbabilityReady()) return null;
const bid = Number(state.bestBid);
const ask = Number(state.bestAsk);
if (!Number.isFinite(bid) || !Number.isFinite(ask)) return null;
const mid = (bid + ask) / 2;
return {
upPct: Math.round(mid * 100),
dnPct: Math.round((1 - mid) * 100),
};
}
function getStrategyDiff(): number | null {
const latestBinancePrice = getLatestBinancePrice();
if (latestBinancePrice == null || state.priceToBeat == null || state.binanceOffset == null) return null;
return latestBinancePrice - (state.priceToBeat - state.binanceOffset);
}
function calcMedian(values: number[]): number | null {
if (!values.length) return null;
const sorted = values.slice().sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
function calcTrimmedMean(values: number[], trimRatio = 0.15): number | null {
if (!values.length) return null;
const sorted = values.slice().sort((a, b) => a - b);
const trim = sorted.length >= 8 ? Math.floor(sorted.length * trimRatio) : 0;
const trimmed = trim > 0 ? sorted.slice(trim, sorted.length - trim) : sorted;
if (!trimmed.length) return null;
return trimmed.reduce((sum, value) => sum + value, 0) / trimmed.length;
}
function calculateBinanceOffset(allowLatestFallback = false): number | null {
if (!state.binanceHistory.length || !state.priceHistory.length) {
if (!allowLatestFallback) return null;
const latestBinancePrice = getLatestBinancePrice();
if (latestBinancePrice == null || state.currentPrice == null) return null;
return state.currentPrice - latestBinancePrice;
}
const now = Date.now();
const binanceRecent = state.binanceHistory.filter((point) => point.t >= now - BINANCE_ALIGN_WINDOW_MS);
const chainlinkRecent = state.priceHistory.filter((point) => point.t >= now - BINANCE_ALIGN_WINDOW_MS);
if (!binanceRecent.length || !chainlinkRecent.length) {
if (!allowLatestFallback) return null;
const latestBinancePrice = getLatestBinancePrice();
if (latestBinancePrice == null || state.currentPrice == null) return null;
return state.currentPrice - latestBinancePrice;
}
const binanceSpan = binanceRecent.length >= 2
? binanceRecent[binanceRecent.length - 1].t - binanceRecent[0].t
: 0;
const chainlinkSpan = chainlinkRecent.length >= 2
? chainlinkRecent[chainlinkRecent.length - 1].t - chainlinkRecent[0].t
: 0;
if (Math.min(binanceSpan, chainlinkSpan) < BINANCE_ALIGN_MIN_SPAN_MS) {
if (!allowLatestFallback) return null;
return chainlinkRecent[chainlinkRecent.length - 1].price - binanceRecent[binanceRecent.length - 1].price;
}
const overlapStart = Math.max(binanceRecent[0].t, chainlinkRecent[0].t);
const overlapEnd = Math.min(binanceRecent[binanceRecent.length - 1].t, chainlinkRecent[chainlinkRecent.length - 1].t);
const diffs: number[] = [];
if (overlapEnd - overlapStart >= BINANCE_ALIGN_BUCKET_MS * 2) {
let binanceIdx = 0;
let chainlinkIdx = 0;
for (let bucketStart = overlapStart; bucketStart <= overlapEnd; bucketStart += BINANCE_ALIGN_BUCKET_MS) {
const bucketEnd = bucketStart + BINANCE_ALIGN_BUCKET_MS;
const binanceBucket: number[] = [];
const chainlinkBucket: number[] = [];
while (binanceIdx < binanceRecent.length && binanceRecent[binanceIdx].t < bucketStart) binanceIdx++;
while (chainlinkIdx < chainlinkRecent.length && chainlinkRecent[chainlinkIdx].t < bucketStart) chainlinkIdx++;
let i = binanceIdx;
while (i < binanceRecent.length && binanceRecent[i].t < bucketEnd) {
binanceBucket.push(binanceRecent[i].price);
i++;
}
let j = chainlinkIdx;
while (j < chainlinkRecent.length && chainlinkRecent[j].t < bucketEnd) {
chainlinkBucket.push(chainlinkRecent[j].price);
j++;
}
const binanceMedian = calcMedian(binanceBucket);
const chainlinkMedian = calcMedian(chainlinkBucket);
if (binanceMedian != null && chainlinkMedian != null) {
diffs.push(chainlinkMedian - binanceMedian);
}
}
}
if (!diffs.length) {
return chainlinkRecent[chainlinkRecent.length - 1].price - binanceRecent[binanceRecent.length - 1].price;
}
if (diffs.length < 5) {
return calcTrimmedMean(diffs, 0);
}
const median = calcMedian(diffs);
if (median == null) return null;
const absDeviations = diffs.map((diff) => Math.abs(diff - median));
const mad = calcMedian(absDeviations) ?? 0;
const threshold = Math.max(10, mad * 3);
const filtered = diffs.filter((diff) => Math.abs(diff - median) <= threshold);
const stable = filtered.length >= 3 ? filtered : diffs;
return calcTrimmedMean(stable, 0.15);
}
function refreshBinanceOffset(reason: string, options: { allowLatestFallback?: boolean; forceLog?: boolean } = {}): boolean {
const nextOffset = calculateBinanceOffset(options.allowLatestFallback ?? false);
if (nextOffset == null) return false;
const prevOffset = state.binanceOffset;
const changed = prevOffset == null || Math.abs(prevOffset - nextOffset) > BINANCE_OFFSET_EPSILON;
state.binanceOffset = nextOffset;
if (!changed) return true;
if (options.forceLog || prevOffset == null) {
const prefix = prevOffset == null ? "init offset" : `${reason} update`;
console.log(`[Price.BinanceOffset] ${prefix} ${nextOffset >= 0 ? "+" : ""}${nextOffset.toFixed(2)}`);
}
broadcastState();
return true;
}
function maybeInitializeBinanceOffset(): void {
if (state.binanceOffset != null) return;
void refreshBinanceOffset("init", { allowLatestFallback: true, forceLog: true });
}
// -- Generic offset computation (other BTC/USD price sources like Coinbase aligned to Chainlink) --
function calcOffsetVsChainlink(
history: Array<{ t: number; price: number }>,
allowLatestFallback: boolean,
): number | null {
if (!history.length || !state.priceHistory.length) {
if (!allowLatestFallback) return null;
const latest = history[history.length - 1]?.price ?? null;
if (latest == null || state.currentPrice == null) return null;
return state.currentPrice - latest;
}
const now = Date.now();
const recent = history.filter((p) => p.t >= now - BINANCE_ALIGN_WINDOW_MS);
const chainlinkRecent = state.priceHistory.filter((p) => p.t >= now - BINANCE_ALIGN_WINDOW_MS);
if (!recent.length || !chainlinkRecent.length) {
if (!allowLatestFallback) return null;
const latest = history[history.length - 1]?.price ?? null;
if (latest == null || state.currentPrice == null) return null;
return state.currentPrice - latest;
}
const span = recent.length >= 2 ? recent[recent.length - 1].t - recent[0].t : 0;
const chSpan = chainlinkRecent.length >= 2
? chainlinkRecent[chainlinkRecent.length - 1].t - chainlinkRecent[0].t
: 0;
if (Math.min(span, chSpan) < BINANCE_ALIGN_MIN_SPAN_MS) {
if (!allowLatestFallback) return null;
return chainlinkRecent[chainlinkRecent.length - 1].price - recent[recent.length - 1].price;
}
const overlapStart = Math.max(recent[0].t, chainlinkRecent[0].t);
const overlapEnd = Math.min(recent[recent.length - 1].t, chainlinkRecent[chainlinkRecent.length - 1].t);
const diffs: number[] = [];
if (overlapEnd - overlapStart >= BINANCE_ALIGN_BUCKET_MS * 2) {
let aIdx = 0;
let cIdx = 0;
for (let bs = overlapStart; bs <= overlapEnd; bs += BINANCE_ALIGN_BUCKET_MS) {
const be = bs + BINANCE_ALIGN_BUCKET_MS;
const aBuck: number[] = [];
const cBuck: number[] = [];
while (aIdx < recent.length && recent[aIdx].t < bs) aIdx++;
while (cIdx < chainlinkRecent.length && chainlinkRecent[cIdx].t < bs) cIdx++;
let i = aIdx;
while (i < recent.length && recent[i].t < be) { aBuck.push(recent[i].price); i++; }
let j = cIdx;
while (j < chainlinkRecent.length && chainlinkRecent[j].t < be) { cBuck.push(chainlinkRecent[j].price); j++; }
const aMed = calcMedian(aBuck);
const cMed = calcMedian(cBuck);
if (aMed != null && cMed != null) diffs.push(cMed - aMed);
}
}
if (!diffs.length) {
return chainlinkRecent[chainlinkRecent.length - 1].price - recent[recent.length - 1].price;
}
if (diffs.length < 5) return calcTrimmedMean(diffs, 0);
const median = calcMedian(diffs);
if (median == null) return null;
const absDev = diffs.map((d) => Math.abs(d - median));
const mad = calcMedian(absDev) ?? 0;
const threshold = Math.max(10, mad * 3);
const filtered = diffs.filter((d) => Math.abs(d - median) <= threshold);
const stable = filtered.length >= 3 ? filtered : diffs;
return calcTrimmedMean(stable, 0.15);
}
interface OffsetSpec {
name: string;
history: () => Array<{ t: number; price: number }>;
getOffset: () => number | null;
setOffset: (v: number | null) => void;
}
const COINBASE_SPEC: OffsetSpec = {
name: "CoinbaseOffset",
history: () => state.coinbaseHistory,
getOffset: () => state.coinbaseOffset,
setOffset: (v) => { state.coinbaseOffset = v; },
};
function refreshGenericOffset(spec: OffsetSpec, reason: string, options: { allowLatestFallback?: boolean; forceLog?: boolean } = {}): boolean {
const next = calcOffsetVsChainlink(spec.history(), options.allowLatestFallback ?? false);
if (next == null) return false;
const prev = spec.getOffset();
const changed = prev == null || Math.abs(prev - next) > BINANCE_OFFSET_EPSILON;
spec.setOffset(next);
if (!changed) return true;
// the "init offset" on window switch is printed by the [System.Window] summary line; here only print on large changes or explicit forceLog
if (options.forceLog) {
const prefix = prev == null ? "init" : `${reason} update`;
console.log(`[${spec.name}] ${prefix} ${next >= 0 ? "+" : ""}${next.toFixed(2)}`);
}
broadcastState();
return true;
}
function maybeInitOffset(spec: OffsetSpec): void {
if (spec.getOffset() != null) return;
void refreshGenericOffset(spec, "init", { allowLatestFallback: true, forceLog: true });
}
function resetStrategyRuntime(reason?: string): void {
strategyRuntime.state = "IDLE";
strategyRuntime.activeStrategy = null;
strategyRuntime.direction = null;
strategyRuntime.buyAmount = 0;
strategyRuntime.posBeforeBuy = 0;
strategyRuntime.posBeforeSell = 0;
strategyRuntime.waitVerifyAfterSell = false;
strategyRuntime.cleanupAfterVerify = false;
strategyRuntime.actionTs = 0;
strategyRuntime.prevUpPct = null;
strategyRuntime.buyLockUntil = 0;
strategyRuntime.roundEntryCount = 0;
for (const s of getAllStrategies()) s.resetState();
if (reason) console.log(`[Strategy] reset: ${reason}`);
}
function transitionToDone(): void {
if (strategyRuntime.roundEntryCount < strategyConfig.maxRoundEntries && anyStrategyEnabled()) {
console.log(`[Strategy.${strategyRuntime.activeStrategy ?? ""}] done, back to scanning (${strategyRuntime.roundEntryCount}/${strategyConfig.maxRoundEntries})`);
strategyRuntime.state = "SCANNING";
strategyRuntime.activeStrategy = null;
strategyRuntime.direction = null;
strategyRuntime.buyAmount = 0;
strategyRuntime.posBeforeBuy = 0;
strategyRuntime.posBeforeSell = 0;
strategyRuntime.waitVerifyAfterSell = false;
strategyRuntime.cleanupAfterVerify = false;
strategyRuntime.actionTs = 0;
} else {
strategyRuntime.state = "DONE";
}
broadcastState();
}
function anyStrategyEnabled(): boolean {
return ALL_STRATEGY_KEYS.some((key) => strategyConfig.enabled[key]);
}
function hasConfirmedBuyPosition(): boolean {
return strategyRuntime.direction != null
&& getDirectionLocalSize(strategyRuntime.direction) > strategyRuntime.posBeforeBuy + 0.01;
}
function canReleaseUnconfirmedBuy(now = Date.now()): boolean {
if (now - strategyRuntime.actionTs < FILL_RECONCILE_TIMEOUT_MS) return false;
if (!strategyRuntime.direction) return true;
if ((positions.lastApiSyncAt ?? 0) <= strategyRuntime.actionTs) return false;
return getDirectionApiSize(strategyRuntime.direction) <= strategyRuntime.posBeforeBuy + 0.01;
}
function buildStrategyRuntimePayload(): Record<string, unknown> {
const perStrategy: Record<string, Record<string, unknown>> = {};
// observe panel data: collect as long as the strategy implements getObservePanel
const observePanels: Record<string, unknown> = {};
for (const s of getAllStrategies()) {
perStrategy[s.key] = s.getStatePayload();
if (typeof s.getObservePanel === "function") {
const panel = s.getObservePanel();
if (panel) observePanels[s.key] = panel;
}
}
return {
observePanels,
state: strategyRuntime.state,
activeStrategy: strategyRuntime.activeStrategy,
direction: strategyRuntime.direction,
buyAmount: strategyRuntime.buyAmount,
posBeforeBuy: strategyRuntime.posBeforeBuy,
posBeforeSell: strategyRuntime.posBeforeSell,
waitVerifyAfterSell: strategyRuntime.waitVerifyAfterSell,
cleanupAfterVerify: strategyRuntime.cleanupAfterVerify,
actionTs: strategyRuntime.actionTs,
prevUpPct: strategyRuntime.prevUpPct,
buyLockUntil: strategyRuntime.buyLockUntil,
positionsReady: strategyRuntime.positionsReady,
roundEntryCount: strategyRuntime.roundEntryCount,
perStrategy,
};
}
/** compute the current fair probability (for real-time display on the s5/s10/s11 frontend panel) */
function computeFairProbPayload(): {
diff: number | null;
rem: number;
upPct: number | null;
fairUp: number | null;
biasUp: number | null;
symbol: MarketSymbol;
hasFairTable: boolean;
} | null {
const diff = getStrategyDiff();
const rem = getStrategyRemainingSeconds();
const snap = getProbabilitySnapshot();
// whether the current market has a fair-probability table (the fair-prob module maintains one per marketKey)
const hasFairTable = hasFairProbTable();
if (diff == null || !snap) {
return { diff, rem, upPct: snap?.upPct ?? null, fairUp: null, biasUp: null, symbol: activeMarket.symbol, hasFairTable };
}
const fairUp = getFairProb(diff, rem);
const biasUp = fairUp != null ? fairUp - snap.upPct : null;
return { diff, rem, upPct: snap.upPct, fairUp, biasUp, symbol: activeMarket.symbol, hasFairTable };
}
function buildStatePayload(options: boolean | StatePayloadOptions = false): Record<string, unknown> {
const normalized = typeof options === "boolean" ? { includeHistory: options } : options;
const includeHistory = normalized.includeHistory === true;
const simple = normalized.simple === true;
const bids = [...state.bids.entries()]
.map(([price, size]) => ({ price: Number(price), size: Number(size) }))
.sort((a, b) => b.price - a.price).slice(0, 8);
const asks = [...state.asks.entries()]
.map(([price, size]) => ({ price: Number(price), size: Number(size) }))
.sort((a, b) => a.price - b.price).slice(0, 8);
const payload: Record<string, unknown> = {
activeMarket: {
key: activeMarket.key,
symbol: activeMarket.symbol,
period: activeMarket.period,
periodSeconds: activeMarket.periodSeconds,
displayName: activeMarket.displayName,
priceDecimals: priceDecimals(state.priceToBeat),
},
windowStart: state.windowStart,
windowEnd: state.windowEnd,
bestBid: state.bestBid,
bestAsk: state.bestAsk,
probabilityReady: isProbabilityReady(),
lastPrice: state.lastPrice,
lastSide: state.lastSide,
updatedAt: state.updatedAt,
priceToBeat: state.priceToBeat,
currentPrice: state.currentPrice,
binanceOffset: state.binanceOffset,
coinbaseOffset: state.coinbaseOffset,
volPct: state.volPct,
accountName: accountName,
klineCounts: { k1m: state.kline1m.length, k5m: state.kline5m.length },
binanceDiff: getStrategyDiff(),
fairProb: computeFairProbPayload(),
usdc: positions.usdc,
usdcAllowanceStatus: positions.usdcAllowanceStatus,
usdcAllowanceMin: positions.usdcAllowanceMin,
upLocalSize: positions.localSize[state.upTokenId] ?? 0,
downLocalSize: positions.localSize[state.downTokenId] ?? 0,
upApiSize: positions.apiSize[state.upTokenId] ?? 0,
downApiSize: positions.apiSize[state.downTokenId] ?? 0,
upApiVerified: positions.apiVerified[state.upTokenId] ?? false,
downApiVerified:positions.apiVerified[state.downTokenId] ?? false,
lastTradeAt: positions.lastTradeAt,
lastApiSyncAt: positions.lastApiSyncAt,
runtimeMode: APP_MODE,
strategyConfig,
strategy: buildStrategyRuntimePayload(),
marketStatus: getUsMarketStatus(),
ts: Date.now(),
};
if (!simple) {
payload.conditionId = state.conditionId;
payload.upTokenId = state.upTokenId;
payload.downTokenId = state.downTokenId;
payload.bids = bids;
payload.asks = asks;
payload.usdcAllowanceDetails = positions.usdcAllowanceDetails;
}
if (includeHistory && !simple) {
payload.priceHistory = state.priceHistory;
payload.binanceHistory = state.binanceHistory;
payload.coinbaseHistory = state.coinbaseHistory;
}
return payload;
}
function sendStateToClient(ws: WebSocket, options: { includeHistory?: boolean } = {}): void {
const session = getClientSession(ws);
const simple = session.dataMode === "low";
send(ws, "state", buildStatePayload({
includeHistory: options.includeHistory === true && !simple,
simple,
}));
session.lastStateSentAt = Date.now();
}
function scheduleStateToClient(ws: WebSocket, includeHistory = false): void {
if (ws.readyState !== WebSocket.OPEN) return;
const session = getClientSession(ws);
session.stateDirty = true;
session.stateIncludeHistory = session.stateIncludeHistory || includeHistory;
if (session.stateTimer) return;
const elapsed = Date.now() - session.lastStateSentAt;
const waitMs = Math.max(0, getStateIntervalMs(session) - elapsed);
session.stateTimer = setTimeout(() => {
const latestSession = clientSessions.get(ws);
if (!latestSession) return;
latestSession.stateTimer = null;
if (!latestSession.stateDirty || ws.readyState !== WebSocket.OPEN) return;
const nextIncludeHistory = latestSession.stateIncludeHistory;
latestSession.stateDirty = false;
latestSession.stateIncludeHistory = false;
if (ws.bufferedAmount > MAX_WS_BUFFERED_BYTES) {
latestSession.stateDirty = true;
latestSession.stateIncludeHistory = nextIncludeHistory;
scheduleStateToClient(ws, nextIncludeHistory);
return;
}
sendStateToClient(ws, { includeHistory: nextIncludeHistory });
}, waitMs);
}
function broadcastState(includeHistory = false): void {
if (!wss) return;
for (const client of wss.clients) {
if (client.readyState !== WebSocket.OPEN) continue;
scheduleStateToClient(client, includeHistory);
}
}
function applyClientConfig(ws: WebSocket, raw: unknown): void {
if (!isRecord(raw) || raw.type !== "clientConfig") return;
const session = getClientSession(ws);
const nextMode = normalizeClientDataMode(raw.dataMode);
if (session.dataMode === nextMode) return;
session.dataMode = nextMode;
session.stateDirty = false;
session.stateIncludeHistory = false;
clearStateTimer(session);
console.log(`[System.Frontend] client data mode switched to ${nextMode}`);
send(ws, "clientConfig", { dataMode: nextMode });
sendStateToClient(ws, { includeHistory: true });
}
async function fetchBookTopOfBook(tokenId: string): Promise<{ bestBid: number; bestAsk: number }> {
const book = await fetch(`${CLOB_URL}/book?token_id=${tokenId}`).then(r => r.json()) as {
bids?: { price: string }[]; asks?: { price: string }[];
};
const bids = (book.bids || []).map(b => Number(b.price)).filter(p => p > 0);
const asks = (book.asks || []).map(a => Number(a.price)).filter(p => p > 0);
return {
bestBid: bids.length ? Math.max(...bids) : 0,
bestAsk: asks.length ? Math.min(...asks) : 0,
};
}
// ── Gamma API ─────────────────────────────────────────────────
async function fetchMarket(windowStart: number): Promise<{
conditionId: string; upTokenId: string; downTokenId: string;
windowStart: number; windowEnd: number;
eventStartTime: string; endDate: string;
} | null> {
const slug = `${activeMarket.slugPrefix}-${windowStart}`;
const startedAt = Date.now();
try {
const res = await fetch(`${GAMMA_URL}/events?slug=${slug}`);
const events = await res.json() as Record<string, unknown>[];
if (!events?.length) {
console.warn(`[System.Window] market not found slug=${slug} elapsed:${Date.now() - startedAt}ms`);
return null;
}
const event = events[0];
const market = ((event.markets || []) as Record<string, unknown>[])[0];
if (!market) {
console.warn(`[System.Window] market missing order book slug=${slug} elapsed:${Date.now() - startedAt}ms`);
return null;
}
const tokens = JSON.parse(market.clobTokenIds as string || "[]") as string[];
const outcomes = JSON.parse(market.outcomes as string || "[]") as string[];
const upIdx = outcomes.findIndex((o) => o.toLowerCase() === "up");
return {
conditionId: market.conditionId as string,
upTokenId: tokens[upIdx >= 0 ? upIdx : 0],
downTokenId: tokens[upIdx >= 0 ? 1 - upIdx : 1],
windowStart,
windowEnd: windowStart + activeMarket.periodSeconds,
eventStartTime: market.eventStartTime as string || new Date(windowStart * 1000).toISOString(),
endDate: market.endDate as string || new Date((windowStart + activeMarket.periodSeconds) * 1000).toISOString(),
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[System.Window] market query failed slug=${slug} elapsed:${Date.now() - startedAt}ms reason:${msg}`);
return null;
}
}
// -- Reference price ----------------------------------------------
async function fetchCryptoPrice(eventStartTime: string, endDate: string): Promise<void> {
try {
const url = `https://polymarket.com/api/crypto/crypto-price?symbol=${activeMarket.cryptoPriceSymbol}&eventStartTime=${encodeURIComponent(eventStartTime)}&variant=${activeMarket.cryptoPriceVariant}&endDate=${encodeURIComponent(endDate)}`;
const data = await fetch(url).then(r => r.json()) as { openPrice?: number | null; closePrice?: number | null };
console.log("[RAW]", `[Price.Open] fetch ${activeMarket.cryptoPriceSymbol} ${activeMarket.cryptoPriceVariant} eventStart=${eventStartTime} -> openPrice=${data.openPrice} closePrice=${data.closePrice}`);
if (data.openPrice != null) state.priceToBeat = data.openPrice;
} catch (err) {
console.warn(`[Price.Open] fetch failed: ${(err as Error).message}`);
}
}
// -- Position API query --------------------------------------------
async function syncPositionsFromApi(): Promise<boolean> {
if (!PROXY_ADDRESS) {
strategyRuntime.positionsReady = true;
return true;
}
try {
const pos = await fetch(
`https://data-api.polymarket.com/positions?user=${PROXY_ADDRESS}&sizeThreshold=0.01`
).then(r => r.json()) as Array<{ asset: string; size: number }>;
const apiMap: Record<string, number> = {};
for (const p of pos) { apiMap[p.asset] = p.size; positions.apiSize[p.asset] = p.size; }
for (const tokenId of [state.upTokenId, state.downTokenId]) {
if (!tokenId) continue;
if (!(tokenId in apiMap)) positions.apiSize[tokenId] = 0;
const apiVal = apiMap[tokenId] ?? 0;
const localVal = positions.localSize[tokenId] ?? 0;
const msSinceTrade = Date.now() - (positions.lastTradeAt ?? 0);
if (msSinceTrade < POST_TRADE_CALIBRATION_MS) continue;
if (Math.abs(apiVal - localVal) <= 0.5) {
positions.localSize[tokenId] = apiVal;
positions.apiVerified[tokenId] = true;
}
}
positions.lastApiSyncAt = Date.now();
strategyRuntime.positionsReady = true;
return true;
} catch {
return false;
}
}
// -- USDC balance query --------------------------------------------
// stop subsequent API calls after a CLOB auth error, to avoid the 401 every 5 seconds + extremely long axios error dump flooding
let clobAuthFailed = false;
function isAuthError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const e = err as any;
return e.response?.status === 401 || /unauthorized|invalid api key/i.test(e.message || "");
}
async function syncUsdcBalance(): Promise<void> {
if (!PROXY_ADDRESS) return;
if (clobAuthFailed) return; // auth failure already reported, stop flooding
try {
if (!(await ensureClobClient())) return;
const resp = await clobClient!.getBalanceAllowance({ asset_type: AssetType.COLLATERAL }) as {
balance?: string;
allowance?: string;
allowances?: Record<string, string>;
};
positions.usdc = resp.balance != null ? parseFloat(ethers.formatUnits(resp.balance, 6)) : null;
const allowanceMap = resp.allowances && typeof resp.allowances === "object"
? Object.entries(resp.allowances)
: resp.allowance != null
? [["default", resp.allowance]]
: [];
const details = allowanceMap.map(([spender, raw]) => {
const amount = raw != null ? parseFloat(ethers.formatUnits(raw, 6)) : null;
return { spender, amount: Number.isFinite(amount) ? amount : null };
});
positions.usdcAllowanceDetails = details;
if (!details.length) {
positions.usdcAllowanceStatus = "NotApproved";
positions.usdcAllowanceMin = null;
return;
}
const positiveCount = details.filter((item) => (item.amount ?? 0) > 0).length;
const minAllowance = details.reduce<number | null>((min, item) => {
if (item.amount == null) return min;
return min == null ? item.amount : Math.min(min, item.amount);
}, null);
positions.usdcAllowanceMin = minAllowance;
positions.usdcAllowanceStatus = positiveCount === 0
? "NotApproved"
: positiveCount === details.length
? "Approved"
: "NotFullyApproved";
} catch (e) {
if (isAuthError(e)) {
clobAuthFailed = true;
console.error("[System.Auth] ❌ CLOB API key auth failed (401 Unauthorized)");
console.error("[System.Auth] possible cause: .env changed PRIVATE_KEY/PROXY_ADDRESS but .polymarket-creds.json is still the old one");
console.error("[System.Auth] fix: delete .polymarket-creds.json then restart to auto re-derive");
console.error("[System.Auth] subsequent API calls stopped, to avoid repeatedly flooding error logs");
return;
}
console.error("[System.Balance] balance/allowance query failed:", e instanceof Error ? (e as any).shortMessage ?? e.message : String(e));
}
}
// -- Backoff reconnect utility ------------------------------------
function backoffDelay(attempt: number): number {
const delays = [0, 1000, 2000, 4000, 8000, 30000];
return delays[Math.min(attempt, delays.length - 1)];
}
// -- User WS (listen for fills) --------------------------------------
let userWs: WebSocket | null = null;
let userWsPingTimer: ReturnType<typeof setInterval> | null = null;
let userWsAttempt = 0;
function startUserWs(): void {
if (!existsSync(CREDS_FILE)) { console.log("[WS.User] credentials file not found, skipping"); return; }
const creds = JSON.parse(readFileSync(CREDS_FILE, "utf-8")) as {
key: string; secret: string; passphrase: string;
};
userWs = new WebSocket(USER_WS_URL);
userWs.on("open", () => {
console.log(userWsAttempt === 0 ? "[WS.User] connected" : "[WS.User] reconnect succeeded");
userWsAttempt = 0;
wsStatus.user = true; broadcastWsStatus();
userWs!.send(JSON.stringify({
auth: { apiKey: creds.key, secret: creds.secret, passphrase: creds.passphrase },
type: "user",
}));
userWsPingTimer = setInterval(() => {
if (userWs?.readyState === WebSocket.OPEN) userWs.send("PING");
}, 10000);
});
userWs.on("message", (data) => {
const msg = data.toString();
if (msg === "PONG") return;
try {
const arr = JSON.parse(msg);
const events = Array.isArray(arr) ? arr : [arr];
for (const evt of events) {
if (!isRecord(evt)) continue;
// -- full event raw print (includes trade / order / any other Polymarket UserWS type) --
// for diagnostics: confirm whether Polymarket pushes order PLACEMENT/UPDATE/CANCELLATION, trade FAILED/RETRYING/CONFIRMED, etc.
// [RAW] marker -> goes only to trade-raw-*.log, does not pollute the terminal
const evtType = (typeof evt.event_type === "string" ? evt.event_type : "") || (typeof evt.type === "string" ? evt.type : "");
const evtStatus = typeof evt.status === "string" ? evt.status : "";
console.log("[RAW]", `[WS.User.Event] event_type:${evtType} status:${evtStatus} raw:`, JSON.stringify(evt));
// -- event_type: "order" - order lifecycle (PLACEMENT / CANCELLATION / UPDATE) --
// used for: (1) unlock pendingOrderConfirm / pendingCancelConfirm; (2) CANCELLATION immediately clears the local Map
// if pending is not yet registered (WS arrives before the HTTP response) -> write earlyCache, check on register
if (evt.event_type === "order") {
const orderID = typeof evt.id === "string" ? evt.id : "";
const orderType = typeof evt.type === "string" ? evt.type.toUpperCase() : "";
const orderStatus = typeof evt.status === "string" ? evt.status.toUpperCase() : "";
if (orderID) {
if (orderType === "PLACEMENT" || orderStatus === "LIVE") {
if (pendingOrderConfirm.has(orderID)) resolveOrderConfirm(orderID, "WS PLACEMENT");
else rememberEarlyWs(earlyOrderConfirmCache, orderID, "WS PLACEMENT");
}
if (orderType === "CANCELLATION" || orderStatus === "CANCELED") {
if (pendingCancelConfirm.has(orderID)) resolveCancelConfirm(orderID, "WS CANCELLATION");
else rememberEarlyWs(earlyCancelConfirmCache, orderID, "WS CANCELLATION");
// even without a pendingCancel, the local Map must be cleared (system-initiated cancel / window-switch cleanup / insufficient balance, etc.)
if (strategyLimitOrders.has(orderID) || manualLimitOrders.has(orderID)) {
console.log(`[WS.User.Order] CANCELLATION arrived, clearing local Map orderID=${fmtOid(orderID)}`);
strategyLimitOrders.delete(orderID);
manualLimitOrders.delete(orderID);
clearPendingTradeMetaByOrderId(orderID);
broadcastManualLimitOrders();
}
}
// UPDATE push (partial fill size_matched increases): matchedSize is handled by trade MATCHED, here we only unlock confirm
if (orderType === "UPDATE") {
if (pendingOrderConfirm.has(orderID)) resolveOrderConfirm(orderID, "WS UPDATE");
else rememberEarlyWs(earlyOrderConfirmCache, orderID, "WS UPDATE");
}
}
continue; // order event handled, do not enter the trade branch
}
// -- latency monitoring: MATCHED event settles the total latency of this machine's order -> match (may arrive before the HTTP response) --
if ((evt.type === "TRADE" || evt.event_type === "trade") && evt.status === "MATCHED") {
console.log("[RAW]", `[WS.User] MATCHED raw:`, JSON.stringify(evt));
const mOrderId = typeof evt.taker_order_id === "string" ? evt.taker_order_id : undefined;
if (mOrderId) {
if (pendingOrderConfirm.has(mOrderId)) resolveOrderConfirm(mOrderId, "WS MATCHED(taker)");
else rememberEarlyWs(earlyOrderConfirmCache, mOrderId, "WS MATCHED(taker)");
}
// MAKER path: our own order is in maker_orders[], must also be unlocked
const moList = Array.isArray(evt.maker_orders) ? evt.maker_orders : [];
for (const mo of moList) {
if (!isRecord(mo)) continue;
const moOid = typeof mo.order_id === "string" ? mo.order_id : "";
if (!moOid) continue;
if (pendingOrderConfirm.has(moOid)) resolveOrderConfirm(moOid, "WS MATCHED(maker)");
else rememberEarlyWs(earlyOrderConfirmCache, moOid, "WS MATCHED(maker)");
}
onWsMatched(mOrderId);
// check whether a conditional order (TP GTC) was matched in this MATCHED (as a maker)
const makerOrders = Array.isArray(evt.maker_orders) ? evt.maker_orders : [];
for (const mo of makerOrders) {
if (!isRecord(mo)) continue;
const makerOrderId = typeof mo.order_id === "string" ? mo.order_id : "";
if (!makerOrderId) continue;
const matchedAmount = parseFloat(String(mo.matched_amount ?? "0"));
for (const cond of activeConditionOrders.values()) {
if (cond.kind !== "tp" || cond.polymarketOrderId !== makerOrderId) continue;
// MATCHED only updates the match-layer remainingSize (display only);
// the real fill decision goes through the MINED on-chain path, to avoid state divergence caused by multiple PM MATCHED pushes over-reporting
if (Number.isFinite(matchedAmount) && matchedAmount > 0) {
cond.remainingSize = Math.max(0, cond.remainingSize - matchedAmount);
console.log(`[Cond.TP] MATCHED ${cond.direction} +${matchedAmount.toFixed(4)} match remaining ${cond.remainingSize.toFixed(4)} (awaiting MINED confirmation)`);
}
}
// strategy limit order taken as maker: MATCHED = match passed (not on-chain), only update matchedSize;
// the real "fill" decision + deleting stratOrder + triggering TP, all done in the MINED branch by on-chain confirmation.
const stratOrder = strategyLimitOrders.get(makerOrderId);
if (stratOrder && Number.isFinite(matchedAmount) && matchedAmount > 0) {
stratOrder.matchedSize = Math.min(stratOrder.shares, stratOrder.matchedSize + matchedAmount);
const tag = stratOrder.side === "sell" ? "TP" : "limit";
const moPrice = mo.price;
const moFee = mo.fee_rate_bps;
const tradeId = typeof evt.id === "string" ? evt.id : "-";
const takerOrderId = typeof evt.taker_order_id === "string" ? evt.taker_order_id : "-";
console.log(`[Strategy.${stratOrder.strategyKey}] ${tag} matched +${matchedAmount} shares matching ${stratOrder.matchedSize}/${stratOrder.shares} maker:${makerOrderId} price:${moPrice ?? "-"} fee_bps:${moFee ?? "-"} tradeId:${tradeId} taker:${takerOrderId} (awaiting on-chain confirmation)`);
}
}
broadcastConditionOrders();
continue; // only settle latency; position updates wait for the MINED event
}
if ((evt.type === "TRADE" || evt.event_type === "trade") && evt.status === "MINED") {
console.log("[RAW]", `[WS.User] MINED raw:`, JSON.stringify(evt));
const tradeId = evt.id as string;
const traderSide = typeof evt.trader_side === "string" ? evt.trader_side.toUpperCase() : "";
const isMaker = traderSide === "MAKER";
const txHash = typeof evt.transaction_hash === "string" && evt.transaction_hash
? evt.transaction_hash
: undefined;
// -- extract the "my fills" list --
// TAKER: use top-level fields (taker_order_id / asset_id / size / side / price) -- 1 entry
// MAKER: filter from maker_orders[] where maker_address === PROXY_ADDRESS or order_id hits the local Map -- may be multiple entries
// note: for MAKER the top-level asset_id/side/size/price are from the counterparty's (taker's) perspective, must never be used
type MyFill = {
dedupKey: string;
orderId: string | undefined;
assetId: string;
side: "buy" | "sell";
size: number;
price: number;
};
const proxyLc = (PROXY_ADDRESS || "").toLowerCase();
const myFills: MyFill[] = [];
if (isMaker) {
const makerOrders = Array.isArray(evt.maker_orders) ? evt.maker_orders : [];
for (const mo of makerOrders) {
if (!isRecord(mo)) continue;
const ma = typeof mo.maker_address === "string" ? mo.maker_address : "";
const oid = typeof mo.order_id === "string" ? mo.order_id : "";
const isMine = (!!ma && ma.toLowerCase() === proxyLc)
|| (!!oid && (manualLimitOrders.has(oid) || strategyLimitOrders.has(oid)));
if (!isMine) continue;
const moAssetId = typeof mo.asset_id === "string" ? mo.asset_id : "";
const moSide = normalizeTradeSide(mo.side);
const moAmount = parseFloat(String(mo.matched_amount ?? "0"));
const moPrice = parseFloat(String(mo.price ?? "0"));
if (!moAssetId || !moSide || !Number.isFinite(moAmount) || moAmount <= 0) continue;
myFills.push({
dedupKey: `${tradeId}:${oid}`,
orderId: oid || undefined,
assetId: moAssetId,
side: moSide,
size: moAmount,
price: Number.isFinite(moPrice) ? moPrice : 0,
});
}
} else {
const topAssetId = typeof evt.asset_id === "string" ? evt.asset_id : "";
const topSize = typeof evt.size === "number" ? evt.size : parseFloat(String(evt.size ?? ""));
const topSide = normalizeTradeSide(evt.side);
const topPrice = typeof evt.price === "number" ? evt.price : parseFloat(String(evt.price ?? ""));
const topOrderId = typeof evt.taker_order_id === "string" && evt.taker_order_id
? evt.taker_order_id
: undefined;
if (topAssetId && topSide && Number.isFinite(topSize) && topSize > 0) {
myFills.push({
dedupKey: tradeId,
orderId: topOrderId,
assetId: topAssetId,
side: topSide,
size: topSize,
price: Number.isFinite(topPrice) ? topPrice : 0,
});
}
}
if (myFills.length === 0) {
console.log(`[WS.User] MINED tradeId=${tradeId} trader_side=${traderSide} no own fill matched, skipping`);
continue;
}
// loop over each fill
for (const fill of myFills) {
if (!rememberBounded(positions.confirmedIds, positions.confirmedIdOrder, fill.dedupKey, MAX_CONFIRMED_TRADE_IDS)) {
console.log(`[WS.User] MINED duplicate dedupKey=${fill.dedupKey}, skipping`);
continue;
}
// pendingMeta is looked up in the Map directly by the fill's own orderId
// fix A: MAKER path does not consume (peek only), multiple MINED fills of the same order share the same meta;
// meta is cleaned up in sync when stratOrder is deleted (cleanupPendingTradeMetaForOrder), with 15-minute fallback aging
// TAKER path still consumes once (one trade has one taker_order_id, no "multiple fills sharing" problem)
let pendingMeta: PendingTradeMeta | null = null;
if (fill.orderId) {
const direct = pendingTradeMeta.get(fill.orderId);
if (direct) {
if (!isMaker) {
pendingTradeMeta.delete(fill.orderId); // only TAKER consumes
}
pendingMeta = direct;
}
}
if (!pendingMeta) pendingMeta = consumePendingTradeMeta(evt);
const direction = pendingMeta?.direction ?? getDirectionByAssetId(fill.assetId);
if (!(fill.assetId in positions.localSize)) positions.localSize[fill.assetId] = 0;
positions.localSize[fill.assetId] = fill.side === "buy"
? positions.localSize[fill.assetId] + fill.size
: Math.max(0, positions.localSize[fill.assetId] - fill.size);
positions.apiVerified[fill.assetId] = false;
positions.lastTradeAt = parseTradeEventTimestamp(evt);
if (direction && Number.isFinite(fill.price) && fill.price > 0) {
notifyTradeForPnl(txHash, pendingMeta?.source ?? "manual");
}
if (fill.side === "buy" && direction && Number.isFinite(fill.price) && fill.price > 0) {
schedulePostTradePush({
direction,
size: fill.size,
price: fill.price,
source: pendingMeta?.source ?? "manual",
});
}
const tradeSymbol = getSymbolByAssetId(fill.assetId).toUpperCase();
const dirArr = direction === "up" ? "⬆" : direction === "down" ? "⬇" : "·";
const sideZh2 = fill.side === "buy" ? "buy" : "sell";
const role = traderSide === "MAKER" ? "M" : "T";
console.log(
`[WS.User] 🔗 on-chain ${dirArr} ${sideZh2} ${fill.size}@${Number.isFinite(fill.price) ? fill.price : "-"}`
+ ` ${tradeSymbol} (${role})`
+ ` orderID=${fmtOid(fill.orderId)}`
+ ` source=${pendingMeta?.source ?? "-"}`
+ ` tx=${fmtOid(txHash)}`
);
// full pendingMeta + asset_id + tradeId go to raw log
console.log("[RAW]", `[WS.User] MINED detail tradeId:${tradeId} asset:${fill.assetId} pendingMeta:${pendingMeta ? JSON.stringify(pendingMeta) : "-"}`);
// limit-strategy TP sell order on-chain confirmation (MINED side=sell + source=strategy*tp)
if (fill.side === "sell" && pendingMeta?.source && /^strategy[a-z0-9]+tp$/i.test(pendingMeta.source) && direction) {
const strategyKey = pendingMeta.source.replace(/^strategy/i, "").replace(/tp$/i, "");
onStrategyTakeProfitMined(strategyKey, direction, fill.size);
}
// conditional-order TP GTC on-chain confirmation (MINED side=sell + source=cond-tp)
// accumulate cond.chainFilledSize; only delete cond + clear same-group SL when it reaches zero (avoids MATCHED over-reporting causing erroneous deletion)
if (fill.side === "sell" && pendingMeta?.source === "cond-tp" && direction) {
for (const cond of activeConditionOrders.values()) {
if (cond.kind !== "tp") continue;
if (cond.polymarketOrderId !== fill.orderId) continue;
cond.chainFilledSize = (cond.chainFilledSize ?? 0) + fill.size;
if (cond.chainFilledSize >= cond.size - 0.01) {
console.log(`[Cond.TP] MINED fully filled ${cond.direction} ${cond.size}@${cond.triggerPrice} (order:${(cond.polymarketOrderId ?? "").slice(0, 10)}...)`);
activeConditionOrders.delete(cond.id);
void clearConditionOrdersByGroup(cond.groupId, cond.id, "TP filled");
broadcast("condFilled", { kind: "tp", direction: cond.direction, price: cond.triggerPrice });
} else {
console.log(`[Cond.TP] MINED partial +${fill.size.toFixed(4)} on-chain ${cond.chainFilledSize.toFixed(4)}/${cond.size} (remainder still resting on PM)`);
}
broadcastConditionOrders();
}
}
// post-buy on-chain calibration + conditional order creation:
// - TAKER: WS size has a ~1% deviation, calibrate with getRealFillFromTx
// - MAKER: mo.matched_amount is already the exact on-chain matched amount, no calibration needed (use directly)
if (fill.side === "buy" && txHash && PROXY_ADDRESS) {
const wsSize = fill.size;
const targetAssetId = fill.assetId;
const condDir = direction;
const condEntryPrice = fill.price;
const condMeta = pendingMeta;
const skipChainCalibration = isMaker;
void (async () => {
let finalSize = wsSize;
if (skipChainCalibration) {
console.log(`[Trade.OnChain] ✓ MAKER path exact fill asset:${targetAssetId} tx:${txHash} matched_amount:${wsSize} (calibration skipped)`);
positions.apiSize[targetAssetId] = positions.localSize[targetAssetId];
positions.apiVerified[targetAssetId] = true;
broadcastState();
} else {
const realFill = await getRealFillFromTx(txHash, PROXY_ADDRESS);
if (realFill == null) {
console.log(`[Trade.OnChain] ⚠ calibration failed tx:${txHash} asset:${targetAssetId} will fall back to REST`);
} else {
const delta = realFill - wsSize;
if (Math.abs(delta) < 0.000001) {
console.log(`[Trade.OnChain] ✓ buy calibration asset:${targetAssetId} tx:${txHash} WS:${wsSize} = on-chain:${realFill}`);
} else {
positions.localSize[targetAssetId] = (positions.localSize[targetAssetId] ?? 0) + delta;
console.log(`[Trade.OnChain] ✓ buy calibration asset:${targetAssetId} tx:${txHash} WS:${wsSize} -> on-chain:${realFill} (delta:${delta >= 0 ? "+" : ""}${delta.toFixed(6)})`);
}
positions.apiSize[targetAssetId] = positions.localSize[targetAssetId];
positions.apiVerified[targetAssetId] = true;
broadcastState();
finalSize = realFill;
}
}
if (condDir && condMeta && Number.isFinite(condEntryPrice) && condEntryPrice > 0 && (condMeta.stopProfit || condMeta.stopLoss)) {
await createConditionOrdersAfterFill({
direction: condDir,
assetId: targetAssetId,
entryPrice: condEntryPrice,
filledSize: finalSize,
windowStart: condMeta.windowStart,
stopProfit: condMeta.stopProfit,
stopLoss: condMeta.stopLoss,
});
}
if (condMeta?.source && /^strategy[a-z0-9]+$/i.test(condMeta.source)
&& !/tp$/i.test(condMeta.source) && condDir) {
const strategyKey = condMeta.source.replace(/^strategy/i, "");
// fix C: pass in fill.orderId so onStrategyLimitMined matches exactly, avoiding cross-talk between multiple stratOrders
await onStrategyLimitMined(strategyKey, condDir, finalSize, fill.orderId);
}
})();
}
}
broadcastState();
}
}
} catch { /* ignore */ }
});
userWs.on("close", () => {
if (userWsPingTimer) clearInterval(userWsPingTimer);
const delay = backoffDelay(userWsAttempt++);
console.log(`[WS.User] disconnected, reconnecting in ${delay}ms (attempt ${userWsAttempt})`);
wsStatus.user = false; broadcastWsStatus();
invalidatePendingLatencyOnWsDisconnect();
if (!stopped) setTimeout(startUserWs, delay);
});
userWs.on("error", (err) => { console.error("[WS.User] error:", err.message); });
}
// ── Market WS ─────────────────────────────────────────────────
let marketWs: WebSocket | null = null;
let marketPingTimer: ReturnType<typeof setInterval> | null = null;
let marketRenderTimer: ReturnType<typeof setInterval> | null = null;
let marketValidationTimer: ReturnType<typeof setInterval> | null = null;
let lastBestBidAskTimestamp = 0;
let bestBidAskPausedUntil = 0;
let marketValidationMismatchStreak = 0;
let marketReconnectPending = false;
let marketBestReady = false;
function isProbabilityReady(now = Date.now()): boolean {
if (!wsStatus.market) return false;
if (!marketBestReady) return false;
if (now < bestBidAskPausedUntil) return false;
const bid = Number(state.bestBid);
const ask = Number(state.bestAsk);
return Number.isFinite(bid) && Number.isFinite(ask);
}
function parseEventTimestamp(value: unknown): number {
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n : 0;
}
function applyBestBidAskUpdate(
bestBid: unknown,
bestAsk: unknown,
timestamp: unknown,
): boolean {
if (typeof bestBid !== "string" || typeof bestAsk !== "string") return false;
if (Date.now() < bestBidAskPausedUntil) return false;
const ts = parseEventTimestamp(timestamp);
if (ts > 0 && ts < lastBestBidAskTimestamp) return false;
if (ts > 0) lastBestBidAskTimestamp = ts;
state.bestBid = bestBid;
state.bestAsk = bestAsk;
marketBestReady = true;
scheduleStrategyTick(); // order book update (probability change) immediately triggers a strategy check
return true;
}
function clearProbabilityForMs(ms: number, reason: string): void {
const until = Date.now() + ms;
if (until > bestBidAskPausedUntil) bestBidAskPausedUntil = until;
marketBestReady = false;
lastBestBidAskTimestamp = 0;
state.bestBid = "-";
state.bestAsk = "-";
state.updatedAt = Date.now();
console.warn(`[Health.Prob] ${reason}, clearing probability ${ms}ms`);
broadcastState();
}
function requestMarketReconnect(reason: string, options?: { clearProbabilityMs?: number }): void {
clearProbabilityForMs(options?.clearProbabilityMs ?? 0, reason);
marketValidationMismatchStreak = 0;
if (marketReconnectPending) return;
marketReconnectPending = true;
console.warn(`[WS.Market] triggering reconnect: ${reason}`);
if (marketWs) {
marketWs.close();
return;
}
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
void subscribeWindow(Math.max(subscribedWindow, getCurrentWindowStart()));
}, 1000);
}
async function validateMarketProbability(expectedWindowStart: number, upTokenId: string): Promise<void> {
if (marketReconnectPending) return;
if (subscribedWindow !== expectedWindowStart) return;
if (!marketWs || marketWs.readyState !== WebSocket.OPEN) return;
try {
const { bestBid, bestAsk } = await fetchBookTopOfBook(upTokenId);
if (subscribedWindow !== expectedWindowStart || upTokenId !== state.upTokenId) return;
if (!(bestBid > 0) || !(bestAsk > 0)) return;
const wsBid = Number(state.bestBid);
const wsAsk = Number(state.bestAsk);
if (!Number.isFinite(wsBid) || !Number.isFinite(wsAsk)) return;
const restMid = (bestBid + bestAsk) / 2;
const wsMid = (wsBid + wsAsk) / 2;
const diffPct = Math.abs(restMid - wsMid) * 100;
if (diffPct > 3) {
marketValidationMismatchStreak++;
// print only when deviation is large (>=5%) or a reconnect is about to trigger (>=2/3), to avoid noise
if (diffPct >= 5 || marketValidationMismatchStreak >= 2) {
console.warn(`[Health.Prob] ⚠ REST deviation ${diffPct.toFixed(2)}% streak ${marketValidationMismatchStreak}/3`);
}
if (marketValidationMismatchStreak >= 3) {
console.warn(`[Health.Prob] ❌ triggering reconnect: 3/3 consecutive deviation >3%`);
requestMarketReconnect(`probability deviation >${3}% 3 times in a row`);
}
return;
}
marketValidationMismatchStreak = 0;
} catch (err) {
if (subscribedWindow !== expectedWindowStart || upTokenId !== state.upTokenId) return;
requestMarketReconnect(
`REST validation failed: ${err instanceof Error ? err.message : String(err)}`,
{ clearProbabilityMs: 2000 },
);
}
}
let _marketWsConnectedOnce = false;
function startMarketWs(expectedWindowStart: number, upTokenId: string, downTokenId: string, onClose: () => void): WebSocket {
const ws = new WebSocket(MARKET_WS_URL);
ws.on("open", () => {
if (ws !== marketWs || subscribedWindow !== expectedWindowStart) return;
console.log(_marketWsConnectedOnce ? "[WS.Market] reconnect succeeded" : "[WS.Market] connected");
_marketWsConnectedOnce = true;
marketReconnectPending = false;
marketValidationMismatchStreak = 0;
marketBestReady = false;
wsStatus.market = true; broadcastWsStatus();
ws.send(JSON.stringify({
assets_ids: [upTokenId, downTokenId],
type: "market",
custom_feature_enabled: true,
}));
marketRenderTimer = setInterval(broadcastState, 1000);
marketValidationTimer = setInterval(() => {
void validateMarketProbability(expectedWindowStart, upTokenId);
}, 1000);
marketPingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send("PING");
}, 10000);
});
ws.on("message", (data) => {
if (ws !== marketWs || subscribedWindow !== expectedWindowStart || state.upTokenId !== upTokenId) return;
const msg = data.toString();
if (msg === "PONG" || msg === "[]") return;
try {
const events = Array.isArray(JSON.parse(msg)) ? JSON.parse(msg) : [JSON.parse(msg)];
for (const evt of events) {
if (evt.bids !== undefined && evt.asks !== undefined) {
if (evt.asset_id && evt.asset_id !== upTokenId) continue;
state.bids.clear(); state.asks.clear();
for (const b of (evt.bids as { price: string; size: string }[])) {
if (Number(b.size) > 0) state.bids.set(b.price, b.size);
}
for (const a of (evt.asks as { price: string; size: string }[])) {
if (Number(a.size) > 0) state.asks.set(a.price, a.size);
}
state.updatedAt = Date.now();
broadcastState();
} else if (evt.event_type === "best_bid_ask") {
if (evt.asset_id && evt.asset_id !== upTokenId) continue;
if (!applyBestBidAskUpdate(evt.best_bid, evt.best_ask, evt.timestamp)) continue;
state.updatedAt = Date.now();
broadcastState();
} else if (evt.event_type === "price_change" && evt.price_changes) {
for (const change of evt.price_changes as Record<string, string>[]) {
if (change.asset_id !== upTokenId) continue;
if (change.price && change.size !== undefined) {
state.lastPrice = Number(change.price).toFixed(2);
state.lastSide = change.side;
// sync update of order book depth
const size = Number(change.size);
const map = change.side === 'BUY' ? state.bids : state.asks;
if (size > 0) map.set(change.price, change.size);
else map.delete(change.price);
}
}
state.updatedAt = Date.now();
broadcastState();
}
}
} catch { /* ignore */ }
});
ws.on("close", () => {
if (ws !== marketWs || subscribedWindow !== expectedWindowStart) return;
if (marketPingTimer) clearInterval(marketPingTimer);
if (marketRenderTimer) clearInterval(marketRenderTimer);
if (marketValidationTimer) {
clearInterval(marketValidationTimer);
marketValidationTimer = null;
}
marketBestReady = false;
state.bestBid = "-";
state.bestAsk = "-";
state.updatedAt = Date.now();
console.log("[WS.Market] connection lost, reconnecting in 1 second");
wsStatus.market = false; broadcastWsStatus();
broadcastState();
broadcast("marketDown", {});
onClose();
});
ws.on("error", (err) => { console.error("[WS.Market] error:", err.message); });
return ws;
}
// ── Chainlink WS ──────────────────────────────────────────────
let chainlinkWs: WebSocket | null = null;
function startChainlinkWs(expectedWindowStart: number, eventSlug: string, onClose: () => void, attempt = 0): WebSocket {
const ws = new WebSocket(CHAINLINK_WS_URL);
ws.on("open", () => {
if (ws !== chainlinkWs || subscribedWindow !== expectedWindowStart) return;
console.log(attempt === 0 ? "[WS.OnChainPrice] connected" : "[WS.OnChainPrice] reconnect succeeded");
wsStatus.chainlink = true; broadcastWsStatus();
ws.send(JSON.stringify({
action: "subscribe",
subscriptions: [
{ topic: "crypto_prices_chainlink", type: "update", filters: JSON.stringify({ symbol: activeMarket.chainlinkSymbol }) },
{ topic: "activity", type: "orders_matched", filters: JSON.stringify({ event_slug: eventSlug }) },
],
}));
});
ws.on("message", (data) => {
if (ws !== chainlinkWs || subscribedWindow !== expectedWindowStart) return;
try {
const msg = JSON.parse(data.toString()) as { topic?: string; type?: string; timestamp?: number; payload?: { value?: number; timestamp?: number } };
if (msg.topic === "crypto_prices_chainlink" && msg.type === "update") {
const val = msg.payload?.value;
if (val != null) {
state.currentPrice = val;
const now = msg.payload?.timestamp ?? msg.timestamp ?? Date.now();
state.priceHistory.push({ t: now, price: val });
trimHistory(state.priceHistory, now - HISTORY_RETENTION_MS, MAX_CHAINLINK_HISTORY_POINTS);
maybeInitializeBinanceOffset();
broadcast("chainlinkPrice", { t: now, price: val });
broadcastState();
scheduleStrategyTick(); // price update immediately triggers a strategy check
}
}
} catch { /* ignore */ }
});
ws.on("close", () => {
if (ws !== chainlinkWs || subscribedWindow !== expectedWindowStart) return;
const delay = backoffDelay(attempt);
console.log(`[WS.OnChainPrice] connection lost, reconnecting in ${delay}ms (attempt ${attempt + 1})`);
wsStatus.chainlink = false; broadcastWsStatus();
broadcast("chainlinkDown", {});
onClose();
});
ws.on("error", (err) => { console.error("[WS.OnChainPrice] error:", err.message); });
return ws;
}
// -- Binance WS ---------------------------------------------------
let binanceWs: WebSocket | null = null;
let binanceWsAttempt = 0;
function updateKlineArray(arr: Kline[], k: Kline, maxSize: number): void {
const last = arr[arr.length - 1];
if (last && last.openTime === k.openTime) {
// within the same kline update
arr[arr.length - 1] = k;
} else {
arr.push(k);
if (arr.length > maxSize) arr.splice(0, arr.length - maxSize);
}
}
/** pull historical klines from Binance REST (for startup pre-fill and gap-filling after reconnect) */
async function fetchHistoricalKlines(interval: "1m" | "5m", limit: number): Promise<Kline[] | null> {
try {
const url = `https://api.binance.com/api/v3/klines?symbol=${activeMarket.binanceSymbol.toUpperCase()}&interval=${interval}&limit=${limit}`;
const res = await fetch(url);
if (!res.ok) return null;
const raw = await res.json() as Array<Array<string | number>>;
return raw.map((r) => ({
openTime: Number(r[0]),
open: parseFloat(String(r[1])),
high: parseFloat(String(r[2])),
low: parseFloat(String(r[3])),
close: parseFloat(String(r[4])),
volume: parseFloat(String(r[5])),
closed: true, // everything REST returns is already closed
}));
} catch (err) {
console.warn(`[Price.Binance] failed to pull historical ${interval} klines: ${(err as Error).message}`);
return null;
}
}
/** merge historical klines into the existing array, dedupe and keep the latest maxSize bars */
function mergeKlines(existing: Kline[], fetched: Kline[], maxSize: number): void {
const map = new Map<number, Kline>();
for (const k of existing) map.set(k.openTime, k);
for (const k of fetched) {
// historical data only overwrites when the current bar is absent or not yet closed
const curr = map.get(k.openTime);
if (!curr || !curr.closed) map.set(k.openTime, k);
}
const sorted = [...map.values()].sort((a, b) => a.openTime - b.openTime);
existing.length = 0;
const start = Math.max(0, sorted.length - maxSize);
for (let i = start; i < sorted.length; i++) existing.push(sorted[i]);
}
async function loadHistoricalKlines(attempt = 1): Promise<void> {
const [k1m, k5m] = await Promise.all([
fetchHistoricalKlines("1m", MAX_KLINE_1M),
fetchHistoricalKlines("5m", MAX_KLINE_5M),
]);
if (k1m) {
mergeKlines(state.kline1m, k1m, MAX_KLINE_1M);
console.log(`[Price.Binance] 1m kline pre-fill ${state.kline1m.length} bars`);
}
if (k5m) {
mergeKlines(state.kline5m, k5m, MAX_KLINE_5M);
console.log(`[Price.Binance] 5m kline pre-fill ${state.kline5m.length} bars`);
}
// if either fails, retry with exponential backoff, up to 5 times (3s / 6s / 12s / 24s / 48s)
const need1m = !k1m && state.kline1m.length < MAX_KLINE_1M;
const need5m = !k5m && state.kline5m.length < MAX_KLINE_5M;
if ((need1m || need5m) && attempt <= 5) {
const delayMs = Math.min(48000, 3000 * Math.pow(2, attempt - 1));
console.warn(`[Price.Binance] kline load failed (1m=${!!k1m} 5m=${!!k5m}), retrying in ${delayMs/1000}s ${attempt}/5`);
setTimeout(() => { void loadHistoricalKlines(attempt + 1); }, delayMs);
} else if (need1m || need5m) {
console.error(`[Price.Binance] kline load failed 5 times in a row, giving up. Strategies may not work properly.`);
}
}
function startBinanceWs(): void {
const url = getBinanceWsUrl(activeMarket.key);
console.log(`[WS.Binance] connecting ${activeMarket.symbol} -> ${url}`);
const ws = new WebSocket(url);
binanceWs = ws;
ws.on("open", () => {
console.log(binanceWsAttempt === 0 ? `[WS.Binance] connected (${activeMarket.symbol})` : `[WS.Binance] reconnect succeeded (${activeMarket.symbol})`);
binanceWsAttempt = 0;
wsStatus.binance = true;
broadcastWsStatus();
// after connecting, asynchronously pull historical klines to fill / patch gaps
void loadHistoricalKlines();
});
ws.on("message", (data) => {
// when switching markets, the old ws may still push messages while closing; discard to avoid polluting the new market's binanceHistory
if (ws !== binanceWs) return;
try {
const raw = JSON.parse(data.toString()) as { stream?: string; data?: Record<string, unknown> };
const stream = raw.stream;
const payload = raw.data;
if (!stream || !payload) return;
if (stream.endsWith("@aggTrade")) {
const p = payload as { p?: string; T?: number };
const price = parseFloat(p.p ?? "");
const t = p.T ?? Date.now();
if (!price) return;
state.binanceHistory.push({ t, price });
trimHistory(state.binanceHistory, t - HISTORY_RETENTION_MS, MAX_BINANCE_HISTORY_POINTS);
recomputeVolPct(t);
maybeInitializeBinanceOffset();
broadcast("binancePrice", { t, price });
scheduleStrategyTick(); // price change immediately triggers a strategy check
return;
}
if (stream.endsWith("@kline_1m") || stream.endsWith("@kline_5m")) {
const kData = (payload as { k?: Record<string, unknown> }).k;
if (!kData) return;
const kline: Kline = {
openTime: Number(kData.t),
open: parseFloat(String(kData.o)),
high: parseFloat(String(kData.h)),
low: parseFloat(String(kData.l)),
close: parseFloat(String(kData.c)),
volume: parseFloat(String(kData.v)),
closed: Boolean(kData.x),
};
if (stream.endsWith("@kline_1m")) {
updateKlineArray(state.kline1m, kline, MAX_KLINE_1M);
} else {
updateKlineArray(state.kline5m, kline, MAX_KLINE_5M);
}
scheduleStrategyTick(); // kline update immediately triggers a strategy check
return;
}
} catch { /* ignore */ }
});
ws.on("close", () => {
if (ws !== binanceWs) return; // an old ws close event should not trigger a reconnect
const delay = backoffDelay(binanceWsAttempt++);
console.log(`[WS.Binance] disconnected, reconnecting in ${delay}ms (attempt ${binanceWsAttempt})`);
wsStatus.binance = false; broadcastWsStatus();
if (!stopped) setTimeout(startBinanceWs, delay);
});
ws.on("error", (err) => { console.error("[WS.Binance] error:", err.message); });
}
// ── Coinbase WS ───────────────────────────────────────────────
let coinbaseWs: WebSocket | null = null;
let coinbaseWsAttempt = 0;
function startCoinbaseWs(): void {
console.log(`[WS.Coinbase] connecting ${activeMarket.coinbaseProduct}`);
const ws = new WebSocket(COINBASE_WS_URL);
coinbaseWs = ws;
ws.on("open", () => {
console.log(coinbaseWsAttempt === 0 ? `[WS.Coinbase] connected (${activeMarket.coinbaseProduct})` : `[WS.Coinbase] reconnect succeeded (${activeMarket.coinbaseProduct})`);
coinbaseWsAttempt = 0;
wsStatus.coinbase = true;
broadcastWsStatus();
// subscribe to the ticker for the current market (pushes the price on every trade)
ws.send(JSON.stringify({
type: "subscribe",
product_ids: [activeMarket.coinbaseProduct],
channels: ["ticker"],
}));
});
ws.on("message", (data) => {
// when switching markets, the old ws may still push messages while closing; discard to avoid polluting the new market's coinbaseHistory
if (ws !== coinbaseWs) return;
try {
const raw = JSON.parse(data.toString()) as Record<string, unknown>;
// ticker push: { type: "ticker", price: "...", time: "ISO", product_id: "BTC-USD", ... }
if (raw.type !== "ticker") return;
const price = typeof raw.price === "string" ? parseFloat(raw.price) : (typeof raw.price === "number" ? raw.price : NaN);
if (!Number.isFinite(price) || price <= 0) return;
const tStr = typeof raw.time === "string" ? raw.time : null;
const t = tStr ? Date.parse(tStr) : Date.now();
state.coinbaseHistory.push({ t, price });
trimHistory(state.coinbaseHistory, t - HISTORY_RETENTION_MS, MAX_COINBASE_HISTORY_POINTS);
maybeInitOffset(COINBASE_SPEC);
broadcast("coinbasePrice", { t, price });
} catch { /* ignore */ }
});
ws.on("close", () => {
if (ws !== coinbaseWs) return;
const delay = backoffDelay(coinbaseWsAttempt++);
console.log(`[WS.Coinbase] disconnected, reconnecting in ${delay}ms (attempt ${coinbaseWsAttempt})`);
wsStatus.coinbase = false; broadcastWsStatus();
if (!stopped) setTimeout(startCoinbaseWs, delay);
});
ws.on("error", (err) => { console.error("[WS.Coinbase] error:", err.message); });
}
// -- Last 4 rounds result query ---------------------------------------
function parseResolvedOutcome(event: Record<string, unknown> | undefined): "up" | "down" | null {
const market = ((event?.markets as Record<string, unknown>[] | undefined) || [])[0];
if (!market) return null;
let outcomes: string[] = [];
let outcomePrices: string[] = [];
try { outcomes = JSON.parse(String(market.outcomes || "[]")) as string[]; } catch { /* ignore */ }
try { outcomePrices = JSON.parse(String(market.outcomePrices || "[]")) as string[]; } catch { /* ignore */ }
if (!outcomes.length || outcomes.length !== outcomePrices.length) return null;
const upIdx = outcomes.findIndex((o) => o.toLowerCase() === "up");
const downIdx = outcomes.findIndex((o) => o.toLowerCase() === "down");
if (upIdx < 0 || downIdx < 0) return null;
const upPrice = Number(outcomePrices[upIdx]);
const downPrice = Number(outcomePrices[downIdx]);
if (!Number.isFinite(upPrice) || !Number.isFinite(downPrice)) return null;
if (upPrice >= 0.999 && downPrice <= 0.001) return "up";
if (downPrice >= 0.999 && upPrice <= 0.001) return "down";
return null;
}
async function fetchRecentResults(currentWindow: number, immediate = false): Promise<void> {
if (!immediate) await new Promise(r => setTimeout(r, 5000));
if (stopped) return;
try {
const slugs = [1,2,3,4].map(i => `${activeMarket.slugPrefix}-${currentWindow - i * activeMarket.periodSeconds}`);
const query = slugs.map(s => `slug=${s}`).join("&");
const events = await fetch(`${GAMMA_URL}/events?${query}`).then(r => r.json()) as Record<string, unknown>[];
const results = slugs.map(slug => {
const event = events.find((e: Record<string, unknown>) => e.slug === slug) as Record<string, unknown> | undefined;
const ws = parseInt(slug.split("-").pop()!);
const timeRange = `${new Date(ws*1000).toLocaleTimeString("zh-CN",{timeZone:"Asia/Shanghai",hour:'2-digit',minute:'2-digit'})}→${new Date((ws+300)*1000).toLocaleTimeString("zh-CN",{timeZone:"Asia/Shanghai",hour:'2-digit',minute:'2-digit'})}`;
const result = parseResolvedOutcome(event);
return { timeRange, result };
});
const summary = results
.map((item) => `${item.timeRange}${item.result === "up" ? "Up won" : item.result === "down" ? "Down won" : "pending"}`)
.join(" | ");
console.log(`[Settlement] ${summary}`);
broadcast("recentResults", { results });
} catch (e) { console.error(`[Settlement] request failed:`, (e as Error).message); }
}
// -- Window switch -----------------------------------------------
let subscribedWindow = 0;
let stopped = false;
let switchTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function disconnectWindowStreams(): void {
const hadMarketFeed = !!marketWs || wsStatus.market;
const hadChainlinkFeed = !!chainlinkWs || wsStatus.chainlink;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (marketPingTimer) {
clearInterval(marketPingTimer);
marketPingTimer = null;
}
if (marketRenderTimer) {
clearInterval(marketRenderTimer);
marketRenderTimer = null;
}
if (marketValidationTimer) {
clearInterval(marketValidationTimer);
marketValidationTimer = null;
}
if (marketWs) {
marketWs.removeAllListeners("close");
marketWs.close();
marketWs = null;
}
if (chainlinkWs) {
chainlinkWs.removeAllListeners("close");
chainlinkWs.close();
chainlinkWs = null;
}
if (wsStatus.market || wsStatus.chainlink) {
wsStatus.market = false;
wsStatus.chainlink = false;
broadcastWsStatus();
}
if (hadMarketFeed) broadcast("marketDown", {});
if (hadChainlinkFeed) broadcast("chainlinkDown", {});
}
function clearWindowRuntimeState(): void {
state.bids.clear();
state.asks.clear();
state.bestBid = "-";
state.bestAsk = "-";
state.lastPrice = "-";
state.lastSide = "";
state.priceToBeat = null;
state.currentPrice = null;
state.binanceOffset = null;
state.coinbaseOffset = null;
state.updatedAt = Date.now();
marketBestReady = false;
marketValidationMismatchStreak = 0;
bestBidAskPausedUntil = 0;
strategyRuntime.positionsReady = !PROXY_ADDRESS;
resetStrategyRuntime();
broadcastState();
}
function getCurrentWindowStart(now = Date.now()): number {
const sec = activeMarket.periodSeconds;
return Math.floor(now / 1000 / sec) * sec;
}
// -- Multi-symbol / multi-period market switch -------------------------
// pre-switch checks (against the **target market's** current window):
// 1. current position = 0
// 2. current open orders = 0
// 3. strategy runtime = IDLE
// note: positions/open orders in the old market are allowed to remain - 5m/15m markets auto-settle at expiry
async function switchMarket(targetKey: MarketKey): Promise<{ ok: boolean; reason?: string }> {
if (targetKey === activeMarket.key) return { ok: true };
if (!MARKETS[targetKey]) return { ok: false, reason: "unknown market" };
// check 1: current market position is 0
const upHas = (positions.localSize[state.upTokenId] || 0) > 0 || (positions.apiSize[state.upTokenId] || 0) > 0;
const downHas = (positions.localSize[state.downTokenId] || 0) > 0 || (positions.apiSize[state.downTokenId] || 0) > 0;
if (upHas || downHas) {
return { ok: false, reason: "holding a position, please close it before switching" };
}
// check 2: current market has no open orders
const hasOpenOrder = [...activeConditionOrders.values()].some(
(c) => c.assetId === state.upTokenId || c.assetId === state.downTokenId
);
if (hasOpenOrder) {
return { ok: false, reason: "there are open orders, please cancel them first" };
}
// check 3: the strategy state machine must be idle
if (strategyRuntime.state !== "IDLE") {
return { ok: false, reason: `strategy is running (${strategyRuntime.state}), please wait for it to finish` };
}
console.log(`[System.Market] switching ${activeMarket.key} -> ${targetKey}`);
// close the market-bound WS (market/chainlink/binance/coinbase)
// keep the user WS, CLOB client, and USDC monitoring
disconnectWindowStreams();
if (binanceWs) {
binanceWs.removeAllListeners("close");
binanceWs.close();
binanceWs = null;
wsStatus.binance = false;
}
if (coinbaseWs) {
coinbaseWs.removeAllListeners("close");
coinbaseWs.close();
coinbaseWs = null;
wsStatus.coinbase = false;
}
broadcastWsStatus();
// clear window-level runtime state + historical price / klines
clearWindowRuntimeState();
state.priceHistory.length = 0;
state.binanceHistory.length = 0;
state.coinbaseHistory.length = 0;
state.kline1m.length = 0;
state.kline5m.length = 0;
pendingTradeMeta.clear();
positions.lastTradeAt = null; // the old market's fill time is no longer relevant
subscribedWindow = 0;
// switch activeMarket and persist
activeMarket = MARKETS[targetKey];
setFairProbMarket(activeMarket.symbol, activeMarket.period); // sync to the fair-prob table
setDiffExtremesMarket(activeMarket.symbol, activeMarket.period); // sync to the diff-extremes table
// when the current market has no table, the relevant strategies (p series, etc.) auto no-op even if enabled
saveActiveMarketKey(targetKey);
// restart the 4 upstream WS (user is left untouched)
startBinanceWs();
startCoinbaseWs();
await advanceToLiveWindow(getCurrentWindowStart());
broadcast("marketSwitched", { key: activeMarket.key, displayName: activeMarket.displayName });
broadcastState();
console.log(`[System.Market] switch complete -> ${activeMarket.displayName}`);
return { ok: true };
}
async function advanceToLiveWindow(targetWindowStart: number): Promise<void> {
const switchStartedAt = Date.now();
let attempt = 0;
let clearedExpiredWindow = false;
while (!stopped) {
const desiredWindow = Math.max(targetWindowStart, getCurrentWindowStart());
if (!clearedExpiredWindow && desiredWindow > subscribedWindow) {
disconnectWindowStreams();
clearWindowRuntimeState();
clearedExpiredWindow = true;
}
const subscribeStartedAt = Date.now();
await subscribeWindow(desiredWindow);
if (subscribedWindow === desiredWindow) {
// summarize window-switch info: elapsed + offset + open price
const fmtTs = (ts: number) => new Date(ts * 1000).toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false });
const cost = Date.now() - switchStartedAt;
const binOff = state.binanceOffset;
const cbOff = state.coinbaseOffset;
const open = state.priceToBeat;
const parts: string[] = [];
if (binOff != null) parts.push(`Binance:${binOff >= 0 ? "+" : ""}${binOff.toFixed(2)}`);
if (cbOff != null) parts.push(`Coinbase:${cbOff >= 0 ? "+" : ""}${cbOff.toFixed(2)}`);
if (open != null) parts.push(`open:${open.toFixed(0)}`);
const tail = parts.length ? `${parts.join(" ")}` : "";
console.log(`[System.Window] ✓ switch ${fmtTs(subscribedWindow)} -> ${fmtTs(desiredWindow)} elapsed ${cost}ms${tail}`);
return;
}
const delay = Math.min(1000 * Math.max(++attempt, 1), 5000);
console.warn(`[System.Window] switch retry windowStart=${desiredWindow} continuing in ${delay}ms`);
await new Promise(r => setTimeout(r, delay));
}
}
function scheduleNextWindow(windowEnd: number): void {
if (switchTimer) clearTimeout(switchTimer);
const msUntilEnd = windowEnd * 1000 - Date.now();
switchTimer = setTimeout(async () => {
if (stopped) return;
await advanceToLiveWindow(windowEnd);
}, Math.max(0, msUntilEnd));
}
async function subscribeWindow(windowStart: number): Promise<void> {
const startedAt = Date.now();
const info = await fetchMarket(windowStart);
if (!info) {
broadcast("error", { message: `market not found windowStart=${windowStart}` });
console.warn(`[System.Window] subscribe failed windowStart=${windowStart} elapsed:${Date.now() - startedAt}ms`);
return;
}
const isNewWindow = subscribedWindow !== windowStart;
const prevWindowStart = subscribedWindow;
subscribedWindow = windowStart;
const slug = `${activeMarket.slugPrefix}-${info.windowStart}`;
state.windowStart = info.windowStart; state.windowEnd = info.windowEnd;
state.upTokenId = info.upTokenId; state.downTokenId = info.downTokenId;
state.conditionId = info.conditionId; state.slug = slug;
// pre-fetch tickSize (BTC 5m fixed at 0.01, also async-validate via API)
getCachedTickSize(info.upTokenId);
getCachedTickSize(info.downTokenId);
state.bids.clear(); state.asks.clear();
state.bestBid = "-"; state.bestAsk = "-";
state.lastPrice = "-"; state.lastSide = "";
state.binanceOffset = null;
state.coinbaseOffset = null;
state.updatedAt = Date.now();
lastBestBidAskTimestamp = 0;
marketBestReady = false;
bestBidAskPausedUntil = 0;
marketValidationMismatchStreak = 0;
marketReconnectPending = false;
if (isNewWindow) {
if (prevWindowStart > 0) fetchRecentResults(windowStart);
state.priceToBeat = null; state.currentPrice = null;
strategyRuntime.positionsReady = !PROXY_ADDRESS;
clearAllPresigned();
resetStrategyRuntime(`switching to window ${windowStart}`);
rememberTokenSymbol(info.upTokenId, activeMarket.symbol);
rememberTokenSymbol(info.downTokenId, activeMarket.symbol);
prunePositionCaches([info.upTokenId, info.downTokenId]);
positions.localSize[info.upTokenId] = 0;
positions.localSize[info.downTokenId] = 0;
positions.apiSize[info.upTokenId] = 0;
positions.apiSize[info.downTokenId] = 0;
positions.apiVerified[info.upTokenId] = false;
positions.apiVerified[info.downTokenId] = false;
// window switch: cancel all GTC take-profit resting orders and clear the local conditional order list
for (const cond of activeConditionOrders.values()) {
if (cond.kind === "tp" && cond.polymarketOrderId) {
void cancelTakeProfitOrder(cond.polymarketOrderId);
}
}
activeConditionOrders.clear();
broadcastConditionOrders();
// window switch: cancel all strategy limit orders (the same window already settled, orders are void but cancel anyway to be safe)
for (const order of strategyLimitOrders.values()) {
if (order.windowStart !== windowStart) {
void cancelStrategyLimitOrder(order.orderID, "window switch");
}
}
// clear the "already placed" markers, the new window can trigger again
strategyLimitWindowMark.clear();
const thisWindow = info.windowStart;
const tryFetch = () => {
if (stopped || subscribedWindow !== thisWindow) return;
fetchCryptoPrice(info.eventStartTime, info.endDate).then(() => {
if (state.priceToBeat == null && !stopped && subscribedWindow === thisWindow) setTimeout(tryFetch, 1000);
else broadcastState();
});
};
tryFetch();
syncPositionsFromApi().then(() => broadcastState());
}
broadcast("window", {
windowStart: info.windowStart, windowEnd: info.windowEnd,
conditionId: info.conditionId, upTokenId: info.upTokenId, downTokenId: info.downTokenId,
});
if (marketWs || chainlinkWs || reconnectTimer) {
disconnectWindowStreams();
}
marketWs = startMarketWs(info.windowStart, info.upTokenId, info.downTokenId, () => {
if (stopped) return;
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
void subscribeWindow(Math.max(subscribedWindow, getCurrentWindowStart()));
}, 1000);
});
const eventSlug = `${activeMarket.slugPrefix}-${info.windowStart}`;
let clAttempt = 0;
const reconnectChainlink = () => {
if (stopped) return;
const delay = backoffDelay(clAttempt);
clAttempt++;
setTimeout(() => {
if (stopped) return;
chainlinkWs = startChainlinkWs(subscribedWindow, `${activeMarket.slugPrefix}-${subscribedWindow}`, reconnectChainlink, clAttempt);
}, delay);
};
chainlinkWs = startChainlinkWs(info.windowStart, eventSlug, reconnectChainlink, 0);
scheduleNextWindow(info.windowEnd);
}
// -- Claim query ------------------------------------------------
interface ClaimPosition {
conditionId: string; title: string; currentValue: number; size: number;
}
let claimablePositions: ClaimPosition[] = [];
let claimableTotal = 0;
let claimCycleTimer: ReturnType<typeof setTimeout> | null = null;
let claimCycleRunning = false;
let claimNextCheckAt = 0;
let claimCooldownUntil = 0; // Claim cooldown deadline timestamp (5 minutes)
function broadcastClaimCooldown(running = false): void {
broadcast("claimCooldown", {
running,
nextCheckAt: claimNextCheckAt,
cooldownUntil: claimCooldownUntil,
});
}
function resetClaimableState(): void {
claimablePositions = [];
claimableTotal = 0;
broadcast("claimable", { total: claimableTotal, positions: claimablePositions });
}
async function syncClaimable(options: { clearOnError?: boolean } = {}): Promise<boolean> {
if (!PROXY_ADDRESS) {
resetClaimableState();
return false;
}
try {
const pos = await fetch(
`https://data-api.polymarket.com/positions?user=${PROXY_ADDRESS}&sizeThreshold=.01&redeemable=true&limit=100&offset=0`
).then(r => r.json()) as Array<{ conditionId: string; title: string; currentValue: number; size: number; curPrice: number; asset?: string; oppositeAsset?: string; outcome?: string; oppositeOutcome?: string }>;
// keep only winning positions (currentValue > 0 means that outcome is the winning direction)
// then verify with on-chain asset balance to filter out already-redeemed ones (data-api cache lag)
const winners = pos.filter(p => p.currentValue > 0 && p.asset);
let verified: typeof pos = winners;
if (winners.length) {
try {
const provider = getClaimProvider();
const ctfRO = new ethers.Contract(getContractConfig(137).conditionalTokens, [
'function balanceOfBatch(address[] accounts, uint256[] ids) view returns (uint256[])',
], provider);
const ids = winners.map(p => BigInt(p.asset!));
const accounts = ids.map(() => PROXY_ADDRESS);
const balances: bigint[] = await ctfRO.balanceOfBatch(accounts, ids);
verified = winners.filter((_, i) => balances[i] > 0n);
} catch (e) {
console.warn(`[Claim] on-chain verification failed (falling back to currentValue>0): ${e instanceof Error ? e.message : e}`);
}
}
claimablePositions = verified.map(p => ({
conditionId: p.conditionId, title: p.title, currentValue: p.currentValue, size: p.size,
}));
claimableTotal = claimablePositions.reduce((s, p) => s + p.currentValue, 0);
broadcast("claimable", { total: claimableTotal, positions: claimablePositions });
return true;
} catch (err) {
if (options.clearOnError) resetClaimableState();
const msg = err instanceof Error ? err.message : String(err);
console.error(`[Claim] failed to query claimable positions: ${msg}`);
return false;
}
}
function scheduleClaimCycle(delayMs = CLAIM_CYCLE_DELAY_MS): void {
if (stopped || !PROXY_ADDRESS) {
if (claimCycleTimer) clearTimeout(claimCycleTimer);
claimCycleTimer = null;
claimNextCheckAt = 0;
broadcastClaimCooldown(false);
return;
}
if (claimCycleTimer) clearTimeout(claimCycleTimer);
claimNextCheckAt = Date.now() + Math.max(0, delayMs);
broadcastClaimCooldown(false);
claimCycleTimer = setTimeout(() => {
void autoClaimCycle().catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[Claim.Auto] background claim exception: ${msg}`);
scheduleClaimCycle();
});
}, Math.max(0, delayMs));
}
async function autoClaimCycle(): Promise<void> {
if (stopped || claimCycleRunning || claimInProgress) {
// the previous claim round hasn't finished (on-chain tx incomplete), skip this round, do not reset cooldown, the next timer will trigger it
if (claimInProgress && !claimCycleRunning) {
console.log(`[Claim.Auto] previous claim still in progress, skipping this loop`);
scheduleClaimCycle();
}
return;
}
// when auto Claim is unchecked, do not query, do not broadcast, just clear the state
if (!strategyConfig.autoClaimEnabled || !PRIVATE_KEY) {
resetClaimableState();
return;
}
claimCycleRunning = true;
claimNextCheckAt = 0;
broadcastClaimCooldown(true);
try {
// Step 1: query the latest claimable amount every loop (high-frequency refresh, frontend shows it in real time)
const synced = await syncClaimable({ clearOnError: true });
if (!synced) return;
if (!claimablePositions.length || claimInProgress) return;
// cooldown period: do not execute again within 5 minutes of the last claim
if (Date.now() < claimCooldownUntil) return;
// strategy busy: entering/holding/exiting -> do not trigger a Safe transaction (avoids nonce conflicts)
const strategyBusy = strategyRuntime.state !== "IDLE"
&& strategyRuntime.state !== "DONE"
&& strategyRuntime.state !== "SCANNING";
if (strategyBusy || hasOpenPosition()) {
console.log(`[Claim.Auto] strategy busy (${strategyRuntime.state}), skipping this time`);
return;
}
// Step 3: refresh the amount once more before claiming (ensures conditionId and quantity are latest)
await syncClaimable({ clearOnError: false });
if (!claimablePositions.length) return;
console.log(`[Claim.Auto] detected ${claimablePositions.length} claimable positions, starting background claim...`);
claimCooldownUntil = Date.now() + CLAIM_COOLDOWN_MS; // enter cooldown (regardless of success or failure below)
await runClaim({ refreshAfter: false });
} finally {
claimCycleRunning = false;
scheduleClaimCycle();
}
}
// -- Claim core logic -------------------------------------------
let claimInProgress = false;
// reuse the provider: avoids new-ing one on every claim, which triggers a network-probe loop
// use the full Network object + staticNetwork object version, skipping the eth_chainId probe at startup
const CLAIM_NETWORK = new ethers.Network("polygon", 137);
let cachedClaimProvider: ethers.JsonRpcProvider | null = null;
// tickSize cache: fetched once per market at startup/window switch, reused fixed within the same window
// extreme price levels (upPct > 99%) cause Polymarket to switch to finer precision; if an order fails here, call invalidateTickSize to force a refresh
const DEFAULT_TICK_SIZE = "0.01" as const;
type TickSize = Awaited<ReturnType<ClobClient["getTickSize"]>>;
const tickSizeCache = new Map<string, TickSize>();
/** synchronously returns the cached value; if not cached returns the default 0.01 and fetches asynchronously (fills the cache immediately after the first call) */
function getCachedTickSize(tokenId: string): TickSize {
const cached = tickSizeCache.get(tokenId);
if (cached !== undefined) return cached;
// not cached: use the default value as a stopgap, fill the real value asynchronously (subscribeWindow pre-fetches at startup)
tickSizeCache.set(tokenId, DEFAULT_TICK_SIZE as TickSize);
void refreshTickSize(tokenId);
return DEFAULT_TICK_SIZE as TickSize;
}
async function refreshTickSize(tokenId: string): Promise<TickSize> {
if (!clobClient) return DEFAULT_TICK_SIZE as TickSize;
try {
const real = await clobClient.getTickSize(tokenId);
const prev = tickSizeCache.get(tokenId);
tickSizeCache.set(tokenId, real);
if (prev !== undefined && String(prev) !== String(real)) {
console.warn(`[Price.Precision] ${tokenId.slice(-8)} changed: ${prev} -> ${real}`);
}
return real;
} catch {
return tickSizeCache.get(tokenId) ?? (DEFAULT_TICK_SIZE as TickSize);
}
}
/** force refresh on order failure (one-sided market precision switch scenario) */
async function invalidateTickSize(tokenId: string): Promise<TickSize> {
tickSizeCache.delete(tokenId);
return refreshTickSize(tokenId);
}
function getClaimProvider(): ethers.JsonRpcProvider {
if (!cachedClaimProvider) {
cachedClaimProvider = new ethers.JsonRpcProvider(
"https://polygon-bor-rpc.publicnode.com",
CLAIM_NETWORK,
{ staticNetwork: CLAIM_NETWORK },
);
// silence RPC error (the original console.error would repeatedly print the same error)
cachedClaimProvider.on("error", () => { /* handled by caller */ });
}
return cachedClaimProvider;
}
async function runClaim(options: { refreshAfter?: boolean } = {}): Promise<{ title: string; txHash?: string; error?: string }[]> {
const { refreshAfter = true } = options;
if (!PROXY_ADDRESS || !PRIVATE_KEY) return [];
if (claimInProgress) return [];
if (!claimablePositions.length) return [];
claimInProgress = true;
const contracts = getContractConfig(137);
const USDC_ADDR = contracts.collateral; // pUSD after the V2 upgrade
// V2: redeem goes through CtfCollateralAdapter (internally burns CTF token then auto-wraps into pUSD)
const ADAPTER = "0xADa100874d00e3331D00F2007a9c336a65009718";
const ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000";
const provider = getClaimProvider();
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const ctfIface = new ethers.Interface([
"function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets)",
"function setApprovalForAll(address operator, bool approved)",
"function isApprovedForAll(address account, address operator) view returns (bool)",
]);
const safeIface = new ethers.Interface([
"function nonce() view returns (uint256)",
"function getTransactionHash(address to, uint256 value, bytes calldata data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, uint256 nonce) view returns (bytes32)",
"function execTransaction(address to, uint256 value, bytes calldata data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address payable refundReceiver, bytes memory signatures) public payable returns (bool)",
]);
const safe = new ethers.Contract(PROXY_ADDRESS, safeIface, wallet);
const snapshot = [...claimablePositions];
const total = snapshot.length;
const results: { title: string; txHash?: string; error?: string }[] = [];
console.log(`[Claim] starting claim, ${total} total: ${snapshot.map(p => p.title).join(' | ')}`);
// maintain nonce locally, to avoid safe.nonce() reading a stale value when the RPC node hasn't synced in time
let localNonce: bigint = await safe.nonce();
console.log(`[Claim] starting nonce:${localNonce}`);
// check and initiate one-time approval: the Safe must approve the Adapter to transferFrom CTF tokens to complete redeem
// not approving will throw GS013 (inner call revert: "ERC1155: need operator approval for 3rd party transfers.")
try {
const ctfRO = new ethers.Contract(contracts.conditionalTokens, [
"function isApprovedForAll(address account, address operator) view returns (bool)",
], provider);
const approved: boolean = await ctfRO.isApprovedForAll(PROXY_ADDRESS, ADAPTER);
if (!approved) {
console.log(`[Claim] Safe has not approved Adapter, initiating one-time approval first...`);
const approveCalldata = ctfIface.encodeFunctionData("setApprovalForAll", [ADAPTER, true]);
const approveTxHash: string = await safe.getTransactionHash(contracts.conditionalTokens, 0, approveCalldata, 0, 0, 0, 0, ethers.ZeroAddress, ethers.ZeroAddress, localNonce);
const approveSig = await wallet.signMessage(ethers.getBytes(approveTxHash));
const approveV = parseInt(approveSig.slice(-2), 16) + 4;
const approveAdjusted = approveSig.slice(0, -2) + approveV.toString(16).padStart(2, '0');
const approveTx = await safe.execTransaction(contracts.conditionalTokens, 0, approveCalldata, 0, 0, 0, 0, ethers.ZeroAddress, ethers.ZeroAddress, approveAdjusted);
console.log(`[Claim] approval transaction txHash:${approveTx.hash}, waiting for on-chain confirmation...`);
const approveReceipt = await Promise.race([
approveTx.wait(),
new Promise<null>((_, reject) => setTimeout(() => reject(new Error('approval on-chain wait timeout (30s)')), 30000)),
]);
if (!approveReceipt) throw new Error('approval on-chain wait timeout');
console.log(`[Claim] ✓ approval succeeded block:${approveReceipt.blockNumber}`);
localNonce++;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[Claim] approval failed, aborting this claim round: ${msg}`);
claimInProgress = false;
return [];
}
try {
const CLAIM_TX_INTERVAL_MS = 5000; // 5-second interval between two transactions
const CLAIM_MAX_RETRIES = 1; // retry a single failed transaction once
for (let i = 0; i < snapshot.length; i++) {
const p = snapshot[i];
console.log(`[Claim] (${i+1}/${total}) ${p.title} amount:${p.currentValue.toFixed(2)} conditionId:${p.conditionId}`);
broadcast("claimProgress", { current: i, total, title: p.title, status: "running" });
let success = false;
let lastErr = "";
for (let attempt = 0; attempt <= CLAIM_MAX_RETRIES; attempt++) {
try {
if (attempt > 0) {
console.log(`[Claim] retry attempt ${attempt}...`);
await new Promise(r => setTimeout(r, 2000));
// refresh nonce before retrying (the previous tx may still be unconfirmed in the mempool or already succeeded)
const onChainNonce: bigint = await safe.nonce();
if (onChainNonce > localNonce) localNonce = onChainNonce;
}
const calldata = ctfIface.encodeFunctionData("redeemPositions", [
USDC_ADDR, ZERO_BYTES32, p.conditionId, [1, 2]
]);
const nonce = localNonce;
console.log(`[Claim] nonce:${nonce} building transaction...`);
const txHash = await safe.getTransactionHash(ADAPTER, 0, calldata, 0, 0, 0, 0, ethers.ZeroAddress, ethers.ZeroAddress, nonce);
const sig = await wallet.signMessage(ethers.getBytes(txHash));
const v = parseInt(sig.slice(-2), 16) + 4;
const adjustedSig = sig.slice(0, -2) + v.toString(16).padStart(2, '0');
console.log(`[Claim] sending transaction...`);
const tx = await safe.execTransaction(ADAPTER, 0, calldata, 0, 0, 0, 0, ethers.ZeroAddress, ethers.ZeroAddress, adjustedSig);
console.log(`[Claim] waiting for on-chain txHash:${tx.hash}`);
const receipt = await Promise.race([
tx.wait(),
new Promise<null>((_, reject) => setTimeout(() => reject(new Error('on-chain wait timeout (30s)')), 30000)),
]);
if (!receipt) throw new Error('on-chain wait timeout (30s)');
console.log(`[Claim] ✓ success ${p.title} -> ${tx.hash}`);
results.push({ title: p.title, txHash: tx.hash });
localNonce++; // local nonce auto-increment
broadcast("claimProgress", { current: i + 1, total, title: p.title, status: "success" });
success = true;
break;
} catch (err) {
lastErr = err instanceof Error ? err.message : String(err);
console.error(`[Claim] ✗ attempt ${attempt + 1} failed ${p.title}: ${lastErr}`);
}
}
if (!success) {
results.push({ title: p.title, error: lastErr });
broadcast("claimProgress", { current: i + 1, total, title: p.title, status: "error", error: lastErr });
}
// interval between two transactions (no wait after the last one)
if (i < snapshot.length - 1) {
await new Promise(r => setTimeout(r, CLAIM_TX_INTERVAL_MS));
}
}
} finally {
claimInProgress = false;
}
console.log(`[Claim] complete success:${results.filter(r=>r.txHash).length} failed:${results.filter(r=>r.error).length}`);
if (refreshAfter) {
await syncClaimable({ clearOnError: true });
await syncUsdcBalance();
broadcastState();
}
return results;
}
function extractOrderError(result: unknown): string {
const obj = result && typeof result === "object" ? result as Record<string, unknown> : {};
const candidates = [obj.error, obj.message, obj.errorMsg, obj.errorMessage];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
}
return "";
}
function fmtOrderField(value: unknown): string {
if (value == null || value === "") return "-";
return String(value);
}
function getDecimalPlaces(value: string | number): number {
const text = String(value);
const [, decimals = ""] = text.split(".");
return decimals.replace(/0+$/, "").length;
}
function floorToDecimals(value: number, decimals: number): number {
const factor = 10 ** decimals;
return Math.floor((value + Number.EPSILON) * factor) / factor;
}
function isOrderWindowStale(now = Date.now()): boolean {
if (!state.windowStart || !state.windowEnd) return true;
if (state.windowEnd * 1000 <= now) return true;
return state.windowStart < getCurrentWindowStart(now);
}
function getStrategyRemainingSeconds(now = Date.now()): number {
return state.windowEnd ? state.windowEnd - Math.floor(now / 1000) : 0;
}
function buildTickContext(rem: number, upPct: number | null, dnPct: number | null, diff: number | null, now: number): import("./strategies/types.js").StrategyTickContext {
// diffBps: a cross-symbol generic percentage (per ten-thousand, i.e. bps).
// BTC ~$70000, ETH ~$3000, SOL ~$200, the dollar diff magnitude varies hugely; using bps lets thresholds be reused across markets.
let diffBps: number | null = null;
if (diff != null && state.priceToBeat != null && state.priceToBeat > 0) {
diffBps = Math.round((diff / state.priceToBeat) * 10000 * 100) / 100;
}
return {
rem, upPct, dnPct, diff, diffBps, volPct: state.volPct, now,
prevUpPct: strategyRuntime.prevUpPct,
kline1m: state.kline1m,
kline5m: state.kline5m,
marketHoursOnly: strategyConfig.marketHoursOnly,
};
}
// whether the current activeMarket is in the strategy's declared supportedMarkets list
// not declared / empty array = supports all markets (backward compatible with old strategies)
function isStrategySupportedHere(s: import("./strategies/types.js").IStrategy): boolean {
const desc = s.getDescription();
const list = desc.supportedMarkets;
if (!list || list.length === 0) return true;
return list.includes(activeMarket.key);
}
function checkEntry(ctx: import("./strategies/types.js").StrategyTickContext): { strategy: StrategyKey; dir: StrategyDirection } | null {
for (const s of getAllStrategies()) {
if (!strategyConfig.enabled[s.key]) continue;
if (!isStrategySupportedHere(s)) continue; // does not support the current market, skip
const signal = s.checkEntry(ctx);
if (signal) return { strategy: s.key, dir: signal.direction };
}
return null;
}
function checkExit(ctx: import("./strategies/types.js").StrategyTickContext): import("./strategies/types.js").ExitSignal {
const key = strategyRuntime.activeStrategy;
const direction = strategyRuntime.direction;
if (!key || !direction) return null;
const s = getStrategy(key);
if (!s) return null;
return s.checkExit(ctx, direction);
}
interface PlaceOrderInput {
direction: StrategyDirection;
side: "buy" | "sell";
amount: number;
slippage?: number;
source?: string;
exitReason?: string;
roundEntry?: string;
// conditional orders (only carried on buy, created after the fill triggers)
stopProfit?: { pctDelta?: number; targetPrice?: number };
stopLoss?: { pctDelta?: number; diffValue?: number; slippage?: number };
}
interface OrderExecutionResult {
success: boolean;
statusCode: number;
body: Record<string, unknown>;
errorMessage?: string;
}
async function placeOrder(input: PlaceOrderInput): Promise<OrderExecutionResult> {
const { direction, side, amount, source = "manual" } = input;
const slippageVal = typeof input.slippage === "number" && input.slippage >= 0
? input.slippage
: strategyConfig.slippage;
const orderTag = `[Order:${source}]`;
if (!direction || !side || !amount || amount <= 0) {
return { success: false, statusCode: 400, body: { error: "invalid params" }, errorMessage: "invalid params" };
}
if (!(await ensureClobClient())) {
return {
success: false,
statusCode: 500,
body: { error: "CLOB client not initialized, please check POLYMARKET_PRIVATE_KEY" },
errorMessage: "CLOB client not initialized, please check POLYMARKET_PRIVATE_KEY",
};
}
if (isOrderWindowStale()) {
return {
success: false,
statusCode: 409,
body: { error: "the current market window has expired, waiting to switch to a new window" },
errorMessage: "the current market window has expired, waiting to switch to a new window",
};
}
if (!isProbabilityReady()) {
return {
success: false,
statusCode: 409,
body: { error: "order book probability temporarily unavailable, waiting for WS to recover" },
errorMessage: "order book probability temporarily unavailable, waiting for WS to recover",
};
}
const tokenId = direction === "up" ? state.upTokenId : state.downTokenId;
if (!tokenId) {
return {
success: false,
statusCode: 400,
body: { error: "the current window market is not ready" },
errorMessage: "the current window market is not ready",
};
}
// prefer the in-memory order book pushed by Market WS (saves ~200ms HTTP); fall back to GET when stale or not ready
// note: state.bestBid / state.bestAsk only track the up token (see the Market WS book event filtering upTokenId).
// binary option identity: up + down = 1 -> down bid = 1 - up ask, down ask = 1 - up bid.
//
// fast-path guard: only require the side's price needed by this order to be valid (buy -> ask / sell -> bid),
// allow the fast path even in a one-sided market (the other side has 0 depth), to avoid unnecessary fallback.
let bestBid = 0;
let bestAsk = 0;
const BOOK_MAX_AGE_MS = 3000;
const bookAgeMs = lastBestBidAskTimestamp > 0 ? Date.now() - lastBestBidAskTimestamp : Infinity;
const stateBid = Number(state.bestBid);
const stateAsk = Number(state.bestAsk);
// convert to the target token's order book based on the order direction first
let candidateBid = 0;
let candidateAsk = 0;
if (Number.isFinite(stateBid) && Number.isFinite(stateAsk)) {
if (direction === "up") {
candidateBid = stateBid;
candidateAsk = stateAsk;
} else {
// down conversion: if the up side is 0, the conversion result 1-0=1 is an unrealistic "full price", treated as invalid
candidateBid = stateAsk > 0 ? 1 - stateAsk : 0;
candidateAsk = stateBid > 0 ? 1 - stateBid : 0;
}
}
const neededForSide = side === "buy" ? candidateAsk : candidateBid;
const wsUsable = marketBestReady && bookAgeMs < BOOK_MAX_AGE_MS && neededForSide > 0;
if (wsUsable) {
bestBid = candidateBid;
bestAsk = candidateAsk;
} else {
// fallback: GET on the spot when the WS order book is not ready / stale / the target side's price is missing
try {
({ bestBid, bestAsk } = await fetchBookTopOfBook(tokenId));
console.log(`${orderTag} WS order book fallback GET (age=${bookAgeMs}ms ready=${marketBestReady} need=${neededForSide}) bid=${bestBid} ask=${bestAsk}`);
} catch {
return {
success: false,
statusCode: 500,
body: { error: "unable to fetch order book price" },
errorMessage: "unable to fetch order book price",
};
}
}
// buy orders need bestAsk (counterparty's sell price); sell orders need bestBid (counterparty's buy price). In extreme markets the other side may be 0.
const needed = side === "buy" ? bestAsk : bestBid;
if (!(needed > 0)) {
return {
success: false,
statusCode: 500,
body: { error: side === "buy" ? "no sell orders in the book (insufficient depth)" : "no buy orders in the book (insufficient depth)" },
errorMessage: side === "buy" ? "no sell orders in the book (insufficient depth)" : "no buy orders in the book (insufficient depth)",
};
}
const worstPrice = side === "buy"
? Math.min(bestAsk + slippageVal, 0.99)
: Math.max(bestBid - slippageVal, 0.01);
try {
const tickSize = getCachedTickSize(tokenId);
const priceDecimals = getDecimalPlaces(tickSize);
const normalizedAmount = floorToDecimals(amount, 2);
const normalizedWorstPrice = floorToDecimals(worstPrice, priceDecimals);
const orderDebug = `tickSize:${tickSize} amount:${amount}->${normalizedAmount} worstPrice:${worstPrice}->${normalizedWorstPrice}`;
if (normalizedAmount <= 0 || normalizedWorstPrice <= 0) {
console.warn(`${orderTag} params invalid after precision handling ${orderDebug}`);
return {
success: false,
statusCode: 400,
body: { error: "order params invalid after precision handling", bestBid, bestAsk, worstPrice: normalizedWorstPrice },
errorMessage: "order params invalid after precision handling",
};
}
const marketOrderArgs = { tokenID: tokenId, side: side === "buy" ? Side.BUY : Side.SELL, amount: normalizedAmount, price: normalizedWorstPrice };
const marketOrderOpts = { tickSize, negRisk: false };
console.log("[RAW]", `${orderTag} createMarketOrder args:`, JSON.stringify(marketOrderArgs), "opts:", JSON.stringify(marketOrderOpts), "ctx:", JSON.stringify({
source, direction, side, amount, normalizedAmount, slippageVal, bestBid, bestAsk, worstPrice, normalizedWorstPrice, tokenId, tickSize, priceDecimals, bookAgeMs, marketBestReady,
exitReason: input.exitReason, roundEntry: input.roundEntry, stopProfit: input.stopProfit, stopLoss: input.stopLoss,
windowStart: state.windowStart, marketKey: activeMarket.key,
}));
const signedOrder = await clobClient!.createMarketOrder(marketOrderArgs, marketOrderOpts);
console.log("[RAW]", `${orderTag} signedOrder:`, JSON.stringify(signedOrder));
// -- latency monitoring (1): start timing before this machine initiates postOrder --
const latencyT0 = Date.now();
const result = await clobClient!.postOrder(signedOrder, OrderType.FOK);
const httpMs = Date.now() - latencyT0; // for log debugging only
console.log("[RAW]", `${orderTag} postOrder raw result:`, JSON.stringify(result), `HTTP:${httpMs}ms t0:${latencyT0}`);
const sideZh = side === "buy" ? "buy" : "sell";
const dirZh = direction === "up" ? "up" : "down";
const rawStatus = result?.status ?? "unknown";
const orderError = extractOrderError(result);
if (result?.status === 400 || orderError) {
console.warn(`${orderTag} ${activeMarket.displayName} ${sideZh} ${dirZh} ${normalizedAmount} status:${rawStatus} reason:${orderError || "-"} ${orderDebug}`);
return {
success: false,
statusCode: 400,
body: { error: orderError || `order rejected status=${rawStatus}`, result, bestBid, bestAsk, worstPrice: normalizedWorstPrice },
errorMessage: orderError || `order rejected status=${rawStatus}`,
};
}
// -- latency monitoring (2): order succeeded, register orderID (if WS already arrived it settles immediately) --
const orderIdFromResult = typeof result?.orderID === "string" ? result.orderID : "";
if (rawStatus === "matched" && orderIdFromResult) {
registerOrderLatencyStart(orderIdFromResult, latencyT0);
}
const dirArrow = direction === "up" ? "⬆" : "⬇";
const okMark = rawStatus === "matched" ? "✓" : "•";
console.log(`${orderTag} ${okMark} ${dirArrow} ${sideZh} ${dirZh} ${normalizedAmount}@${normalizedWorstPrice} filled=${fmtOrderField(result?.takingAmount)} spent=${fmtOrderField(result?.makingAmount)} orderID=${fmtOid(orderIdFromResult)} HTTP=${httpMs}ms`);
rememberPendingTradeMeta({
orderId: typeof result?.orderID === "string" && result.orderID ? result.orderID : undefined,
ts: Date.now(),
windowStart: state.windowStart,
side,
direction,
amount: normalizedAmount,
worstPrice: normalizedWorstPrice,
source,
exitReason: input.exitReason,
roundEntry: input.roundEntry,
stopProfit: side === "buy" ? input.stopProfit : undefined,
stopLoss: side === "buy" ? input.stopLoss : undefined,
});
if (!(typeof result?.orderID === "string" && result.orderID)) {
console.warn(`${orderTag} order response missing orderID, the MINED event will degrade to matching by direction/quantity`);
}
broadcastState();
return {
success: true,
statusCode: 200,
body: { success: true, result, bestBid, bestAsk, worstPrice: normalizedWorstPrice },
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`${orderTag} failed:`, msg);
return {
success: false,
statusCode: 500,
body: { error: msg },
errorMessage: msg,
};
}
}
async function strategyBuy(direction: StrategyDirection, amount: number): Promise<void> {
strategyRuntime.posBeforeBuy = getDirectionLocalSize(direction);
strategyRuntime.actionTs = Date.now();
strategyRuntime.buyLockUntil = Date.now() + STRAT_BUY_LOCK_MS;
strategyRuntime.state = "WAIT_FILL";
broadcastState();
// read the strategy config's GTC take-profit target price (e.g. d1 = 0.99)
const stratKey = strategyRuntime.activeStrategy ?? "";
const stratInstance = stratKey ? getStrategy(stratKey) : undefined;
const tpPrice = stratInstance?.getMarketTakeProfitPrice?.() ?? null;
const orderResult = await placeOrder({
direction,
side: "buy",
amount,
slippage: strategyConfig.slippage,
source: `strategy${stratKey}`,
roundEntry: `${strategyRuntime.roundEntryCount}/${strategyConfig.maxRoundEntries}`,
stopProfit: tpPrice != null && tpPrice > 0 && tpPrice < 1 ? { targetPrice: tpPrice } : undefined,
});
if (!orderResult.success) {
console.log(`[Strategy.${strategyRuntime.activeStrategy ?? ""}] buy failed: ${orderResult.errorMessage || "order failed"}`);
strategyRuntime.buyLockUntil = 0;
strategyRuntime.state = "SCANNING";
strategyRuntime.activeStrategy = null;
broadcastState();
}
}
async function strategySell(direction: StrategyDirection, exitReason?: string): Promise<void> {
const totalPos = getDirectionLocalSize(direction);
const shares = getSellableShares(direction);
if (shares <= 0) {
transitionToDone();
return;
}
strategyRuntime.posBeforeSell = totalPos;
strategyRuntime.waitVerifyAfterSell = !isDirectionVerified(direction);
strategyRuntime.actionTs = Date.now();
strategyRuntime.state = "WAIT_SELL_FILL";
broadcastState();
const orderResult = await placeOrder({
direction,
side: "sell",
amount: shares,
slippage: strategyConfig.slippage,
source: `strategy${strategyRuntime.activeStrategy ?? ""}`,
exitReason,
roundEntry: `${strategyRuntime.roundEntryCount}/${strategyConfig.maxRoundEntries}`,
});
if (!orderResult.success) {
console.log(`[Strategy.${strategyRuntime.activeStrategy ?? ""}] sell failed: ${orderResult.errorMessage || "order failed"}`);
strategyRuntime.waitVerifyAfterSell = false;
strategyRuntime.state = "HOLDING";
broadcastState();
}
}
// -- Backtest data collection -------------------------------------
const BACKTEST_STATE_FILE = resolve(__dirname, ".backtest-state.json");
function loadBacktestState(): boolean {
try {
if (!existsSync(BACKTEST_STATE_FILE)) return false; // off by default on first run, enable in the frontend as needed
const data = JSON.parse(readFileSync(BACKTEST_STATE_FILE, "utf-8"));
return typeof data.collecting === "boolean" ? data.collecting : false;
} catch {
return false;
}
}
function persistBacktestState(): void {
try {
writeFileSync(BACKTEST_STATE_FILE, JSON.stringify({ collecting: backtestCollecting }, null, 2) + "\n", "utf-8");
} catch (err) {
console.warn(`[Backtest] state save failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
let backtestCollecting = loadBacktestState();
let backtestLastTickTs = 0;
let backtestLastCleanupDate = "";
const BACKTEST_RETENTION_DAYS = 30;
// ensure the data directory exists at startup (prevents the first write from failing)
if (backtestCollecting) {
try { mkdirSync(BACKTEST_DATA_DIR, { recursive: true }); } catch { /* ignore */ }
}
function setBacktestCollecting(enabled: boolean): void {
backtestCollecting = enabled;
console.log(`[Backtest] data collection ${enabled ? "enabled" : "disabled"}`);
persistBacktestState();
if (enabled) {
mkdirSync(BACKTEST_DATA_DIR, { recursive: true });
cleanupOldBacktestFiles();
}
broadcastBacktestStatus();
}
function cleanupOldBacktestFiles(): void {
try {
if (!existsSync(BACKTEST_DATA_DIR)) return;
// compatible with three naming schemes: YYYY-MM-DD.jsonl (old BTC 5m) / YYYY-MM-DD-{sym}.jsonl (old multi-symbol 5m) / YYYY-MM-DD-{sym}-{period}.jsonl (new)
const files = readdirSync(BACKTEST_DATA_DIR).filter((f) => /^\d{4}-\d{2}-\d{2}(-\w+)?(-\w+)?\.jsonl$/.test(f));
// group by "base date", keep the latest BACKTEST_RETENTION_DAYS days per group
const byDate = new Map<string, string[]>();
for (const f of files) {
const date = f.slice(0, 10);
if (!byDate.has(date)) byDate.set(date, []);
byDate.get(date)!.push(f);
}
const dates = [...byDate.keys()].sort();
if (dates.length <= BACKTEST_RETENTION_DAYS) return;
const datesToDelete = dates.slice(0, dates.length - BACKTEST_RETENTION_DAYS);
for (const d of datesToDelete) {
for (const f of byDate.get(d) || []) {
try {
unlinkSync(resolve(BACKTEST_DATA_DIR, f));
console.log(`[Backtest] cleaned up old data file: ${f}`);
} catch {}
}
}
} catch (err) {
console.warn(`[Backtest] failed to clean up old files: ${(err as Error).message}`);
}
}
function broadcastBacktestStatus(): void {
broadcast("backtestStatus", { collecting: backtestCollecting });
}
function getBacktestFilePath(): string {
// BTC 5m keeps the original filename (backward compatible with existing data), others use the -{sym}-{period} suffix
// naming rule: YYYY-MM-DD.jsonl (old BTC 5m) / YYYY-MM-DD-{sym}-{period}.jsonl (new)
const sym = activeMarket.symbol;
const period = activeMarket.period;
const suffix = (sym === "btc" && period === "5m") ? "" : `-${sym}-${period}`;
return resolve(BACKTEST_DATA_DIR, `${getCstDateStr()}${suffix}.jsonl`);
}
function backtestAppend(record: Record<string, unknown>): void {
try {
appendFileSync(getBacktestFilePath(), JSON.stringify(record) + "\n");
} catch (err) {
console.warn(`[Backtest] write failed: ${(err as Error).message}`);
}
}
function backtestTick(): void {
if (!backtestCollecting) return;
const now = Date.now();
if (now - backtestLastTickTs < 1000) return;
const snapshot = getProbabilitySnapshot();
const diff = getStrategyDiff();
const rem = getStrategyRemainingSeconds(now);
if (snapshot == null || diff == null || !state.windowStart) return;
// clean up old files (older than the retention days) on the first write each day
const todayStr = getCstDateStr();
if (todayStr !== backtestLastCleanupDate) {
cleanupOldBacktestFiles();
backtestLastCleanupDate = todayStr;
}
// diff precision based on the current price magnitude (shares the same threshold definition as the frontend priceDecimals)
// avoids small-price symbols like SOL/XRP/DOGE losing precision due to 2-decimal rounding (e.g. SOL diff -0.0712 -> -0.07)
const dec = priceDecimals(state.priceToBeat);
const factor = Math.pow(10, dec);
backtestAppend({
type: "tick",
ts: now,
symbol: activeMarket.symbol,
period: activeMarket.period,
windowStart: state.windowStart,
diff: Math.round(diff * factor) / factor,
upPct: snapshot.upPct,
rem,
});
backtestLastTickTs = now;
}
// event-driven strategy scheduler: multiple events in a short time trigger only one tick
let strategyTickScheduled = false;
let strategyTickLastRunTs = 0;
const STRATEGY_TICK_MIN_GAP_MS = 10; // minimum interval between consecutive ticks (prevents storms)
function scheduleStrategyTick(): void {
if (strategyTickScheduled) return;
strategyTickScheduled = true;
const now = Date.now();
const elapsed = now - strategyTickLastRunTs;
if (elapsed >= STRATEGY_TICK_MIN_GAP_MS) {
setImmediate(() => {
strategyTickScheduled = false;
strategyTickLastRunTs = Date.now();
runStrategyTick();
});
} else {
setTimeout(() => {
strategyTickScheduled = false;
strategyTickLastRunTs = Date.now();
runStrategyTick();
}, STRATEGY_TICK_MIN_GAP_MS - elapsed);
}
}
function runStrategyTick(): void {
const snapshot = getProbabilitySnapshot();
const upPct = snapshot?.upPct ?? null;
const dnPct = snapshot?.dnPct ?? null;
const diff = getStrategyDiff();
const now = Date.now();
const rem = getStrategyRemainingSeconds(now);
const currentPosition = getDirectionLocalSize(strategyRuntime.direction);
const ctx = buildTickContext(rem, upPct, dnPct, diff, now);
const finalize = () => {
strategyRuntime.prevUpPct = upPct;
// notify all strategies that have finalizeTick (record historical data like lastDiff, regardless of whether enabled)
for (const s of getAllStrategies()) {
if ("finalizeTick" in s && typeof (s as any).finalizeTick === "function") {
(s as any).finalizeTick(diff);
}
}
};
if (isOrderWindowStale(now)) {
finalize();
return;
}
if (!strategyRuntime.positionsReady) {
finalize();
return;
}
// update the guard state of enabled strategies (cooldown locks, etc.);
// strategies that are not enabled but declare alwaysComputeData also call computeData (e.g. the s6 factor panel)
// note: strategies that do not support the current market are skipped entirely, to avoid running computations on the wrong market
for (const s of getAllStrategies()) {
if (!isStrategySupportedHere(s)) continue;
if (strategyConfig.enabled[s.key]) {
s.updateGuards(ctx);
} else if (s.alwaysComputeData && typeof s.computeData === "function") {
s.computeData(ctx);
}
}
// conditional order monitoring: TP/SL tick check (independent of the strategy state machine)
if (activeConditionOrders.size > 0 && upPct != null && dnPct != null) {
void checkConditionOrdersTick(upPct, dnPct, diff);
}
// limit strategies: first scan pre-sign requirements (async-sign and cache), then run the limit scheduler
runPresignTick();
runLimitStrategyTick();
if (strategyRuntime.cleanupAfterVerify && strategyRuntime.direction) {
if (!isDirectionVerified(strategyRuntime.direction)) {
finalize();
return;
}
if (currentPosition < 0.01) {
strategyRuntime.cleanupAfterVerify = false;
transitionToDone();
finalize();
return;
}
strategyRuntime.cleanupAfterVerify = false;
strategyRuntime.state = "SELLING";
console.log(`[Strategy.${strategyRuntime.activeStrategy ?? ""}] position calibrated, ${currentPosition.toFixed(2)} remaining, executing liquidation sell`);
broadcastState();
void strategySell(strategyRuntime.direction, `calibrated liquidation, ${currentPosition.toFixed(2)} remaining`);
finalize();
return;
}
if (strategyRuntime.state === "IDLE") {
if (upPct == null || diff == null) {
finalize();
return;
}
if (anyStrategyEnabled()) {
strategyRuntime.state = "SCANNING";
broadcastState();
}
finalize();
return;
}
if (strategyRuntime.state === "SCANNING") {
if (!anyStrategyEnabled()) {
strategyRuntime.state = "IDLE";
broadcastState();
finalize();
return;
}
if (hasOpenPosition() || hasPendingStrategyBuyLock(now) || upPct == null || dnPct == null || diff == null) {
finalize();
return;
}
if (strategyRuntime.roundEntryCount >= strategyConfig.maxRoundEntries) {
finalize();
return;
}
// US Eastern weekend pause: do not enter new entries (positions/TP/SL/Claim are unaffected)
if (strategyConfig.weekendPause && isUsWeekend()) {
logWeekendPauseOnce();
finalize();
return;
}
const entry = checkEntry(ctx);
if (!entry) {
finalize();
return;
}
const buyAmount = strategyConfig.amount[entry.strategy];
if (!hasEnoughUsdcForBuy(buyAmount)) {
finalize();
return;
}
strategyRuntime.roundEntryCount++;
strategyRuntime.activeStrategy = entry.strategy;
strategyRuntime.direction = entry.dir;
strategyRuntime.buyAmount = buyAmount;
strategyRuntime.state = "BUYING";
console.log(`[Strategy.${entry.strategy}] entry triggered (${strategyRuntime.roundEntryCount}/${strategyConfig.maxRoundEntries}) ${entry.dir === "up" ? "buy up" : "buy down"} amount:${buyAmount}`);
broadcastState();
void strategyBuy(entry.dir, buyAmount);
finalize();
return;
}
if (strategyRuntime.state === "WAIT_FILL") {
if (hasConfirmedBuyPosition()) {
strategyRuntime.buyLockUntil = 0;
strategyRuntime.state = "HOLDING";
console.log(`[Strategy.${strategyRuntime.activeStrategy ?? ""}] buy fill confirmed`);
// notify the strategy of the buy fill (used for initializing the tracking peak, etc.)
if (strategyRuntime.activeStrategy && strategyRuntime.direction) {
const activeStrat = getStrategy(strategyRuntime.activeStrategy);
if (activeStrat?.onEntryFilled) activeStrat.onEntryFilled(ctx, strategyRuntime.direction);
}
broadcastState();
} else if (now - strategyRuntime.actionTs > WAIT_FILL_TIMEOUT_MS) {
console.log(`[Strategy.${strategyRuntime.activeStrategy ?? ""}] buy not confirmed after 10s, entering delayed-confirmation wait`);
strategyRuntime.state = "RECONCILING_FILL";
broadcastState();
finalize();
return;
} else {
finalize();
return;
}
}
if (strategyRuntime.state === "RECONCILING_FILL") {
if (hasConfirmedBuyPosition()) {
strategyRuntime.buyLockUntil = 0;
strategyRuntime.state = "HOLDING";
console.log(`[Strategy.${strategyRuntime.activeStrategy ?? ""}] delayed confirmation succeeded, resuming position management`);
if (strategyRuntime.activeStrategy && strategyRuntime.direction) {
const activeStrat = getStrategy(strategyRuntime.activeStrategy);
if (activeStrat?.onEntryFilled) activeStrat.onEntryFilled(ctx, strategyRuntime.direction);
}
broadcastState();
} else if (canReleaseUnconfirmedBuy(now)) {
console.log(`[Strategy.${strategyRuntime.activeStrategy ?? ""}] over 15s and API confirms no position, resuming scanning`);
strategyRuntime.state = "SCANNING";
strategyRuntime.activeStrategy = null;
strategyRuntime.direction = null;
strategyRuntime.buyAmount = 0;
strategyRuntime.posBeforeBuy = 0;
strategyRuntime.actionTs = 0;
strategyRuntime.buyLockUntil = 0;
broadcastState();
finalize();
return;
} else {
finalize();
return;
}
}
if (strategyRuntime.state === "HOLDING") {
if (currentPosition <= 0) {
transitionToDone();
finalize();
return;
}
if (upPct == null || dnPct == null || diff == null) {
finalize();
return;
}
const exit = checkExit(ctx);
if (exit && strategyRuntime.direction) {
console.log(`[Strategy.${strategyRuntime.activeStrategy ?? ""}] ${exit.signal === "tp" ? "TP" : "SL"} triggered: ${exit.reason}`);
strategyRuntime.state = "SELLING";
broadcastState();
void strategySell(strategyRuntime.direction, exit.reason);
}
finalize();
return;
}
if (strategyRuntime.state === "WAIT_SELL_FILL") {
if (currentPosition < strategyRuntime.posBeforeSell - 0.01) {
if (currentPosition < 0.01) {
strategyRuntime.waitVerifyAfterSell = false;
strategyRuntime.cleanupAfterVerify = false;
console.log(`[Strategy.${strategyRuntime.activeStrategy ?? ""}] sell confirmed, done`);
transitionToDone();
finalize();
return;
}
if (strategyRuntime.waitVerifyAfterSell) {
strategyRuntime.waitVerifyAfterSell = false;
if (isDirectionVerified(strategyRuntime.direction)) {
console.log(`[Strategy.${strategyRuntime.activeStrategy ?? ""}] calibrated after sell, ${currentPosition.toFixed(2)} remaining, executing liquidation immediately`);
strategyRuntime.cleanupAfterVerify = false;
strategyRuntime.state = "SELLING";
broadcastState();
if (strategyRuntime.direction) void strategySell(strategyRuntime.direction, `calibrated liquidation, ${currentPosition.toFixed(2)} remaining`);
finalize();
return;
}
strategyRuntime.cleanupAfterVerify = true;
strategyRuntime.state = "DONE";
console.log(`[Strategy.${strategyRuntime.activeStrategy ?? ""}] sell confirmed, waiting for calibration before checking remaining position`);
broadcastState();
finalize();
return;
}
strategyRuntime.waitVerifyAfterSell = false;
strategyRuntime.state = "HOLDING";
console.log(`[Strategy.${strategyRuntime.activeStrategy ?? ""}] sell confirmed, ${currentPosition.toFixed(2)} remaining, continuing to process`);
broadcastState();
finalize();
return;
}
if (now - strategyRuntime.actionTs > WAIT_FILL_TIMEOUT_MS) {
console.log(`[Strategy.${strategyRuntime.activeStrategy ?? ""}] sell timeout, back to holding`);
strategyRuntime.waitVerifyAfterSell = false;
strategyRuntime.state = "HOLDING";
broadcastState();
}
finalize();
return;
}
finalize();
}
function buildApiStatePayload(includeHistory: boolean = true): Record<string, unknown> {
return {
...buildStatePayload(includeHistory),
wsStatus,
claimable: {
total: claimableTotal,
positions: claimablePositions,
},
claimCooldown: {
running: claimCycleRunning || claimInProgress,
nextCheckAt: claimNextCheckAt,
cooldownUntil: claimCooldownUntil,
},
};
}
// derived metrics for the monitor page (today's PnL/win rate/trade count), same algorithm as the TG push
// also returns todayClosedCount + todayWins so the monitor page can correctly weight-aggregate (averaging win rates directly would distort)
function computeTodayPnlMetrics(): {
todayPnl: number | null;
todayCount: number | null;
todayWinRate: number | null;
todayClosedCount: number | null;
todayWins: number | null;
} {
if (!pmPnlManager.isInitialized()) {
return { todayPnl: null, todayCount: null, todayWinRate: null, todayClosedCount: null, todayWins: null };
}
const todaySec = Math.floor(getCstDayStartMs() / 1000);
const snap = pmPnlManager.computeSnapshot(todaySec);
return {
todayPnl: snap.netPnl,
todayCount: snap.positions,
todayWinRate: snap.closedPositions > 0 ? snap.wins / snap.closedPositions : null,
todayClosedCount: snap.closedPositions,
todayWins: snap.wins,
};
}
app.get("/api/state", (req, res) => {
// ?lite=1 skips priceHistory/binanceHistory/coinbaseHistory (for the monitor page, saves ~98% traffic)
const lite = req.query.lite === "1" || req.query.lite === "true";
const payload = buildApiStatePayload(!lite) as Record<string, unknown>;
// inject the derived metrics the monitor page needs (same source data as the TG push)
payload.pmPnl = computeTodayPnlMetrics();
res.json(payload);
});
app.get("/api/version", (_req, res) => {
res.json({ version: APP_VERSION });
});
app.get("/api/market/list", (_req, res) => {
res.json({
active: activeMarket.key,
activeSymbol: activeMarket.symbol,
activePeriod: activeMarket.period,
symbols: ALL_SYMBOLS,
periods: ALL_PERIODS,
markets: Object.values(MARKETS).map(m => ({
key: m.key, symbol: m.symbol, period: m.period, displayName: m.displayName,
})),
});
});
app.post("/api/market/switch", async (req, res) => {
// supports two input forms: { key: "btc-15m" } or { symbol: "btc", period: "15m" }
const body = (req.body as Record<string, unknown> | undefined) || {};
let key = String(body.key || "");
if (!key && body.symbol && body.period) key = `${body.symbol}-${body.period}`;
if (!isValidKey(key)) {
return res.status(400).json({ ok: false, reason: `unknown market: ${key}` });
}
const result = await switchMarket(key);
if (!result.ok) return res.status(409).json(result);
res.json(result);
});
app.get("/api/strategy/descriptions", (_req, res) => {
res.json(getAllDescriptions());
});
app.get("/api/backtest/status", (_req, res) => {
res.json({ collecting: backtestCollecting });
});
app.post("/api/backtest/toggle", (_req, res) => {
setBacktestCollecting(!backtestCollecting);
res.json({ collecting: backtestCollecting });
});
// manually trigger a full PmPnl refresh (frontend refresh button)
app.post("/api/pmpnl/refresh", async (_req, res) => {
const ok = await pmPnlManager.fetchAll();
pmPnlNextRefreshAt = Date.now() + PMPNL_REFRESH_INTERVAL_MS;
broadcastPmPnl();
res.json({ ok, nextRefreshAt: pmPnlNextRefreshAt, lastRefreshAt: pmPnlManager.getLastRefreshAt() });
});
app.post("/api/strategy/config", (req, res) => {
const prevAutoClaim = strategyConfig.autoClaimEnabled;
const { config, error } = applyStrategyConfigUpdate(strategyConfig, req.body);
if (!config) {
res.status(400).json({ error: error || "config error" });
return;
}
strategyConfig = config;
savePersistedStrategyConfig(config);
// immediately inject the tunable params into all strategy instances (avoids the next fill reading stale values)
for (const s of getAllStrategies()) applyTunableParamsToStrategy(s);
const configSummary = ALL_STRATEGY_KEYS.map((k) => `${k}:${config.enabled[k] ? "on" : "off"}(${config.amount[k]})`).join(" ");
console.log(`[Strategy.Config] updated ${configSummary} maxRound:${config.maxRoundEntries} active in the current process`);
broadcastState();
// autoClaimEnabled from off -> on: immediately trigger a query and claim check
if (!prevAutoClaim && config.autoClaimEnabled) {
scheduleClaimCycle(0);
}
res.json({ success: true, strategyConfig });
});
// read/write the manual order config (amount / slippage / TP-SL)
app.get("/api/manual/config", (_req, res) => {
res.json({ manualConfig });
});
app.post("/api/manual/config", (req, res) => {
const err = applyManualConfigUpdate(req.body);
if (err) { res.status(400).json({ error: err }); return; }
broadcast("manualConfig", { manualConfig });
res.json({ success: true, manualConfig });
});
// -- REST: order endpoint ----------------------------------------
// manually cancel a conditional order
app.post("/api/cond/cancel", async (req, res) => {
const { id } = req.body as { id: string };
const cond = activeConditionOrders.get(id);
if (!cond) {
res.status(404).json({ error: "conditional order does not exist or has ended" });
return;
}
if (cond.kind === "tp" && cond.polymarketOrderId) {
const ok = await cancelTakeProfitOrder(cond.polymarketOrderId);
if (!ok) {
// cancel failed: keep it in the list, mark failed, the user can retry manually or wait for pollGtcOrderStatus fallback
cond.status = "failed";
cond.failReason = "cancel resting order failed, please retry";
broadcastConditionOrders();
res.status(500).json({ error: "Polymarket cancel resting order failed", polymarketCancelFailed: true });
return;
}
}
cond.status = "canceled";
activeConditionOrders.delete(cond.id);
broadcastConditionOrders();
res.json({ success: true });
});
// one-click clear all active conditional orders (includes TP GTC and local SL)
app.post("/api/cond/cancel-all", async (_req, res) => {
const all = [...activeConditionOrders.values()];
if (all.length === 0) {
res.json({ success: true, count: 0, failed: 0 });
return;
}
let canceled = 0;
let failed = 0;
for (const cond of all) {
if (cond.kind === "tp" && cond.polymarketOrderId) {
const ok = await cancelTakeProfitOrder(cond.polymarketOrderId);
if (!ok) {
// keep the failed ones for the user / polling fallback to handle
cond.status = "failed";
cond.failReason = "cancel resting order failed during full clear";
failed++;
continue;
}
}
cond.status = "canceled";
activeConditionOrders.delete(cond.id);
canceled++;
}
broadcastConditionOrders();
console.log(`[Cond] manual full clear: ${canceled} succeeded, ${failed} failed (failed ones kept for retry)`);
res.json({ success: failed === 0, count: canceled, failed });
});
// in-memory limit order list (shared by manual + strategy)
interface ManualLimitOrder {
orderID: string;
direction: StrategyDirection;
side: "buy" | "sell";
size: number;
price: number;
createdAt: number;
/** source: manual=manual, strategy:t8=strategy resting order */
source?: string;
/** the window it belongs to (used for window management of strategy resting orders) */
windowStart?: number;
/** filled shares (partial-fill tracking) */
filledSize?: number;
}
const manualLimitOrders = new Map<string, ManualLimitOrder>();
// -- Strategy limit order scheduling (limit/both strategies like t8) --------------
//
// design:
// - each strategy has at most 1 active resting order (per window)
// - place only once per window (after cancel, do not re-place, wait for the next window)
// - on cancel failure, enter the fallback retry queue (every 5s, up to 12 times or 90s or window end)
interface StrategyLimitOrder {
orderID: string;
strategyKey: string;
/** "buy" = entry buy order (default); "sell" = take-profit sell order placed after the fill */
side: "buy" | "sell";
direction: StrategyDirection;
/** the token id at resting time, ensures placing TP after MINED uses the same window's token (state has changed after window switch) */
tokenId: string;
price: number;
shares: number;
/** cumulative filled shares confirmed at the match layer (MATCHED push, not on-chain) */
matchedSize: number;
/** cumulative filled shares confirmed on-chain (MINED push + on-chain calibration) */
filledSize: number;
/** total TP sell shares already placed for on-chain-confirmed shares (avoids placing TP twice for the same shares) */
takeProfitPlacedSize: number;
windowStart: number;
createdAt: number;
/** on cancel, found already matched/filled, keep the order awaiting MINED handling (avoids stratOrder being deleted prematurely causing TP/SL loss) */
pendingMined?: boolean;
}
// the currently active strategy limit order (at most 1 per strategy)
const strategyLimitOrders = new Map<string, StrategyLimitOrder>();
// the set of strategies that have "already attempted to place" within this window (implements "place only once per window")
const strategyLimitWindowMark = new Set<string>(); // value: `${strategyKey}:${windowStart}`
// orderIDs with cancel in progress (prevents the same tick / poll from concurrently invoking the same cancel API)
const cancelInFlight = new Set<string>();
// -- GTC order status WS+REST dual confirmation mechanism --------------------
// purpose: resolve the blind spot of "called the order/cancel HTTP but the actual status did not change"
// flow: after postOrder/cancelOrder register pending -> wait for WS event_type:order push or getOrder fallback after 8 seconds
// market FOK does not use this path (the HTTP response already contains the full result, it will not stay on the book)
const ORDER_CONFIRM_TIMEOUT_MS = 3000;
// the middle of the order ID is elided for easier terminal reading (raw log still keeps the full ID)
function fmtOid(id: string | undefined | null): string {
if (!id || typeof id !== "string") return "-";
if (id.length <= 16) return id;
return `${id.slice(0, 10)}${id.slice(-4)}`;
}
// order context (for logging only, prints price / direction / side / size on cancel/confirm)
type OrderCtx = { price?: number; direction?: string; side?: string; size?: number };
function fmtOrderCtx(ctx: OrderCtx | undefined): string {
if (!ctx) return "";
const parts: string[] = [];
if (ctx.side) parts.push(`side=${ctx.side}`);
if (ctx.direction) parts.push(`dir=${ctx.direction}`);
if (typeof ctx.size === "number") parts.push(`size=${ctx.size}`);
if (typeof ctx.price === "number") parts.push(`price=${ctx.price}`);
return parts.length ? ` [${parts.join(" ")}]` : "";
}
function getOrderCtxFromMaps(orderID: string): OrderCtx | undefined {
const so = strategyLimitOrders.get(orderID);
if (so) return { price: so.price, direction: so.direction, side: so.side, size: so.shares };
const mo = manualLimitOrders.get(orderID);
if (mo) return { price: mo.price, direction: mo.direction, side: mo.side, size: mo.size };
return undefined;
}
type PendingOrderConfirm = {
orderID: string;
source: string; // caller identifier, for logging only
ctx?: OrderCtx;
t0: number;
resolved: boolean; // set to true after WS arrives, the timeout branch will skip the query
timer: ReturnType<typeof setTimeout>;
};
type PendingCancelConfirm = {
orderID: string;
reason: string;
ctx?: OrderCtx;
t0: number;
resolved: boolean;
timer: ReturnType<typeof setTimeout>;
rounds: number; // how many rounds of 8s fallback have elapsed (avoids an infinite loop when both WS+REST are down)
};
const CANCEL_MAX_ROUNDS = 3; // at most 3 rounds = ~24 seconds cumulative + 3 cancelOrderImmediate (each with 3 internal HTTP retries)
// when verifyCancelViaRest re-cancels, pass through the current round to cancelOrderImmediate -> registerCancelConfirm
const cancelRoundOverride = new Map<string, number>();
const pendingOrderConfirm = new Map<string, PendingOrderConfirm>();
const pendingCancelConfirm = new Map<string, PendingCancelConfirm>();
// scenario where WS arrives before the HTTP response: cache the WS signal first, on register check for a hit and do not start the timer
type EarlyWsCache = { ts: number; reason: string };
const earlyOrderConfirmCache = new Map<string, EarlyWsCache>();
const earlyCancelConfirmCache = new Map<string, EarlyWsCache>();
const EARLY_WS_CACHE_TTL_MS = 30_000;
function rememberEarlyWs(map: Map<string, EarlyWsCache>, orderID: string, reason: string): void {
if (!orderID) return;
map.set(orderID, { ts: Date.now(), reason });
setTimeout(() => map.delete(orderID), EARLY_WS_CACHE_TTL_MS).unref?.();
}
function registerOrderConfirm(orderID: string, source: string, ctx?: OrderCtx): void {
if (!orderID) return;
// an existing one (same orderID registered twice, should not happen) -> clear the old one
const old = pendingOrderConfirm.get(orderID);
if (old) clearTimeout(old.timer);
// WS arrived before the HTTP response? accept immediately
const early = earlyOrderConfirmCache.get(orderID);
if (early) {
earlyOrderConfirmCache.delete(orderID);
console.log(`[Trade.Confirm] WS arrived first orderID=${fmtOid(orderID)}${fmtOrderCtx(ctx)} reason:${early.reason} (source=${source})`);
return;
}
const entry: PendingOrderConfirm = {
orderID, source, ctx, t0: Date.now(), resolved: false,
timer: setTimeout(() => verifyOrderViaRest(orderID), ORDER_CONFIRM_TIMEOUT_MS),
};
pendingOrderConfirm.set(orderID, entry);
}
function resolveOrderConfirm(orderID: string, reason: string): void {
const entry = pendingOrderConfirm.get(orderID);
if (!entry) return;
entry.resolved = true;
clearTimeout(entry.timer);
pendingOrderConfirm.delete(orderID);
console.log(`[Trade.Confirm] WS confirmed orderID=${fmtOid(orderID)}${fmtOrderCtx(entry.ctx)} elapsed:${Date.now() - entry.t0}ms reason:${reason} (source=${entry.source})`);
}
async function verifyOrderViaRest(orderID: string): Promise<void> {
const entry = pendingOrderConfirm.get(orderID);
if (!entry || entry.resolved) return;
pendingOrderConfirm.delete(orderID);
console.warn(`[Trade.Confirm] ${ORDER_CONFIRM_TIMEOUT_MS}ms no WS PLACEMENT/MATCHED received, getOrder fallback orderID=${fmtOid(orderID)} source=${entry.source}`);
if (!clobClient) {
console.warn(`[Trade.Confirm] CLOB not initialized, cannot fall back`);
return;
}
try {
const order = await clobClient.getOrder(orderID);
console.log("[RAW]", `[Trade.Confirm] getOrder fallback orderID=${orderID} raw:`, JSON.stringify(order));
const status = typeof order?.status === "string" ? order.status.toUpperCase() : "";
if (status === "LIVE" || status === "MATCHED" || status === "FILLED") {
console.log(`[Trade.Confirm] ✓ getOrder confirmed exists orderID=${fmtOid(orderID)} status=${status} (just slow WS)`);
} else if (status === "CANCELED") {
console.warn(`[Trade.Confirm] ⚠ orderID=${fmtOid(orderID)} was cancelled (abnormal state, clearing local)`);
strategyLimitOrders.delete(orderID);
manualLimitOrders.delete(orderID);
clearPendingTradeMetaByOrderId(orderID);
broadcastManualLimitOrders();
} else {
console.warn(`[Trade.Confirm] ⚠ orderID=${fmtOid(orderID)} unknown status status=${status || "(empty)"}`);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/not found|404|does not exist/i.test(msg)) {
console.warn(`[Trade.Confirm] ❌ getOrder 404 -> order not placed orderID=${fmtOid(orderID)} source=${entry.source} (clearing local, not auto re-placing)`);
strategyLimitOrders.delete(orderID);
manualLimitOrders.delete(orderID);
clearPendingTradeMetaByOrderId(orderID);
broadcastManualLimitOrders();
} else {
console.warn(`[Trade.Confirm] getOrder failed orderID=${fmtOid(orderID)}:`, msg);
}
}
}
function registerCancelConfirm(orderID: string, reason: string, ctx?: OrderCtx, rounds: number = 0): void {
if (!orderID) return;
const old = pendingCancelConfirm.get(orderID);
if (old) clearTimeout(old.timer);
// ctx prefers the input arg, otherwise reverse-look it up from the in-memory Map (the Map is usually still there on cancel)
const finalCtx = ctx ?? getOrderCtxFromMaps(orderID);
// WS CANCELED arrived before the HTTP response? accept immediately
const early = earlyCancelConfirmCache.get(orderID);
if (early) {
earlyCancelConfirmCache.delete(orderID);
console.log(`[Trade.Cancel] WS arrived first orderID=${fmtOid(orderID)}${fmtOrderCtx(finalCtx)} reason:${early.reason} (call reason=${reason})`);
return;
}
const entry: PendingCancelConfirm = {
orderID, reason, ctx: finalCtx, t0: Date.now(), resolved: false, rounds,
timer: setTimeout(() => verifyCancelViaRest(orderID), ORDER_CONFIRM_TIMEOUT_MS),
};
pendingCancelConfirm.set(orderID, entry);
}
function resolveCancelConfirm(orderID: string, reason: string): void {
const entry = pendingCancelConfirm.get(orderID);
if (!entry) return;
entry.resolved = true;
clearTimeout(entry.timer);
pendingCancelConfirm.delete(orderID);
console.log(`[Trade.Cancel] WS confirmed cancel orderID=${fmtOid(orderID)}${fmtOrderCtx(entry.ctx)} elapsed:${Date.now() - entry.t0}ms reason:${reason}`);
}
async function verifyCancelViaRest(orderID: string): Promise<void> {
const entry = pendingCancelConfirm.get(orderID);
if (!entry || entry.resolved) return;
pendingCancelConfirm.delete(orderID);
const round = entry.rounds + 1;
console.warn(`[Trade.Cancel] ${ORDER_CONFIRM_TIMEOUT_MS}ms no WS CANCELED received, getOrder fallback orderID=${fmtOid(orderID)} round=${round}/${CANCEL_MAX_ROUNDS}`);
if (!clobClient) return;
try {
const order = await clobClient.getOrder(orderID);
console.log("[RAW]", `[Trade.Cancel] getOrder fallback orderID=${orderID} raw:`, JSON.stringify(order));
const status = typeof order?.status === "string" ? order.status.toUpperCase() : "";
if (status === "CANCELED" || status === "CANCELLED") {
console.log(`[Trade.Cancel] ✓ orderID=${fmtOid(orderID)} already ${status} (clearing local)`);
strategyLimitOrders.delete(orderID);
manualLimitOrders.delete(orderID);
clearPendingTradeMetaByOrderId(orderID);
broadcastManualLimitOrders();
} else if (status === "FILLED") {
// fully filled: keep the order awaiting MINED handling (avoids stratOrder being deleted prematurely causing TP/SL loss)
const so = strategyLimitOrders.get(orderID);
if (so) so.pendingMined = true;
console.log(`[Trade.Cancel] ⚠ orderID=${fmtOid(orderID)} already FILLED, keeping order awaiting MINED handling`);
} else if (status === "LIVE" || status === "MATCHED") {
if (round >= CANCEL_MAX_ROUNDS) {
console.error(`[Trade.Cancel] ❌ orderID=${fmtOid(orderID)} still ${status}, reached max re-cancel rounds ${CANCEL_MAX_ROUNDS}, giving up and force-clearing local (PM actual status may still be LIVE, needs manual check or wait for window-switch cleanup)`);
strategyLimitOrders.delete(orderID);
manualLimitOrders.delete(orderID);
broadcastManualLimitOrders();
} else {
console.warn(`[Trade.Cancel] ⚠ orderID=${fmtOid(orderID)} still ${status}, triggering re-cancel round=${round}`);
// pass through round to the next register (cancelOrderImmediate will call registerCancelConfirm)
// use a temp variable so cancelOrderImmediate knows the current round
cancelRoundOverride.set(orderID, round);
void cancelOrderImmediate(orderID);
}
} else {
console.warn(`[Trade.Cancel] ⚠ orderID=${fmtOid(orderID)} unknown status status=${status || "(empty)"}`);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/not found|404|does not exist/i.test(msg)) {
console.log(`[Trade.Cancel] ✓ orderID=${fmtOid(orderID)} REST 404 (no longer exists, treated as cancelled)`);
strategyLimitOrders.delete(orderID);
manualLimitOrders.delete(orderID);
clearPendingTradeMetaByOrderId(orderID);
broadcastManualLimitOrders();
} else {
console.warn(`[Trade.Cancel] getOrder failed orderID=${fmtOid(orderID)}:`, msg);
}
}
}
type CancelResult = "canceled" | "soft_matched" | "soft_other" | false;
/** immediately attempt to cancel (retry 3 times), returns a tri-state
* parses the cancelOrder response structure: { canceled: [orderID...], not_canceled: { orderID: reason } }
* - canceled contains the target -> "canceled" (really cancelled)
* - not_canceled reason contains filled/matched -> "soft_matched" (actually filled, caller must keep the order awaiting MINED)
* - not_canceled reason contains not.?found/already, etc. -> "soft_other" (order does not exist, can be cleaned up)
* - otherwise -> returns false when retries are exhausted
*/
async function cancelOrderImmediate(orderID: string, reason: string = "immediate"): Promise<CancelResult> {
if (!clobClient) return false;
// register WS fallback confirmation; if this is a re-cancel from verifyCancelViaRest, pass through the current round
const overrideRound = cancelRoundOverride.get(orderID);
if (overrideRound !== undefined) cancelRoundOverride.delete(orderID);
registerCancelConfirm(orderID, reason, undefined, overrideRound ?? 0);
for (let attempt = 1; attempt <= 3; attempt++) {
const t0 = Date.now();
console.log("[RAW]", `[Trade.Cancel] cancelOrder args:`, JSON.stringify({ orderID }), `attempt:${attempt}/3 reason:${reason}`);
try {
const result = await clobClient.cancelOrder({ orderID });
const dt = Date.now() - t0;
console.log("[RAW]", `[Trade.Cancel] cancelOrder raw result:`, JSON.stringify(result), `HTTP:${dt}ms attempt:${attempt}`);
const canceledList = Array.isArray((result as any)?.canceled) ? (result as any).canceled as string[] : [];
const notCanceled = (result as any)?.not_canceled;
const wasCanceled = canceledList.includes(orderID);
const ncReason = notCanceled && typeof notCanceled === "object" ? String((notCanceled as Record<string, unknown>)[orderID] ?? "") : "";
if (wasCanceled) {
// HTTP confirmed success, but the WS fallback is still kept (so verifyCancelViaRest does not double-handle when the WS actually arrives)
return "canceled";
}
if (ncReason) {
// matched/filled takes priority: also classified as soft_matched when the reason also contains already
if (/filled|matched/i.test(ncReason)) {
console.log(`[Trade.Cancel] orderID=${fmtOid(orderID)} not_canceled already filled (keeping order awaiting MINED): ${ncReason}`);
resolveCancelConfirm(orderID, "HTTP not_canceled soft success (matched)");
return "soft_matched";
}
if (/not.?found|does not exist|already/i.test(ncReason)) {
console.log(`[Trade.Cancel] orderID=${fmtOid(orderID)} not_canceled order does not exist: ${ncReason}`);
resolveCancelConfirm(orderID, "HTTP not_canceled soft success (not_found)");
return "soft_other";
}
console.warn(`[Trade.Cancel] orderID=${fmtOid(orderID)} not_canceled reason needs retry: ${ncReason}`);
} else {
console.warn(`[Trade.Cancel] orderID=${fmtOid(orderID)} response has neither a canceled hit nor a not_canceled reason, retrying`);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const dt = Date.now() - t0;
console.log("[RAW]", `[Trade.Cancel] cancelOrder threw attempt:${attempt}/3 HTTP:${dt}ms err:`, msg);
// the throw path cannot distinguish matched/canceled, conservatively classify as soft_other (does not affect the fix goal -- matched goes through the HTTP response path, not the throw path)
if (/not found|does not exist|already/i.test(msg)) {
resolveCancelConfirm(orderID, "HTTP threw not found");
return "soft_other";
}
}
if (attempt < 3) await new Promise(r => setTimeout(r, 500));
}
return false;
}
/**
* cancel a strategy limit order (unified entry point)
* - first cancel: event-driven call (instant)
* - after failure: 1-second poll auto-retry (pollLimitOrderCancellations)
* - if the strategy changes its mind during retry (checkCancelOrder=false) -> auto-stop
* - after cancel succeeds: delete stratOrder + clear mark (if the strategy allows re-placing)
*/
async function cancelStrategyLimitOrder(orderID: string, reason: string): Promise<void> {
// prevent concurrent cancels of the same orderID (event-driven + polling may collide)
if (cancelInFlight.has(orderID)) return;
cancelInFlight.add(orderID);
const stratOrder = strategyLimitOrders.get(orderID);
const tag = stratOrder?.strategyKey ?? "?";
let result: CancelResult = false;
try {
result = await cancelOrderImmediate(orderID);
} finally {
cancelInFlight.delete(orderID);
}
if (result === "canceled" || result === "soft_other") {
// really cancelled / order no longer exists: clear local
strategyLimitOrders.delete(orderID);
manualLimitOrders.delete(orderID);
broadcastManualLimitOrders();
if (stratOrder && stratOrder.side === "buy") {
const stratInstance = getStrategy(stratOrder.strategyKey);
if (stratInstance?.limitAllowReplaceAfterCancel) {
strategyLimitWindowMark.delete(`${stratOrder.strategyKey}:${stratOrder.windowStart}`);
console.log(`[Strategy.${tag}] re-placing allowed after cancel, window mark cleared`);
}
}
console.log(`[Strategy.${tag}] ✖ cancel succeeded orderID=${fmtOid(orderID)} reason: ${reason}`);
} else if (result === "soft_matched") {
// hit a fill during cancel: keep the order awaiting MINED to trigger TP/SL placement (fixes the bug where stratOrder was deleted prematurely leaving the position exposed)
if (stratOrder) stratOrder.pendingMined = true;
console.log(`[Strategy.${tag}] ⚠ already filled during cancel orderID=${fmtOid(orderID)} keeping order awaiting MINED handling reason: ${reason}`);
} else {
// do not enqueue, pollLimitOrderCancellations 1-second poll auto-retries
console.warn(`[Strategy.${tag}] ⚠ cancel failed, will auto-retry via polling orderID=${fmtOid(orderID)} reason: ${reason}`);
}
}
/**
* limit order cancel polling (every 1 second)
*
* logic: scan all active stratOrders, the strategy says cancel + the order is still there -> call cancel
* - order not in strategyLimitOrders = already successfully cancelled, auto-skip
* - strategy checkCancelOrder=false (condition bounced back) = let the order keep resting, do not cancel
* - cancel succeeds -> delete + the next loop no longer iterates it
* - cancel fails -> retry after 1 second (no limit, naturally terminated by the strategy's intent)
*/
async function pollLimitOrderCancellations(): Promise<void> {
if (strategyLimitOrders.size === 0) return;
const snapshot = getProbabilitySnapshot();
const upPct = snapshot?.upPct ?? null;
const dnPct = snapshot?.dnPct ?? null;
const diff = getStrategyDiff();
const now = Date.now();
const rem = getStrategyRemainingSeconds(now);
const ctx = buildTickContext(rem, upPct, dnPct, diff, now);
const currentWs = getCurrentWindowStart();
for (const stratOrder of [...strategyLimitOrders.values()]) {
if (cancelInFlight.has(stratOrder.orderID)) continue;
// order already filled awaiting MINED handling: no longer cancel, no longer trigger any action (wait for MINED to place TP/SL + clean up)
if (stratOrder.pendingMined) continue;
// cross-window residual -> force cancel (applies to both buy/sell, the position has settled and is meaningless)
if (stratOrder.windowStart !== currentWs) {
void cancelStrategyLimitOrder(stratOrder.orderID, "window expired");
continue;
}
if (stratOrder.side !== "buy") continue; // sell (TP) is managed by the cond system within the current window, does not participate in strategy checkCancelOrder
// strategy disabled -> force cancel
if (!strategyConfig.enabled[stratOrder.strategyKey]) {
void cancelStrategyLimitOrder(stratOrder.orderID, "strategy disabled");
continue;
}
const strat = getStrategy(stratOrder.strategyKey);
if (!strat?.checkCancelOrder) continue;
const runtime = {
direction: stratOrder.direction,
price: stratOrder.price,
shares: stratOrder.shares,
filledSize: stratOrder.filledSize,
windowStart: stratOrder.windowStart,
};
if (strat.checkCancelOrder(ctx, runtime)) {
void cancelStrategyLimitOrder(stratOrder.orderID, "polling cancel");
}
}
}
// -- Pre-sign infrastructure (generic) --------------------------------------
// strategies declare requirements via getPresignRequest, the server async-creates and caches the signed package when rem in [remMin, remMax]
// async-createOrder caches the signature package in the background; on trigger placeStrategyLimitOrder prefers the cache
// skipping signing latency. Conservatively no retry on failure, falls back to live-signing when the cache is empty.
interface PresignedPack {
strategyKey: string;
direction: StrategyDirection;
windowStart: number;
tokenId: string;
price: number;
shares: number;
signed: unknown; // the signature package returned by clobClient.createOrder, type determined by the SDK
}
/** key = `${strategyKey}:${windowStart}:${direction}` */
const strategyPresigned = new Map<string, PresignedPack>();
/** the set of keys currently being signed, to avoid duplicate triggers within the same tick */
const presignInflight = new Set<string>();
function presignKey(strategyKey: string, ws: number, direction: StrategyDirection): string {
return `${strategyKey}:${ws}:${direction}`;
}
/** clear all pre-sign caches on window switch (the old window's tokenId is no longer valid) */
function clearAllPresigned(): void {
if (strategyPresigned.size === 0 && presignInflight.size === 0) return;
console.log(`[Strategy.Presign] cleared ${strategyPresigned.size} cached pre-sign packages (window switch)`);
strategyPresigned.clear();
presignInflight.clear();
}
async function presignOne(
strategyKey: string,
direction: StrategyDirection,
price: number,
shares: number,
ws: number,
): Promise<void> {
const key = presignKey(strategyKey, ws, direction);
if (presignInflight.has(key)) return;
if (strategyPresigned.has(key)) return;
if (!(await ensureClobClient())) return;
const tokenId = direction === "up" ? state.upTokenId : state.downTokenId;
if (!tokenId) return;
// the window may have already switched
if (getCurrentWindowStart() !== ws) return;
presignInflight.add(key);
const startedAt = Date.now();
try {
const tickSize = getCachedTickSize(tokenId);
const priceDecimals = getDecimalPlaces(tickSize);
const normalizedSize = floorToDecimals(shares, 2);
const normalizedPrice = floorToDecimals(price, priceDecimals);
if (normalizedSize < 5 || normalizedPrice <= 0) {
console.warn(`[Strategy.Presign.${strategyKey}] params invalid size=${normalizedSize} price=${normalizedPrice}`);
return;
}
const signed = await clobClient!.createOrder(
{ tokenID: tokenId, side: Side.BUY, price: normalizedPrice, size: normalizedSize },
{ tickSize, negRisk: false },
);
// check the window again after signing (it may have switched during the async period)
if (getCurrentWindowStart() !== ws) {
console.log(`[Strategy.Presign.${strategyKey}] ${direction} signed but window switched, discarding`);
return;
}
strategyPresigned.set(key, {
strategyKey,
direction,
windowStart: ws,
tokenId,
price: normalizedPrice,
shares: normalizedSize,
signed,
});
console.log(`[Strategy.Presign.${strategyKey}] ${direction} signed size=${normalizedSize} price=${normalizedPrice} elapsed ${Date.now() - startedAt}ms`);
} catch (err) {
// conservative: no retry on failure, falls back when the cache is empty on trigger
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[Strategy.Presign.${strategyKey}] ${direction} signing failed (conservatively no retry): ${msg}`);
} finally {
presignInflight.delete(key);
}
}
/** called every tick: scan all strategies' pre-sign requirements, async-sign when the timing is right (does not block the tick) */
function runPresignTick(): void {
const ws = getCurrentWindowStart();
if (!ws) return;
const rem = getStrategyRemainingSeconds(Date.now());
for (const s of getAllStrategies()) {
if (!isStrategySupportedHere(s)) continue;
if (!strategyConfig.enabled[s.key]) continue;
if (!s.getPresignRequest) continue;
const req = s.getPresignRequest();
if (!req) continue;
if (rem < req.remMin || rem > req.remMax) continue;
for (const direction of req.directions) {
const key = presignKey(s.key, ws, direction);
if (strategyPresigned.has(key) || presignInflight.has(key)) continue;
// async-sign, do not await
void presignOne(s.key, direction, req.price, req.shares, ws);
}
}
}
/** strategy limit order (place a GTC order by the strategy signal) */
async function placeStrategyLimitOrder(
strategyKey: string,
signal: { direction: StrategyDirection; price: number; shares: number },
): Promise<void> {
if (!(await ensureClobClient())) return;
const tokenId = signal.direction === "up" ? state.upTokenId : state.downTokenId;
if (!tokenId) {
console.warn(`[Strategy.${strategyKey}] current window not ready, skipping resting order`);
return;
}
try {
const tickSize = getCachedTickSize(tokenId);
const priceDecimals = getDecimalPlaces(tickSize);
const normalizedSize = floorToDecimals(signal.shares, 2);
const normalizedPrice = floorToDecimals(signal.price, priceDecimals);
if (normalizedSize < 5 || normalizedPrice <= 0) {
console.warn(`[Strategy.${strategyKey}] params invalid size=${normalizedSize} price=${normalizedPrice}`);
return;
}
// prefer the pre-sign cache: on hit skip createOrder (saves ~200ms signing latency)
// only use it when direction + tokenId + price + shares all match, otherwise live-sign
const ws = getCurrentWindowStart();
const pkey = presignKey(strategyKey, ws, signal.direction);
const cached = strategyPresigned.get(pkey);
let signed;
let usedPresign = false;
const createOrderArgs = { tokenID: tokenId, side: Side.BUY, price: normalizedPrice, size: normalizedSize };
const createOrderOpts = { tickSize, negRisk: false };
console.log("[RAW]", `[Strategy.${strategyKey}] placeStrategyLimitOrder ctx:`, JSON.stringify({
strategyKey, signal, normalizedSize, normalizedPrice, tickSize, priceDecimals,
tokenId, windowStart: ws, presignCacheKey: pkey, presignHit: !!cached,
}), "createOrder args:", JSON.stringify(createOrderArgs), "opts:", JSON.stringify(createOrderOpts));
if (
cached &&
cached.tokenId === tokenId &&
cached.price === normalizedPrice &&
cached.shares === normalizedSize
) {
signed = cached.signed as Awaited<ReturnType<NonNullable<typeof clobClient>["createOrder"]>>;
usedPresign = true;
strategyPresigned.delete(pkey);
// the other direction's pre-sign package will not be used again within the same window after trigger, clear it immediately to save memory
const otherDir: StrategyDirection = signal.direction === "up" ? "down" : "up";
strategyPresigned.delete(presignKey(strategyKey, ws, otherDir));
} else {
signed = await clobClient!.createOrder(createOrderArgs, createOrderOpts);
}
console.log("[RAW]", `[Strategy.${strategyKey}] signedOrder:`, JSON.stringify(signed));
const postT0 = Date.now();
const result = await clobClient!.postOrder(signed, OrderType.GTC);
const postHttpMs = Date.now() - postT0;
console.log("[RAW]", `[Strategy.${strategyKey}] postOrder GTC raw result:`, JSON.stringify(result), `HTTP:${postHttpMs}ms t0:${postT0}`);
const orderID = typeof result?.orderID === "string" ? result.orderID : "";
if (!orderID) {
const errMsg = typeof result?.error === "string" ? result.error : "response has no orderID";
console.warn(`[Strategy.${strategyKey}] resting order failed: ${errMsg}`);
return;
}
const order: StrategyLimitOrder = {
orderID,
strategyKey,
side: "buy",
direction: signal.direction,
tokenId,
price: normalizedPrice,
shares: normalizedSize,
matchedSize: 0,
filledSize: 0,
takeProfitPlacedSize: 0,
windowStart: ws,
createdAt: Date.now(),
};
strategyLimitOrders.set(orderID, order);
// also stuff into manualLimitOrders so the frontend can see it
manualLimitOrders.set(orderID, {
orderID,
direction: signal.direction,
side: "buy",
size: normalizedSize,
price: normalizedPrice,
createdAt: order.createdAt,
source: `strategy:${strategyKey}`,
windowStart: ws,
filledSize: 0,
});
// write pendingTradeMeta: after the MINED event hits, source is used to call notifyTradeForPnl,
// so the real PnL panel attributes this fill to the corresponding strategy (otherwise it falls to "manual")
rememberPendingTradeMeta({
orderId: orderID,
ts: order.createdAt,
windowStart: ws,
side: "buy",
direction: signal.direction,
amount: normalizedSize,
worstPrice: normalizedPrice,
source: `strategy${strategyKey}`,
});
broadcastManualLimitOrders();
strategyLimitWindowMark.add(`${strategyKey}:${ws}`);
console.log(`[Strategy.${strategyKey}] ${signal.direction === "up" ? "⬆" : "⬇"} resting order buy ${signal.direction === "up" ? "up" : "down"} ${normalizedSize}@${normalizedPrice} orderID=${fmtOid(orderID)}${usedPresign ? " ⚡presign" : ""}`);
registerOrderConfirm(orderID, `strategy:${strategyKey}`, {
side: "buy", direction: signal.direction, size: normalizedSize, price: normalizedPrice,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[Strategy.${strategyKey}] resting order exception: ${msg}`);
}
}
/**
* strategy take-profit resting order: called after the buy maker order is confirmed filled on-chain, places a same-direction GTC sell order
*
* price fixed at 0.10 (10x profit), shares = the newly confirmed fill shares this time (partial fills are accumulated and placed)
* will not place twice: order.takeProfitPlacedSize accumulates the already-placed amount, ensuring the same shares are placed only once
*/
async function placeStrategyTakeProfitOrder(
buyOrder: StrategyLimitOrder,
sellSize: number,
sellPrice: number,
): Promise<void> {
if (!(await ensureClobClient())) return;
if (sellSize < 5) {
console.warn(`[Strategy.${buyOrder.strategyKey}] TP shares ${sellSize.toFixed(2)} < 5, skipping maker path`);
return;
}
// use the buy order's tokenId at the time (avoids state.upTokenId already being the new window's after a window switch)
const tokenId = buyOrder.tokenId;
if (!tokenId) {
console.warn(`[Strategy.${buyOrder.strategyKey}] TP: buy order missing tokenId, skipping`);
return;
}
// window-switch protection: do not place if the buy order's window has ended (position settled or zeroed)
const currentWs = getCurrentWindowStart();
if (buyOrder.windowStart !== currentWs) {
console.warn(`[Strategy.${buyOrder.strategyKey}] TP: buy order belongs to the previous window (${buyOrder.windowStart} != ${currentWs}), position settled, skipping TP`);
return;
}
try {
const tickSize = getCachedTickSize(tokenId);
const priceDecimals = getDecimalPlaces(tickSize);
const normalizedSize = floorToDecimals(sellSize, 2);
const normalizedPrice = floorToDecimals(sellPrice, priceDecimals);
if (normalizedSize < 5 || normalizedPrice <= 0) {
console.warn(`[Strategy.${buyOrder.strategyKey}] TP params invalid size=${normalizedSize} price=${normalizedPrice}`);
return;
}
const stratTpArgs = { tokenID: tokenId, side: Side.SELL, price: normalizedPrice, size: normalizedSize };
const stratTpOpts = { tickSize, negRisk: false };
console.log("[RAW]", `[Strategy.${buyOrder.strategyKey}] TP createOrder args:`, JSON.stringify(stratTpArgs), "opts:", JSON.stringify(stratTpOpts), "ctx:", JSON.stringify({
buyOrderID: buyOrder.orderID, buyDirection: buyOrder.direction, buyWindow: buyOrder.windowStart,
sellSize, sellPrice, normalizedSize, normalizedPrice, tickSize, priceDecimals, currentWs,
}));
const signed = await clobClient!.createOrder(stratTpArgs, stratTpOpts);
console.log("[RAW]", `[Strategy.${buyOrder.strategyKey}] TP signedOrder:`, JSON.stringify(signed));
const stratTpT0 = Date.now();
const result = await clobClient!.postOrder(signed, OrderType.GTC);
const stratTpHttpMs = Date.now() - stratTpT0;
console.log("[RAW]", `[Strategy.${buyOrder.strategyKey}] TP postOrder GTC raw result:`, JSON.stringify(result), `HTTP:${stratTpHttpMs}ms`);
const orderID = typeof result?.orderID === "string" ? result.orderID : "";
if (!orderID) {
const errMsg = typeof result?.error === "string" ? result.error : "response has no orderID";
console.warn(`[Strategy.${buyOrder.strategyKey}] TP resting order failed: ${errMsg}`);
return;
}
const ws = getCurrentWindowStart();
const sellOrder: StrategyLimitOrder = {
orderID,
strategyKey: buyOrder.strategyKey,
side: "sell",
direction: buyOrder.direction,
tokenId,
price: normalizedPrice,
shares: normalizedSize,
matchedSize: 0,
filledSize: 0,
takeProfitPlacedSize: 0,
windowStart: ws,
createdAt: Date.now(),
};
strategyLimitOrders.set(orderID, sellOrder);
manualLimitOrders.set(orderID, {
orderID,
direction: buyOrder.direction,
side: "sell",
size: normalizedSize,
price: normalizedPrice,
createdAt: sellOrder.createdAt,
source: `strategy:${buyOrder.strategyKey}:tp`,
windowStart: ws,
filledSize: 0,
});
registerOrderConfirm(orderID, `strategy:${buyOrder.strategyKey}:tp`, {
side: "sell", direction: buyOrder.direction, size: normalizedSize, price: normalizedPrice,
});
// write pendingTradeMeta: after the MINED sell event hits, source can be found
rememberPendingTradeMeta({
orderId: orderID,
ts: sellOrder.createdAt,
windowStart: ws,
side: "sell",
direction: buyOrder.direction,
amount: normalizedSize,
worstPrice: normalizedPrice,
source: `strategy${buyOrder.strategyKey}tp`,
});
broadcastManualLimitOrders();
buyOrder.takeProfitPlacedSize += normalizedSize;
console.log(`[Strategy.${buyOrder.strategyKey}] TP resting order sell ${buyOrder.direction} size=${normalizedSize} price=${normalizedPrice} orderID=${fmtOid(orderID)}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[Strategy.${buyOrder.strategyKey}] TP resting order exception: ${msg}`);
}
}
/**
* limit-strategy TP sell order on-chain confirmation (MINED side=sell):
* accumulate the sell stratOrder's filledSize, clean up when it reaches shares
*/
function onStrategyTakeProfitMined(
strategyKey: string,
direction: StrategyDirection,
minedSize: number,
): void {
if (minedSize <= 0) return;
const candidates = [...strategyLimitOrders.values()].filter(o =>
o.strategyKey === strategyKey && o.side === "sell" && o.direction === direction
&& o.filledSize < o.shares - 0.01
);
if (candidates.length === 0) {
console.log(`[Strategy.${strategyKey}] TP MINED ${minedSize.toFixed(2)} shares but no matching sell order found`);
return;
}
candidates.sort((a, b) => a.createdAt - b.createdAt);
let remaining = minedSize;
for (const order of candidates) {
if (remaining <= 0) break;
const need = order.shares - order.filledSize;
const take = Math.min(remaining, need);
order.filledSize += take;
remaining -= take;
const ml = manualLimitOrders.get(order.orderID);
if (ml) ml.filledSize = order.filledSize;
if (order.filledSize >= order.shares - 0.01) {
console.log(`[Strategy.${strategyKey}] TP sell on-chain confirmed ${order.shares} shares @${order.price} (order:${order.orderID.slice(0, 10)}...)`);
strategyLimitOrders.delete(order.orderID);
manualLimitOrders.delete(order.orderID);
clearPendingTradeMetaByOrderId(order.orderID);
} else {
console.log(`[Strategy.${strategyKey}] TP sell partial on-chain confirmation +${take.toFixed(2)} shares confirmed ${order.filledSize.toFixed(2)}/${order.shares}`);
}
broadcastManualLimitOrders();
}
}
/**
* called after a limit-strategy maker buy order is confirmed filled on-chain:
* - find the corresponding buy stratOrder
* - accumulate filledSize (on-chain confirmed shares)
* - place TP sell orders for the newly confirmed shares (place on every MINED increment, plan B)
* - clean up the buy stratOrder when filledSize reaches shares
*/
async function onStrategyLimitMined(
strategyKey: string,
direction: StrategyDirection,
minedSize: number,
orderID?: string,
): Promise<void> {
if (minedSize <= 0) return;
// fix C: prefer exact matching by orderID (avoids cross-talk between multiple stratOrders)
// when orderID is not passed, fall back to fuzzy lookup by strategyKey + direction (backward compatible)
let candidates: StrategyLimitOrder[];
if (orderID) {
const order = strategyLimitOrders.get(orderID);
if (!order || order.side !== "buy" || order.direction !== direction || order.filledSize >= order.shares - 0.01) {
console.log(`[Strategy.${strategyKey}] MINED on-chain confirmed ${minedSize.toFixed(2)} shares orderID=${fmtOid(orderID)} but the order is no longer present or already full (may have been cleaned up or duplicated)`);
return;
}
candidates = [order];
} else {
candidates = [...strategyLimitOrders.values()].filter(o =>
o.strategyKey === strategyKey && o.side === "buy" && o.direction === direction
&& o.filledSize < o.shares - 0.01
);
if (candidates.length === 0) {
console.log(`[Strategy.${strategyKey}] MINED on-chain confirmed ${minedSize.toFixed(2)} shares but no matching buy order found (may have been cleaned up)`);
return;
}
// ascending by createdAt: consume the old ones first (those placed earlier in the same window confirm first)
candidates.sort((a, b) => a.createdAt - b.createdAt);
}
let remaining = minedSize;
for (const order of candidates) {
if (remaining <= 0) break;
const need = order.shares - order.filledSize;
const take = Math.min(remaining, need);
order.filledSize += take;
remaining -= take;
const ml = manualLimitOrders.get(order.orderID);
if (ml) ml.filledSize = order.filledSize;
if (order.filledSize >= order.shares - 0.01) {
console.log(`[Strategy.${strategyKey}] limit order on-chain confirmed ${order.direction} ${order.shares} shares @${order.price} (order:${order.orderID.slice(0, 10)}...)`);
strategyLimitOrders.delete(order.orderID);
manualLimitOrders.delete(order.orderID);
clearPendingTradeMetaByOrderId(order.orderID);
} else {
console.log(`[Strategy.${strategyKey}] limit order partial on-chain confirmation +${take.toFixed(2)} shares confirmed ${order.filledSize.toFixed(2)}/${order.shares}`);
}
broadcastManualLimitOrders();
// place TP/SL for the newly confirmed shares
const tpPending = order.filledSize - order.takeProfitPlacedSize;
if (tpPending > 0) {
const stratInstance = getStrategy(strategyKey);
// prefer the cond system (TP+SL co-group management)
const condCfg = stratInstance?.getLimitConditionOrder?.() ?? null;
if (condCfg && (condCfg.stopProfit || condCfg.stopLoss)) {
await createConditionOrdersAfterFill({
direction: order.direction,
assetId: order.tokenId,
entryPrice: order.price,
filledSize: tpPending,
windowStart: order.windowStart,
stopProfit: condCfg.stopProfit,
stopLoss: condCfg.stopLoss,
});
order.takeProfitPlacedSize = order.filledSize;
console.log(`[Strategy.${strategyKey}] created conditional orders (TP/SL) for ${tpPending.toFixed(2)} shares`);
} else {
// fall back to the old TP-only path
const tpPrice = stratInstance?.getLimitTakeProfitPrice?.() ?? null;
if (tpPrice == null) {
// no TP, skip (filled shares are held to settlement)
order.takeProfitPlacedSize = order.filledSize;
} else if (tpPending >= 5) {
await placeStrategyTakeProfitOrder(order, tpPending, tpPrice);
} else {
console.log(`[Strategy.${strategyKey}] TP pending ${tpPending.toFixed(2)} shares < 5 (maker minimum limit), not placing for now`);
}
}
}
}
}
/** limit-strategy scheduling (called every tick) */
function runLimitStrategyTick(): void {
const snapshot = getProbabilitySnapshot();
const upPct = snapshot?.upPct ?? null;
const dnPct = snapshot?.dnPct ?? null;
const diff = getStrategyDiff();
const now = Date.now();
const rem = getStrategyRemainingSeconds(now);
const ctx = buildTickContext(rem, upPct, dnPct, diff, now);
const currentWs = getCurrentWindowStart();
for (const s of getAllStrategies()) {
if (!isStrategySupportedHere(s)) continue;
if (!strategyConfig.enabled[s.key]) continue;
if (!s.checkLimitOrder) continue; // not a limit strategy
// find the current strategy's active buy resting order (sell is the TP order, auto-managed by the server, strategy does not participate)
// orders with pendingMined have hit a fill, awaiting MINED handling, do not participate in this path (avoids repeatedly triggering cancel)
const existingOrder = [...strategyLimitOrders.values()].find(o => o.strategyKey === s.key && o.side === "buy" && !o.pendingMined);
if (existingOrder) {
// check whether to cancel
if (s.checkCancelOrder) {
const runtime = {
direction: existingOrder.direction,
price: existingOrder.price,
shares: existingOrder.shares,
filledSize: existingOrder.filledSize,
windowStart: existingOrder.windowStart,
};
if (s.checkCancelOrder(ctx, runtime)) {
void cancelStrategyLimitOrder(existingOrder.orderID, "strategy cancel condition triggered");
}
}
} else {
// no resting order, check whether to place a new one (note: place only once per window)
const mark = `${s.key}:${currentWs}`;
if (strategyLimitWindowMark.has(mark)) continue;
// inject the latest shares config into the strategy instance (so checkLimitOrder uses the latest value)
if ("shares" in s) {
const cfgShares = strategyConfig.shares[s.key];
if (cfgShares != null && cfgShares >= 5) {
(s as any).shares = cfgShares;
}
}
// inject the latest tunable params into the strategy instance (e.g. l1's tpDelta/slDiff)
applyTunableParamsToStrategy(s);
// US Eastern weekend pause: do not place new limit orders (existing ones are not cancelled, filled ones' TP/SL run as usual)
if (strategyConfig.weekendPause && isUsWeekend()) {
logWeekendPauseOnce();
continue;
}
const signal = s.checkLimitOrder(ctx);
if (signal) {
// mark immediately, to avoid the next tick placing again
strategyLimitWindowMark.add(mark);
void placeStrategyLimitOrder(s.key, signal);
}
}
}
}
function broadcastManualLimitOrders(): void {
broadcast("manualLimitOrders", { list: [...manualLimitOrders.values()].sort((a, b) => b.createdAt - a.createdAt) });
}
app.get("/api/orders/open", (_req, res) => {
res.json({ list: [...manualLimitOrders.values()].sort((a, b) => b.createdAt - a.createdAt) });
});
app.post("/api/orders/cancel", async (req, res) => {
const { orderID } = req.body as { orderID: string };
if (!orderID) { res.status(400).json({ error: "invalid params" }); return; }
if (!(await ensureClobClient())) { res.status(500).json({ error: "CLOB not initialized" }); return; }
registerCancelConfirm(orderID, "manual");
const t0 = Date.now();
console.log("[RAW]", `[Trade.Limit] cancelOrder args:`, JSON.stringify({ orderID }));
try {
const result = await clobClient!.cancelOrder({ orderID });
const dt = Date.now() - t0;
console.log("[RAW]", `[Trade.Limit] cancelOrder raw result:`, JSON.stringify(result), `HTTP:${dt}ms`);
const canceledList = Array.isArray((result as any)?.canceled) ? (result as any).canceled as string[] : [];
const notCanceled = (result as any)?.not_canceled;
const ncReason = notCanceled && typeof notCanceled === "object" ? String((notCanceled as Record<string, unknown>)[orderID] ?? "") : "";
if (canceledList.includes(orderID)) {
manualLimitOrders.delete(orderID);
broadcastManualLimitOrders();
console.log(`[Trade.Limit] ✖ cancel orderID=${fmtOid(orderID)}`);
res.json({ success: true });
return;
}
if (ncReason) {
if (/filled|matched|not.?found|does not exist|already/i.test(ncReason)) {
manualLimitOrders.delete(orderID);
broadcastManualLimitOrders();
resolveCancelConfirm(orderID, "HTTP not_canceled soft success");
console.log(`[Trade.Limit] orderID=${fmtOid(orderID)} handled: ${ncReason}`);
res.json({ success: true, note: ncReason });
return;
}
console.warn(`[Trade.Limit] cancel not_canceled: ${ncReason}`);
res.status(500).json({ error: `cancel rejected: ${ncReason}` });
return;
}
console.warn(`[Trade.Limit] cancel response ambiguous orderID=${fmtOid(orderID)}`);
res.status(500).json({ error: "cancel response not confirmed" });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const dt = Date.now() - t0;
console.log("[RAW]", `[Trade.Limit] cancelOrder threw HTTP:${dt}ms err:`, msg);
if (/not found|does not exist|already/i.test(msg)) {
manualLimitOrders.delete(orderID);
broadcastManualLimitOrders();
resolveCancelConfirm(orderID, "HTTP threw not found");
res.json({ success: true, note: "order no longer exists" });
return;
}
console.warn(`[Trade.Limit] cancel failed orderID=${fmtOid(orderID)} err:`, msg);
res.status(500).json({ error: msg });
}
});
app.post("/api/order/limit", async (req, res) => {
const { direction, side, size, price } = req.body as {
direction: StrategyDirection; side: "buy" | "sell"; size: number; price: number;
};
if (!direction || !side || !size || !price || size < 5 || price <= 0 || price >= 1) {
res.status(400).json({ error: "invalid params" });
return;
}
if (!(await ensureClobClient())) {
res.status(500).json({ error: "CLOB client not initialized" });
return;
}
const tokenId = direction === "up" ? state.upTokenId : state.downTokenId;
if (!tokenId) { res.status(400).json({ error: "the current window market is not ready" }); return; }
try {
const tickSize = getCachedTickSize(tokenId);
const priceDecimals = getDecimalPlaces(tickSize);
const normalizedSize = floorToDecimals(size, 2);
const normalizedPrice = floorToDecimals(price, priceDecimals);
if (normalizedSize <= 0 || normalizedPrice <= 0) {
res.status(400).json({ error: "params invalid after precision handling" }); return;
}
const manualLimitArgs = { tokenID: tokenId, side: side === "buy" ? Side.BUY : Side.SELL, price: normalizedPrice, size: normalizedSize };
const manualLimitOpts = { tickSize, negRisk: false };
console.log("[RAW]", `[Trade.Limit] createOrder args:`, JSON.stringify(manualLimitArgs), "opts:", JSON.stringify(manualLimitOpts), "ctx:", JSON.stringify({ direction, side, size, price, normalizedSize, normalizedPrice, tickSize, priceDecimals, tokenId, windowStart: state.windowStart }));
const signed = await clobClient!.createOrder(manualLimitArgs, manualLimitOpts);
console.log("[RAW]", `[Trade.Limit] signedOrder:`, JSON.stringify(signed));
const limitT0 = Date.now();
const result = await clobClient!.postOrder(signed, OrderType.GTC);
const limitHttpMs = Date.now() - limitT0;
console.log("[RAW]", `[Trade.Limit] postOrder GTC raw result:`, JSON.stringify(result), `HTTP:${limitHttpMs}ms`);
const orderID = typeof result?.orderID === "string" ? result.orderID : "";
if (!orderID) {
const errMsg = typeof result?.error === "string" ? result.error : "response has no orderID";
res.status(400).json({ error: errMsg }); return;
}
console.log(`[Trade.Limit] ${direction === "up" ? "⬆" : "⬇"} ${side === "buy" ? "buy" : "sell"} ${direction === "up" ? "up" : "down"} ${normalizedSize}@${normalizedPrice} orderID=${fmtOid(orderID)}`);
registerOrderConfirm(orderID, "manual-limit", { side, direction, size: normalizedSize, price: normalizedPrice });
manualLimitOrders.set(orderID, { orderID, direction, side, size: normalizedSize, price: normalizedPrice, createdAt: Date.now() });
broadcastManualLimitOrders();
res.json({ orderID });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
res.status(500).json({ error: msg });
}
});
app.post("/api/order", async (req, res) => {
const { direction, side, amount, slippage, stopProfit, stopLoss } = req.body as {
direction: "up" | "down";
side: "buy" | "sell";
amount: number;
slippage?: number;
// conditional order params (only effective when side=buy; percentage is the delta relative to the fill price, 0~1 float)
stopProfit?: { pctDelta: number };
stopLoss?: { pctDelta: number; slippage?: number };
};
if (side === "buy" && (stopProfit || stopLoss)) {
const tp = stopProfit ? `TP+${(stopProfit.pctDelta * 100).toFixed(1)}%` : "";
const sl = stopLoss ? `SL-${(stopLoss.pctDelta * 100).toFixed(1)}%(slippage ${((stopLoss.slippage ?? 0.15) * 100).toFixed(0)}%)` : "";
console.log(`[Cond] received conditional order params ${[tp, sl].filter(Boolean).join(" · ")} (direction=${direction} amount=${amount})`);
}
const result = await placeOrder({
direction, side, amount, slippage,
source: "manual",
stopProfit: side === "buy" ? stopProfit : undefined,
stopLoss: side === "buy" ? stopLoss : undefined,
});
res.status(result.statusCode).json(result.body);
});
// -- REST: Telegram push config ----------------------------------
app.get("/api/tg/config", (_req, res) => {
const masked = tgConfig.botToken ? tgConfig.botToken.slice(0, 10) + "***" + tgConfig.botToken.slice(-4) : "";
res.json({
enabled: tgConfig.enabled,
botToken: masked,
botTokenSet: !!tgConfig.botToken,
chatId: tgConfig.chatId,
intervalMinutes: tgConfig.intervalMinutes,
scheduledEnabled: tgConfig.scheduledEnabled,
postTradeEnabled: tgConfig.postTradeEnabled,
});
});
app.post("/api/tg/config", (req, res) => {
const body = req.body as Partial<TgConfig>;
if (typeof body.enabled === "boolean") tgConfig.enabled = body.enabled;
if (typeof body.botToken === "string" && body.botToken && !body.botToken.includes("***")) {
tgConfig.botToken = body.botToken.trim();
}
if (typeof body.chatId === "string") tgConfig.chatId = body.chatId.trim();
if (typeof body.intervalMinutes === "number" && body.intervalMinutes >= 5) {
tgConfig.intervalMinutes = Math.floor(body.intervalMinutes);
}
if (typeof body.scheduledEnabled === "boolean") tgConfig.scheduledEnabled = body.scheduledEnabled;
if (typeof body.postTradeEnabled === "boolean") tgConfig.postTradeEnabled = body.postTradeEnabled;
saveTgConfig(tgConfig);
startTgPushLoop(); // hot-restart the scheduled task
broadcastTgConfig();
res.json({ ok: true });
});
app.post("/api/tg/test", async (_req, res) => {
if (!tgConfig.botToken || !tgConfig.chatId) {
res.status(400).json({ ok: false, error: "please configure the Bot Token and Chat ID first" });
return;
}
const text = `🧪 ${activeMarket.displayName} test message\n\n${buildTgMessage()}`;
const result = await sendTgMessage(tgConfig, text);
res.json(result);
});
// auto-detect Chat ID (take the chat.id of the most recent message from getUpdates)
app.post("/api/tg/detect-chat-id", async (req, res) => {
const body = req.body as { botToken?: string };
// prefer the token in the request (just entered by the frontend user), otherwise use the saved one
let token = body.botToken && !body.botToken.includes("***") ? body.botToken.trim() : tgConfig.botToken;
if (!token) {
res.status(400).json({ ok: false, error: "please enter the Bot Token first" });
return;
}
const result = await autoDetectChatId(token);
res.json(result);
});
// -- Browser WS connection ----------------------------------------
if (wss) {
wss.on("connection", (ws, req) => {
const dataMode = resolveClientDataModeFromUrl(req.url);
clientSessions.set(ws, createClientSession(dataMode));
console.log(`[System.Frontend] browser connected, current: ${wss!.clients.size} mode=${dataMode}`);
send(ws, "clientConfig", { dataMode });
sendStateToClient(ws, { includeHistory: true });
sendPmPnlToClient(ws);
sendHttpHeartbeatToClient(ws);
sendOrderLatencyToClient(ws);
sendConditionOrdersToClient(ws);
send(ws, "manualLimitOrders", { list: [...manualLimitOrders.values()].sort((a, b) => b.createdAt - a.createdAt) });
send(ws, "manualConfig", { manualConfig });
sendTgConfigToClient(ws);
send(ws, "wsStatus", wsStatus as unknown as Record<string, unknown>);
send(ws, "claimable", { total: claimableTotal, positions: claimablePositions });
send(ws, "claimCooldown", { running: claimCycleRunning || claimInProgress, nextCheckAt: claimNextCheckAt, cooldownUntil: claimCooldownUntil });
send(ws, "backtestStatus", { collecting: backtestCollecting });
ws.on("message", (raw) => {
try {
const msg = JSON.parse(raw.toString());
// frontend -> backend RTT test: reply pong immediately (echo clientTs back, frontend computes RTT)
if (msg && msg.type === "ping") {
send(ws, "pong", { clientTs: msg.clientTs });
return;
}
applyClientConfig(ws, msg);
} catch {
// ignore non-JSON or non-config messages
}
});
ws.on("close", () => {
const session = clientSessions.get(ws);
if (session) {
clearStateTimer(session);
clientSessions.delete(ws);
}
console.log(`[System.Frontend] browser disconnected, current: ${wss!.clients.size}`);
});
});
}
// -- Before startup: load all strategy plugins first (the core of the plugin architecture) --------------
await initStrategies();
initStrategyConfig();
// limit strategies: read the shares config from env and inject into the strategy instance
for (const s of getAllStrategies()) {
const upper = s.key.toUpperCase();
const sharesEnv = process.env[`STRATEGY_${upper}_SHARES`];
if (sharesEnv != null && "shares" in s) {
const v = Number(sharesEnv);
if (Number.isFinite(v) && v >= 5) {
(s as any).shares = v;
console.log(`[Strategy] ${s.key} shares config set to ${v}`);
}
}
// tunable params: env STRATEGY_<KEY>_PARAM_<NAME> override -> write into strategyConfig.params
const defs = getStrategyTunableParams(s.key);
if (defs.length) {
if (strategyConfig.params[s.key] == null) strategyConfig.params[s.key] = {};
for (const def of defs) {
const envName = `STRATEGY_${upper}_PARAM_${def.key.toUpperCase()}`;
const envRaw = process.env[envName];
if (envRaw != null) {
const v = Number(envRaw);
if (Number.isFinite(v) && v >= def.min && (def.max == null || v <= def.max)) {
strategyConfig.params[s.key][def.key] = v;
console.log(`[Strategy] ${s.key}.${def.key} = ${v} (from ${envName})`);
}
}
}
}
// inject the persisted/env params into the strategy instance
applyTunableParamsToStrategy(s);
}
// -- Startup ----------------------------------------------------
// WebSocketServer is attached to the http server; when the server reports EADDRINUSE, wss also fires an error event.
// must register a silent handler on wss to prevent Node from treating it as an unhandled 'error' and crashing directly.
if (wss) wss.on("error", (err) => {
const e = err as NodeJS.ErrnoException;
if (e.code === "EADDRINUSE") return; // handled by listenWithFallback retry
console.warn(`[System.Frontend] error:`, e.message);
});
async function listenWithFallback(): Promise<void> {
for (let i = 0; i < PORT_MAX_TRIES; i++) {
const tryPort = PORT_BASE + i;
try {
await new Promise<void>((resolve, reject) => {
const onError = (err: NodeJS.ErrnoException) => {
server.off("listening", onListening);
reject(err);
};
const onListening = () => {
server.off("error", onError);
resolve();
};
server.once("error", onError);
server.once("listening", onListening);
server.listen(tryPort);
});
PORT = tryPort;
if (i > 0) console.log(`[System.Port] ${PORT_BASE} is occupied, auto-switched to ${PORT}`);
// write .port so start.sh knows the actual port (readable immediately after startup; a stale file from a dead PID is harmless)
try { writeFileSync(resolve(__dirname, ".port"), String(PORT)); } catch { /* ignore */ }
return;
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code !== "EADDRINUSE") throw err;
console.log(`[System.Port] ${tryPort} is occupied, trying the next one...`);
}
}
throw new Error(`[System.Port] ports ${PORT_BASE}-${PORT_BASE + PORT_MAX_TRIES - 1} are all occupied, startup failed`);
}
await listenWithFallback();
(async () => {
console.log(`\n ${activeMarket.displayName} 5m order book monitor service started v${APP_VERSION}`);
console.log(` by Penguin Sensei · 岳 | X: @x_188888_x`);
console.log(` run mode: ${APP_MODE}`);
console.log(` state API: http://localhost:${PORT}/api/state`);
if (IS_FULL_MODE) {
console.log(` open browser: http://localhost:${PORT}`);
console.log(` WS address: ws://localhost:${PORT}`);
}
console.log("");
await ensureClobClient();
void ensureAccountName();
startUserWs();
startBinanceWs();
startCoinbaseWs();
await syncPositionsFromApi();
await syncUsdcBalance();
// start the 15s polling loop (whether it actually queries/claims is decided by the autoClaimEnabled config)
scheduleClaimCycle();
setInterval(async () => { await syncPositionsFromApi(); broadcastState(); }, 2000);
setInterval(async () => { await syncUsdcBalance(); broadcastState(); }, 5000);
setInterval(() => { refreshBinanceOffset("scheduled", { allowLatestFallback: false }); }, BINANCE_ALIGN_REFRESH_MS);
setInterval(() => { refreshGenericOffset(COINBASE_SPEC, "scheduled", { allowLatestFallback: false }); }, BINANCE_ALIGN_REFRESH_MS);
// market + limit strategies share a tick (event-driven + 250ms fallback, so limit-strategy cancels also respond in a second)
setInterval(() => { runStrategyTick(); backtestTick(); }, STRATEGY_TICK_MS);
// limit cancel polling (every 1 second: strategy says cancel + order still there -> call cancel API)
setInterval(() => { void pollLimitOrderCancellations(); }, 1000);
// the old GTC take-profit fallback polling has been removed: now relies on UserWS event_type:order real-time push + 8s register fallback,
// on window switch all GTC are cancelled uniformly, residual UI state lags at most until the next window
// the Claim feature has moved to the Polymarket official site (Settings -> Auto Redeem), no longer auto-executed locally
// HTTP Keep-Alive heartbeat: ping CLOB /time every 20 seconds, to prevent the server from closing the idle connection
// measured: the server closes in 30-60 seconds, a 20-second interval has a 10+ second safety margin
// each channel pings once: undici (fetch) + axios (Node https globalAgent)
setInterval(async () => {
// undici channel
{
const t0 = Date.now();
try {
const r = await fetch(`${CLOB_URL}/time`);
await r.text();
httpHeartbeat.latencyMs = Date.now() - t0;
httpHeartbeat.lastAt = Date.now();
httpHeartbeat.ok = true;
// print only on anomaly (>1000ms considered suspicious)
if (httpHeartbeat.latencyMs > 1000) {
console.warn(`[Health.undici] ⚠ ${httpHeartbeat.latencyMs}ms (>1000ms)`);
}
} catch (err) {
httpHeartbeat.latencyMs = -1;
httpHeartbeat.lastAt = Date.now();
httpHeartbeat.ok = false;
console.error(`[Health.undici] ❌ heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
}
broadcastHttpHeartbeat();
}
// axios channel: use Node native https (defaults to globalAgent, shares the connection pool with axios)
{
const t0 = Date.now();
try {
await new Promise<void>((res, rej) => {
const req = httpsMod.request(`${CLOB_URL}/time`, { method: "GET" }, (r) => {
r.on("data", () => { /* drain */ });
r.on("end", () => res());
r.on("error", rej);
});
req.on("error", rej);
req.setTimeout(5000, () => { req.destroy(new Error("timeout")); });
req.end();
});
axiosHeartbeat.latencyMs = Date.now() - t0;
axiosHeartbeat.lastAt = Date.now();
axiosHeartbeat.ok = true;
if (axiosHeartbeat.latencyMs > 1000) {
console.warn(`[Health.axios] ⚠ ${axiosHeartbeat.latencyMs}ms (>1000ms)`);
}
} catch (err) {
axiosHeartbeat.latencyMs = -1;
axiosHeartbeat.lastAt = Date.now();
axiosHeartbeat.ok = false;
console.error(`[Health.axios] ❌ heartbeat failed: ${err instanceof Error ? err.message : String(err)}`);
}
broadcastAxiosHeartbeat();
}
}, 20000);
// Polymarket real PnL: full load at startup + full refresh every 5 minutes
const schedulePmPnlRefresh = () => {
pmPnlNextRefreshAt = Date.now() + PMPNL_REFRESH_INTERVAL_MS;
};
pmPnlManager.init().then(() => {
schedulePmPnlRefresh();
broadcastPmPnl();
}).catch((err) => {
console.warn(`[PnL] startup load failed: ${err instanceof Error ? err.message : String(err)}`);
schedulePmPnlRefresh();
});
setInterval(async () => {
await pmPnlManager.fetchAll();
schedulePmPnlRefresh();
broadcastPmPnl();
}, PMPNL_REFRESH_INTERVAL_MS);
// Telegram push: read the config, start the scheduled task per the config
startTgPushLoop();
const currentWindow = getCurrentWindowStart();
fetchRecentResults(currentWindow, true);
await subscribeWindow(currentWindow);
})();
function gracefulShutdown(signal: string): void {
console.log(`[System.Exit] received ${signal}, preparing to shut down (force exit after 3 seconds)`);
// fallback: SIGKILL if not exited within 3 seconds (unref so it does not block a normal exit)
setTimeout(() => {
console.warn("[System.Exit] not finished in 3 seconds, forcing SIGKILL");
process.kill(process.pid, "SIGKILL");
}, 3000).unref();
stopped = true;
if (switchTimer) clearTimeout(switchTimer);
if (reconnectTimer) clearTimeout(reconnectTimer);
if (claimCycleTimer) clearTimeout(claimCycleTimer);
if (marketWs) marketWs.close();
if (chainlinkWs) chainlinkWs.close();
if (userWs) (userWs as WebSocket).close();
if (binanceWs) binanceWs.close();
server.close();
process.exit(0);
}
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGHUP", () => gracefulShutdown("SIGHUP"));