commit cd988d9c3ab97721bbe15c69e0a30c0f7391605c Author: doge-8 Date: Sun May 31 13:49:36 2026 +0800 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..97f5d57 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# ================================================== +# Required: only these two; all other config is set in the frontend panel +# ================================================== + +# Polymarket private key (used to generate API credentials on first run; can be removed after generation) +POLYMARKET_PRIVATE_KEY=0xYourPrivateKey + +# Polymarket proxy wallet address - note: this is NOT the deposit address! +# How to get it: log in to polymarket.com → top-right avatar → Settings → Wallet → copy "Proxy Wallet" (also called Funder Address) +# This is the Gnosis Safe the platform auto-deploys for your EOA, mapping one-to-one with the private key; a wrong value triggers invalid signature +POLYMARKET_PROXY_ADDRESS=0xYourProxyWalletAddress + +# HTTP service starting port (auto +1 increment if taken, up to 10 times) +# When running multiple instances, change to 3556 / 3656 etc. +PORT=3456 + +# Verbose raw log switch (trade-raw-YYYY-MM-DD.log): off by default to save disk +# Set to true and restart when you need to debug raw data such as orders/signatures/polling +RAW_LOG_ENABLED=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..899aae1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Dependencies +node_modules/ + +# Sensitive config and credentials +.env +.polymarket-creds.json +.tg-config.json +monitor/.tg-config.json +monitor/.balance-history.json +monitor/accounts.json + +# Runtime data +.strategy-config.json +.strategy-sources.json +.manual-config.json +.backtest-state.json +.active-market.json +.trade-history.json +.port + +# Backtest data (collected at runtime, not committed) +backtest-data/*.jsonl +backtest-data/collector/**/*.jsonl + +# System files +.DS_Store +**/.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# AI assistant / dev-tool files (not for the public repo) +CLAUDE.md +docs/AGENTS.md +.mcp.json +.claude/ +.cursor/ + +# Logs +*.log +npm-debug.log* +release/*.log + +# Release packaging (build artifacts, not committed) +*.zip +release/*.zip diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..63537cc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Penguin Sensei · 岳 (@x_188888_x) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9744c51 --- /dev/null +++ b/README.md @@ -0,0 +1,286 @@ +# BTC 5m — Quick Order Tool & Automated Strategy Framework + +> A quick-order tool and automated strategy framework for Polymarket's **BTC 5-minute up/down** market. + +![License](https://img.shields.io/badge/license-MIT-blue.svg) +![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg) +![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178c6.svg) + +Run it fully locally for fast manual trading with helper overlays, or deploy it to a cloud server for low-latency, 24/7 automated trading. It connects to multiple upstream WebSockets (Polymarket order book / Chainlink price / Binance reference price) and shows the live order book, price trends, positions, and an order panel — with a pluggable strategy system for automation. + +**Multi-market by design:** the data layer supports **BTC / ETH / SOL** across **5m / 15m** windows (6 markets, switchable from the UI), and adding a new symbol is a one-line config change. The bundled example strategies target BTC 5m; other markets are ready for your own strategies (thresholds differ a lot per coin, so they shouldn't be blindly reused). + +![Main dashboard](docs/screenshots/dashboard.png) + +> _Main dashboard (full mode): live order book, strategy tooltip, probability & price curves, and the order panel._ + +--- + +## Table of Contents + +- [Why this tool](#why-this-tool) +- [Features](#features) +- [Quick Start](#quick-start) +- [Project Structure](#project-structure) +- [Strategies](#strategies) +- [API](#api) +- [Cloud Deployment](#cloud-deployment) +- [Security](#security) +- [Contributing](#contributing) +- [Support this project](#support-this-project) +- [License](#license) + +--- + +## Why this tool + +| | Polymarket Official | This Tool | +|---|---|---| +| **Order flow** | Wallet signature required every time, easy to miss the moment | Credentials auto-cached after the first signature, one-click order | +| **Price reference** | Chainlink oracle price only | Also shows Binance real-time price, faster reaction | +| **Data visualization** | Probability numbers only | Probability curve + Binance price curve + diff comparison | +| **Automated trading** | None | Modular strategy framework, fully automated execution of custom strategies | +| **Deployment** | Browser only | Local run / cloud server, 24/7 unattended | + +> ⚠️ **Disclaimer:** The built-in strategies are framework examples only and **cannot guarantee profits**. Develop and tune your own strategies based on your own analysis. Trading involves risk; you are solely responsible for any losses. + +![Multi-account monitor](docs/screenshots/monitor.png) + +> _Multi-account monitor: track balance, PnL, win rate, and live status across multiple instances at a glance._ + +--- + +## Features + +### ⚡ Quick Order +- After the first private-key signature, API credentials are auto-cached — no repeated signing +- FOK (fill-or-kill) market orders, avoiding resting-order risk +- One-click buy up / buy down from the panel, with slippage settings + +### 🌐 Multi-Market +- **BTC / ETH / SOL** × **5m / 15m** = 6 markets, switchable from the UI +- Data-driven config (`market-configs.ts`) — add a new symbol/period in one entry +- Per-coin price precision handled automatically (BTC integer, SOL 4 decimals, etc.) +- Built-in strategies target BTC 5m; other markets are ready for your own strategies + +### 📊 Real-Time Data +- Four parallel WebSockets: Polymarket order book, Chainlink oracle, user fills, Binance real-time price +- Binance price reacts faster than the Chainlink oracle, providing a leading signal +- Automatic price-offset calibration (MAD outlier filtering + trimmed mean) +- Automatic market-window switch (every 5m / 15m depending on the market) + +### 🧩 Strategy Framework +- Each strategy is a single file under `strategies/`, implementing a unified interface +- Add a strategy: drop in a file → restart, and the frontend shows it automatically +- Strategy parameters carry comments; frontend hover descriptions are generated automatically +- Supports both **market** and **limit (maker)** order strategies +- Multiple entries per round (count configurable, persisted) +- Trade records saved automatically, with entry/exit reasons and PnL details + +### 🖥 Frontend Panel (Dual Mode) +- **Full mode** — Complete dashboard: order-book depth, probability/price curves, manual order panel, strategy controls, trade records — for manual trading and monitoring +- **Low mode** — Lean panel for automation: core data (probability/diff/countdown) + strategy status + trade records, low bandwidth — for unattended running and mobile viewing +- Toggle strategy switches, amounts, and per-round counts in real time +- Auto-claim of expired positions + +### 🔬 Data Collection & Backtesting +- One-click backtest data collection from the frontend +- Records diff, probability, time remaining, and other key metrics every second +- Companion Python analysis script with parameter-sweep optimization (multi-core) + +--- + +## Quick Start + +### Requirements +- Node.js **20+** + +### Install +```bash +npm install +``` + +### Configure +```bash +cp .env.example .env +``` + +**Required:** +- `POLYMARKET_PRIVATE_KEY` — Polygon private key (auto-generates API credentials on first run) +- `POLYMARKET_PROXY_ADDRESS` — Polymarket proxy wallet address (**not the deposit address**; log in to polymarket.com → top-right avatar → Settings → Wallet → copy "Proxy Wallet", which maps one-to-one with the private key; a wrong value triggers `invalid signature`) + +**Optional:** +- `APP_MODE` — `full` (with panel) or `headless` (API only) +- `STRATEGY__ENABLED` / `STRATEGY__AMOUNT` — strategy switch and amount (KEY is the uppercase strategy name, e.g. `D1`, `P1`, `P2`) +- `ORDER_DEFAULT_SLIPPAGE` — default slippage +- `AUTO_CLAIM_ENABLED` — auto-claim expired positions + +### Run +```bash +# macOS / Linux +./start.sh + +# Windows +start.bat + +# or +npm start +``` + +Then open **http://localhost:3456** + +--- + +## Project Structure + +``` +├── server.ts # Backend service (Express + WebSocket, port 3456) +├── index.html # Frontend panel +├── strategies/ # Strategy modules (plugin-based: add/remove files, no registry edits) +│ ├── types.ts # Shared types and the IStrategy interface +│ ├── registry.ts # Strategy registry (driven by _runtime/loader) +│ ├── _runtime/ # Dynamic loader +│ ├── _core/ # Shared core logic (fair-prob / momentum factors) +│ ├── d1.ts # Diff-based +│ └── p1.ts, p2.ts # Prob-chase +├── backtest-data/ # Backtest data (generated at runtime) +├── .env.example # Environment variable template +├── start.sh # macOS/Linux launch script +└── start.bat # Windows launch script +``` + +--- + +## Strategies + +### Built-in + +| Key | Name | Logic Summary | +|-----|------|---------------| +| **D1** | Diff 1 · Tail Sweep | large-diff entry at the window tail, diff-cross-0 stop-loss, holds to settlement | +| **P1** | Prob-Chase 1 | fair-prob table lookup, entry when probability lags the diff, reverse ±5 stop-loss | +| **P2** | Prob-Chase 2 · End-Game Crossing | rem 90~30s crossing entry + bias/probability filter, reverse ±5 stop-loss | + +Each strategy file has full parameters and comments at the top; hover over the strategy name in the UI to see its detailed rules. + +### Add your own (plugin-based) + +Create a file under `strategies/` whose name matches `.ts` (e.g. `d3.ts`, `x1.ts`) and export a class implementing `IStrategy`. After restarting, the dynamic loader registers it automatically and the frontend generates the UI for it. + +```ts +// strategies/x1.ts example +import type { IStrategy, StrategyTickContext, EntrySignal, ExitSignal } from "./types.js"; + +export default class X1 implements IStrategy { + readonly key = "x1"; + readonly number = 1; + readonly name = "My Strategy"; + getDescription() { return { key: this.key, number: this.number, name: this.name, title: "X1", lines: [] }; } + updateGuards(_ctx: StrategyTickContext) {} + checkEntry(_ctx: StrategyTickContext): EntrySignal | null { return null; } + checkExit(_ctx: StrategyTickContext): ExitSignal { return null; } + resetState() {} + getStatePayload() { return {}; } +} +``` + +To remove a strategy, just delete its file — no registry changes needed. + +Prefixes: `d` diff · `p` prob-chase · `t` trend · `l` limit-order (maker) · `m` momentum (reserved). + +See **[strategies/STRATEGY-GUIDE.md](strategies/STRATEGY-GUIDE.md)** for the full development guide, including how to build limit-order (maker) strategies. + +--- + +## API + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/state` | Full state snapshot | +| `GET` | `/api/strategy/descriptions` | Strategy descriptions | +| `POST` | `/api/strategy/config` | Update strategy config | +| `POST` | `/api/order` | Manual order | +| `POST` | `/api/claim` | Claim expired positions | +| `POST` | `/api/backtest/toggle` | Toggle backtest data collection | + +--- + +## Cloud Deployment + +After uploading the project to your server, configure and start it as above. Keep it running in the background with `screen`: + +```bash +screen -S btc5m +npm start +# press Ctrl+A then D to detach +``` + +Access the panel securely via an SSH tunnel (do **not** expose the port publicly): + +```bash +ssh -L 3456:127.0.0.1:3456 username@server_ip +``` + +Then open **http://127.0.0.1:3456** in your local browser. + +--- + +## Security + +**Private key & credentials** +- The private key lives only in your local `.env` and is never uploaded anywhere +- API credentials are derived from it and cached to `.polymarket-creds.json` for reuse +- Both are excluded via `.gitignore` + +**Network access** +- The service listens on `localhost:3456`, accessible only from the local machine +- **Never expose the port directly to the public internet** — anyone who can reach it can place orders via the API +- Always use an SSH tunnel for remote access + +**Fund safety** +- Test with the smallest amount first; scale up only after confirming the behavior +- Strategy switches and amounts can be adjusted from the frontend at any time +- Built-in strategies are examples only and are not investment advice + +**Never share or commit these files:** + +| File | Contents | +|------|----------| +| `.env` | Private key and wallet address | +| `.polymarket-creds.json` | API credentials | +| `.strategy-config.json` | Persisted config | + +--- + +## Contributing + +The tool provides a complete strategy framework and backtesting capability, but good strategies need continuous iteration. Contributions and ideas are very welcome — especially if you: + +- Have better entry/exit ideas or have discovered new data patterns +- Want to do strategy backtesting and optimization together +- Want to **build more powerful features on top of this project** (forks encouraged!) +- Have any thoughts on the Polymarket BTC 5-minute market + +Open an issue / PR, or reach out directly — let's achieve a 1+1 > 2 effect. + +--- + +## Support this project + +This project is free and open source. If you find it useful, here are a few ways to support it — all cost you nothing and mean a lot: + +- ⭐ **Star this repo** — it helps more people discover the project and keeps me motivated to maintain it +- 🔗 **Sign up for Polymarket via my referral link** — directly supports continued development: + ### 👉 https://polymarket.com/?r=yue188888x +- 🛠 **Build on top of it** — fork it and create something more powerful; I'd love to see what you make +- 🤖 **Prefer copy-trading?** If you'd rather follow trades than run your own strategies, try **Kreo** — a copy-trading Telegram bot ([@kreoapp](https://x.com/kreoapp)). Tap to open it in Telegram: **https://t.me/KreoPolyBot?start=ref-188888x** +- 📈 **Trade crypto & RWAs?** Check out **Variational (Omni)** ([@variational_io](https://x.com/variational_io)) — a trading platform for crypto, RWAs, and more (recently raised $50M). Trade and earn points along the way: **https://omni.variational.io/?ref=OMNI88888** +- 🐦 **Get in touch** — questions, ideas, or collaboration on X (Twitter): **[@x_188888_x](https://x.com/x_188888_x)** + +Thank you for supporting open source! 🙏 + +--- + +## License + +MIT — for personal use and learning/research. The risk of using this tool for trading is borne solely by the user. diff --git a/backtest-data/README.md b/backtest-data/README.md new file mode 100644 index 0000000..7ba4b0a --- /dev/null +++ b/backtest-data/README.md @@ -0,0 +1,104 @@ +# 回测数据与分析方案 + +## 数据格式 + +文件命名: +- BTC:`YYYY-MM-DD.jsonl`(保留旧命名,向后兼容历史数据) +- 其他币种:`YYYY-MM-DD-{sym}.jsonl`(如 `2026-04-26-eth.jsonl`、`2026-04-26-sol.jsonl`) + +每行一条 JSON: + +```json +{"type":"tick","ts":1775195098289,"symbol":"btc","windowStart":1775194800,"diff":51.16,"upPct":100,"rem":2} +``` + +| 字段 | 含义 | +|---|---| +| ts | 时间戳(毫秒) | +| symbol | 币种(btc/eth/sol)。**老数据无此字段时默认 btc** | +| windowStart | 所属5分钟窗口的起始时间(秒) | +| diff | 参考价 - (priceToBeat - 偏移),正值=涨,负值=跌 | +| upPct | Polymarket 涨概率(0-100整数) | +| rem | 窗口剩余秒数 | + +采样频率:每秒1条,每天约86400条,约4MB。 + +## 分析目标 + +找到 diff 和 upPct 之间的历史映射关系,当实际概率偏离历史均值时入场。 + +## 分析步骤 + +### 1. 建立 diff → 合理概率 映射 + +将 diff 按区间分桶(如每5一档:-60~-55, -55~-50, ..., 55~60),统计每个桶内 upPct 的中位数,得到"在某个 diff 水平下,市场通常给出的概率"。 + +```python +import json, glob +import pandas as pd + +ticks = [] +for f in sorted(glob.glob('*.jsonl')): + for line in open(f): + row = json.loads(line) + if row.get('type') == 'tick': + ticks.append(row) + +df = pd.DataFrame(ticks) + +# 按 diff 分桶,rem 分段 +df['diff_bin'] = (df['diff'] / 5).round() * 5 +df['rem_bin'] = pd.cut(df['rem'], bins=[0, 30, 60, 120, 180, 300], labels=['0-30','30-60','60-120','120-180','180-300']) + +# 每个 (diff_bin, rem_bin) 的概率中位数 +mapping = df.groupby(['diff_bin', 'rem_bin'])['upPct'].agg(['median', 'mean', 'std', 'count']) +``` + +### 2. 计算偏差 + +```python +# 对每条 tick,查找对应的合理概率 +fair_prob = mapping.loc[(diff_bin, rem_bin), 'median'] +bias = fair_prob - actual_upPct + +# bias > 0:市场低估涨(买涨机会) +# bias < 0:市场高估涨(买跌机会) +``` + +### 3. 确定入场阈值 + +统计不同 bias 阈值下的入场次数和后续概率走势: + +```python +# 找出 bias > N 的时刻,看之后概率是否向合理概率回归 +for threshold in [5, 8, 10, 12, 15]: + entries = df[df['bias'] > threshold] + # 看入场后 10s/30s/60s 概率变化 + # 如果概率确实向 fair_prob 回归 → 该阈值可用 +``` + +### 4. 考虑波动率 + +用 diff 序列的标准差衡量波动率: + +```python +# 每个窗口内 diff 的标准差 +vol = df.groupby('windowStart')['diff'].std() + +# 高波动 vs 低波动时,同样 diff 对应的概率分布是否不同 +# 如果不同,映射关系需要加入波动率维度 +``` + +### 5. 输出 + +- diff → 合理概率 的映射表(按 rem 分段) +- 最优入场偏差阈值 +- 波动率是否需要作为额外维度 +- 模拟入场后的概率回归速度和幅度 + +## 注意事项 + +- 数据至少收集 2-3 天再分析(需要覆盖不同时段和市场状态) +- diff 极端值(>80 或 <-80)样本可能很少,映射不可靠 +- rem 对映射有影响:窗口早期(rem>180)概率波动大,晚期(rem<30)概率趋于收敛 +- 分析脚本在 backtest-data/ 目录下运行 diff --git a/backtest-data/analyze.py b/backtest-data/analyze.py new file mode 100644 index 0000000..09495f9 --- /dev/null +++ b/backtest-data/analyze.py @@ -0,0 +1,701 @@ +""" +回测分析脚本 +用法: cd backtest-data && python3 analyze.py + +功能: +1. 建立 diff → 合理概率 映射(按 rem 分段) +2. 计算当前概率与合理概率的偏差 +3. 找出最优入场偏差阈值 +4. 模拟策略1(常规加强)的历史表现 +""" + +import json +import glob +import sys +from collections import defaultdict + +# ── 1. 加载数据 ────────────────────────────────────────────── + +ticks = [] +for f in sorted(glob.glob("*.jsonl")) + sorted(glob.glob("ticks-*.jsonl")): + for line in open(f): + try: + r = json.loads(line) + if r.get("type") == "tick": + ticks.append(r) + elif "diff" in r and "upPct" in r and "type" not in r: + # 兼容旧格式(无 type 字段) + ticks.append(r) + except: + pass + +if not ticks: + print("没有找到 tick 数据") + sys.exit(1) + +windows = sorted(set(t["windowStart"] for t in ticks)) +print(f"总 tick 数: {len(ticks)}") +print(f"覆盖窗口数: {len(windows)}") +print(f"时间范围: {ticks[0]['ts']} ~ {ticks[-1]['ts']}") +print() + +# ── 2. 建立 diff → 合理概率 映射 ───────────────────────────── + +# 按 (diff 桶, rem 段) 分组 +DIFF_BUCKET = 5 # 每5一档 +REM_BINS = [(0, 30), (30, 60), (60, 120), (120, 180), (180, 300)] + +def diff_bucket(d): + return round(d / DIFF_BUCKET) * DIFF_BUCKET + +def rem_bin(r): + for lo, hi in REM_BINS: + if lo <= r < hi: + return f"{lo}-{hi}" + return "300+" + +mapping = defaultdict(list) # (diff_bucket, rem_bin) -> [upPct, ...] + +for t in ticks: + db = diff_bucket(t["diff"]) + rb = rem_bin(t["rem"]) + mapping[(db, rb)].append(t["upPct"]) + +print("=" * 70) +print("diff → 合理概率 映射(中位数,按 rem 分段)") +print("=" * 70) +print(f"{'diff':>6} ", end="") +for lo, hi in REM_BINS: + print(f" {lo}-{hi}s", end="") +print(f" {'样本':>6}") +print("-" * 70) + +all_buckets = sorted(set(db for db, _ in mapping)) +for db in all_buckets: + total = 0 + row = f"{db:+6d} " + for lo, hi in REM_BINS: + rb = f"{lo}-{hi}" + vals = mapping.get((db, rb), []) + total += len(vals) + if vals: + vals_sorted = sorted(vals) + median = vals_sorted[len(vals_sorted) // 2] + row += f" {median:5d}%" + else: + row += f" —" + row += f" {total:6d}" + if total >= 5: # 只显示有足够样本的 + print(row) + +# ── 3. 计算偏差分布 ────────────────────────────────────────── + +print() +print("=" * 70) +print("偏差分析:实际概率 vs 合理概率(中位数)") +print("=" * 70) + +biases = [] +for t in ticks: + db = diff_bucket(t["diff"]) + rb = rem_bin(t["rem"]) + vals = mapping.get((db, rb), []) + if len(vals) < 10: + continue + vals_sorted = sorted(vals) + fair_prob = vals_sorted[len(vals_sorted) // 2] + bias = fair_prob - t["upPct"] + biases.append({ + "bias": bias, + "diff": t["diff"], + "upPct": t["upPct"], + "fair": fair_prob, + "rem": t["rem"], + "windowStart": t["windowStart"], + "ts": t["ts"], + }) + +if biases: + abs_biases = [abs(b["bias"]) for b in biases] + print(f"有效样本数: {len(biases)}") + print(f"偏差均值: {sum(b['bias'] for b in biases) / len(biases):.1f}%") + print(f"偏差绝对值均值: {sum(abs_biases) / len(abs_biases):.1f}%") + print(f"偏差绝对值中位数: {sorted(abs_biases)[len(abs_biases)//2]:.1f}%") + print() + + # 偏差分布 + print("偏差分布:") + from collections import Counter + bias_bins = Counter() + for b in biases: + bb = round(b["bias"] / 2) * 2 + bias_bins[bb] += 1 + for bb in sorted(bias_bins): + pct = bias_bins[bb] / len(biases) * 100 + bar = "█" * int(pct) + print(f" {bb:+4d}%: {bias_bins[bb]:5d} ({pct:4.1f}%) {bar}") + +# ── 4. 入场机会分析 ────────────────────────────────────────── + +print() +print("=" * 70) +print("入场机会分析:偏差超过阈值时概率后续走势") +print("=" * 70) + +# 按窗口分组 +window_ticks = defaultdict(list) +for t in ticks: + window_ticks[t["windowStart"]].append(t) + +for threshold in [5, 8, 10, 12, 15]: + entries = [] + for b in biases: + if abs(b["bias"]) >= threshold and 50 <= b["rem"] <= 200: + entries.append(b) + + if not entries: + print(f"\n偏差阈值 {threshold}%: 无入场机会") + continue + + # 看入场后概率是否回归 + convergences = [] + for entry in entries: + ws = entry["windowStart"] + wticks = window_ticks.get(ws, []) + # 找入场后10秒、30秒的概率变化 + entry_ts = entry["ts"] + for dt_label, dt_ms in [("10s", 10000), ("30s", 30000)]: + future = [t for t in wticks if 0 < t["ts"] - entry_ts <= dt_ms] + if future: + future_prob = future[-1]["upPct"] + prob_change = future_prob - entry["upPct"] + # 如果偏差为正(市场低估涨),概率应该涨 + expected_dir = 1 if entry["bias"] > 0 else -1 + correct = (prob_change * expected_dir) > 0 + convergences.append({ + "dt": dt_label, + "change": prob_change, + "correct": correct, + "bias_dir": "低估" if entry["bias"] > 0 else "高估", + }) + + if convergences: + for dt_label in ["10s", "30s"]: + dt_items = [c for c in convergences if c["dt"] == dt_label] + if dt_items: + correct_count = sum(1 for c in dt_items if c["correct"]) + avg_change = sum(abs(c["change"]) for c in dt_items) / len(dt_items) + print(f"\n偏差阈值 {threshold}%: {len(entries)} 次入场机会") + print(f" {dt_label}后回归率: {correct_count}/{len(dt_items)} ({correct_count/len(dt_items)*100:.0f}%)") + print(f" {dt_label}后平均概率变化: {avg_change:.1f}%") + +# ── 5. 策略1模拟回测 ───────────────────────────────────────── + +print() +print("=" * 70) +print("策略1(常规加强)模拟回测") +print("=" * 70) + +ENTRY_DIFF = 35 +ENTRY_PROB_CAP = 80 +WINDOW_MAX_REM = 210 +WINDOW_MIN_REM = 50 +TRAILING_STOP_RETRACEMENT = 20 +TRAILING_STOP_MIN_DIFF = 5 +PROB_PEAK_MIN = 85 +PROB_PEAK_RETRACEMENT = 8 +FORCE_EXIT_REM = 10 + +trades = [] +for ws in windows: + wticks = sorted(window_ticks[ws], key=lambda t: t["ts"]) + if len(wticks) < 10: + continue + + last_diff = None + holding = False + direction = None + entry_price = None + peak_diff = None + peak_prob = None + + for t in wticks: + diff = t["diff"] + upPct = t["upPct"] + rem = t["rem"] + + if not holding: + # 检查入场 + if rem <= WINDOW_MAX_REM and rem > WINDOW_MIN_REM and last_diff is not None: + # 买涨穿越 + if last_diff <= ENTRY_DIFF and diff > ENTRY_DIFF and upPct < ENTRY_PROB_CAP: + holding = True + direction = "up" + entry_price = upPct / 100 # 简化:用概率作为买入价 + peak_diff = diff + peak_prob = upPct + # 买跌穿越 + elif last_diff >= -ENTRY_DIFF and diff < -ENTRY_DIFF and (100 - upPct) < ENTRY_PROB_CAP: + holding = True + direction = "down" + entry_price = (100 - upPct) / 100 + peak_diff = -diff + peak_prob = 100 - upPct + else: + my_pct = upPct if direction == "up" else (100 - upPct) + fav_diff = diff if direction == "up" else -diff + + # 更新峰值 + if fav_diff > peak_diff: + peak_diff = fav_diff + if my_pct > peak_prob: + peak_prob = my_pct + + exit_reason = None + exit_signal = None + + # 强制平仓 + if rem <= FORCE_EXIT_REM and rem > 0: + exit_signal = "tp" if my_pct >= 70 else "sl" + exit_reason = f"强制平仓 rem={rem}" + + # 阶梯止盈 + if not exit_reason and rem >= FORCE_EXIT_REM: + span = WINDOW_MAX_REM - FORCE_EXIT_REM + elapsed = max(0, WINDOW_MAX_REM - rem) + tp_thr = 90 + int(elapsed / span * 10) + tp_capped = min(tp_thr, 100) + if my_pct >= tp_capped: + exit_signal = "tp" + exit_reason = f"阶梯止盈 {my_pct}%>={tp_capped}%" + + # 回撤止盈 + if not exit_reason and peak_prob >= PROB_PEAK_MIN and my_pct <= peak_prob - PROB_PEAK_RETRACEMENT: + exit_signal = "tp" + exit_reason = f"回撤止盈 {my_pct}% 峰{peak_prob}%" + + # 兜底止损 + if not exit_reason: + if direction == "up" and diff <= TRAILING_STOP_MIN_DIFF: + exit_signal = "sl" + exit_reason = f"兜底止损 diff={diff:.0f}" + elif direction == "down" and diff >= -TRAILING_STOP_MIN_DIFF: + exit_signal = "sl" + exit_reason = f"兜底止损 diff={diff:.0f}" + + # 追踪止损 + if not exit_reason and peak_diff - fav_diff >= TRAILING_STOP_RETRACEMENT: + exit_signal = "sl" + exit_reason = f"追踪止损 回撤{peak_diff - fav_diff:.0f}" + + if exit_reason: + exit_price = my_pct / 100 + pnl = exit_price - entry_price + trades.append({ + "window": ws, + "direction": direction, + "entry_price": entry_price, + "exit_price": exit_price, + "pnl": pnl, + "signal": exit_signal, + "reason": exit_reason, + }) + holding = False + direction = None + + last_diff = diff + +if trades: + wins = [t for t in trades if t["pnl"] > 0] + losses = [t for t in trades if t["pnl"] <= 0] + total_pnl = sum(t["pnl"] for t in trades) + + print(f"总交易次数: {len(trades)}") + print(f"盈利次数: {len(wins)} ({len(wins)/len(trades)*100:.0f}%)") + print(f"亏损次数: {len(losses)} ({len(losses)/len(trades)*100:.0f}%)") + print(f"总 PnL: {total_pnl:+.4f}") + if wins: + print(f"平均盈利: +{sum(t['pnl'] for t in wins)/len(wins):.4f}") + if losses: + print(f"平均亏损: {sum(t['pnl'] for t in losses)/len(losses):.4f}") + print() + + # 按出场原因统计 + reason_stats = defaultdict(lambda: {"count": 0, "pnl": 0}) + for t in trades: + key = t["reason"].split(" ")[0] + " " + t["reason"].split(" ")[1] if len(t["reason"].split(" ")) > 1 else t["reason"] + # 简化为类型 + if "阶梯" in t["reason"]: + key = "阶梯止盈" + elif "回撤止盈" in t["reason"]: + key = "回撤止盈" + elif "兜底" in t["reason"]: + key = "兜底止损" + elif "追踪" in t["reason"]: + key = "追踪止损" + elif "强制" in t["reason"]: + key = "强制平仓" + reason_stats[key]["count"] += 1 + reason_stats[key]["pnl"] += t["pnl"] + + print("按出场原因统计:") + for key in sorted(reason_stats, key=lambda k: -reason_stats[k]["count"]): + s = reason_stats[key] + print(f" {key}: {s['count']}次, PnL {s['pnl']:+.4f}") + + print() + print("逐笔明细:") + for t in trades: + dir_zh = "涨" if t["direction"] == "up" else "跌" + print(f" 窗口{t['window']} 买{dir_zh} 入{t['entry_price']:.2f}→出{t['exit_price']:.2f} PnL{t['pnl']:+.4f} {t['reason']}") +else: + print("无交易触发") + +# ── 6. 策略2(常规)模拟回测 ────────────────────────────────── + +print() +print("=" * 70) +print("策略2(常规)模拟回测") +print("=" * 70) + +S2_ENTRY_DIFF = 40 +S2_ENTRY_PROB_CAP = 75 +S2_WINDOW_MAX_REM = 168 +S2_WINDOW_MIN_REM = 48 +S2_STOP_LOSS_DIFF = 5 +S2_TP_LADDER_FLOOR = 8 + +trades2 = [] +for ws in windows: + wticks = sorted(window_ticks[ws], key=lambda t: t["ts"]) + if len(wticks) < 10: + continue + + last_diff = None + holding = False + direction = None + entry_price = None + + for t in wticks: + diff = t["diff"] + upPct = t["upPct"] + rem = t["rem"] + + if not holding: + if rem <= S2_WINDOW_MAX_REM and rem > S2_WINDOW_MIN_REM and last_diff is not None: + if last_diff <= S2_ENTRY_DIFF and diff > S2_ENTRY_DIFF and upPct < S2_ENTRY_PROB_CAP: + holding = True + direction = "up" + entry_price = upPct / 100 + elif last_diff >= -S2_ENTRY_DIFF and diff < -S2_ENTRY_DIFF and (100 - upPct) < S2_ENTRY_PROB_CAP: + holding = True + direction = "down" + entry_price = (100 - upPct) / 100 + else: + my_pct = upPct if direction == "up" else (100 - upPct) + exit_reason = None + exit_signal = None + + # 阶梯止盈(固定公式:168→8,每16s升1%) + if rem >= S2_TP_LADDER_FLOOR: + span = S2_WINDOW_MAX_REM - S2_TP_LADDER_FLOOR + elapsed = max(0, S2_WINDOW_MAX_REM - max(rem, S2_TP_LADDER_FLOOR)) + tp_thr = 90 + int(elapsed / span * 10) + tp_capped = min(tp_thr, 100) + if my_pct >= tp_capped: + exit_signal = "tp" + exit_reason = f"阶梯止盈 {my_pct}%>={tp_capped}%" + + # 止损 + if not exit_reason: + if direction == "up" and diff <= S2_STOP_LOSS_DIFF: + exit_signal = "sl" + exit_reason = f"止损 diff={diff:.0f}" + elif direction == "down" and diff >= -S2_STOP_LOSS_DIFF: + exit_signal = "sl" + exit_reason = f"止损 diff={diff:.0f}" + + if exit_reason: + exit_price = my_pct / 100 + pnl = exit_price - entry_price + trades2.append({ + "window": ws, + "direction": direction, + "entry_price": entry_price, + "exit_price": exit_price, + "pnl": pnl, + "signal": exit_signal, + "reason": exit_reason, + }) + holding = False + direction = None + + last_diff = diff + +if trades2: + wins2 = [t for t in trades2 if t["pnl"] > 0] + losses2 = [t for t in trades2 if t["pnl"] <= 0] + total_pnl2 = sum(t["pnl"] for t in trades2) + + print(f"总交易次数: {len(trades2)}") + print(f"盈利次数: {len(wins2)} ({len(wins2)/len(trades2)*100:.0f}%)") + print(f"亏损次数: {len(losses2)} ({len(losses2)/len(trades2)*100:.0f}%)") + print(f"总 PnL: {total_pnl2:+.4f}") + if wins2: + print(f"平均盈利: +{sum(t['pnl'] for t in wins2)/len(wins2):.4f}") + if losses2: + print(f"平均亏损: {sum(t['pnl'] for t in losses2)/len(losses2):.4f}") + print() + + reason_stats2 = defaultdict(lambda: {"count": 0, "pnl": 0}) + for t in trades2: + if "阶梯" in t["reason"]: + key = "阶梯止盈" + elif "止损" in t["reason"]: + key = "止损" + else: + key = t["reason"] + reason_stats2[key]["count"] += 1 + reason_stats2[key]["pnl"] += t["pnl"] + + print("按出场原因统计:") + for key in sorted(reason_stats2, key=lambda k: -reason_stats2[k]["count"]): + s = reason_stats2[key] + print(f" {key}: {s['count']}次, PnL {s['pnl']:+.4f}") + + print() + print("逐笔明细:") + for t in trades2: + dir_zh = "涨" if t["direction"] == "up" else "跌" + print(f" 窗口{t['window']} 买{dir_zh} 入{t['entry_price']:.2f}→出{t['exit_price']:.2f} PnL{t['pnl']:+.4f} {t['reason']}") +else: + print("无交易触发") + +# ── 7. 对比总结 ────────────────────────────────────────────── + +print() +print("=" * 70) +print("策略对比") +print("=" * 70) +s1_pnl = sum(t["pnl"] for t in trades) if trades else 0 +s2_pnl = sum(t["pnl"] for t in trades2) if trades2 else 0 +s1_wins = sum(1 for t in trades if t["pnl"] > 0) if trades else 0 +s2_wins = sum(1 for t in trades2 if t["pnl"] > 0) if trades2 else 0 +print(f"{'':15} {'常规加强':>10} {'常规':>10}") +print(f"{'交易次数':15} {len(trades):>10} {len(trades2):>10}") +print(f"{'胜率':15} {(s1_wins/len(trades)*100 if trades else 0):>9.0f}% {(s2_wins/len(trades2)*100 if trades2 else 0):>9.0f}%") +print(f"{'总PnL':15} {s1_pnl:>+10.4f} {s2_pnl:>+10.4f}") + +# ── 8. 波动率分析 ───────────────────────────────────────────── + +print() +print("=" * 70) +print("波动率分析(每个窗口内 diff 标准差)") +print("=" * 70) + +import math + +vol_data = [] +for ws in windows: + wticks = window_ticks[ws] + if len(wticks) < 10: + continue + diffs = [t["diff"] for t in wticks] + mean = sum(diffs) / len(diffs) + variance = sum((d - mean) ** 2 for d in diffs) / len(diffs) + std = math.sqrt(variance) + vol_data.append({"window": ws, "std": std, "count": len(wticks)}) + +if vol_data: + stds = [v["std"] for v in vol_data] + print(f"窗口数: {len(vol_data)}") + print(f"diff 标准差 范围: {min(stds):.1f} ~ {max(stds):.1f}") + print(f"diff 标准差 均值: {sum(stds)/len(stds):.1f}") + print(f"diff 标准差 中位数: {sorted(stds)[len(stds)//2]:.1f}") + + # 高波动 vs 低波动时的概率偏差 + median_std = sorted(stds)[len(stds) // 2] + high_vol_windows = set(v["window"] for v in vol_data if v["std"] > median_std) + low_vol_windows = set(v["window"] for v in vol_data if v["std"] <= median_std) + + if biases: + high_biases = [b for b in biases if b["windowStart"] in high_vol_windows] + low_biases = [b for b in biases if b["windowStart"] in low_vol_windows] + if high_biases and low_biases: + print(f"\n高波动窗口 偏差绝对值均值: {sum(abs(b['bias']) for b in high_biases)/len(high_biases):.1f}%") + print(f"低波动窗口 偏差绝对值均值: {sum(abs(b['bias']) for b in low_biases)/len(low_biases):.1f}%") + +# ── 9. 参数优化(常规加强) ─────────────────────────────────── + +print() +print("=" * 70) +print("参数优化:常规加强策略 — 遍历参数组合找最优") +print("=" * 70) + +def run_backtest(params): + """用给定参数跑一遍回测,返回交易列表""" + ed = params["entry_diff"] + epc = params["entry_prob_cap"] + wmx = params["window_max_rem"] + wmn = params["window_min_rem"] + tsr = params["trailing_stop_ret"] + tsm = params["trailing_stop_min"] + ppm = params["prob_peak_min"] + ppr = params["prob_peak_ret"] + fer = params["force_exit_rem"] + tps = params["tp_start"] + tpn = 100 - tps # 从起始值到100%的百分点数,每1%一档 + + result = [] + for ws in windows: + wticks = sorted(window_ticks[ws], key=lambda t: t["ts"]) + if len(wticks) < 10: + continue + + last_diff = None + holding = False + direction = None + entry_price = None + peak_diff = None + peak_prob = None + + for t in wticks: + diff = t["diff"] + upPct = t["upPct"] + rem = t["rem"] + + if not holding: + if rem <= wmx and rem > wmn and last_diff is not None: + if last_diff <= ed and diff > ed and upPct < epc: + holding = True + direction = "up" + entry_price = upPct / 100 + peak_diff = diff + peak_prob = upPct + elif last_diff >= -ed and diff < -ed and (100 - upPct) < epc: + holding = True + direction = "down" + entry_price = (100 - upPct) / 100 + peak_diff = -diff + peak_prob = 100 - upPct + else: + my_pct = upPct if direction == "up" else (100 - upPct) + fav_diff = diff if direction == "up" else -diff + + if fav_diff > peak_diff: peak_diff = fav_diff + if my_pct > peak_prob: peak_prob = my_pct + + exit_reason = None + + # 强制平仓 + if rem <= fer and rem > 0: + exit_reason = "强制平仓" + + # 阶梯止盈 + if not exit_reason and rem >= fer: + span = wmx - fer + elapsed = max(0, wmx - rem) + tp_capped = min(tps + int(elapsed / span * tpn), 100) + if my_pct >= tp_capped: + exit_reason = "阶梯止盈" + + # 回撤止盈 + if not exit_reason and peak_prob >= ppm and my_pct <= peak_prob - ppr: + exit_reason = "回撤止盈" + + # 兜底止损 + if not exit_reason: + if direction == "up" and diff <= tsm: + exit_reason = "兜底止损" + elif direction == "down" and diff >= -tsm: + exit_reason = "兜底止损" + + # 追踪止损 + if not exit_reason and peak_diff - fav_diff >= tsr: + exit_reason = "追踪止损" + + if exit_reason: + exit_price = my_pct / 100 + pnl = exit_price - entry_price + result.append(pnl) + holding = False + direction = None + + last_diff = diff + return result + +# 参数搜索空间 +param_grid = { + "entry_diff": [25, 30, 35, 40, 45], + "entry_prob_cap": [75, 80, 85], + "window_max_rem": [190, 210, 240], + "window_min_rem": [30, 50], + "trailing_stop_ret":[15, 20, 25], + "trailing_stop_min":[5], # 上轮不敏感,固定 + "prob_peak_min": [80, 85], + "prob_peak_ret": [5, 8, 10], + "force_exit_rem": [8, 10], + "tp_start": [88, 90, 92, 95], +} + +# 生成所有组合 +from itertools import product +import multiprocessing as mp +mp.set_start_method("fork", force=True) +from multiprocessing import Pool, cpu_count + +keys = list(param_grid.keys()) +combos = list(product(*[param_grid[k] for k in keys])) +print(f"总参数组合数: {len(combos)}, 使用 {cpu_count()} 核并行计算") +print("计算中...") + +def eval_combo(combo): + params = dict(zip(keys, combo)) + pnls = run_backtest(params) + if not pnls: + return None + total = sum(pnls) + wins = sum(1 for p in pnls if p > 0) + return { + "params": params, + "total_pnl": total, + "trades": len(pnls), + "win_rate": wins / len(pnls) * 100, + } + +with Pool(cpu_count()) as pool: + raw_results = pool.map(eval_combo, combos) + +top_results = [r for r in raw_results if r is not None] + +# 按 PnL 排序,显示 Top 15 +top_results.sort(key=lambda x: -x["total_pnl"]) + +best = top_results[0] if top_results else None + +print(f"\n{'排名':>4} {'交易':>4} {'胜率':>6} {'总PnL':>8} 参数") +print("-" * 100) +for i, r in enumerate(top_results[:15]): + p = r["params"] + param_str = f"diff={p['entry_diff']} cap={p['entry_prob_cap']} win={p['window_max_rem']}-{p['window_min_rem']} ts={p['trailing_stop_ret']}/{p['trailing_stop_min']} pp={p['prob_peak_min']}/{p['prob_peak_ret']} fer={p['force_exit_rem']} tp={p['tp_start']}→100%" + marker = " ★" if i == 0 else "" + print(f"{i+1:>4} {r['trades']:>4} {r['win_rate']:>5.0f}% {r['total_pnl']:>+8.4f} {param_str}{marker}") + +if best: + print(f"\n最优参数:") + for k, v in best["params"].items(): + label = { + "entry_diff": "ENTRY_DIFF(入场差价阈值)", + "entry_prob_cap": "ENTRY_PROB_CAP(入场概率上限)", + "window_max_rem": "WINDOW_MAX_REMAINING(扫描起始)", + "window_min_rem": "WINDOW_MIN_REMAINING(扫描截止)", + "trailing_stop_ret": "TRAILING_STOP_RETRACEMENT(追踪止损回撤)", + "trailing_stop_min": "TRAILING_STOP_MIN_DIFF(兜底止损)", + "prob_peak_min": "PROB_PEAK_MIN_THRESHOLD(回撤止盈门槛)", + "prob_peak_ret": "PROB_PEAK_RETRACEMENT(回撤止盈幅度)", + "force_exit_rem": "FORCE_EXIT_REM(强制平仓秒数)", + "tp_start": "阶梯止盈起始概率(每1%一档升至100%)", + }.get(k, k) + print(f" {label}: {v}") + print(f" 总PnL: {best['total_pnl']:+.4f}, 交易{best['trades']}笔, 胜率{best['win_rate']:.0f}%") + print(f" 总PnL: {best_pnl:+.4f}, 交易{len(best_trades)}笔, 胜率{sum(1 for p in best_trades if p>0)/len(best_trades)*100:.0f}%") diff --git a/backtest-data/collector/.gitkeep b/backtest-data/collector/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/chain-watcher.ts b/chain-watcher.ts new file mode 100644 index 0000000..54190ab --- /dev/null +++ b/chain-watcher.ts @@ -0,0 +1,105 @@ +/** + * On-chain fill size calibration + * + * Parses the CTF TransferSingle events in a Polygon transaction receipt + * to obtain the real buy/sell fill size (on-chain truth). + * + * Purpose: correct the size deviation pushed by Polymarket UserWS (on buy, the + * size reported by WS differs from the real on-chain size by about 1%). + * + * Measured latency: 300-700ms (dRPC), about 5 seconds faster than REST API polling. + */ + +import { ethers } from "ethers"; + +// ERC-1155 TransferSingle event topic +const TRANSFER_SINGLE = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"; + +// Public Polygon RPCs (sorted by measured latency) +const RPCS = [ + "https://polygon.drpc.org", // fastest ~200ms + "https://polygon-bor-rpc.publicnode.com", // fallback 1 + "https://1rpc.io/matic", // fallback 2 +]; + +const QUERY_TIMEOUT_MS = 3000; +// Use a full Network object + staticNetwork object version to avoid ethers v6 still triggering eth_chainId detection during construction +const POLYGON_NETWORK = new ethers.Network("polygon", 137); + +// Reuse provider instances to avoid triggering network detection on each creation +const providerCache = new Map(); + +function getProvider(url: string): ethers.JsonRpcProvider { + let p = providerCache.get(url); + if (!p) { + // Pass the Network object to staticNetwork so ethers skips the eth_chainId detection at startup + p = new ethers.JsonRpcProvider(url, POLYGON_NETWORK, { staticNetwork: POLYGON_NETWORK }); + // Silence the error event (ethers v6 throws by default; we handle it ourselves with catch) + p.on("error", () => { /* ignore, handled by the caller */ }); + providerCache.set(url, p); + } + return p; +} + +/** + * Query the tx receipt from one RPC (with timeout) + */ +async function queryReceipt(url: string, txHash: string): Promise { + try { + const rpc = getProvider(url); + return await Promise.race([ + rpc.getTransactionReceipt(txHash), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`timeout ${QUERY_TIMEOUT_MS}ms`)), QUERY_TIMEOUT_MS), + ), + ]); + } catch { + return null; + } +} + +/** + * Parse the CTF TransferSingle events in a tx that involve the Proxy + * @returns net fill size (positive = buy/inflow, negative = sell/outflow); + * null means all RPCs failed to query, should fall back to REST + */ +export async function getRealFillFromTx( + txHash: string, + proxy: string, +): Promise { + if (!txHash || !proxy) return null; + + const proxyPadded = ethers.zeroPadValue(proxy.toLowerCase(), 32); + + for (const url of RPCS) { + const receipt = await queryReceipt(url, txHash); + if (!receipt) continue; + + // tx failed (in theory a UserWS MINED won't fail, but handle defensively) + if (receipt.status !== 1) return 0; + + let totalIn = 0n; + let totalOut = 0n; + + for (const log of receipt.logs) { + if (log.topics[0] !== TRANSFER_SINGLE) continue; + // TransferSingle(operator, from, to, id, value) + // topics[0] = event sig + // topics[1] = operator + // topics[2] = from + // topics[3] = to + // data = id (32 bytes) + value (32 bytes) + if (log.topics.length < 4) continue; + const value = BigInt("0x" + log.data.slice(66)); + const isIn = log.topics[3].toLowerCase() === proxyPadded.toLowerCase(); + const isOut = log.topics[2].toLowerCase() === proxyPadded.toLowerCase(); + if (isIn) totalIn += value; + else if (isOut) totalOut += value; + } + + // CTF size has 6 decimals (same as USDC) + return Number(totalIn - totalOut) / 1e6; + } + + return null; // all RPCs failed +} diff --git a/collect.sh b/collect.sh new file mode 100755 index 0000000..ac63ce0 --- /dev/null +++ b/collect.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Data collector launch script (with auto-restart) +# +# Usage: +# ./collect.sh # default 60-day retention +# ./collect.sh 90 # 90-day retention +# +# Behavior: +# - Launches data-collector.ts to continuously collect data from 6 markets +# - Auto-restarts 5 seconds after a process crash +# - Ctrl+C exits gracefully (no further restart) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Retention days (optional argument) +RETENTION_DAYS="${1:-60}" +export COLLECTOR_RETENTION_DAYS="$RETENTION_DAYS" + +# Ensure node_modules exists +if [ ! -d "node_modules" ]; then + echo "[Collect] node_modules not found, running npm install..." + npm install +fi + +# Ensure the data directory exists +mkdir -p backtest-data/collector + +echo "════════════════════════════════════════════" +echo " Polymarket multi-market data collector" +echo " Retention days: $RETENTION_DAYS" +echo " Data directory: backtest-data/collector/" +echo " Ctrl+C to exit" +echo "════════════════════════════════════════════" +echo "" + +# Catch Ctrl+C for a clean exit +SHOULD_RESTART=true +trap 'SHOULD_RESTART=false; echo ""; echo "[Collect] Exit signal received, stopping..."' INT TERM + +# Main loop: auto-restart +while $SHOULD_RESTART; do + npm run collect || true + if $SHOULD_RESTART; then + echo "" + echo "[Collect] Process exited ($(date '+%Y-%m-%d %H:%M:%S')), auto-restarting in 5 seconds..." + sleep 5 + fi +done + +echo "[Collect] Stopped" diff --git a/data-collector.ts b/data-collector.ts new file mode 100644 index 0000000..d4ee97c --- /dev/null +++ b/data-collector.ts @@ -0,0 +1,739 @@ +/** + * Data collector — standalone process dedicated to collecting multi-market backtest data + * + * Completely independent from server.ts: + * - Does not place orders, does not need a private key + * - Does not need a frontend, does not need an HTTP API + * - Only collects tick data for 6 markets (BTC/ETH/SOL × 5m/15m) + * + * Data output: + * backtest-data/collector/YYYY-MM-DD-{sym}-{period}.jsonl + * one row per second per market, in exactly the same format as server.ts backtestTick + * + * Start: + * npm run collect + * or ./collect.sh + * + * Error handling: aggressive reconnect, error isolation, never exits on its own + */ + +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, readdirSync, unlinkSync } from "fs"; +import { WebSocket } from "ws"; +import { Agent, setGlobalDispatcher } from "undici"; +import { + MARKETS, priceDecimals, ALL_SYMBOLS, + type MarketKey, type MarketSymbol, type MarketConfig, +} from "./market-configs.js"; + +// ── HTTP keep-alive ── +setGlobalDispatcher(new Agent({ + keepAliveTimeout: 60000, + keepAliveMaxTimeout: 600000, + connections: 10, + pipelining: 1, +})); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = resolve(__dirname, "backtest-data", "collector"); +mkdirSync(DATA_DIR, { recursive: true }); + +// ── Constants (consistent with server.ts) ── +const MARKET_WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market"; +const CHAINLINK_WS_URL = "wss://ws-live-data.polymarket.com"; +const GAMMA_URL = "https://gamma-api.polymarket.com"; + +const HISTORY_RETENTION_MS = 130000; +const MAX_CHAINLINK_HISTORY_POINTS = 2000; +const MAX_BINANCE_HISTORY_POINTS = 4000; + +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 PTB_RETRY_INTERVAL_MS = 2000; +const PTB_MAX_RETRIES = 5; + +const HEALTH_CHECK_INTERVAL_MS = 60000; +const STALE_DATA_THRESHOLD_MS = 60000; + +const TICK_WRITE_INTERVAL_MS = 1000; + +const RETENTION_DAYS = parseInt(process.env.COLLECTOR_RETENTION_DAYS || "60"); + +// ── Data types ── +interface PricePoint { t: number; price: number; } + +interface SymbolState { + symbol: MarketSymbol; + binanceWs: WebSocket | null; + binanceWsAttempt: number; + binanceLastTickAt: number; + chainlinkWs: WebSocket | null; + chainlinkWsAttempt: number; + chainlinkLastTickAt: number; + binanceHistory: PricePoint[]; + priceHistory: PricePoint[]; // chainlink + currentPrice: number | null; + binanceOffset: number | null; +} + +interface MarketState { + key: MarketKey; + config: MarketConfig; + upTokenId: string; + downTokenId: string; + windowStart: number; // currently subscribed window + windowEnd: number; + conditionId: string; + priceToBeat: number | null; + bids: Map; // price → size + asks: Map; + bestBid: string; // up token best bid + bestAsk: string; // up token best ask + marketWs: WebSocket | null; + marketWsAttempt: number; + marketLastTickAt: number; // time of the last marketWs data received + marketPingTimer: NodeJS.Timeout | null; + switchTimer: NodeJS.Timeout | null; + ptbRetryCount: number; +} + +// ── Global state ── +const symbolStates = new Map(); +const marketStates = new Map(); +let stopped = false; +let lastCleanupDate = ""; + +// ── Utility functions ── +function backoffDelay(attempt: number): number { + return Math.min(60000, 1000 * Math.pow(2, attempt)); +} + +function getCurrentWindowStart(periodSeconds: number, now = Date.now()): number { + return Math.floor(now / 1000 / periodSeconds) * periodSeconds; +} + +function trimHistory(arr: T[], minTs: number, maxLen: number): void { + while (arr.length > 0 && arr[0].t < minTs) arr.shift(); + while (arr.length > maxLen) arr.shift(); +} + +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; + if (values.length < 4) { + return values.reduce((s, v) => s + v, 0) / values.length; + } + const sorted = values.slice().sort((a, b) => a - b); + const trim = Math.floor(sorted.length * trimRatio); + const middle = sorted.slice(trim, sorted.length - trim); + return middle.reduce((s, v) => s + v, 0) / middle.length; +} + +function getCstDateStr(now = Date.now()): string { + const d = new Date(now + 8 * 3600 * 1000); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`; +} + +// ── Binance offset calculation (exactly consistent with server.ts) ── +function calculateBinanceOffset(sym: MarketSymbol, allowLatestFallback = false): number | null { + const ss = symbolStates.get(sym)!; + if (!ss.binanceHistory.length || !ss.priceHistory.length) { + if (!allowLatestFallback) return null; + const last = ss.binanceHistory[ss.binanceHistory.length - 1]; + if (!last || ss.currentPrice == null) return null; + return ss.currentPrice - last.price; + } + const now = Date.now(); + const binanceRecent = ss.binanceHistory.filter((p) => p.t >= now - BINANCE_ALIGN_WINDOW_MS); + const chainlinkRecent = ss.priceHistory.filter((p) => p.t >= now - BINANCE_ALIGN_WINDOW_MS); + if (!binanceRecent.length || !chainlinkRecent.length) { + if (!allowLatestFallback) return null; + const lastB = ss.binanceHistory[ss.binanceHistory.length - 1]; + if (!lastB || ss.currentPrice == null) return null; + return ss.currentPrice - lastB.price; + } + + const bSpan = binanceRecent.length >= 2 ? binanceRecent[binanceRecent.length - 1].t - binanceRecent[0].t : 0; + const cSpan = chainlinkRecent.length >= 2 ? chainlinkRecent[chainlinkRecent.length - 1].t - chainlinkRecent[0].t : 0; + if (Math.min(bSpan, cSpan) < 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 bIdx = 0, cIdx = 0; + for (let bucketStart = overlapStart; bucketStart <= overlapEnd; bucketStart += BINANCE_ALIGN_BUCKET_MS) { + const bucketEnd = bucketStart + BINANCE_ALIGN_BUCKET_MS; + const bBucket: number[] = [], cBucket: number[] = []; + while (bIdx < binanceRecent.length && binanceRecent[bIdx].t < bucketStart) bIdx++; + while (cIdx < chainlinkRecent.length && chainlinkRecent[cIdx].t < bucketStart) cIdx++; + let i = bIdx; + while (i < binanceRecent.length && binanceRecent[i].t < bucketEnd) { bBucket.push(binanceRecent[i].price); i++; } + let j = cIdx; + while (j < chainlinkRecent.length && chainlinkRecent[j].t < bucketEnd) { cBucket.push(chainlinkRecent[j].price); j++; } + const bMed = calcMedian(bBucket), cMed = calcMedian(cBucket); + if (bMed != null && cMed != null) diffs.push(cMed - bMed); + } + } + + 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((d) => Math.abs(d - median)); + const mad = calcMedian(absDeviations) ?? 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); +} + +function refreshBinanceOffset(sym: MarketSymbol): void { + const next = calculateBinanceOffset(sym, false); + if (next == null) return; + const ss = symbolStates.get(sym)!; + const prev = ss.binanceOffset; + if (prev != null && Math.abs(prev - next) <= BINANCE_OFFSET_EPSILON) return; + ss.binanceOffset = next; + if (prev == null) { + console.log(`[BinanceOffset] ${sym.toUpperCase()} init offset ${next >= 0 ? "+" : ""}${next.toFixed(2)}`); + } +} + +function maybeInitBinanceOffset(sym: MarketSymbol): void { + const ss = symbolStates.get(sym)!; + if (ss.binanceOffset != null) return; + const v = calculateBinanceOffset(sym, true); + if (v == null) return; + ss.binanceOffset = v; + console.log(`[BinanceOffset] ${sym.toUpperCase()} init offset ${v >= 0 ? "+" : ""}${v.toFixed(2)}`); +} + +// ── Binance WS (subscribes only to aggTrade for binanceOffset calibration) ── +function startBinanceWs(sym: MarketSymbol): void { + const ss = symbolStates.get(sym)!; + const config = MARKETS[`${sym}-5m` as MarketKey]; + // Subscribe only to aggTrade, no klines needed (collector does not run momentum strategies) + const url = `wss://stream.binance.com:9443/stream?streams=${config.binanceSymbol}@aggTrade`; + const ws = new WebSocket(url); + ss.binanceWs = ws; + + ws.on("open", () => { + if (stopped) return; + console.log(ss.binanceWsAttempt === 0 ? `[BinanceWS] ${sym.toUpperCase()} connected` : `[BinanceWS] ${sym.toUpperCase()} reconnected`); + ss.binanceWsAttempt = 0; + }); + + ws.on("message", (data) => { + if (stopped) return; + try { + const raw = JSON.parse(data.toString()) as { stream?: string; data?: Record }; + 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; + ss.binanceHistory.push({ t, price }); + trimHistory(ss.binanceHistory, t - HISTORY_RETENTION_MS, MAX_BINANCE_HISTORY_POINTS); + ss.binanceLastTickAt = Date.now(); + maybeInitBinanceOffset(sym); + } + } catch { /* ignore */ } + }); + + ws.on("close", () => { + if (stopped) return; + const delay = backoffDelay(ss.binanceWsAttempt++); + console.log(`[BinanceWS] ${sym.toUpperCase()} disconnected, reconnecting in ${delay}ms (attempt ${ss.binanceWsAttempt})`); + setTimeout(() => startBinanceWs(sym), delay); + }); + + ws.on("error", (err) => { + console.error(`[BinanceWS] ${sym.toUpperCase()} error:`, err.message); + }); +} + +// ── Chainlink WS (shared per symbol) ── +function startChainlinkWs(sym: MarketSymbol): void { + const ss = symbolStates.get(sym)!; + const config = MARKETS[`${sym}-5m` as MarketKey]; + const ws = new WebSocket(CHAINLINK_WS_URL); + ss.chainlinkWs = ws; + + ws.on("open", () => { + if (stopped) return; + console.log(ss.chainlinkWsAttempt === 0 ? `[ChainlinkWS] ${sym.toUpperCase()} connected` : `[ChainlinkWS] ${sym.toUpperCase()} reconnected`); + ss.chainlinkWsAttempt = 0; + // Subscribe only to the price topic, not activity (order events not needed) + ws.send(JSON.stringify({ + action: "subscribe", + subscriptions: [ + { topic: "crypto_prices_chainlink", type: "update", filters: JSON.stringify({ symbol: config.chainlinkSymbol }) }, + ], + })); + }); + + ws.on("message", (data) => { + if (stopped) 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) return; + const t = msg.payload?.timestamp ?? msg.timestamp ?? Date.now(); + ss.currentPrice = val; + ss.priceHistory.push({ t, price: val }); + trimHistory(ss.priceHistory, t - HISTORY_RETENTION_MS, MAX_CHAINLINK_HISTORY_POINTS); + ss.chainlinkLastTickAt = Date.now(); + maybeInitBinanceOffset(sym); + } + } catch { /* ignore */ } + }); + + ws.on("close", () => { + if (stopped) return; + const delay = backoffDelay(ss.chainlinkWsAttempt++); + console.log(`[ChainlinkWS] ${sym.toUpperCase()} disconnected, reconnecting in ${delay}ms`); + setTimeout(() => startChainlinkWs(sym), delay); + }); + + ws.on("error", (err) => { + console.error(`[ChainlinkWS] ${sym.toUpperCase()} error:`, err.message); + }); +} + +// ── Polymarket Market WS (independent per market) ── +function startMarketWs(ms: MarketState): void { + const ws = new WebSocket(MARKET_WS_URL); + ms.marketWs = ws; + const upTokenId = ms.upTokenId; + + ws.on("open", () => { + if (stopped) return; + console.log(ms.marketWsAttempt === 0 ? `[MarketWS] ${ms.key} connected` : `[MarketWS] ${ms.key} reconnected`); + ms.marketWsAttempt = 0; + ws.send(JSON.stringify({ + assets_ids: [ms.upTokenId, ms.downTokenId], + type: "market", + custom_feature_enabled: true, + })); + if (ms.marketPingTimer) clearInterval(ms.marketPingTimer); + ms.marketPingTimer = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) ws.send("PING"); + }, 10000); + }); + + ws.on("message", (data) => { + if (stopped) 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; + ms.bids.clear(); ms.asks.clear(); + for (const b of (evt.bids as { price: string; size: string }[])) { + if (Number(b.size) > 0) ms.bids.set(b.price, b.size); + } + for (const a of (evt.asks as { price: string; size: string }[])) { + if (Number(a.size) > 0) ms.asks.set(a.price, a.size); + } + updateBestBidAsk(ms); + ms.marketLastTickAt = Date.now(); + } else if (evt.event_type === "best_bid_ask") { + if (evt.asset_id && evt.asset_id !== upTokenId) continue; + if (evt.best_bid != null) ms.bestBid = String(evt.best_bid); + if (evt.best_ask != null) ms.bestAsk = String(evt.best_ask); + ms.marketLastTickAt = Date.now(); + } else if (evt.event_type === "price_change" && evt.price_changes) { + for (const change of evt.price_changes as Record[]) { + if (change.asset_id !== upTokenId) continue; + if (change.price && change.size !== undefined) { + const size = Number(change.size); + const map = change.side === "BUY" ? ms.bids : ms.asks; + if (size > 0) map.set(change.price, change.size); + else map.delete(change.price); + } + } + updateBestBidAsk(ms); + ms.marketLastTickAt = Date.now(); + } + } + } catch { /* ignore */ } + }); + + ws.on("close", () => { + if (stopped) return; + if (ms.marketPingTimer) { clearInterval(ms.marketPingTimer); ms.marketPingTimer = null; } + const delay = backoffDelay(ms.marketWsAttempt++); + console.log(`[MarketWS] ${ms.key} disconnected, reconnecting in ${delay}ms`); + setTimeout(() => startMarketWs(ms), delay); + }); + + ws.on("error", (err) => { + console.error(`[MarketWS] ${ms.key} error:`, err.message); + }); +} + +function updateBestBidAsk(ms: MarketState): void { + // Take the best bid/ask from the order book + let bestBid = 0, bestAsk = 1; + for (const p of ms.bids.keys()) { const n = Number(p); if (n > bestBid) bestBid = n; } + for (const p of ms.asks.keys()) { const n = Number(p); if (n < bestAsk && n > 0) bestAsk = n; } + if (bestBid > 0) ms.bestBid = bestBid.toFixed(2); + if (bestAsk < 1) ms.bestAsk = bestAsk.toFixed(2); +} + +// ── Polymarket REST: get token ── +interface MarketInfo { + conditionId: string; + upTokenId: string; + downTokenId: string; + windowStart: number; + windowEnd: number; + eventStartTime: string; + endDate: string; +} + +async function fetchMarketInfo(config: MarketConfig, windowStart: number): Promise { + const slug = `${config.slugPrefix}-${windowStart}`; + try { + const res = await fetch(`${GAMMA_URL}/events?slug=${slug}`); + const events = await res.json() as Record[]; + if (!events?.length) return null; + const event = events[0]; + const market = ((event.markets || []) as Record[])[0]; + if (!market) 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 + config.periodSeconds, + eventStartTime: market.eventStartTime as string || new Date(windowStart * 1000).toISOString(), + endDate: market.endDate as string || new Date((windowStart + config.periodSeconds) * 1000).toISOString(), + }; + } catch (err) { + console.warn(`[Market] ${config.key} query failed slug=${slug}:`, (err as Error).message); + return null; + } +} + +// ── Polymarket REST: get PTB ── +async function fetchPTB(config: MarketConfig, eventStartTime: string, endDate: string): Promise { + try { + const url = `https://polymarket.com/api/crypto/crypto-price?symbol=${config.cryptoPriceSymbol}&eventStartTime=${encodeURIComponent(eventStartTime)}&variant=${config.cryptoPriceVariant}&endDate=${encodeURIComponent(endDate)}`; + const data = await fetch(url).then((r) => r.json()) as { openPrice?: number | null }; + if (data.openPrice != null) return data.openPrice; + return null; + } catch { + return null; + } +} + +// ── Switch window ── +async function switchToWindow(ms: MarketState, targetWindow: number): Promise { + const info = await fetchMarketInfo(ms.config, targetWindow); + if (!info) { + console.warn(`[Window] ${ms.key} switch failed windowStart=${targetWindow}, retrying in 5s`); + setTimeout(() => switchToWindow(ms, targetWindow), 5000); + return; + } + + // Close the old marketWs + if (ms.marketWs) { + ms.marketWs.removeAllListeners("close"); + ms.marketWs.close(); + ms.marketWs = null; + } + if (ms.marketPingTimer) { clearInterval(ms.marketPingTimer); ms.marketPingTimer = null; } + + // Update window info + ms.upTokenId = info.upTokenId; + ms.downTokenId = info.downTokenId; + ms.conditionId = info.conditionId; + ms.windowStart = info.windowStart; + ms.windowEnd = info.windowEnd; + ms.priceToBeat = null; + ms.bids.clear(); + ms.asks.clear(); + ms.bestBid = "-"; + ms.bestAsk = "-"; + ms.marketWsAttempt = 0; + ms.ptbRetryCount = 0; + + console.log(`[Window] ${ms.key} → ${info.windowStart} (${new Date(info.windowStart * 1000).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })})`); + + // Start the new marketWs + startMarketWs(ms); + + // Fetch PTB asynchronously + const tryFetchPTB = async () => { + if (stopped || ms.windowStart !== info.windowStart) return; + if (ms.ptbRetryCount >= PTB_MAX_RETRIES) { + console.warn(`[PTB] ${ms.key} failed ${PTB_MAX_RETRIES} times, skipping this window`); + return; + } + const ptb = await fetchPTB(ms.config, info.eventStartTime, info.endDate); + if (ptb != null) { + ms.priceToBeat = ptb; + console.log(`[PTB] ${ms.key} → $${ptb.toFixed(priceDecimals(ptb))}`); + } else { + ms.ptbRetryCount++; + setTimeout(tryFetchPTB, PTB_RETRY_INTERVAL_MS); + } + }; + tryFetchPTB(); + + // Schedule the next switch + if (ms.switchTimer) clearTimeout(ms.switchTimer); + const msUntilEnd = info.windowEnd * 1000 - Date.now(); + ms.switchTimer = setTimeout(() => { + const nextWindow = getCurrentWindowStart(ms.config.periodSeconds); + switchToWindow(ms, nextWindow); + }, Math.max(0, msUntilEnd)); +} + +// ── Write tick ── +function writeTickFor(ms: MarketState): void { + if (ms.priceToBeat == null) return; + const ss = symbolStates.get(ms.config.symbol)!; + if (ss.binanceOffset == null) return; + const lastBinance = ss.binanceHistory[ss.binanceHistory.length - 1]; + if (!lastBinance) return; + + const diff = lastBinance.price - (ms.priceToBeat - ss.binanceOffset); + const bid = Number(ms.bestBid); + const ask = Number(ms.bestAsk); + if (!Number.isFinite(bid) || !Number.isFinite(ask) || bid <= 0 || ask <= 0) return; + const upPct = Math.round((bid + ask) / 2 * 100); + const now = Date.now(); + const rem = Math.max(0, ms.windowEnd - Math.floor(now / 1000)); + + const dec = priceDecimals(ms.priceToBeat); + const factor = Math.pow(10, dec); + + const record = { + type: "tick", + ts: now, + symbol: ms.config.symbol, + period: ms.config.period, + windowStart: ms.windowStart, + diff: Math.round(diff * factor) / factor, + upPct, + rem, + }; + + // File naming: YYYY-MM-DD-{sym}-{period}.jsonl (unified new format, BTC 5m no longer uses the old name) + const date = getCstDateStr(now); + const filename = `${date}-${ms.config.symbol}-${ms.config.period}.jsonl`; + const path = resolve(DATA_DIR, filename); + try { + appendFileSync(path, JSON.stringify(record) + "\n"); + } catch (err) { + console.warn(`[Write] ${ms.key} failed:`, (err as Error).message); + } +} + +// ── Clean up old files ── +function cleanupOldFiles(): void { + try { + if (!existsSync(DATA_DIR)) return; + const files = readdirSync(DATA_DIR).filter((f) => /^\d{4}-\d{2}-\d{2}-\w+-\w+\.jsonl$/.test(f)); + const byDate = new Map(); + 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 <= RETENTION_DAYS) return; + const toDelete = dates.slice(0, dates.length - RETENTION_DAYS); + for (const d of toDelete) { + for (const f of byDate.get(d) || []) { + try { + unlinkSync(resolve(DATA_DIR, f)); + console.log(`[Cleanup] deleted old file: ${f}`); + } catch {} + } + } + } catch (err) { + console.warn(`[Cleanup] cleanup failed:`, (err as Error).message); + } +} + +// ── Health check ── +function healthCheck(): void { + const now = Date.now(); + for (const [sym, ss] of symbolStates) { + if (ss.binanceLastTickAt > 0 && now - ss.binanceLastTickAt > STALE_DATA_THRESHOLD_MS) { + console.warn(`[Health] ${sym.toUpperCase()} BinanceWS no data for ${Math.floor((now - ss.binanceLastTickAt)/1000)}s, forcing reconnect`); + ss.binanceWs?.close(); // trigger auto reconnect + } + if (ss.chainlinkLastTickAt > 0 && now - ss.chainlinkLastTickAt > STALE_DATA_THRESHOLD_MS) { + console.warn(`[Health] ${sym.toUpperCase()} ChainlinkWS no data for ${Math.floor((now - ss.chainlinkLastTickAt)/1000)}s, forcing reconnect`); + ss.chainlinkWs?.close(); + } + } + for (const [key, ms] of marketStates) { + if (ms.marketLastTickAt > 0 && now - ms.marketLastTickAt > STALE_DATA_THRESHOLD_MS) { + console.warn(`[Health] ${key} MarketWS no data for ${Math.floor((now - ms.marketLastTickAt)/1000)}s, forcing reconnect`); + ms.marketWs?.close(); + } + } +} + +// ── Print status summary ── +function printStatus(): void { + console.log(`\n────────── Status summary (${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}) ──────────`); + for (const [sym, ss] of symbolStates) { + const offsetStr = ss.binanceOffset != null ? `${ss.binanceOffset >= 0 ? "+" : ""}${ss.binanceOffset.toFixed(2)}` : "—"; + console.log(` ${sym.toUpperCase()}: binance ${ss.binanceHistory.length}pt, chainlink ${ss.priceHistory.length}pt, offset ${offsetStr}`); + } + for (const [key, ms] of marketStates) { + const ptb = ms.priceToBeat != null ? `$${ms.priceToBeat.toFixed(priceDecimals(ms.priceToBeat))}` : "—"; + console.log(` ${key}: window ${ms.windowStart}, PTB ${ptb}, bid/ask ${ms.bestBid}/${ms.bestAsk}`); + } + console.log(`────────────────────────────────────────\n`); +} + +// ── Start ── +async function start(): Promise { + console.log(`╔══════════════════════════════════════════╗`); + console.log(`║ Data collector starting ║`); + console.log(`║ Data dir: backtest-data/collector/ ║`); + console.log(`║ Retention days: ${String(RETENTION_DAYS).padEnd(24)}║`); + console.log(`╚══════════════════════════════════════════╝\n`); + + // Initialize per-symbol state + for (const sym of ALL_SYMBOLS) { + symbolStates.set(sym, { + symbol: sym, + binanceWs: null, binanceWsAttempt: 0, binanceLastTickAt: 0, + chainlinkWs: null, chainlinkWsAttempt: 0, chainlinkLastTickAt: 0, + binanceHistory: [], priceHistory: [], + currentPrice: null, binanceOffset: null, + }); + } + + // Initialize per-market state + for (const [key, config] of Object.entries(MARKETS)) { + marketStates.set(key as MarketKey, { + key: key as MarketKey, + config, + upTokenId: "", downTokenId: "", conditionId: "", + windowStart: 0, windowEnd: 0, + priceToBeat: null, + bids: new Map(), asks: new Map(), + bestBid: "-", bestAsk: "-", + marketWs: null, marketWsAttempt: 0, marketLastTickAt: 0, + marketPingTimer: null, switchTimer: null, ptbRetryCount: 0, + }); + } + + // Start BinanceWS (per symbol, staggered by 500ms) + console.log(`[Start] Connecting Binance WS...`); + for (const sym of ALL_SYMBOLS) { + startBinanceWs(sym); + await new Promise((r) => setTimeout(r, 500)); + } + + // Start ChainlinkWS (per symbol, staggered by 500ms) + console.log(`\n[Start] Connecting Chainlink WS...`); + for (const sym of ALL_SYMBOLS) { + startChainlinkWs(sym); + await new Promise((r) => setTimeout(r, 500)); + } + + // Start the window subscription for each market (staggered by 500ms) + console.log(`\n[Start] Subscribing to market windows...`); + for (const [key, ms] of marketStates) { + const w = getCurrentWindowStart(ms.config.periodSeconds); + switchToWindow(ms, w); + await new Promise((r) => setTimeout(r, 500)); + } + + console.log(`\n[Start] ✓ All ready, starting collection...\n`); + + // Start timers + setInterval(() => { + for (const ms of marketStates.values()) writeTickFor(ms); + }, TICK_WRITE_INTERVAL_MS); + + setInterval(() => { + for (const sym of ALL_SYMBOLS) refreshBinanceOffset(sym); + }, BINANCE_ALIGN_REFRESH_MS); + + setInterval(healthCheck, HEALTH_CHECK_INTERVAL_MS); + + setInterval(() => { + const today = getCstDateStr(); + if (today !== lastCleanupDate) { + cleanupOldFiles(); + lastCleanupDate = today; + } + }, 3600 * 1000); // check once per hour + + setInterval(printStatus, 5 * 60 * 1000); // print status summary every 5 minutes +} + +// ── Process-level protection ── +process.on("uncaughtException", (err) => { + console.error("[Collector] uncaught exception:", err); + // do not exit +}); + +process.on("unhandledRejection", (err) => { + console.error("[Collector] unhandled Promise rejection:", err); +}); + +process.on("SIGTERM", () => { + console.log("\n[Collector] received SIGTERM, shutting down gracefully..."); + stopped = true; + for (const ms of marketStates.values()) { + if (ms.marketWs) ms.marketWs.close(); + if (ms.marketPingTimer) clearInterval(ms.marketPingTimer); + if (ms.switchTimer) clearTimeout(ms.switchTimer); + } + for (const ss of symbolStates.values()) { + if (ss.binanceWs) ss.binanceWs.close(); + if (ss.chainlinkWs) ss.chainlinkWs.close(); + } + setTimeout(() => process.exit(0), 1000); +}); + +process.on("SIGINT", () => { + console.log("\n[Collector] received SIGINT, shutting down gracefully..."); + process.emit("SIGTERM"); +}); + +start().catch((err) => { + console.error("[Collector] startup failed:", err); + process.exit(1); +}); diff --git a/docs/README-USER.md b/docs/README-USER.md new file mode 100644 index 0000000..427c40c --- /dev/null +++ b/docs/README-USER.md @@ -0,0 +1,128 @@ +# BTC 5m User Edition + +## Included Files + +- `server.ts` +- `index.html` +- `package.json` +- `package-lock.json` +- `.env.example` +- `start.sh` +- `start.bat` +- `STRATEGIES-GUIDE.md` + +## How to Use + +1. Install Node.js 20 or higher. +2. Copy `.env.example` to `.env`. +3. Fill in: + - `POLYMARKET_PRIVATE_KEY` — Polygon wallet private key + - `POLYMARKET_PROXY_ADDRESS` — **NOT the deposit address!** Log in to polymarket.com → top-right avatar → Settings → Wallet → copy "Proxy Wallet" (maps one-to-one with the private key; a wrong value triggers invalid signature) +4. Adjust as needed: + - `APP_MODE=full`: with the web panel + - `APP_MODE=headless`: backend only + - `STRATEGY_S1_ENABLED/STRATEGY_S2_ENABLED/STRATEGY_S3_ENABLED` + - `STRATEGY_S1_AMOUNT/STRATEGY_S2_AMOUNT/STRATEGY_S3_AMOUNT` +5. Start: + - macOS / Linux: `./start.sh` + - Windows: double-click `start.bat` + +## Notes + +- On every startup, the default strategy config is initialized from `.env`. +- Changing strategy switches and amounts in the web UI only applies to the current run; after restart it follows `.env` again. +- In `full` mode you can view status, place orders manually, and temporarily toggle strategies via the web UI. +- In `headless` mode you can view status via `/api/state`. +- `STRATEGIES-GUIDE.md` summarizes the triggers, take-profit/stop-loss, buy confirmation, and state-machine semantics of the current 3 strategies. + +## Security + +- Do not give your real `.env` and `.polymarket-creds.json` to anyone. +- If deploying to a cloud server, we recommend accessing the web panel via an SSH tunnel and not exposing the port directly to the public internet. + +## Accessing the Panel via SSH Tunnel + +If the service runs on a cloud server, we recommend using an SSH tunnel to access the panel from your local browser. + +### 1. Server-Side Requirements + +- Set `APP_MODE=full` in `.env` +- The service is already running normally + +### 2. Password Login + +Run on your own computer: + +```bash +ssh -L 3456:127.0.0.1:3456 username@server_IP +``` + +For example: + +```bash +ssh -L 3456:127.0.0.1:3456 root@1.2.3.4 +``` + +Then open in your local browser: + +```text +http://127.0.0.1:3456 +``` + +### 3. Key-Based Login + +If the server uses private-key login: + +```bash +ssh -i ~/.ssh/your_private_key_file -L 3456:127.0.0.1:3456 username@server_IP +``` + +For example: + +```bash +ssh -i ~/.ssh/my-server.pem -L 3456:127.0.0.1:3456 ubuntu@1.2.3.4 +``` + +Then open in your local browser: + +```text +http://127.0.0.1:3456 +``` + +### 4. If the SSH Port Is Not 22 + +For example, if the SSH port is `2222`: + +```bash +ssh -i ~/.ssh/my-server.pem -p 2222 -L 3456:127.0.0.1:3456 ubuntu@1.2.3.4 +``` + +### 5. If Local Port 3456 Is Already in Use + +You can change the local port to `8888`: + +```bash +ssh -L 8888:127.0.0.1:3456 username@server_IP +``` + +Or: + +```bash +ssh -i ~/.ssh/my-server.pem -L 8888:127.0.0.1:3456 ubuntu@1.2.3.4 +``` + +Then open in the browser: + +```text +http://127.0.0.1:8888 +``` + +### 6. Notes + +- As long as you can SSH into the server, you can access the panel this way. +- Closing the SSH tunnel only affects local viewing; it does not affect the program continuing to run on the server. +- If you only want to check the API status, you can also access it locally: + +```bash +curl http://127.0.0.1:3456/api/state +``` diff --git a/docs/STRATEGIES-GUIDE.md b/docs/STRATEGIES-GUIDE.md new file mode 100644 index 0000000..da111dd --- /dev/null +++ b/docs/STRATEGIES-GUIDE.md @@ -0,0 +1,183 @@ +# Strategy Guide + +> **Version:** v4.2.0 +> **Author:** Penguin Sensei · 岳 · [@x_188888_x](https://x.com/x_188888_x) + +## ⚠ Important Disclaimer + +The strategies built into this tool are **examples only**, intended to demonstrate how to use the strategy framework, and **cannot guarantee profits**. +The Polymarket BTC 5-minute market is highly volatile, and any strategy with fixed parameters carries the risk of becoming ineffective. + +**Recommendations:** + +- Run one or two windows with the smallest amount and observe whether the entry/exit logic matches your judgment +- Hover in the frontend to see each strategy's entry/exit conditions +- If you have good entry/exit ideas, new data patterns, or want to work on backtest optimization together, **feel free to contact the author and refine them jointly**, + to achieve a 1+1 > 2 effect + +--- + +## Overview + +There are currently 3 built-in example strategies (only the diff and momentum types are shown; the prob-chase type is not included as an example): + +| Key | Name | Type | Summary | +|-----|------|------|------| +| D1 | Diff 1 · Standard Enhanced | Diff | diff cross entry + trailing stop + drawdown take-profit + stepped take-profit | +| D2 | Diff 2 · Tail Sweep | Diff | large-diff entry at the window tail + stepped take-profit | +| M1 | Momentum 1 | Momentum | 6-factor scoring entry, holds to window end and is decided by settlement | + +Core principles: + +- The authoritative state of automated strategies lives in the backend `server.ts` +- Buy confirmation relies on the local position `localSize` advanced by `UserWS` +- API positions are used only for reconciliation, releasing timed-out buy orders, and clearing residual positions after a sell +- Closing the frontend does not affect the backend strategy from continuing to run + +--- + +## Common Terms + +- **`diff`** — Binance latest price - (PriceToBeat - BinanceOffset); the core indicator for diff-strategy entry +- **`upPct / dnPct`** — the current up/down order book implied probability +- **`rem`** — seconds remaining in the current 5-minute window +- **`localSize`** — the local position advanced by UserWS; both buy confirmation and sell tracking rely on it +- **`apiVerified`** — the API and local positions are aligned + +--- + +## Backend State Machine + +- `IDLE` — no strategy is enabled +- `SCANNING` — scanning for entry conditions +- `BUYING` — buy triggered, order being sent +- `WAIT_FILL` — the first 10 seconds after the buy order is sent, only waiting for UserWS fill confirmation +- `RECONCILING_FILL` — not confirmed within 10 seconds, entering the deferred-confirmation state; after 15 seconds, only if the API also confirms no position does it return to `SCANNING` +- `HOLDING` — position confirmed, starting to run take-profit/stop-loss +- `SELLING` / `WAIT_SELL_FILL` — selling / waiting for sell confirmation +- `DONE` — round ended; when the position is not reconciled, it waits for API reconciliation before checking for residual positions + +--- + +## Strategy D1 · Standard Enhanced (Diff Type) + +### Entry Window + +- Detected between `210s ~ 50s` remaining + +### Entry Conditions + +- **Buy up**: previous tick diff ≤ +35, current tick diff > +35, up probability < 80% +- **Buy down**: previous tick diff ≥ -35, current tick diff < -35, down probability < 80% + +("Re-cross above/below triggers," not "buy whenever the current value is met") + +### Cooldown Lock (Prevents Chasing Highs and Flip-Flopping) + +**Neutral reset**: `|diff| ≤ 25` sustained for 3 seconds → release all cooldown locks + +**Single-direction lock** (locks that direction if any is met, until returning to neutral): + +- High-probability contamination seen first: while diff is within the trigger threshold, the up/down probability is already ≥ 80% +- Overheated: diff ≥ +55 and up probability ≥ 85% (buy-up direction) / diff ≤ -55 and down probability ≥ 85% (buy-down direction) + +### Exit Mechanisms (Multiple) + +1. **Stepped take-profit** — rises linearly from 90% at 210s to 100% at 10s; sells when the current probability reaches the threshold of the moment +2. **Drawdown take-profit** — after the probability peak during holding reaches ≥ 85%, sells once it pulls back 8 percentage points +3. **Trailing stop** — enabled after a minimum holding of 3 seconds; triggered when diff pulls back 20 points from its peak +4. **Backstop stop-loss** — buy-up diff ≤ +5 / buy-down diff ≥ -5, stop out immediately +5. **Forced close** — when rem ≤ 10s: take profit if probability ≥ 70%, otherwise stop out + +--- + +## Strategy D2 · Tail Sweep (Diff Type) + +### Entry Window + +- Detected between `60s ~ 1s` remaining + +### Entry Conditions + +- **Buy up**: diff > +50 and up probability < 95% +- **Buy down**: diff < -50 and down probability < 95% + +### Exit Mechanisms + +**Stepped take-profit** (tightened in tiers by time remaining): + +- `rem ≥ 40s`: probability ≥ 98% +- `20s ≤ rem < 40s`: probability ≥ 99% +- `10s ≤ rem < 20s`: probability ≥ 100% +- `rem < 10s`: hold to the end, decided by settlement + +**Stop-loss**: + +- Buy-up diff ≤ +5 +- Buy-down diff ≥ -5 + +--- + +## Strategy M1 · Momentum (Momentum Type) + +### Entry Window + +- Detected when more than 60s remain (the final segment of the window does not participate in momentum evaluation) + +### Entry Logic + +Based on **6-factor momentum scoring** (see `strategies/_core/s6-core.ts` for details): + +- RSI deviation +- Volume expansion +- 1-minute candle direction +- Price change magnitude +- Candle body ratio +- Number of consecutive same-color candles +- MA7 position +- (Auxiliary filter) MA120 long-term trend + 5-minute structure + +An UP threshold triggers buy up, a DOWN threshold triggers buy down (the short threshold is stricter). + +### Exit Mechanisms + +**No take-profit, no stop-loss, no forced close**; holds to the window end and the win/loss is decided by Polymarket settlement. + +This is a "pure settlement" style strategy example: verifying "whether the momentum direction judgment is accurate" rather than "agonizing over mid-window take-profit/stop-loss." + +--- + +## Buy/Sell Confirmation and Residual-Position Handling + +### Buy Confirmation Flow + +1. Strategy triggers → `BUYING` sends order → `WAIT_FILL` waits for UserWS +2. Not confirmed within 10 seconds: enter `RECONCILING_FILL`, keep waiting for UserWS +3. After 15 seconds, only if the API also confirms no position is the buy order released and it returns to scanning + +### Selling + +- When selling an unaligned position, reserve a `0.05`-share buffer to avoid insufficient balance +- If residual positions remain after API alignment, clear them again + +--- + +## Configuration Source + +- On startup, strategy config is read from `.env` (`STRATEGY_{D1,D2,M1}_ENABLED`, etc.) +- Frontend changes only affect the current process and are not persisted across restarts +- After restart, `.env` still takes precedence + +--- + +## Usage Recommendations + +- We recommend running on the premise that "the account has no position in the current window at startup" +- If you need to view status remotely, prefer `APP_MODE=full` + an SSH tunnel +- The example strategy parameters are all empirical values under historical data; please backtest and verify them yourself before live trading + +--- + +## Co-Development + +Got a good strategy? Let's optimize it together! Contact the author: [@x_188888_x](https://x.com/x_188888_x) diff --git a/docs/STRATEGIES-TODO.md b/docs/STRATEGIES-TODO.md new file mode 100644 index 0000000..90b7e50 --- /dev/null +++ b/docs/STRATEGIES-TODO.md @@ -0,0 +1,66 @@ +Currently, after one strategy finishes executing, the subsequent strategies are not executed; optimize to place multiple orders + +bestBid/bestAsk is sometimes fetched inaccurately; use the REST API to periodically calibrate the WS, fetching once every 5 seconds, and reconnect this WS after 3 consecutive readings exceed the threshold + +Do a code review and optimize the code + +Build a backend-only version for deployment on a cloud server + + +Crash bug to reproduce (observed that every time memory fills up, the Binance line lags on the time axis, but the data trend is real-time; only the overall line becomes laggy, and the yellow dots also drift toward the left of the x-axis; after switching the time window, memory is released again) +<--- Last few GCs ---> + +[57571:0x748400000] 855007 ms: Mark-Compact 3998.8 (4144.0) -> 3998.8 (4144.0) MB, pooled: 0 MB, 29.54 / 0.00 ms (average mu = 0.384, current mu = 0.086) allocation failure; scavenge might not succeed +[57571:0x748400000] 855051 ms: Mark-Compact 3999.0 (4144.2) -> 3998.9 (4144.2) MB, pooled: 0 MB, 40.33 / 0.00 ms (average mu = 0.242, current mu = 0.072) allocation failure; scavenge might not succeed + + +<--- JS stacktrace ---> + +FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory +----- Native stack trace ----- + + 1: 0x1006039c0 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] + 2: 0x1007dae90 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] + 3: 0x100a044cc v8::internal::Heap::stack() [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] + 4: 0x100a0272c v8::internal::Heap::CollectGarbage(v8::internal::AllocationSpace, v8::internal::GarbageCollectionReason, v8::GCCallbackFlags) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] + 5: 0x1009f83c0 v8::internal::HeapAllocator::AllocateRawWithLightRetrySlowPath(int, v8::internal::AllocationType, v8::internal::AllocationOrigin, v8::internal::AllocationAlignment) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] + 6: 0x1009f8d8c v8::internal::HeapAllocator::AllocateRawWithRetryOrFailSlowPath(int, v8::internal::AllocationType, v8::internal::AllocationOrigin, v8::internal::AllocationAlignment) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] + 7: 0x1009cb68c v8::internal::FactoryBase::NewRawOneByteString(int, v8::internal::AllocationType) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] + 8: 0x1009cb4c8 v8::internal::FactoryBase::NewStringFromOneByte(v8::base::Vector, v8::internal::AllocationType) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] + 9: 0x100b0dee0 v8::internal::JsonStringifier::Stringify(v8::internal::Handle, v8::internal::Handle, v8::internal::Handle) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +10: 0x100b0dc50 v8::internal::JsonStringify(v8::internal::Isolate*, v8::internal::Handle, v8::internal::Handle, v8::internal::Handle) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +11: 0x100850114 v8::internal::Builtin_JsonStringify(int, unsigned long*, v8::internal::Isolate*) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +12: 0x1014bb914 Builtins_CEntry_Return1_ArgvOnStack_BuiltinExit [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +13: 0x10bfcf98c +14: 0x10bf3ea20 +15: 0x10c082408 +16: 0x10c0808cc +17: 0x10bfacaf4 +18: 0x10c12b3d0 +19: 0x10c0a9a44 +20: 0x10bfd4390 +21: 0x10c173f18 +22: 0x10c0b03ec +23: 0x10bf8f344 +24: 0x10c09eed0 +25: 0x10bea3fc4 +26: 0x10142250c Builtins_JSEntryTrampoline [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +27: 0x1014221b0 Builtins_JSEntry [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +28: 0x100957ebc v8::internal::(anonymous namespace)::Invoke(v8::internal::Isolate*, v8::internal::(anonymous namespace)::InvokeParams const&) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +29: 0x10095781c v8::internal::Execution::Call(v8::internal::Isolate*, v8::internal::Handle, v8::internal::Handle, int, v8::internal::Handle*) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +30: 0x1007f2930 v8::Function::Call(v8::Isolate*, v8::Local, v8::Local, int, v8::Local*) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +31: 0x100505cec node::InternalMakeCallback(node::Environment*, v8::Local, v8::Local, v8::Local, int, v8::Local*, node::async_context, v8::Local) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +32: 0x10051b1cc node::AsyncWrap::MakeCallback(v8::Local, int, v8::Local*) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +33: 0x1007324b0 node::StreamBase::CallJSOnreadMethod(long, v8::Local, unsigned long, node::StreamBase::StreamBaseJSChecks) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +34: 0x100733c48 node::EmitToJSStreamListener::OnStreamRead(long, uv_buf_t const&) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +35: 0x1007b4c34 node::crypto::TLSWrap::ClearOut() [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +36: 0x1007b6b98 node::crypto::TLSWrap::OnStreamRead(long, uv_buf_t const&) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +37: 0x100738080 node::LibuvStreamWrap::OnUvRead(long, uv_buf_t const*) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +38: 0x1007387c8 node::LibuvStreamWrap::ReadStart()::$_1::__invoke(uv_stream_s*, long, uv_buf_t const*) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +39: 0x10140c8c4 uv__stream_io [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +40: 0x101414edc uv__io_poll [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +41: 0x101401850 uv_run [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +42: 0x100506508 node::SpinEventLoopInternal(node::Environment*) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +43: 0x10064d250 node::NodeMainInstance::Run() [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +44: 0x1005bf5f4 node::Start(int, char**) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node] +45: 0x186b79d54 start [/usr/lib/dyld] \ No newline at end of file diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 0000000..3f2b3a0 Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/monitor.png b/docs/screenshots/monitor.png new file mode 100644 index 0000000..a6888c5 Binary files /dev/null and b/docs/screenshots/monitor.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..a865772 --- /dev/null +++ b/index.html @@ -0,0 +1,6619 @@ + + + + +BTC 5m Up/Down Order Book + + + + + + + +
+
+
Price to Beat
+
+
+
+
BTC current price
+
+
+
+
Diff (CL)
+
+
+
+
Diff (BN)
+
+
+
+
Volatility (30s) high>%
+
+
+
+
Remaining
+
--:--
+
+ + + + + + + + + +
+ +
+
+
+
Probability
+
Waiting for order book
+
+
+
+
Order book summary
+
Waiting for order book
+
+
+ +
+
Strategy
+
+
+
+
+
+ + +
+
+
+ +
+ Run params +
+ Per round + + x +
+
+ Slippage + + % +
+ + ⓘ This run only +
+
+
+ +
+
+ + Disabled +
+ + | + TP/SL: none +
+
+ + + + ⓘ + + + +
+
+
+ Backtest data + +
+
+ +
+
+ 📱 TG push > + ⓘ Help + + +
+ +
+
+
+ + +
+
+
+ Prob chase + [BTC] + | + + | + + | + Deviation - +
+
+ +
+ + +
+
+ Curve + +
+
+
+ +
+
+ + + +
+ + +
+
+
Order
+
+ + +
+
+ + +
+
+
USDC
+
+
+
+
+
+
+
+
Up position
+
+ Not calibrated +
API:
+
+
+
Down position
+
+ Not calibrated +
API:
+
+
API sync: -
+
+ + +
+
Action
+
+ + +
+
+ + +
+
+ Direction +
+ Remaining: --:-- + Diff: - + Deviation: - +
+
+
+ + +
+
+ + + +
+
+
Amount
+
+ +
+ + USDC +
+ + +
+
+ + + + +
+
+
Price: -  Estimate: -
+ +
+
+ + + + + + +
+ + +
+
+ + 📌 Conditional orders + +
+ +
+ +
+
Slippage protection (buy+/sell-)
+
+ + % +
+
+
+ +
+ + +
+
+ Real PnL - Polymarket + Loading... +
+
+ + +
+
+ Time + Type + Direction + Strategy + Shares x Price + Fee + Market + Cash flow + Position PnL +
+
Loading...
+
+ + + + diff --git a/market-configs.ts b/market-configs.ts new file mode 100644 index 0000000..48d3f6d --- /dev/null +++ b/market-configs.ts @@ -0,0 +1,110 @@ +// Multi-symbol / multi-period market configuration +// To add a new symbol / period, just add an entry to MARKETS and confirm the market exists on Polymarket + +export type MarketSymbol = "btc" | "eth" | "sol"; +export type MarketPeriod = "5m" | "15m"; + +// Composite key: `${symbol}-${period}`, used for MARKETS indexing and .active-market.json persistence +export type MarketKey = `${MarketSymbol}-${MarketPeriod}`; + +export interface MarketConfig { + key: MarketKey; + symbol: MarketSymbol; + period: MarketPeriod; + periodSeconds: number; // 5m=300, 15m=900 + displayName: string; // Frontend display, e.g. "BTC 5m" + slugPrefix: string; // Polymarket slug prefix, joined as ${slugPrefix}-${windowStart} + binanceSymbol: string; // Binance spot symbol (lowercase) + coinbaseProduct: string; // Coinbase product_id + chainlinkSymbol: string; // symbol subscribed on Polymarket Chainlink WS (e.g. "btc/usd") + cryptoPriceSymbol: string; // symbol param for polymarket.com /api/crypto/crypto-price (uppercase) + cryptoPriceVariant: string; // variant param for /api/crypto/crypto-price (fiveminute / fifteenminute) +} + +const SYMBOL_DEFS: Record> = { + btc: { + symbol: "btc", + binanceSymbol: "btcusdt", + coinbaseProduct: "BTC-USD", + chainlinkSymbol: "btc/usd", + cryptoPriceSymbol: "BTC", + }, + eth: { + symbol: "eth", + binanceSymbol: "ethusdt", + coinbaseProduct: "ETH-USD", + chainlinkSymbol: "eth/usd", + cryptoPriceSymbol: "ETH", + }, + sol: { + symbol: "sol", + binanceSymbol: "solusdt", + coinbaseProduct: "SOL-USD", + chainlinkSymbol: "sol/usd", + cryptoPriceSymbol: "SOL", + }, +}; + +const PERIOD_DEFS: Record = { + // Note: in Polymarket's crypto-price API the fifteenminute variant does not return the 15m window open price (looks like 1h or daily data), + // while the fiveminute variant's eventStartTime, after rounding, aligns exactly with the first 5m segment of the 15m window, so 15m also uses fiveminute. + "5m": { seconds: 300, periodLabel: "5m", cryptoPriceVariant: "fiveminute" }, + "15m": { seconds: 900, periodLabel: "15m", cryptoPriceVariant: "fiveminute" }, +}; + +const SYMBOL_DISPLAY: Record = { btc: "BTC", eth: "ETH", sol: "SOL" }; + +function buildMarkets(): Record { + const out = {} as Record; + for (const sym of Object.keys(SYMBOL_DEFS) as MarketSymbol[]) { + for (const p of Object.keys(PERIOD_DEFS) as MarketPeriod[]) { + const key: MarketKey = `${sym}-${p}`; + out[key] = { + ...SYMBOL_DEFS[sym], + key, + period: p, + periodSeconds: PERIOD_DEFS[p].seconds, + displayName: `${SYMBOL_DISPLAY[sym]} ${PERIOD_DEFS[p].periodLabel}`, + slugPrefix: `${sym}-updown-${p}`, + cryptoPriceVariant: PERIOD_DEFS[p].cryptoPriceVariant, + }; + } + } + return out; +} + +export const MARKETS: Record = buildMarkets(); + +export const DEFAULT_KEY: MarketKey = "btc-5m"; + +export function isValidKey(s: string): s is MarketKey { + return s in MARKETS; +} + +// Compatible with the legacy .active-market.json that only stored the symbol field +export function isLegacySymbol(s: string): s is MarketSymbol { + return s === "btc" || s === "eth" || s === "sol"; +} + +export function getBinanceWsUrl(key: MarketKey): string { + const s = MARKETS[key].binanceSymbol; + return `wss://stream.binance.com:9443/stream?streams=${s}@aggTrade/${s}@kline_1m/${s}@kline_5m`; +} + +export const ALL_PERIODS: MarketPeriod[] = ["5m", "15m"]; +export const ALL_SYMBOLS: MarketSymbol[] = ["btc", "eth", "sol"]; + +// Return a suitable number of decimal places for the current price magnitude (shared by frontend and backend, to keep display and backtest precision consistent) +// BTC ~$70000 → 0 decimals (integer) +// ETH ~$3000 → 2 decimals +// SOL ~$200 → 4 decimals +// XRP ~$2 → 4 decimals +// DOGE ~$0.x → 5 decimals +export function priceDecimals(price: number | null | undefined): number { + const p = price || 0; + if (p >= 10000) return 0; + if (p >= 1000) return 2; + if (p >= 10) return 4; + if (p >= 1) return 4; + return 5; +} diff --git a/monitor/monitor-server.ts b/monitor/monitor-server.ts new file mode 100644 index 0000000..cfe61b8 --- /dev/null +++ b/monitor/monitor-server.ts @@ -0,0 +1,678 @@ +/** + * Polymarket multi-account monitor server + * + * Start: npx tsx monitor-server.ts + * Open: http://localhost:8080 + * + * Features: + * - Read the accounts.json list + * - Serve the monitor.html static page + * - Reverse-proxy /api/proxy?port=XXXX → http://localhost:XXXX/api/state?lite=1 + * - Provide /api/accounts GET/PUT + * - Provide /api/tg/* config endpoints + long-polling responses for /status /d commands + */ +import { createServer } from "http"; +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import { loadTgConfig, saveTgConfig, autoDetectChatId, sendTgMessage, editTgMessage, deleteTgMessage, answerCallbackQuery, maskToken, TgPoller, type TgConfig, type CallbackContext } from "./tg-push.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PORT = Number(process.env.MONITOR_PORT) || 8080; +const ACCOUNTS_FILE = resolve(__dirname, "accounts.json"); +const HTML_FILE = resolve(__dirname, "monitor.html"); +const HISTORY_FILE = resolve(__dirname, ".balance-history.json"); + +interface Account { name: string; port: number; enabled?: boolean } +function loadAccounts(includeDisabled = false): Account[] { + try { + const data = JSON.parse(readFileSync(ACCOUNTS_FILE, "utf-8")); + if (Array.isArray(data)) { + const valid: Account[] = data.filter(a => a && typeof a.name === "string" && typeof a.port === "number"); + if (includeDisabled) return valid; + return valid.filter(a => a.enabled !== false); // Enabled by default, only false disables + } + } catch (err) { + console.warn("[monitor] Failed to read accounts.json:", err instanceof Error ? err.message : String(err)); + } + return []; +} + +// ── TG config (memory + file) ── +let tgConfig: TgConfig = loadTgConfig(); +function tgConfigPublic(): Record { + return { + enabled: tgConfig.enabled, + botTokenSet: !!tgConfig.botToken, + botTokenMasked: maskToken(tgConfig.botToken), + chatId: tgConfig.chatId, + }; +} + +// ── Fetch one machine's state (lite, ~5KB) ── +// On failure, return the cache if it's within 60s, marking it stale + age; TG push and the monitor page share this buffer +type FetchStateResult = + | { ok: true; state: any; stale?: boolean; staleAgeMs?: number; error?: undefined } + | { ok: false; state?: undefined; error: string }; +const STATE_CACHE_MAX_MS = 60_000; +const _stateCache = new Map(); + +async function fetchAccountState(port: number, timeoutMs = 8000): Promise { + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + const r = await fetch(`http://localhost:${port}/api/state?lite=1`, { signal: ctrl.signal }); + clearTimeout(timer); + if (!r.ok) return _fallbackFromCache(port, `HTTP ${r.status}`); + const state = await r.json(); + _stateCache.set(port, { state, at: Date.now() }); + return { ok: true, state }; + } catch (e) { + const raw = e instanceof Error ? e.message : String(e); + const cause = (e as { cause?: { code?: string } })?.cause?.code || ""; + let err: string; + if (cause === "ECONNREFUSED" || raw.includes("ECONNREFUSED")) err = "Connection closed"; + else if (cause === "ETIMEDOUT" || raw.includes("ETIMEDOUT")) err = "Connection timed out"; + else if (raw.includes("aborted") || raw.includes("AbortError")) err = "Request timed out"; + else if (cause === "ECONNRESET" || raw.includes("ECONNRESET")) err = "Connection closed"; + else if (raw === "fetch failed") err = "Connection closed"; + else err = raw.slice(0, 60); + return _fallbackFromCache(port, err); + } +} + +function _fallbackFromCache(port: number, error: string): FetchStateResult { + const cached = _stateCache.get(port); + if (!cached) return { ok: false, error }; + const age = Date.now() - cached.at; + if (age > STATE_CACHE_MAX_MS) return { ok: false, error }; + return { ok: true, state: cached.state, stale: true, staleAgeMs: age }; +} + +function fmtUsd(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return "—"; + const sign = v >= 0 ? "" : "-"; + return `${sign}$${Math.abs(v).toFixed(2)}`; +} + +// ── Balance history (record a total-balance snapshot once a day at CST 00:05) ── +interface BalanceSnapshot { total: number; online: number; offline: number; } +type BalanceHistory = Record; // key: YYYY-MM-DD (CST) + +function loadBalanceHistory(): BalanceHistory { + try { + if (!existsSync(HISTORY_FILE)) return {}; + const data = JSON.parse(readFileSync(HISTORY_FILE, "utf-8")); + return (data && typeof data === "object" && !Array.isArray(data)) ? data : {}; + } catch (err) { + console.warn("[monitor] Failed to read .balance-history.json:", err instanceof Error ? err.message : String(err)); + return {}; + } +} +function saveBalanceHistory(h: BalanceHistory): void { + try { + writeFileSync(HISTORY_FILE, JSON.stringify(h, null, 2) + "\n", "utf-8"); + } catch (err) { + console.warn("[monitor] Failed to write .balance-history.json:", err instanceof Error ? err.message : String(err)); + } +} + +/** CST date string: 2026-05-09 */ +function cstDateStr(d = new Date()): string { + const cn = new Date(d.getTime() + 8 * 3600 * 1000); + return cn.toISOString().slice(0, 10); +} + +/** Fetch all account balances, tally the total + online/offline counts */ +async function snapshotAllBalances(): Promise { + const accounts = loadAccounts(); + if (accounts.length === 0) return { total: 0, online: 0, offline: 0 }; + const results = await Promise.all(accounts.map(a => fetchAccountState(a.port))); + let total = 0, online = 0, offline = 0; + for (const r of results) { + if (r.ok && typeof r.state.usdc === "number") { + total += r.state.usdc; + online++; + } else { + offline++; + } + } + return { total: Math.round(total * 100) / 100, online, offline }; +} + +/** Record a snapshot (overwrite mode: multiple records on the same day use the latest value) */ +async function recordBalanceSnapshot(reason: string): Promise { + const accounts = loadAccounts(); + if (accounts.length === 0) { + console.log(`[balance] Skipping snapshot (no accounts), reason: ${reason}`); + return; + } + const snap = await snapshotAllBalances(); + if (snap.online === 0) { + console.log(`[balance] Skipping snapshot (all offline), reason: ${reason}`); + return; + } + const dateKey = cstDateStr(); + const history = loadBalanceHistory(); + history[dateKey] = snap; + saveBalanceHistory(history); + console.log(`[balance] Snapshot saved ${dateKey}: total=$${snap.total.toFixed(2)} online=${snap.online} offline=${snap.offline} (${reason})`); +} + +/** Schedule the daily CST 0:05 snapshot trigger */ +function scheduleDailyBalanceSnapshot(): void { + const now = new Date(); + const cnNow = now.getTime() + 8 * 3600 * 1000; + const next = new Date(cnNow); + next.setUTCHours(0, 5, 0, 0); + // If today's 0:05 has already passed, schedule for tomorrow + if (next.getTime() <= cnNow) next.setUTCDate(next.getUTCDate() + 1); + const delayMs = next.getTime() - cnNow; + setTimeout(() => { + void recordBalanceSnapshot("CST 00:05 scheduled"); + scheduleDailyBalanceSnapshot(); // Schedule the next day + }, Math.max(60_000, delayMs)).unref?.(); +} + +/** Backfill on startup: if today has no data yet → record one immediately (avoids losing the day's data after a server restart) */ +async function bootstrapBalanceSnapshot(): Promise { + const dateKey = cstDateStr(); + const history = loadBalanceHistory(); + if (history[dateKey]) { + console.log(`[balance] Today ${dateKey} already has a snapshot, skipping startup backfill`); + return; + } + // Delay 30 seconds so all accounts can connect before recording + setTimeout(() => { void recordBalanceSnapshot("startup backfill"); }, 30_000); +} + +function fmtPnl(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return "—"; + const sign = v >= 0 ? "+" : "-"; + return `${sign}$${Math.abs(v).toFixed(2)}`; +} + +const STATUS_PAGE_SIZE = 10; + +// ── Build overview text (tree layout + pagination, offline first) ── +async function buildStatusText(page: number = 1): Promise<{ text: string; page: number; totalPages: number }> { + const accounts = loadAccounts(); + if (accounts.length === 0) return { text: "⚠ accounts.json is empty, configure accounts on the monitor page first", page: 1, totalPages: 1 }; + + const results = await Promise.all(accounts.map(async a => ({ + account: a, + result: await fetchAccountState(a.port), + }))); + + // Full aggregation (independent of pagination); stale still counts as online + let online = 0, offline = 0, staleCount = 0; + let totalUsdc = 0, totalPnl = 0, totalCount = 0, totalClosed = 0, totalWins = 0; + for (const { result } of results) { + if (!result.ok) { offline++; continue; } + online++; + if (result.stale) staleCount++; + const s = result.state; + if (typeof s.usdc === "number") totalUsdc += s.usdc; + const p = s.pmPnl || {}; + if (typeof p.todayPnl === "number") totalPnl += p.todayPnl; + if (typeof p.todayCount === "number") totalCount += p.todayCount; + if (typeof p.todayClosedCount === "number") totalClosed += p.todayClosedCount; + if (typeof p.todayWins === "number") totalWins += p.todayWins; + } + + // Sort: offline first, the rest keep accounts.json's original order + const sorted = [...results].sort((a, b) => { + const aOff = a.result.ok ? 1 : 0; + const bOff = b.result.ok ? 1 : 0; + return aOff - bOff; + }); + + // Pagination + const totalPages = Math.max(1, Math.ceil(sorted.length / STATUS_PAGE_SIZE)); + const safePage = Math.min(Math.max(1, page), totalPages); + const start = (safePage - 1) * STATUS_PAGE_SIZE; + const pageItems = sorted.slice(start, start + STATUS_PAGE_SIZE); + + const blocks: string[] = []; + // Name format: account name takes priority (s.accountName), machine name (account.name) as a parenthesized subtitle + const fmtName = (accName: string | null | undefined, machine: string): string => { + if (accName && accName !== machine) return `${accName} (${machine})`; + return machine; + }; + for (const { account, result } of pageItems) { + if (!result.ok) { + blocks.push(`🔴 ${account.name}\n└ ${result.error}`); + continue; + } + const s = result.state; + const displayName = fmtName(typeof s.accountName === "string" ? s.accountName : null, account.name); + const usdc = typeof s.usdc === "number" ? s.usdc : null; + const p = s.pmPnl || {}; + const todayPnl = typeof p.todayPnl === "number" ? p.todayPnl : null; + const todayCount = typeof p.todayCount === "number" ? p.todayCount : null; + const todayClosed = typeof p.todayClosedCount === "number" ? p.todayClosedCount : null; + const todayWins = typeof p.todayWins === "number" ? p.todayWins : null; + + const market = s.activeMarket?.displayName || "—"; + const cfg = s.strategyConfig || {}; + const fmtStratSize = (key: string): string | null => { + const lk = String(key).toLowerCase(); + const isLimit = lk.startsWith("l"); + if (isLimit) { + const sh = cfg.shares?.[lk]; + return typeof sh === "number" ? `${sh} shares` : null; + } + const am = cfg.amount?.[lk]; + return typeof am === "number" ? `$${am}` : null; + }; + const enabledKeys = Object.keys(cfg.enabled || {}).filter(k => cfg.enabled[k]); + let stratStr: string; + if (!enabledKeys.length) { + stratStr = "No strategy enabled"; + } else { + stratStr = enabledKeys.map(k => { + const sz = fmtStratSize(k); + return sz ? `${k.toUpperCase()}(${sz})` : k.toUpperCase(); + }).join(" "); + } + const ws = s.wsStatus || {}; + const wsAllOk = ws.user && ws.market && ws.chainlink && ws.binance; + // stale shows a yellow dot + appends the data age after the name + const dot = result.stale ? "🟡" : (wsAllOk ? "🟢" : "🟡"); + let staleTag = ""; + if (result.stale) { + const ageSec = Math.max(1, Math.round((result.staleAgeMs || 0) / 1000)); + staleTag = ageSec >= 60 ? ` ⏳${Math.round(ageSec / 60)}m ago` : ` ⏳${ageSec}s ago`; + } + + const wrStr = todayClosed && todayClosed > 0 + ? `${Math.round((todayWins ?? 0) / todayClosed * 100)}%(${todayWins ?? 0}/${todayClosed})` + : "—"; + + blocks.push( + `${dot} ${displayName}${staleTag} · ${market}\n` + + `├ Balance ${fmtUsd(usdc)} · PnL ${fmtPnl(todayPnl)} · ${todayCount ?? 0} trades\n` + + `└ Win rate ${wrStr} · Strategy ${stratStr}` + ); + } + + const winRate = totalClosed > 0 ? `${Math.round((totalWins / totalClosed) * 100)}% (${totalWins}/${totalClosed})` : "—"; + const pageLine = totalPages > 1 ? `\n📄 Page ${safePage}/${totalPages}` : ""; + const head = [ + `📊 Monitor overview`, + `🕒 ${new Date().toLocaleString("en-US", { hour12: false })}`, + "", + `📦 Machines ${accounts.length} (online ${online}${staleCount > 0 ? ` / cached ${staleCount}` : ""} / offline ${offline})`, + "", + `💰 Overview`, + `├ Balance ${fmtUsd(totalUsdc)}`, + `├ PnL ${fmtPnl(totalPnl)}`, + `├ Trades ${totalCount}`, + `└ Win rate ${winRate}`, + "", + `📋 Accounts${pageLine}`, + ].join("\n"); + return { text: head + "\n" + blocks.join("\n\n"), page: safePage, totalPages }; +} + +/** Build the pagination + refresh inline keyboard */ +function buildStatusKeyboard(page: number, totalPages: number): { text: string; callback_data: string }[][] { + const buttons: { text: string; callback_data: string }[] = []; + if (totalPages > 1) { + if (page > 1) buttons.push({ text: "← Prev", callback_data: `status_page:${page - 1}` }); + buttons.push({ text: `${page}/${totalPages}`, callback_data: `noop` }); + if (page < totalPages) buttons.push({ text: "Next →", callback_data: `status_page:${page + 1}` }); + } + buttons.push({ text: "🔄 Refresh", callback_data: `refresh:${page}` }); + return [buttons]; +} + +// ── Build single-account detail text ── +async function buildDetailText(name: string): Promise { + const accounts = loadAccounts(); + const target = accounts.find(a => a.name.toLowerCase() === name.toLowerCase()); + if (!target) { + const list = accounts.map(a => a.name).join(", ") || "(empty)"; + return `⚠ Account "${name}" not found\n\nAvailable accounts: ${list}`; + } + const result = await fetchAccountState(target.port); + if (!result.ok) return `${target.name} (port=${target.port})\nOffline: ${result.error}`; + const s = result.state; + const accName = s.accountName || target.name; + const market = s.activeMarket?.displayName || "—"; + const usdc = typeof s.usdc === "number" ? s.usdc : null; + const upPos = typeof s.upLocalSize === "number" ? s.upLocalSize : 0; + const dnPos = typeof s.downLocalSize === "number" ? s.downLocalSize : 0; + const lastTrade = s.lastTradeAt; + const stratState = s.strategy?.state || "—"; + const stratActive = s.strategy?.activeStrategy; + const enabledKeys = Object.keys(s.strategyConfig?.enabled || {}).filter(k => s.strategyConfig.enabled[k]).map(k => k.toUpperCase()); + const ws = s.wsStatus || {}; + const wsList = Object.entries(ws).map(([k, v]) => `${k}:${v ? "●" : "○"}`).join(" "); + const p = s.pmPnl || {}; + + const lines = [ + `📋 ${accName} (port=${target.port})`, + `Market: ${market}`, + `USDC: ${fmtUsd(usdc)}`, + `Position: Up ${upPos.toFixed(2)} / Down ${dnPos.toFixed(2)}`, + `Strategy: ${stratActive ? `${String(stratActive).toUpperCase()}·${stratState}` : `Enabled ${enabledKeys.join(" ") || "none"} · ${stratState}`}`, + `Today: PnL ${fmtPnl(p.todayPnl)} | ${p.todayCount ?? 0} trades | Win rate ${p.todayClosedCount ? Math.round((p.todayWins ?? 0) / p.todayClosedCount * 100) + "%" : "—"} (${p.todayWins ?? 0}/${p.todayClosedCount ?? 0})`, + `Last fill: ${lastTrade ? new Date(lastTrade).toLocaleString("en-US", { hour12: false }) : "—"}`, + `WS: ${wsList}`, + ]; + return lines.join("\n"); +} + +// ── Command dispatch ── +async function handleTgCommand(cmd: string, args: string, fromChatId: number): Promise { + // Security: only respond to the configured chatId (prevents strangers from sending commands) + if (tgConfig.chatId && String(fromChatId) !== tgConfig.chatId) { + console.warn(`[monitor.TG] Ignoring command from unauthorized chat_id: ${fromChatId}`); + return; + } + if (cmd === "/start" || cmd === "/help") { + const text = [ + "📊 Polymarket multi-account monitor", + "", + "Available commands:", + "/status — Overview (all accounts)", + "/d — Single-account details (e.g. /d T4-A)", + "/help — Help", + ].join("\n"); + await sendTgMessage({ ...tgConfig, chatId: String(fromChatId) }, text); + return; + } + if (cmd === "/status" || cmd === "/s") { + const r = await buildStatusText(1); + await sendTgMessage({ ...tgConfig, chatId: String(fromChatId) }, r.text, { + inlineKeyboard: buildStatusKeyboard(r.page, r.totalPages), + }); + return; + } + if (cmd === "/d" || cmd === "/detail") { + if (!args) { + await sendTgMessage({ ...tgConfig, chatId: String(fromChatId) }, "Usage: /d \nExample: /d T4-A"); + return; + } + const text = await buildDetailText(args); + await sendTgMessage({ ...tgConfig, chatId: String(fromChatId) }, text); + return; + } + // Unknown commands are silently ignored +} + +async function handleTgCallback(ctx: CallbackContext): Promise { + // Security: only respond to the authorized chat_id + if (tgConfig.chatId && String(ctx.fromChatId) !== tgConfig.chatId) { + await answerCallbackQuery(tgConfig, ctx.callbackQueryId, "Unauthorized"); + return; + } + // Compatible with the old refresh_status, plus the new formats status_page:N / refresh:N + // Refresh (refresh:N): delete+send, triggers the animation, visibly signals a successful refresh + // Page change (status_page:N): edit in place + top toast, doesn't jump to the bottom of the chat + if (ctx.messageId && (ctx.data === "refresh_status" || ctx.data.startsWith("refresh:") || ctx.data.startsWith("status_page:"))) { + const isRefresh = ctx.data === "refresh_status" || ctx.data.startsWith("refresh:"); + let page = 1; + if (ctx.data.startsWith("refresh:")) { + page = parseInt(ctx.data.slice("refresh:".length), 10) || 1; + } else if (ctx.data.startsWith("status_page:")) { + page = parseInt(ctx.data.slice("status_page:".length), 10) || 1; + } + const ackText = isRefresh ? "Refreshed" : `→ Page ${page}`; + await answerCallbackQuery(tgConfig, ctx.callbackQueryId, ackText); + const r = await buildStatusText(page); + const keyboard = buildStatusKeyboard(r.page, r.totalPages); + if (isRefresh) { + const delRes = await deleteTgMessage(tgConfig, String(ctx.fromChatId), ctx.messageId); + if (delRes.ok) { + await sendTgMessage(tgConfig, r.text, { toChatId: String(ctx.fromChatId), inlineKeyboard: keyboard }); + } else { + await editTgMessage(tgConfig, String(ctx.fromChatId), ctx.messageId, r.text, { inlineKeyboard: keyboard }); + } + } else { + await editTgMessage(tgConfig, String(ctx.fromChatId), ctx.messageId, r.text, { inlineKeyboard: keyboard }); + } + return; + } + if (ctx.data === "noop") { + await answerCallbackQuery(tgConfig, ctx.callbackQueryId); + return; + } + await answerCallbackQuery(tgConfig, ctx.callbackQueryId); +} + +const tgPoller = new TgPoller(() => tgConfig, handleTgCommand, handleTgCallback); +tgPoller.start(); + +// ── HTTP server ── +const server = createServer(async (req, res) => { + const url = new URL(req.url || "/", `http://localhost:${PORT}`); + const path = url.pathname; + + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Headers", "*"); + + // ── /api/accounts ── + if (path === "/api/accounts") { + if (req.method === "GET") { + // The management panel needs to see disabled accounts; add ?all=1 to return everything; by default only enabled accounts are returned + const includeDisabled = url.searchParams.get("all") === "1"; + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(loadAccounts(includeDisabled))); + return; + } + if (req.method === "PUT") { + try { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const body = JSON.parse(Buffer.concat(chunks).toString("utf-8")); + if (!Array.isArray(body)) throw new Error("body must be an array"); + const seen = new Set(); + const cleaned: Account[] = []; + for (const a of body) { + if (!a || typeof a.name !== "string" || typeof a.port !== "number") { + throw new Error("each item must contain name(string) and port(number)"); + } + const name = a.name.trim(); + const port = Math.floor(a.port); + if (!name) throw new Error("name cannot be empty"); + if (port < 1 || port > 65535) throw new Error(`port out of range: ${port}`); + if (seen.has(port)) throw new Error(`duplicate port: ${port}`); + seen.add(port); + const item: Account = { name, port }; + if (a.enabled === false) item.enabled = false; // Only write the field when explicitly disabled, keep enabled items clean + cleaned.push(item); + } + writeFileSync(ACCOUNTS_FILE, JSON.stringify(cleaned, null, 2) + "\n", "utf-8"); + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(cleaned)); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "Save failed", detail: msg })); + } + return; + } + res.writeHead(405, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Method Not Allowed"); + return; + } + + // ── /api/proxy ── + if (path === "/api/proxy") { + const port = Number(url.searchParams.get("port")); + if (!port || port < 1 || port > 65535) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid port" })); + return; + } + const result = await fetchAccountState(port); + if (result.ok) { + res.writeHead(200, { + "Content-Type": "application/json; charset=utf-8", + ...(result.stale ? { "X-State-Stale": "1", "X-State-Age-Ms": String(result.staleAgeMs || 0) } : {}), + }); + res.end(JSON.stringify(result.state)); + } else { + res.writeHead(503, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "Upstream unreachable", detail: result.error })); + } + return; + } + + // ── /api/tg/config GET / POST ── + if (path === "/api/tg/config") { + if (req.method === "GET") { + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(tgConfigPublic())); + return; + } + if (req.method === "POST") { + try { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const body = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as Record; + if (typeof body.enabled === "boolean") tgConfig.enabled = body.enabled; + // botToken containing *** means the frontend didn't modify it (mask echoed back), skip + if (typeof body.botToken === "string" && !body.botToken.includes("***")) { + const oldToken = tgConfig.botToken; + tgConfig.botToken = body.botToken.trim(); + if (oldToken !== tgConfig.botToken) tgPoller.resetOnTokenChange(); + } + if (typeof body.chatId === "string") tgConfig.chatId = body.chatId.trim(); + saveTgConfig(tgConfig); + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(tgConfigPublic())); + } catch (e) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "Save failed", detail: e instanceof Error ? e.message : String(e) })); + } + return; + } + } + + // ── /api/tg/auto-chat: fetch chat_id from getUpdates ── + if (path === "/api/tg/auto-chat" && req.method === "POST") { + try { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const body = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as Record; + // Prefer the token in the body (the frontend may have typed it and clicked fetch before saving) + let token = typeof body.botToken === "string" && !body.botToken.includes("***") ? body.botToken.trim() : ""; + if (!token) token = tgConfig.botToken; + if (!token) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ ok: false, error: "Bot Token not configured" })); + return; + } + const r = await autoDetectChatId(token); + res.writeHead(r.ok ? 200 : 400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(r)); + } catch (e) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) })); + } + return; + } + + // ── /api/balance-history: return balance snapshots for all dates ── + if (path === "/api/balance-history" && req.method === "GET") { + const history = loadBalanceHistory(); + // Wrap once so the frontend can sort by date + const items = Object.entries(history) + .map(([date, snap]) => ({ date, ...snap })) + .sort((a, b) => a.date.localeCompare(b.date)); + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(items)); + return; + } + + // ── /api/balance-history/now: trigger a snapshot immediately (for debugging/manual backfill) ── + if (path === "/api/balance-history/now" && req.method === "POST") { + void recordBalanceSnapshot("manual trigger"); + res.writeHead(202, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ ok: true, message: "Snapshot triggered, query /api/balance-history in 30 seconds" })); + return; + } + + // ── /api/balance-history?date=YYYY-MM-DD: delete the snapshot for the given date ── + if (path === "/api/balance-history" && req.method === "DELETE") { + const date = url.searchParams.get("date") || ""; + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ ok: false, error: "date parameter missing or malformed (need YYYY-MM-DD)" })); + return; + } + const history = loadBalanceHistory(); + if (!(date in history)) { + res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ ok: false, error: "No snapshot record for that date" })); + return; + } + delete history[date]; + saveBalanceHistory(history); + console.log(`[monitor] Deleted balance snapshot: ${date}`); + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ ok: true, date })); + return; + } + + // ── /api/tg/test: push an actual overview immediately (same as /status, with refresh button) ── + if (path === "/api/tg/test" && req.method === "POST") { + if (!tgConfig.botToken || !tgConfig.chatId) { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ ok: false, error: "Bot Token or Chat ID not configured" })); + return; + } + const status = await buildStatusText(1); + const r = await sendTgMessage(tgConfig, status.text, { + inlineKeyboard: buildStatusKeyboard(status.page, status.totalPages), + }); + res.writeHead(r.ok ? 200 : 400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(r)); + return; + } + + // ── Static files ── + if (path === "/" || path === "/monitor.html") { + if (existsSync(HTML_FILE)) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(readFileSync(HTML_FILE)); + } else { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("monitor.html does not exist"); + } + return; + } + + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); +}); + +server.listen(PORT, () => { + const accounts = loadAccounts(); + console.log(`✓ Monitor server started`); + console.log(` URL: http://localhost:${PORT}`); + console.log(` Accounts: ${accounts.length}`); + if (accounts.length > 0) { + accounts.forEach(a => console.log(` - ${a.name} (port=${a.port})`)); + } else { + console.log(` ⚠ accounts.json is empty or missing, please edit ${ACCOUNTS_FILE}`); + } + if (tgConfig.enabled && tgConfig.botToken) { + console.log(`✓ TG bot started (chatId=${tgConfig.chatId || "not configured"})`); + } else { + console.log(` TG bot not enabled (configure it in the top-right of the monitor page)`); + } + // Balance history collection: backfill today on startup + schedule tomorrow's auto snapshot at 0:05 + void bootstrapBalanceSnapshot(); + scheduleDailyBalanceSnapshot(); +}); + +process.on("SIGINT", () => { + console.log("\n[monitor] Received SIGINT, exiting"); + tgPoller.stop(); + server.close(); + process.exit(0); +}); diff --git a/monitor/monitor.html b/monitor/monitor.html new file mode 100644 index 0000000..a2d184c --- /dev/null +++ b/monitor/monitor.html @@ -0,0 +1,1691 @@ + + + + + +Polymarket Multi-Account Monitor + + + + +
+

📊 Polymarket Multi-Account Monitor

+
+ Machines + Online + Offline + Total Balance + Today's PnL + Total Trades + Total Win Rate + Next Refresh5s + + + + + +
+
+ +
Loading account list...
+
+ + + + + + + + + + + + + + + + + diff --git a/monitor/start.sh b/monitor/start.sh new file mode 100755 index 0000000..c4b6bd1 --- /dev/null +++ b/monitor/start.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Start the Polymarket multi-account monitor page +# Default port 8080; edit the MONITOR_PORT environment variable to change it +cd "$(dirname "$0")" +exec npx tsx ./monitor-server.ts diff --git a/monitor/tg-push.ts b/monitor/tg-push.ts new file mode 100644 index 0000000..7d1f2cb --- /dev/null +++ b/monitor/tg-push.ts @@ -0,0 +1,303 @@ +/** + * Monitor Telegram module (independent of the main project's tg-push) + * + * Features: + * - Config persisted to monitor/.tg-config.json + * - Auto-fetch chat_id (only available after the user has messaged the bot) + * - Long-poll getUpdates to receive and respond to commands + * - Commands: /status overview, /d single account, /help + */ + +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TG_CONFIG_FILE = resolve(__dirname, ".tg-config.json"); + +export interface TgConfig { + enabled: boolean; + botToken: string; + chatId: string; +} + +const DEFAULT_CONFIG: TgConfig = { + enabled: false, + botToken: "", + chatId: "", +}; + +export function loadTgConfig(): TgConfig { + try { + if (!existsSync(TG_CONFIG_FILE)) return { ...DEFAULT_CONFIG }; + const raw = JSON.parse(readFileSync(TG_CONFIG_FILE, "utf-8")); + return { + enabled: !!raw.enabled, + botToken: typeof raw.botToken === "string" ? raw.botToken : "", + chatId: typeof raw.chatId === "string" ? raw.chatId : "", + }; + } catch (err) { + console.warn(`[monitor.TG] Failed to load config: ${err instanceof Error ? err.message : String(err)}`); + return { ...DEFAULT_CONFIG }; + } +} + +export function saveTgConfig(cfg: TgConfig): void { + try { + writeFileSync(TG_CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", "utf-8"); + } catch (err) { + console.warn(`[monitor.TG] Failed to save config: ${err instanceof Error ? err.message : String(err)}`); + } +} + +export function maskToken(token: string): string { + if (!token) return ""; + if (token.length < 14) return token.slice(0, 4) + "***"; + return token.slice(0, 10) + "***" + token.slice(-4); +} + +/** Auto-fetch chat_id: the user must have sent the bot any message (e.g. /start) before updates exist */ +export async function autoDetectChatId(botToken: string): Promise<{ ok: boolean; chatId?: string; error?: string }> { + if (!botToken) return { ok: false, error: "Bot Token is empty" }; + const url = `https://api.telegram.org/bot${botToken}/getUpdates`; + try { + const res = await fetch(url); + const data = await res.json() as { ok: boolean; result?: Array<{ message?: { chat?: { id?: number } } }>; description?: string }; + if (!data.ok) return { ok: false, error: data.description || `HTTP ${res.status}` }; + const results = data.result || []; + if (results.length === 0) return { ok: false, error: "No message found. Send the bot a message in Telegram first (e.g. /start), then try again" }; + for (let i = results.length - 1; i >= 0; i--) { + const id = results[i].message?.chat?.id; + if (typeof id === "number") return { ok: true, chatId: String(id) }; + } + return { ok: false, error: "chat.id not found in the message" }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export interface InlineButton { text: string; callback_data: string; } +export interface SendOpts { + parseMode?: "Markdown" | "MarkdownV2" | "HTML"; + inlineKeyboard?: InlineButton[][]; + /** Specify a chatId to override cfg.chatId (used when responding to someone else's command) */ + toChatId?: string; +} + +/** Send a message */ +export async function sendTgMessage(cfg: TgConfig, text: string, opts: SendOpts = {}): Promise<{ ok: boolean; error?: string; messageId?: number }> { + const targetChat = opts.toChatId || cfg.chatId; + if (!cfg.botToken || !targetChat) return { ok: false, error: "Bot Token or Chat ID not configured" }; + const url = `https://api.telegram.org/bot${cfg.botToken}/sendMessage`; + try { + const body: Record = { + chat_id: targetChat, + text, + disable_web_page_preview: true, + }; + if (opts.parseMode) body.parse_mode = opts.parseMode; + if (opts.inlineKeyboard) body.reply_markup = { inline_keyboard: opts.inlineKeyboard }; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json() as { ok: boolean; description?: string; result?: { message_id?: number } }; + if (!data.ok) return { ok: false, error: data.description || `HTTP ${res.status}` }; + return { ok: true, messageId: data.result?.message_id }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** Delete a message (used by the "🔄 Refresh" button: delete the old one then send a new one, triggering TG's animation to signal a successful refresh) */ +export async function deleteTgMessage(cfg: TgConfig, chatId: string, messageId: number): Promise<{ ok: boolean; error?: string }> { + const url = `https://api.telegram.org/bot${cfg.botToken}/deleteMessage`; + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chat_id: chatId, message_id: messageId }), + }); + const data = await res.json() as { ok: boolean; description?: string }; + if (!data.ok) return { ok: false, error: data.description || `HTTP ${res.status}` }; + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** Edit an existing message (kept as a fallback; currently refresh uses the delete+send path) */ +export async function editTgMessage(cfg: TgConfig, chatId: string, messageId: number, text: string, opts: { inlineKeyboard?: InlineButton[][]; parseMode?: "Markdown" | "MarkdownV2" | "HTML" } = {}): Promise<{ ok: boolean; error?: string }> { + const url = `https://api.telegram.org/bot${cfg.botToken}/editMessageText`; + try { + const body: Record = { + chat_id: chatId, + message_id: messageId, + text, + disable_web_page_preview: true, + }; + if (opts.parseMode) body.parse_mode = opts.parseMode; + if (opts.inlineKeyboard) body.reply_markup = { inline_keyboard: opts.inlineKeyboard }; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json() as { ok: boolean; description?: string }; + // "message is not modified" is not an error (TG rejects the edit when content is identical, but semantically it succeeded) + if (!data.ok) { + const desc = data.description || ""; + if (desc.includes("message is not modified")) return { ok: true }; + return { ok: false, error: desc || `HTTP ${res.status}` }; + } + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** Answer a callback_query (must ack after a button press, otherwise the TG client keeps spinning) */ +export async function answerCallbackQuery(cfg: TgConfig, callbackQueryId: string, text?: string): Promise { + if (!cfg.botToken) return; + const url = `https://api.telegram.org/bot${cfg.botToken}/answerCallbackQuery`; + try { + await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ callback_query_id: callbackQueryId, text }), + }); + } catch { + // Ignore ack errors + } +} + +// ── Long polling ── +export interface TgUpdate { + update_id: number; + message?: { + message_id: number; + chat: { id: number; type: string }; + from?: { id: number; username?: string }; + text?: string; + date: number; + }; + callback_query?: { + id: string; + from: { id: number; username?: string }; + message?: { message_id: number; chat: { id: number; type: string } }; + data?: string; + }; +} + +export interface CallbackContext { + callbackQueryId: string; + fromChatId: number; + messageId?: number; + data: string; +} + +/** Long-poll getUpdates */ +export class TgPoller { + private offset = 0; + private stopped = false; + private timer: NodeJS.Timeout | null = null; + private currentToken = ""; + + constructor( + private readonly getCfg: () => TgConfig, + private readonly onCommand: (cmd: string, args: string, fromChatId: number) => Promise | void, + private readonly onCallback?: (ctx: CallbackContext) => Promise | void, + ) {} + + start(): void { + this.stopped = false; + void this.loop(); + } + + stop(): void { + this.stopped = true; + if (this.timer) { clearTimeout(this.timer); this.timer = null; } + } + + /** Called when the token changes - resets offset to avoid getting stuck on 401 */ + resetOnTokenChange(): void { + this.offset = 0; + } + + private async loop(): Promise { + while (!this.stopped) { + const cfg = this.getCfg(); + // Idle when config is invalid, check every 2s + if (!cfg.enabled || !cfg.botToken) { + if (this.currentToken !== cfg.botToken) this.offset = 0; + this.currentToken = cfg.botToken; + await this.sleep(2000); + continue; + } + // token changed → reset offset + if (this.currentToken !== cfg.botToken) { + this.offset = 0; + this.currentToken = cfg.botToken; + } + try { + const url = `https://api.telegram.org/bot${cfg.botToken}/getUpdates?timeout=25&offset=${this.offset}`; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 30_000); + const res = await fetch(url, { signal: ctrl.signal }); + clearTimeout(timer); + const data = await res.json() as { ok: boolean; result?: TgUpdate[]; description?: string }; + if (!data.ok) { + console.warn(`[monitor.TG] getUpdates failed: ${data.description || res.status}`); + await this.sleep(5000); + continue; + } + for (const update of data.result || []) { + this.offset = Math.max(this.offset, update.update_id + 1); + // callback_query: button click + if (update.callback_query && this.onCallback) { + const cq = update.callback_query; + try { + await this.onCallback({ + callbackQueryId: cq.id, + fromChatId: cq.from.id, + messageId: cq.message?.message_id, + data: cq.data || "", + }); + } catch (err) { + console.warn(`[monitor.TG] Error handling callback: ${err instanceof Error ? err.message : String(err)}`); + } + continue; + } + const msg = update.message; + if (!msg || typeof msg.text !== "string") continue; + const text = msg.text.trim(); + if (!text.startsWith("/")) continue; + const space = text.indexOf(" "); + // Command may carry an @bot suffix (in groups), strip it + let cmd = (space === -1 ? text : text.slice(0, space)).toLowerCase(); + const at = cmd.indexOf("@"); + if (at !== -1) cmd = cmd.slice(0, at); + const args = space === -1 ? "" : text.slice(space + 1).trim(); + try { + await this.onCommand(cmd, args, msg.chat.id); + } catch (err) { + console.warn(`[monitor.TG] Error handling command: ${err instanceof Error ? err.message : String(err)}`); + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!this.stopped) { + console.warn(`[monitor.TG] Long-polling error: ${msg}`); + await this.sleep(5000); + } + } + } + } + + private sleep(ms: number): Promise { + return new Promise(resolve => { + this.timer = setTimeout(() => { this.timer = null; resolve(); }, ms); + }); + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0632672 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2799 @@ +{ + "name": "btc5m-web", + "version": "5.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "btc5m-web", + "version": "5.0.0", + "dependencies": { + "@polymarket/clob-client-v2": "^1.0.3", + "dotenv": "^16.0.0", + "ethers": "^6.0.0", + "express": "^5.2.1", + "undici": "^6.25.0", + "ws": "^8.0.0" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^20.0.0", + "@types/ws": "^8.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@ethersproject/abstract-provider": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-provider/-/abstract-provider-5.8.0.tgz", + "integrity": "sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/networks": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/web": "^5.8.0" + } + }, + "node_modules/@ethersproject/abstract-signer": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/abstract-signer/-/abstract-signer-5.8.0.tgz", + "integrity": "sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0" + } + }, + "node_modules/@ethersproject/address": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/address/-/address-5.8.0.tgz", + "integrity": "sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/rlp": "^5.8.0" + } + }, + "node_modules/@ethersproject/base64": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/base64/-/base64-5.8.0.tgz", + "integrity": "sha512-lN0oIwfkYj9LbPx4xEkie6rAMJtySbpOAFXSDVQaBnAzYfB4X2Qr+FXJGxMoc3Bxp2Sm8OwvzMrywxyw0gLjIQ==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0" + } + }, + "node_modules/@ethersproject/basex": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/basex/-/basex-5.8.0.tgz", + "integrity": "sha512-PIgTszMlDRmNwW9nhS6iqtVfdTAKosA7llYXNmGPw4YAI1PUyMv28988wAb41/gHF/WqGdoLv0erHaRcHRKW2Q==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/properties": "^5.8.0" + } + }, + "node_modules/@ethersproject/bignumber": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.8.0.tgz", + "integrity": "sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "bn.js": "^5.2.1" + } + }, + "node_modules/@ethersproject/bytes": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.8.0.tgz", + "integrity": "sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/constants": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/constants/-/constants-5.8.0.tgz", + "integrity": "sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bignumber": "^5.8.0" + } + }, + "node_modules/@ethersproject/hash": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/hash/-/hash-5.8.0.tgz", + "integrity": "sha512-ac/lBcTbEWW/VGJij0CNSw/wPcw9bSRgCB0AIBz8CvED/jfvDoV9hsIIiWfvWmFEi8RcXtlNwp2jv6ozWOsooA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/base64": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/hdnode": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/hdnode/-/hdnode-5.8.0.tgz", + "integrity": "sha512-4bK1VF6E83/3/Im0ERnnUeWOY3P1BZml4ZD3wcH8Ys0/d1h1xaFt6Zc+Dh9zXf9TapGro0T4wvO71UTCp3/uoA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/basex": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/pbkdf2": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/sha2": "^5.8.0", + "@ethersproject/signing-key": "^5.8.0", + "@ethersproject/strings": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/wordlists": "^5.8.0" + } + }, + "node_modules/@ethersproject/json-wallets": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/json-wallets/-/json-wallets-5.8.0.tgz", + "integrity": "sha512-HxblNck8FVUtNxS3VTEYJAcwiKYsBIF77W15HufqlBF9gGfhmYOJtYZp8fSDZtn9y5EaXTE87zDwzxRoTFk11w==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/hdnode": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/pbkdf2": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/random": "^5.8.0", + "@ethersproject/strings": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "aes-js": "3.0.0", + "scrypt-js": "3.0.1" + } + }, + "node_modules/@ethersproject/json-wallets/node_modules/aes-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz", + "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==", + "license": "MIT" + }, + "node_modules/@ethersproject/keccak256": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/keccak256/-/keccak256-5.8.0.tgz", + "integrity": "sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "js-sha3": "0.8.0" + } + }, + "node_modules/@ethersproject/logger": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.8.0.tgz", + "integrity": "sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT" + }, + "node_modules/@ethersproject/networks": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/networks/-/networks-5.8.0.tgz", + "integrity": "sha512-egPJh3aPVAzbHwq8DD7Po53J4OUSsA1MjQp8Vf/OZPav5rlmWUaFLiq8cvQiGK0Z5K6LYzm29+VA/p4RL1FzNg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/pbkdf2": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/pbkdf2/-/pbkdf2-5.8.0.tgz", + "integrity": "sha512-wuHiv97BrzCmfEaPbUFpMjlVg/IDkZThp9Ri88BpjRleg4iePJaj2SW8AIyE8cXn5V1tuAaMj6lzvsGJkGWskg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/sha2": "^5.8.0" + } + }, + "node_modules/@ethersproject/properties": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/properties/-/properties-5.8.0.tgz", + "integrity": "sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/providers": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/providers/-/providers-5.8.0.tgz", + "integrity": "sha512-3Il3oTzEx3o6kzcg9ZzbE+oCZYyY+3Zh83sKkn4s1DZfTUjIegHnN2Cm0kbn9YFy45FDVcuCLLONhU7ny0SsCw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/base64": "^5.8.0", + "@ethersproject/basex": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/networks": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/random": "^5.8.0", + "@ethersproject/rlp": "^5.8.0", + "@ethersproject/sha2": "^5.8.0", + "@ethersproject/strings": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/web": "^5.8.0", + "bech32": "1.1.4", + "ws": "8.18.0" + } + }, + "node_modules/@ethersproject/providers/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@ethersproject/random": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.8.0.tgz", + "integrity": "sha512-E4I5TDl7SVqyg4/kkA/qTfuLWAQGXmSOgYyO01So8hLfwgKvYK5snIlzxJMk72IFdG/7oh8yuSqY2KX7MMwg+A==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/rlp": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/rlp/-/rlp-5.8.0.tgz", + "integrity": "sha512-LqZgAznqDbiEunaUvykH2JAoXTT9NV0Atqk8rQN9nx9SEgThA/WMx5DnW8a9FOufo//6FZOCHZ+XiClzgbqV9Q==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/sha2": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/sha2/-/sha2-5.8.0.tgz", + "integrity": "sha512-dDOUrXr9wF/YFltgTBYS0tKslPEKr6AekjqDW2dbn1L1xmjGR+9GiKu4ajxovnrDbwxAKdHjW8jNcwfz8PAz4A==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "hash.js": "1.1.7" + } + }, + "node_modules/@ethersproject/signing-key": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/signing-key/-/signing-key-5.8.0.tgz", + "integrity": "sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "bn.js": "^5.2.1", + "elliptic": "6.6.1", + "hash.js": "1.1.7" + } + }, + "node_modules/@ethersproject/strings": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/strings/-/strings-5.8.0.tgz", + "integrity": "sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/logger": "^5.8.0" + } + }, + "node_modules/@ethersproject/transactions": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/transactions/-/transactions-5.8.0.tgz", + "integrity": "sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/constants": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/rlp": "^5.8.0", + "@ethersproject/signing-key": "^5.8.0" + } + }, + "node_modules/@ethersproject/wallet": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/wallet/-/wallet-5.8.0.tgz", + "integrity": "sha512-G+jnzmgg6UxurVKRKvw27h0kvG75YKXZKdlLYmAHeF32TGUzHkOFd7Zn6QHOTYRFWnfjtSSFjBowKo7vfrXzPA==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/abstract-provider": "^5.8.0", + "@ethersproject/abstract-signer": "^5.8.0", + "@ethersproject/address": "^5.8.0", + "@ethersproject/bignumber": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/hdnode": "^5.8.0", + "@ethersproject/json-wallets": "^5.8.0", + "@ethersproject/keccak256": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/random": "^5.8.0", + "@ethersproject/signing-key": "^5.8.0", + "@ethersproject/transactions": "^5.8.0", + "@ethersproject/wordlists": "^5.8.0" + } + }, + "node_modules/@ethersproject/web": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/web/-/web-5.8.0.tgz", + "integrity": "sha512-j7+Ksi/9KfGviws6Qtf9Q7KCqRhpwrYKQPs+JBA/rKVFF/yaWLHJEH3zfVP2plVu+eys0d2DlFmhoQJayFewcw==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/base64": "^5.8.0", + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@ethersproject/wordlists": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@ethersproject/wordlists/-/wordlists-5.8.0.tgz", + "integrity": "sha512-2df9bbXicZws2Sb5S6ET493uJ0Z84Fjr3pC4tu/qlnZERibZCeUVuqdtt+7Tv9xxhUxHoIekIA7avrKUWHrezg==", + "funding": [ + { + "type": "individual", + "url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@ethersproject/bytes": "^5.8.0", + "@ethersproject/hash": "^5.8.0", + "@ethersproject/logger": "^5.8.0", + "@ethersproject/properties": "^5.8.0", + "@ethersproject/strings": "^5.8.0" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@polymarket/clob-client-v2": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@polymarket/clob-client-v2/-/clob-client-v2-1.0.3.tgz", + "integrity": "sha512-dA2q/IoDh2u0CdEBRSvmbdC/+e+oxbRz1KeuydL3Yv3oGSudG0PAyyK7n/I80n/RV+5ZwreUKjb0pYT1aadVtg==", + "license": "MIT", + "dependencies": { + "@ethersproject/providers": "^5.8.0", + "@ethersproject/wallet": "^5.8.0", + "axios": "^1.0.0", + "browser-or-node": "^3.0.0", + "tslib": "^2.8.1", + "viem": "^2.46.3" + } + }, + "node_modules/@polymarket/clob-client-v2/node_modules/browser-or-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-3.0.0.tgz", + "integrity": "sha512-iczIdVJzGEYhP5DqQxYM9Hh7Ztpqqi+CXZpSmX8ALFs9ecXkQIeqRyM6TfxEfMVpwhl3dSuDvxdzzo9sUOIVBQ==", + "license": "MIT" + }, + "node_modules/@polymarket/clob-client-v2/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/bech32": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", + "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==", + "license": "MIT" + }, + "node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/ethers/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/ox": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.7.tgz", + "integrity": "sha512-zSQ/cfBdolj7U4++NAvH7sI+VG0T3pEohITCgcQj8KlawvTDY4vGVhDT64Atsm0d6adWfIYHDpu88iUBMMp+AQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ox/node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scrypt-js": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/scrypt-js/-/scrypt-js-3.0.1.tgz", + "integrity": "sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/viem": { + "version": "2.47.6", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.47.6.tgz", + "integrity": "sha512-zExmbI99NGvMdYa7fmqSTLgkwh48dmhgEqFrUgkpL4kfG4XkVefZ8dZqIKVUhZo6Uhf0FrrEXOsHm9LUyIvI2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.7", + "ws": "8.18.3" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2de44f7 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "btc5m-web", + "version": "5.0.0", + "description": "BTC 5-minute up/down order book monitor - standalone deployment edition", + "type": "module", + "scripts": { + "start": "npx tsx ./server.ts", + "collect": "npx tsx ./data-collector.ts" + }, + "dependencies": { + "@polymarket/clob-client-v2": "^1.0.3", + "dotenv": "^16.0.0", + "ethers": "^6.0.0", + "express": "^5.2.1", + "undici": "^6.25.0", + "ws": "^8.0.0" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^20.0.0", + "@types/ws": "^8.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + } +} diff --git a/polymarket-pnl.ts b/polymarket-pnl.ts new file mode 100644 index 0000000..9fd85a1 --- /dev/null +++ b/polymarket-pnl.ts @@ -0,0 +1,654 @@ +/** + * Polymarket real PnL module + * + * Data sources: + * - /activity?type=TRADE all CLOB buy/sell fills (both maker / taker, covers cases like t8 limit orders being taken) + * - /activity?type=REDEEM Claim credited + * - /positions current unprocessed positions (not sold / not claimed) + * + * Note: earlier we used the /trades endpoint, but it only returns fills from the taker's perspective, + * so limit orders like t8 that get taken as maker would be missing; therefore we switched to /activity?type=TRADE. + * + * Design: + * - Full load on startup (paginate to the end) + * - Incremental sync filtered by lastSyncTs, only pulling new data + * - A full refresh every 5 minutes as a fallback + * - Pair by (conditionId, outcome) to compute the full PnL of each position + */ + +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// ── Constants ─────────────────────────────────────────────── +const API_BASE = "https://data-api.polymarket.com"; +const API_HEADERS = { "User-Agent": "Mozilla/5.0" }; +const PAGE_SIZE = 100; +const REQUEST_TIMEOUT_MS = 15000; +// Polymarket official fee formula: fee = C × feeRate × p × (1 - p) +// For crypto markets (BTC 5m etc.) feeRate = 7.2% (max fee $1.80 / 100 shares @ p=0.5) +// Reference: https://docs.polymarket.com/trading/fees +const CRYPTO_FEE_RATE = 0.072; + +// ── Types ─────────────────────────────────────────────────── +export interface PmTrade { + proxyWallet: string; + side: "BUY" | "SELL"; + asset: string; + conditionId: string; + size: number; + price: number; + timestamp: number; // Unix seconds + outcome: string; // "Up" / "Down" + outcomeIndex: number; + title: string; + slug: string; + eventSlug: string; + transactionHash: string; +} + +export interface PmRedeem { + proxyWallet: string; + conditionId: string; + timestamp: number; // Unix seconds + size: number; + usdcSize: number; // Claim credited amount + transactionHash: string; + title: string; + slug: string; + eventSlug: string; +} + +export interface PmPosition { + proxyWallet: string; + conditionId: string; + asset: string; + size: number; + avgPrice: number; + initialValue: number; + currentValue: number; + cashPnl: number; + realizedPnl: number; + redeemable: boolean; + outcome: string; + outcomeIndex: number; + title: string; + endDate: string; +} + +export interface PositionSummary { + conditionId: string; + outcome: string; + outcomeIndex: number; + title: string; + slug: string; + windowStart: number; // parsed from slug + firstTs: number; // first trade time (seconds) + lastTs: number; // last trade time (seconds) + buys: PmTrade[]; + sells: PmTrade[]; + redeems: PmRedeem[]; + buyCost: number; // total buy spend (excluding fee) + sellRevenue: number; // total sell revenue (excluding fee) + redeemRevenue: number; // total Claim payback + totalFee: number; // total fee + netPnl: number; // real net PnL = sell + Claim - buy - fee + status: "claimed" | "sold" | "pending" | "settled_lost"; + strategySource?: string; // source from local .strategy-sources.json + // Extra info for unsettled positions (from /positions) + currentValue?: number; + currentRedeemable?: boolean; +} + +/** A flattened single row (one per BUY/SELL/REDEEM/LOST) */ +export interface PnlEvent { + ts: number; // seconds + kind: "BUY" | "SELL" | "REDEEM" | "LOST"; // LOST = settled to zero (virtual event) + outcome: string; // Up / Down + outcomeIndex: number; + conditionId: string; + title: string; + slug: string; // market slug (e.g. "btc-updown-5m-1777139100"), used by frontend to filter by market + size: number; + price: number; // = 1 for REDEEM (payout at 1 USDC/share) + cost: number; // BUY=spend, SELL=revenue, REDEEM=credited + fee: number; // BUY/SELL fee, REDEEM=0 + netAmount: number; // net cash change (out=negative, in=positive, fee included) + transactionHash: string; + strategySource?: string; + positionPnl?: number; // position settlement PnL, attached only to the last exit row (SELL/REDEEM/LOST) + positionStatus?: "claimed" | "sold" | "pending" | "settled_lost"; + pending?: true; // locally pre-inserted, shows "pending calibration" before API data returns +} + +// ── Network utilities ──────────────────────────────────────── +async function fetchJson(url: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const res = await fetch(url, { headers: API_HEADERS, signal: controller.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return (await res.json()) as T; + } finally { + clearTimeout(timer); + } +} + +async function fetchPaged(path: string, extraQs: string = ""): Promise { + const items: T[] = []; + let offset = 0; + while (true) { + const qs = `limit=${PAGE_SIZE}&offset=${offset}${extraQs ? "&" + extraQs : ""}`; + const url = `${API_BASE}/${path}${path.includes("?") ? "&" : "?"}${qs}`; + const batch = await fetchJson(url); + if (!Array.isArray(batch) || batch.length === 0) break; + items.push(...batch); + if (batch.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } + return items; +} + +// ── API wrappers ───────────────────────────────────────────── +// Use /activity?type=TRADE instead of /trades: the former covers fills from the maker's perspective (e.g. t8 limit orders being taken), +// while the latter only returns taker fills and would miss records of being passively filled as the resting order side. +export async function fetchAllTrades(proxy: string): Promise { + return fetchPaged(`activity?user=${proxy}&type=TRADE`); +} + +export async function fetchAllRedeems(proxy: string): Promise { + // Polymarket returns many empty redeem records with size=0 (multi-direction split noise from the same tx), filter them out + const all = await fetchPaged(`activity?user=${proxy}&type=REDEEM`); + return all.filter(r => (r.usdcSize > 0) || (r.size > 0)); +} + +export async function fetchAllPositions(proxy: string): Promise { + return fetchPaged(`positions?user=${proxy}`); +} + +/** Incremental fetch: only data with timestamp > sinceSec */ +export async function fetchTradesSince(proxy: string, sinceSec: number): Promise { + // The Polymarket API returns in reverse chronological order. Fetch the first page; if the last item is still > sinceSec, continue to the next page + const collected: PmTrade[] = []; + let offset = 0; + while (true) { + const url = `${API_BASE}/activity?user=${proxy}&type=TRADE&limit=${PAGE_SIZE}&offset=${offset}`; + const batch = await fetchJson(url); + if (!Array.isArray(batch) || batch.length === 0) break; + const fresh = batch.filter(t => t.timestamp > sinceSec); + collected.push(...fresh); + if (fresh.length < batch.length) break; // old data appeared, stop paging + if (batch.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } + return collected; +} + +export async function fetchRedeemsSince(proxy: string, sinceSec: number): Promise { + const collected: PmRedeem[] = []; + let offset = 0; + while (true) { + const url = `${API_BASE}/activity?user=${proxy}&type=REDEEM&limit=${PAGE_SIZE}&offset=${offset}`; + const batch = await fetchJson(url); + if (!Array.isArray(batch) || batch.length === 0) break; + const fresh = batch.filter(r => r.timestamp > sinceSec); + collected.push(...fresh); + if (fresh.length < batch.length) break; + if (batch.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } + return collected.filter(r => (r.usdcSize > 0) || (r.size > 0)); +} + +// ── Fee formula ────────────────────────────────────────────── +// Official formula: fee = C × feeRate × p × (1 - p), symmetric for buy/sell +// Makers are not charged, only the taker pays; our FOK orders are all takers +function feeOf(_side: "BUY" | "SELL", size: number, price: number): number { + return size * CRYPTO_FEE_RATE * price * (1 - price); +} + +/** Return the Unix seconds of today 0:00 in CST (UTC+8) */ +function getCstDayStartSec(): number { + const offsetMs = 8 * 3600_000; + const cstMs = Date.now() + offsetMs; + const cstDay = new Date(cstMs); + cstDay.setUTCHours(0, 0, 0, 0); + return Math.floor(cstDay.getTime() / 1000) - 8 * 3600; +} + +// ── Position pairing ───────────────────────────────────────── +/** + * Group trades + redeems by (conditionId, outcome), computing the full PnL for each group + * + * Note: redeem events do not contain outcome info; they are attributed to this market via conditionId. + * If you bought both Up and Down under the same conditionId (rare), the redeem + * is attributed to every outcome that appeared (only one side can win, the other has usdcSize=0 and has no effect). + */ +export function summarizePositions( + trades: PmTrade[], + redeems: PmRedeem[], + positions: PmPosition[], + strategySources: Map, +): PositionSummary[] { + type Key = string; + const mk = (c: string, o: string): Key => `${c}::${o}`; + const groups = new Map(); + + // 1. First group all trades by (conditionId, outcome) + for (const t of trades) { + const k = mk(t.conditionId, t.outcome); + let g = groups.get(k); + if (!g) { + const ws = parseWindowStartFromSlug(t.slug); + g = { + conditionId: t.conditionId, + outcome: t.outcome, + outcomeIndex: t.outcomeIndex, + title: t.title, + slug: t.slug, + windowStart: ws, + firstTs: t.timestamp, + lastTs: t.timestamp, + buys: [], + sells: [], + redeems: [], + buyCost: 0, sellRevenue: 0, redeemRevenue: 0, totalFee: 0, netPnl: 0, + status: "pending", + }; + groups.set(k, g); + } + if (t.side === "BUY") g.buys.push(t); + else g.sells.push(t); + g.firstTs = Math.min(g.firstTs, t.timestamp); + g.lastTs = Math.max(g.lastTs, t.timestamp); + } + + // 2. Attribute redeems by conditionId (a single conditionId may have multiple outcome groups) + const redeemsByCond = new Map(); + for (const r of redeems) { + const arr = redeemsByCond.get(r.conditionId) ?? []; + arr.push(r); + redeemsByCond.set(r.conditionId, arr); + } + + // 3. Compute the PnL of each group + for (const g of groups.values()) { + const rs = redeemsByCond.get(g.conditionId) ?? []; + // All redeems of the same conditionId are attached here (the winning side) + g.redeems = rs; + if (rs.length) g.lastTs = Math.max(g.lastTs, ...rs.map(r => r.timestamp)); + + g.buyCost = g.buys.reduce((s, b) => s + b.size * b.price, 0); + g.sellRevenue = g.sells.reduce((s, x) => s + x.size * x.price, 0); + g.redeemRevenue = rs.reduce((s, r) => s + r.usdcSize, 0); + g.totalFee = + g.buys.reduce((s, b) => s + feeOf("BUY", b.size, b.price), 0) + + g.sells.reduce((s, x) => s + feeOf("SELL", x.size, x.price), 0); + + g.netPnl = g.sellRevenue + g.redeemRevenue - g.buyCost - g.totalFee; + + // Determine status + if (rs.length > 0) g.status = "claimed"; + else if (g.sells.length > 0) g.status = "sold"; + else g.status = "pending"; + + // Strategy source: look up by the txHash of the first buy + if (g.buys.length) { + const src = strategySources.get(g.buys[0].transactionHash.toLowerCase()); + if (src) g.strategySource = src; + } + } + + // 4. Unsettled position info: supplement from /positions + for (const p of positions) { + const k = mk(p.conditionId, p.outcome); + const g = groups.get(k); + if (!g) continue; + g.currentValue = p.currentValue; + g.currentRedeemable = p.redeemable; + // Settled but zeroed out: upgrade from pending to settled_lost + if (g.status === "pending" && p.redeemable && p.currentValue === 0) { + g.status = "settled_lost"; + // In this case cashPnl is -initialValue (position value goes to zero) + // Already reflected in g.netPnl (sell=0, redeem=0, buyCost - fee is the loss) + } + } + + // 5. Return sorted by most recent time descending + return [...groups.values()].sort((a, b) => b.lastTs - a.lastTs); +} + +function parseWindowStartFromSlug(slug: string): number { + // slug format "btc-updown-5m-1776762000" + const m = slug.match(/(\d{10,})$/); + return m ? parseInt(m[1], 10) : 0; +} + +// ── Strategy source mapping (local persistence) ────────────── +const STRATEGY_SOURCES_FILE = resolve(__dirname, ".strategy-sources.json"); + +export function loadStrategySources(): Map { + try { + if (!existsSync(STRATEGY_SOURCES_FILE)) return new Map(); + const data = JSON.parse(readFileSync(STRATEGY_SOURCES_FILE, "utf-8")); + if (typeof data !== "object" || data == null) return new Map(); + return new Map(Object.entries(data as Record).map(([k, v]) => [k.toLowerCase(), v])); + } catch (err) { + console.warn(`[PmPnl] Failed to load strategy-sources: ${err instanceof Error ? err.message : String(err)}`); + return new Map(); + } +} + +export function saveStrategySources(map: Map): void { + try { + const obj: Record = {}; + for (const [k, v] of map) obj[k] = v; + writeFileSync(STRATEGY_SOURCES_FILE, JSON.stringify(obj, null, 2) + "\n", "utf-8"); + } catch (err) { + console.warn(`[PmPnl] Failed to save strategy-sources: ${err instanceof Error ? err.message : String(err)}`); + } +} + +// ── Manager: state + sync ──────────────────────────────────── +export class PmPnlManager { + private trades: PmTrade[] = []; + private redeems: PmRedeem[] = []; + private positions: PmPosition[] = []; + private strategySources: Map = loadStrategySources(); + private initialized = false; + private refreshing = false; + private lastRefreshAt = 0; // timestamp of the most recent successful full fetch (ms) + + constructor(private proxy: string) {} + + /** Record the strategy source of a trade (txHash → source) */ + recordStrategySource(txHash: string, source: string): void { + if (!txHash) return; + this.strategySources.set(txHash.toLowerCase(), source); + saveStrategySources(this.strategySources); + } + + /** Startup load (fetch today's CST data, equivalent to fetchAll) */ + async init(): Promise { + if (this.initialized) return; + await this.fetchAll(); + this.initialized = true; + } + + /** + * Fetch today's (from CST 0:00) trades/redeems + current positions, overwriting the local cache + * + * Note: although the function is named fetchAll, it actually only fetches "today's" fills, not the full history. + * Design reason: refreshing every 5 minutes + checking cross-day data on the Polymarket website is enough, no need to cache all history on the backend. + */ + async fetchAll(): Promise { + if (!this.proxy || this.refreshing) return false; + this.refreshing = true; + try { + const sinceSec = getCstDayStartSec(); + const [tradesRes, redeemsRes, positionsRes] = await Promise.allSettled([ + fetchTradesSince(this.proxy, sinceSec), + fetchRedeemsSince(this.proxy, sinceSec), + fetchAllPositions(this.proxy), + ]); + if (tradesRes.status === "fulfilled") this.trades = tradesRes.value; + else console.warn(`[PmPnl] today's trades failed: ${tradesRes.reason?.message ?? tradesRes.reason}`); + if (redeemsRes.status === "fulfilled") this.redeems = redeemsRes.value; + else console.warn(`[PmPnl] today's redeems failed: ${redeemsRes.reason?.message ?? redeemsRes.reason}`); + if (positionsRes.status === "fulfilled") this.positions = positionsRes.value; + else console.warn(`[PmPnl] positions failed: ${positionsRes.reason?.message ?? positionsRes.reason}`); + this.lastRefreshAt = Date.now(); + console.log(`[PmPnl] refresh (today CST): trades ${this.trades.length} / redeems ${this.redeems.length} / positions ${this.positions.length}`); + return true; + } catch (err) { + console.warn(`[PmPnl] refresh exception: ${err instanceof Error ? err.message : String(err)}`); + return false; + } finally { + this.refreshing = false; + } + } + + getLastRefreshAt(): number { return this.lastRefreshAt; } + + /** Return a snapshot aggregated by position */ + getSummaries(limit?: number): PositionSummary[] { + const all = summarizePositions(this.trades, this.redeems, this.positions, this.strategySources); + return limit ? all.slice(0, limit) : all; + } + + /** Return flattened per-event rows, in reverse chronological order. Returns only the last 7 days by default. */ + getEvents(opts?: { limit?: number; sinceDays?: number }): PnlEvent[] { + const sinceDays = opts?.sinceDays ?? 7; + const limit = opts?.limit; + const nowSec = Math.floor(Date.now() / 1000); + const sinceSec = sinceDays > 0 ? nowSec - sinceDays * 86400 : 0; + const summaries = summarizePositions(this.trades, this.redeems, this.positions, this.strategySources); + // Split each position: BUY + SELL + REDEEM each become a row, with position info attached + const events: PnlEvent[] = []; + for (const s of summaries) { + for (const b of s.buys) { + const fee = feeOf("BUY", b.size, b.price); + const cost = b.size * b.price; + events.push({ + ts: b.timestamp, + kind: "BUY", + outcome: b.outcome, + outcomeIndex: b.outcomeIndex, + conditionId: b.conditionId, + title: b.title, + slug: s.slug, + size: b.size, + price: b.price, + cost, fee, + netAmount: -(cost + fee), + transactionHash: b.transactionHash, + strategySource: s.strategySource, + }); + } + // Position settlement PnL is attached only to the last exit row (the one with the largest ts among SELL/REDEEM) + // To avoid showing the same netPnl value repeatedly when a position has multiple exits + const lastExitTs = Math.max( + ...s.sells.map(x => x.timestamp), + ...s.redeems.map(r => r.timestamp), + -Infinity, + ); + let pnlAttached = false; // attach only once when multiple rows share the same ts + for (const x of s.sells) { + const fee = feeOf("SELL", x.size, x.price); + const revenue = x.size * x.price; + const isLastExit = !pnlAttached && x.timestamp === lastExitTs; + if (isLastExit) pnlAttached = true; + events.push({ + ts: x.timestamp, + kind: "SELL", + outcome: x.outcome, + outcomeIndex: x.outcomeIndex, + conditionId: x.conditionId, + title: x.title, + slug: s.slug, + size: x.size, + price: x.price, + cost: revenue, fee, + netAmount: revenue - fee, + transactionHash: x.transactionHash, + strategySource: s.strategySource, + ...(isLastExit ? { positionPnl: s.netPnl, positionStatus: s.status } : {}), + }); + } + for (const r of s.redeems) { + const isLastExit = !pnlAttached && r.timestamp === lastExitTs; + if (isLastExit) pnlAttached = true; + events.push({ + ts: r.timestamp, + kind: "REDEEM", + outcome: s.outcome, + outcomeIndex: s.outcomeIndex, + conditionId: r.conditionId, + title: r.title, + slug: s.slug, + size: r.size, + price: 1, + cost: r.usdcSize, fee: 0, + netAmount: r.usdcSize, + transactionHash: r.transactionHash, + strategySource: s.strategySource, + ...(isLastExit ? { positionPnl: s.netPnl, positionStatus: s.status } : {}), + }); + } + + // Virtual "settled to zero" event: BUY exists + no SELL + no REDEEM + the window's settlement time has passed + // windowStart is parsed from slug, settlement time = windowStart + 300 seconds + if (s.buys.length > 0 && s.sells.length === 0 && s.redeems.length === 0 && s.windowStart > 0) { + const settleTs = s.windowStart + 300; + if (nowSec >= settleTs) { + // Synthesize a LOST row + const totalSize = s.buys.reduce((sum, b) => sum + b.size, 0); + events.push({ + ts: settleTs, + kind: "LOST", + outcome: s.outcome, + outcomeIndex: s.outcomeIndex, + conditionId: s.conditionId, + title: s.title, + slug: s.slug, + size: totalSize, + price: 0, + cost: 0, fee: 0, + netAmount: 0, // zeroing out produces no cash flow (the money was already spent at buy time) + transactionHash: s.buys[0].transactionHash, + strategySource: s.strategySource, + positionPnl: s.netPnl, // real PnL of this position = -buy cost - fee + positionStatus: "settled_lost", + }); + } + } + } + const filtered = sinceSec > 0 ? events.filter(e => e.ts >= sinceSec) : events; + filtered.sort((a, b) => b.ts - a.ts); + return limit ? filtered.slice(0, limit) : filtered; + } + + /** + * Unified stats snapshot (frontend panel / monitor page / TG share the same definition) + * + * Rules: + * - One trade = one trading window (deduped by conditionId) that has had a BUY + * - Settled = the window has either a SELL/REDEEM, or satisfies "window settlement time has passed + no sell, no redeem" (fallback, to handle the case where PM /positions does not return small positions) + * - Win = the window's net PnL > 0 + * + * sinceSec=0 means all history; other values are Unix seconds, counting only positions with firstTs >= sinceSec + */ + computeSnapshot(sinceSec: number = 0): { + positions: number; // total count (number of windows, including unsettled) + closedPositions: number; // number of settled trades + wins: number; // number of settled trades with net PnL > 0 + netPnl: number; // net PnL (sum of settled positions + unsettled floating loss i.e. -buyCost-fee also counted, consistent with the frontend recalcTotal behavior) + totalFee: number; + buyCost: number; + sellRevenue: number; + redeemRevenue: number; + } { + const summaries = summarizePositions(this.trades, this.redeems, this.positions, this.strategySources); + const nowSec = Math.floor(Date.now() / 1000); + + // Dedupe by conditionId into "windows", merging stats of multiple outcomes within the same cond + interface WinRow { + conditionId: string; + firstTs: number; + hasBuy: boolean; + hasSettled: boolean; // sells/redeems/settlement time has passed + buyCost: number; + sellRevenue: number; + redeemRevenue: number; + totalFee: number; + } + const windows = new Map(); + + for (const s of summaries) { + // Skip those never bought (defensive) + if (s.buys.length === 0) continue; + const cond = s.conditionId; + let row = windows.get(cond); + if (!row) { + row = { + conditionId: cond, + firstTs: s.firstTs, + hasBuy: false, + hasSettled: false, + buyCost: 0, + sellRevenue: 0, + redeemRevenue: 0, + totalFee: 0, + }; + windows.set(cond, row); + } + row.hasBuy = true; + row.firstTs = Math.min(row.firstTs, s.firstTs); + row.buyCost += s.buyCost; + row.sellRevenue += s.sellRevenue; + row.redeemRevenue += s.redeemRevenue; + row.totalFee += s.totalFee; + + // Whether this outcome is settled + const outcomeSettled = + s.sells.length > 0 || + s.redeems.length > 0 || + s.status === "settled_lost" || + // Fallback: windowStart has passed + no sell, no redeem (PM /positions may not return small positions settled to zero) + (s.windowStart > 0 && nowSec >= s.windowStart + 300 && s.sells.length === 0 && s.redeems.length === 0); + if (outcomeSettled) row.hasSettled = true; + } + + // Apply the sinceSec filter + const filtered = sinceSec > 0 + ? [...windows.values()].filter(w => w.firstTs >= sinceSec) + : [...windows.values()]; + + let positions = 0, closedPositions = 0, wins = 0; + let netPnl = 0, totalFee = 0, buyCost = 0, sellRevenue = 0, redeemRevenue = 0; + for (const w of filtered) { + if (!w.hasBuy) continue; + positions++; + buyCost += w.buyCost; + sellRevenue += w.sellRevenue; + redeemRevenue += w.redeemRevenue; + totalFee += w.totalFee; + const winNet = w.sellRevenue + w.redeemRevenue - w.buyCost - w.totalFee; + netPnl += winNet; + if (w.hasSettled) { + closedPositions++; + if (winNet > 0) wins++; + } + } + return { positions, closedPositions, wins, netPnl, totalFee, buyCost, sellRevenue, redeemRevenue }; + } + + /** Total PnL (last 7 days only by default; pass sinceDays=0 for all) */ + getTotalPnl(sinceDays: number = 7): { totalBuy: number; totalSell: number; totalRedeem: number; totalFee: number; netPnl: number; positionCount: number } { + const sinceSec = sinceDays > 0 ? Math.floor(Date.now() / 1000) - sinceDays * 86400 : 0; + let totalBuy = 0, totalSell = 0, totalRedeem = 0, totalFee = 0; + let count = 0; + for (const t of this.trades) { + if (sinceSec > 0 && t.timestamp < sinceSec) continue; + if (t.side === "BUY") totalBuy += t.size * t.price; + else totalSell += t.size * t.price; + totalFee += feeOf(t.side, t.size, t.price); + count++; + } + for (const r of this.redeems) { + if (sinceSec > 0 && r.timestamp < sinceSec) continue; + totalRedeem += r.usdcSize; + } + return { + totalBuy, totalSell, totalRedeem, totalFee, + netPnl: totalSell + totalRedeem - totalBuy - totalFee, + positionCount: count, + }; + } + + isInitialized(): boolean { + return this.initialized; + } +} diff --git a/scripts/check-adapter.ts b/scripts/check-adapter.ts new file mode 100644 index 0000000..50c124b --- /dev/null +++ b/scripts/check-adapter.ts @@ -0,0 +1,54 @@ +import 'dotenv/config'; +import { ethers } from 'ethers'; + +const RPC = 'https://polygon-bor-rpc.publicnode.com'; +const SAFE = process.env.POLYMARKET_PROXY_ADDRESS!; +const ADAPTER = '0xADa100874d00e3331D00F2007a9c336a65009718'; +const CTF = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045'; +const PUSD = '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB'; + +const provider = new ethers.JsonRpcProvider(RPC, 137, { staticNetwork: true }); + +const ctfRO = new ethers.Contract(CTF, [ + 'function getCollectionId(bytes32, bytes32, uint256) view returns (bytes32)', + 'function getPositionId(address, bytes32) view returns (uint256)', + 'function balanceOf(address, uint256) view returns (uint256)', + 'function payoutDenominator(bytes32) view returns (uint256)', + 'function payoutNumerators(bytes32, uint256) view returns (uint256)', +], provider); + +async function main() { + console.log('Safe:', SAFE); + console.log('CtfCollateralAdapter:', ADAPTER); + + // Fetch all claimable (do not filter curPrice, see the full set) + const r = await fetch(`https://data-api.polymarket.com/positions?user=${SAFE}&sizeThreshold=.01&redeemable=true&limit=100`); + const arr: any[] = await r.json(); + console.log(`data-api reports: ${arr.length} candidates\n`); + + // For each conditionId, test both collateralToken types: CTF direct PUSD vs adapter + // But actually the V2 token uses adapter as the collateralToken to create the positionId, so: + // Old positions (V1, pre-migration) use PUSD as collateralToken + // New positions (V2 post-migration) use adapter as collateralToken + for (const p of arr.slice(0, 5)) { + console.log(`\n=== ${p.title} (cond=${p.conditionId.slice(0,12)}...) ===`); + const denom = await ctfRO.payoutDenominator(p.conditionId); + if (denom === 0n) { console.log('Not resolved'); continue; } + const num0 = await ctfRO.payoutNumerators(p.conditionId, 0); + const num1 = await ctfRO.payoutNumerators(p.conditionId, 1); + console.log(`payout: [${num0}, ${num1}] denom=${denom}`); + + // Compute positionId using PUSD as collateralToken + for (const collat of [PUSD, ADAPTER]) { + console.log(` collateral=${collat === PUSD ? 'PUSD' : 'ADAPTER'}`); + for (const idx of [1, 2]) { + const collId = await ctfRO.getCollectionId(ethers.ZeroHash, p.conditionId, idx); + const posId = await ctfRO.getPositionId(collat, collId); + const bal = await ctfRO.balanceOf(SAFE, posId); + console.log(` indexSet=${idx} balance=${ethers.formatUnits(bal, 6)}`); + } + } + } +} + +main().catch(e => console.error(e)); diff --git a/scripts/check-balance.ts b/scripts/check-balance.ts new file mode 100644 index 0000000..42605ef --- /dev/null +++ b/scripts/check-balance.ts @@ -0,0 +1,64 @@ +import 'dotenv/config'; +import { ethers } from 'ethers'; + +const RPC = 'https://polygon-bor-rpc.publicnode.com'; +const SAFE = '0xeCbD41A018cAD2BdD3Fd560b40b472f6ff54c336'; +const COLLATERAL = '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB'; +const CTF = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045'; +const CONDITION_ID = '0x104f27e82a923cf3832854ae080bd8838ad288ee719e5cb1ac140e3b982d8f3d'; + +const provider = new ethers.JsonRpcProvider(RPC, 137, { staticNetwork: true }); + +const erc20 = new ethers.Contract(COLLATERAL, [ + 'function balanceOf(address) view returns (uint256)', + 'function decimals() view returns (uint8)', + 'function symbol() view returns (string)', +], provider); + +const ctfIface = new ethers.Interface([ + 'function getPositionId(address collateralToken, bytes32 collectionId) view returns (uint256)', + 'function getCollectionId(bytes32 parentCollectionId, bytes32 conditionId, uint256 indexSet) view returns (bytes32)', + 'function balanceOf(address account, uint256 id) view returns (uint256)', + 'function payoutDenominator(bytes32 conditionId) view returns (uint256)', + 'function payoutNumerators(bytes32 conditionId, uint256 index) view returns (uint256)', +]); +const ctf = new ethers.Contract(CTF, ctfIface, provider); + +async function main() { + const sym = await erc20.symbol(); + const dec = await erc20.decimals(); + const bal = await erc20.balanceOf(SAFE); + console.log(`Safe ${SAFE}`); + console.log(`${sym} balance: ${ethers.formatUnits(bal, dec)} (decimals=${dec})`); + + // Query the token balance on each outcome (real on-chain data) + for (const indexSet of [1, 2]) { + const collId = await ctf.getCollectionId(ethers.ZeroHash, CONDITION_ID, indexSet); + const posId = await ctf.getPositionId(COLLATERAL, collId); + const tokBal = await ctf.balanceOf(SAFE, posId); + const human = ethers.formatUnits(tokBal, 6); + console.log(`indexSet=${indexSet} (${indexSet === 1 ? 'No' : 'Yes/Down?'}) tokenBalance=${human}`); + } + + // payouts + const denom = await ctf.payoutDenominator(CONDITION_ID); + console.log(`payoutDenominator: ${denom}`); + for (let i = 0; i < 2; i++) { + const num = await ctf.payoutNumerators(CONDITION_ID, i); + console.log(`payoutNumerators[${i}]: ${num} (${num > 0n ? 'win' : 'lose'})`); + } + + // Check whether data-api still lists it as redeemable + console.log('\n--- data-api positions ---'); + const r1 = await fetch(`https://data-api.polymarket.com/positions?user=${SAFE}&sizeThreshold=.01&redeemable=true&limit=100`); + const a1: any[] = await r1.json(); + console.log('redeemable=true returned:', a1.length, 'items'); + a1.forEach(p => console.log(` curPrice=${p.curPrice} size=${p.size} value=${p.currentValue} outcome=${p.outcome} title=${p.title}`)); + + console.log('\n--- Recent activity ---'); + const r2 = await fetch(`https://data-api.polymarket.com/activity?user=${SAFE}&limit=10`); + const a2: any[] = await r2.json(); + a2.forEach(a => console.log(` ${a.type} ${a.title || ''} size=${a.size || ''} ts=${a.timestamp}`)); +} + +main().catch(e => console.error(e)); diff --git a/scripts/check-claim-tx.ts b/scripts/check-claim-tx.ts new file mode 100644 index 0000000..abee079 --- /dev/null +++ b/scripts/check-claim-tx.ts @@ -0,0 +1,84 @@ +import 'dotenv/config'; +import { ethers } from 'ethers'; + +const RPC = 'https://polygon-bor-rpc.publicnode.com'; +const TX = '0x098206326325e231fc5068c46fb7fabd5930b3789f0ed3a99cf2e0415a97a4ef'; +const SAFE = '0xeCbD41A018cAD2BdD3Fd560b40b472f6ff54c336'; +const COLLATERAL = '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB'; +const CTF = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045'; +const CONDITION_ID = '0x104f27e82a923cf3832854ae080bd8838ad288ee719e5cb1ac140e3b982d8f3d'; + +const provider = new ethers.JsonRpcProvider(RPC, 137, { staticNetwork: true }); + +const ctfIface = new ethers.Interface([ + 'event PayoutRedemption(address indexed redeemer, address indexed collateralToken, bytes32 indexed parentCollectionId, bytes32 conditionId, uint256[] indexSets, uint256 payout)', + 'function payoutNumerators(bytes32 conditionId, uint256 index) view returns (uint256)', + 'function payoutDenominator(bytes32 conditionId) view returns (uint256)', + 'function getOutcomeSlotCount(bytes32 conditionId) view returns (uint256)', + 'function getPositionId(address collateralToken, bytes32 collectionId) view returns (uint256)', + 'function getCollectionId(bytes32 parentCollectionId, bytes32 conditionId, uint256 indexSet) view returns (bytes32)', + 'function balanceOf(address account, uint256 id) view returns (uint256)', +]); +const safeIface = new ethers.Interface([ + 'event ExecutionSuccess(bytes32 txHash, uint256 payment)', + 'event ExecutionFailure(bytes32 txHash, uint256 payment)', +]); +const erc20Iface = new ethers.Interface([ + 'event Transfer(address indexed from, address indexed to, uint256 value)', +]); + +async function main() { + const receipt = await provider.getTransactionReceipt(TX); + if (!receipt) { console.error('Could not fetch receipt'); return; } + console.log('Block:', receipt.blockNumber, 'Status:', receipt.status, 'Logs count:', receipt.logs.length); + + // Parse all logs + for (const log of receipt.logs) { + console.log('\n---'); + console.log('addr:', log.address); + // Try to parse PayoutRedemption + try { + const parsed = ctfIface.parseLog({ topics: [...log.topics], data: log.data }); + if (parsed) { console.log('CTF event:', parsed.name, parsed.args); continue; } + } catch {} + try { + const parsed = safeIface.parseLog({ topics: [...log.topics], data: log.data }); + if (parsed) { console.log('Safe event:', parsed.name, parsed.args); continue; } + } catch {} + try { + const parsed = erc20Iface.parseLog({ topics: [...log.topics], data: log.data }); + if (parsed) { + console.log('ERC20 Transfer:', parsed.args.from, '→', parsed.args.to, ethers.formatUnits(parsed.args.value, 6), '(assuming 6 decimals)'); + continue; + } + } catch {} + console.log('Unknown event topics[0]:', log.topics[0]); + } + + // Query condition status on-chain + console.log('\n--- Condition status ---'); + const ctf = new ethers.Contract(CTF, ctfIface, provider); + const denom = await ctf.payoutDenominator(CONDITION_ID); + console.log('payoutDenominator:', denom.toString()); + if (denom > 0n) { + const slot = await ctf.getOutcomeSlotCount(CONDITION_ID); + console.log('outcomeSlotCount:', slot.toString()); + for (let i = 0; i < Number(slot); i++) { + const num = await ctf.payoutNumerators(CONDITION_ID, i); + console.log(` slot[${i}] payout:`, num.toString()); + } + } else { + console.log('⚠️ payoutDenominator=0 means the market has not resolved / has not reportPayouts yet'); + } + + // Query the Safe's previous token balances on both outcomes (after redeem they should theoretically both be 0) + console.log('\n--- Safe current token balances ---'); + for (const indexSet of [1, 2]) { + const collId = await ctf.getCollectionId(ethers.ZeroHash, CONDITION_ID, indexSet); + const posId = await ctf.getPositionId(COLLATERAL, collId); + const bal = await ctf.balanceOf(SAFE, posId); + console.log(`indexSet=${indexSet} positionId=${posId.toString().slice(0,20)}... balance=${bal}`); + } +} + +main().catch(e => console.error(e)); diff --git a/scripts/check-claimable-onchain.ts b/scripts/check-claimable-onchain.ts new file mode 100644 index 0000000..f07b49a --- /dev/null +++ b/scripts/check-claimable-onchain.ts @@ -0,0 +1,54 @@ +import 'dotenv/config'; +import { ethers } from 'ethers'; + +const RPC = 'https://polygon-bor-rpc.publicnode.com'; +const SAFE = process.env.POLYMARKET_PROXY_ADDRESS!; +const COLLATERAL = '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB'; +const CTF = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045'; + +const provider = new ethers.JsonRpcProvider(RPC, 137, { staticNetwork: true }); +const ctf = new ethers.Contract(CTF, [ + 'function payoutDenominator(bytes32) view returns (uint256)', + 'function payoutNumerators(bytes32, uint256) view returns (uint256)', + 'function getCollectionId(bytes32, bytes32, uint256) view returns (bytes32)', + 'function getPositionId(address, bytes32) view returns (uint256)', + 'function balanceOf(address, uint256) view returns (uint256)', +], provider); + +async function main() { + console.log('Safe:', SAFE); + + // data-api candidates + const r = await fetch(`https://data-api.polymarket.com/positions?user=${SAFE}&sizeThreshold=.01&redeemable=true&limit=100`); + const arr: any[] = await r.json(); + console.log(`data-api reports: ${arr.length} redeemable, of which curPrice=1: ${arr.filter(p=>p.curPrice===1).length}\n`); + + let realTotal = 0; + let fakeCount = 0; + for (const p of arr) { + const denom = await ctf.payoutDenominator(p.conditionId); + if (denom === 0n) { + console.log(`✗ ${p.title} | market not resolved`); + continue; + } + let realPayout = 0n; + for (const idx of [1, 2]) { + const collId = await ctf.getCollectionId(ethers.ZeroHash, p.conditionId, idx); + const posId = await ctf.getPositionId(COLLATERAL, collId); + const bal = await ctf.balanceOf(SAFE, posId); + const num = await ctf.payoutNumerators(p.conditionId, idx - 1); + realPayout += bal * num / denom; + } + const real = Number(ethers.formatUnits(realPayout, 6)); + if (real > 0) { + console.log(`✓ Claimable $${real.toFixed(4)} | ${p.title} | data-api reports $${p.currentValue.toFixed(4)}`); + realTotal += real; + } else { + console.log(`✗ Already claimed $0 | ${p.title} | data-api still reports $${p.currentValue.toFixed(4)} (index not refreshed)`); + fakeCount++; + } + } + console.log(`\n=== On-chain real claimable: $${realTotal.toFixed(4)} | data-api stale data: ${fakeCount} items ===`); +} + +main().catch(e => console.error(e)); diff --git a/scripts/check-real.ts b/scripts/check-real.ts new file mode 100644 index 0000000..b97dab8 --- /dev/null +++ b/scripts/check-real.ts @@ -0,0 +1,45 @@ +import 'dotenv/config'; +import { ethers } from 'ethers'; + +const SAFE = process.env.POLYMARKET_PROXY_ADDRESS!; +const CTF = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045'; +const provider = new ethers.JsonRpcProvider('https://polygon-bor-rpc.publicnode.com', 137, { staticNetwork: true }); +const ctf = new ethers.Contract(CTF, [ + 'function balanceOfBatch(address[], uint256[]) view returns (uint256[])', +], provider); + +async function main() { + console.log('Safe:', SAFE); + const r = await fetch(`https://data-api.polymarket.com/positions?user=${SAFE}&sizeThreshold=.01&redeemable=true&limit=100`); + const arr: any[] = await r.json(); + + const allTokens = new Set(); + for (const p of arr) { + if (p.asset) allTokens.add(p.asset); + if (p.oppositeAsset) allTokens.add(p.oppositeAsset); + } + const ids = [...allTokens].map(s => BigInt(s)); + console.log(`Checking ${ids.length} tokenIds (from data-api asset/oppositeAsset fields)\n`); + + const accounts = ids.map(() => SAFE); + const balances: bigint[] = await ctf.balanceOfBatch(accounts, ids); + + let nonZero = 0; + let totalRedeemable = 0; + for (let i = 0; i < ids.length; i++) { + if (balances[i] > 0n) { + nonZero++; + const p = arr.find(x => BigInt(x.asset) === ids[i] || (x.oppositeAsset && BigInt(x.oppositeAsset) === ids[i])); + const isMain = p && BigInt(p.asset) === ids[i]; + const outcome = isMain ? p?.outcome : p?.oppositeOutcome; + const bal = Number(ethers.formatUnits(balances[i], 6)); + console.log(`✓ balance=${bal.toFixed(4)} | ${p?.title} | ${outcome} | conditionId=${p?.conditionId.slice(0,12)}...`); + // Is this outcome the winner? data-api does not give payout directly, infer from curPrice + if (isMain && p.curPrice === 1) totalRedeemable += bal; + if (!isMain && p.curPrice === 0) totalRedeemable += bal; // opposite is the winner + } + } + console.log(`\n=== Safe holds ${nonZero} non-zero tokens, total winning shares $${totalRedeemable.toFixed(4)} ===`); +} + +main().catch(e => console.error(e)); diff --git a/scripts/claim-once.ts b/scripts/claim-once.ts new file mode 100644 index 0000000..70189a0 --- /dev/null +++ b/scripts/claim-once.ts @@ -0,0 +1,110 @@ +import 'dotenv/config'; +import { ethers } from 'ethers'; +import { getContractConfig } from '@polymarket/clob-client-v2'; + +const RPC = process.env.POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com'; +const PRIVATE_KEY = process.env.POLYMARKET_PRIVATE_KEY!; +const PROXY = process.env.POLYMARKET_PROXY_ADDRESS!; +const DRY_RUN = process.argv.includes('--dry-run'); + +if (!PRIVATE_KEY || !PROXY) { console.error('Missing env'); process.exit(1); } + +async function main() { + const provider = new ethers.JsonRpcProvider(RPC, 137, { staticNetwork: true }); + const wallet = new ethers.Wallet(PRIVATE_KEY, provider); + const contracts = getContractConfig(137); + const CTF = contracts.conditionalTokens; + const COLLATERAL = contracts.collateral; + // V2: redeem goes through CtfCollateralAdapter, which internally redeems CTF + auto-wraps pUSD + const ADAPTER = '0xADa100874d00e3331D00F2007a9c336a65009718'; + const ZERO_BYTES32 = '0x' + '0'.repeat(64); + + console.log('EOA:', wallet.address); + console.log('Safe:', PROXY); + console.log('CTF:', CTF); + console.log('Adapter:', ADAPTER); + console.log('Collateral:', COLLATERAL); + console.log('RPC:', RPC); + console.log('DRY_RUN:', DRY_RUN); + + const ctfIface = new ethers.Interface([ + 'function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets)' + ]); + const safeIface = new ethers.Interface([ + 'function nonce() view returns (uint256)', + 'function getOwners() view returns (address[])', + '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, safeIface, wallet); + + const owners: string[] = await safe.getOwners(); + console.log('Safe owners:', owners); + const isOwner = owners.map(o => o.toLowerCase()).includes(wallet.address.toLowerCase()); + console.log('EOA is owner?:', isOwner); + if (!isOwner) { console.error('❌ EOA is not a Safe owner'); process.exit(1); } + + const bal = await provider.getBalance(wallet.address); + console.log('EOA POL balance:', ethers.formatEther(bal)); + + // Query claimable positions + const res = await fetch(`https://data-api.polymarket.com/positions?user=${PROXY}&sizeThreshold=.01&redeemable=true&limit=100&offset=0`); + const arr: any[] = await res.json(); + const claimable = arr.filter(p => p.curPrice === 1); + console.log(`\nClaimable position count: ${claimable.length}`); + claimable.forEach(p => console.log(` - ${p.title} | $${p.currentValue.toFixed(4)} | ${p.conditionId}`)); + if (!claimable.length) { console.log('Nothing to claim, exiting'); return; } + + for (let i = 0; i < claimable.length; i++) { + const p = claimable[i]; + console.log(`\n[${i+1}/${claimable.length}] ${p.title}`); + try { + const calldata = ctfIface.encodeFunctionData('redeemPositions', [ + COLLATERAL, ZERO_BYTES32, p.conditionId, [1, 2] + ]); + const nonce: bigint = await safe.nonce(); + console.log(` nonce: ${nonce}`); + const txHash: string = 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'); + + // First verify with estimateGas / staticCall + try { + await safe.execTransaction.staticCall( + ADAPTER, 0, calldata, 0, 0, 0, 0, ethers.ZeroAddress, ethers.ZeroAddress, adjustedSig + ); + console.log(' ✓ staticCall passed'); + } catch (e: any) { + console.error(` ✗ staticCall failed: ${e.shortMessage || e.message}`); + continue; + } + + const gasEst = await safe.execTransaction.estimateGas( + ADAPTER, 0, calldata, 0, 0, 0, 0, ethers.ZeroAddress, ethers.ZeroAddress, adjustedSig + ); + console.log(` estimateGas: ${gasEst}`); + + if (DRY_RUN) { + console.log(' (DRY_RUN, skipping send)'); + continue; + } + + const tx = await safe.execTransaction( + ADAPTER, 0, calldata, 0, 0, 0, 0, ethers.ZeroAddress, ethers.ZeroAddress, adjustedSig + ); + console.log(` Sent txHash: ${tx.hash}`); + const receipt = await Promise.race([ + tx.wait(), + new Promise((_, reject) => setTimeout(() => reject(new Error('On-chain timeout 60s')), 60000)) + ]) as any; + console.log(` ✓ Success block:${receipt.blockNumber} gasUsed:${receipt.gasUsed}`); + } catch (e: any) { + console.error(` ✗ Failed: ${e.shortMessage || e.message}`); + } + } +} + +main().catch(e => { console.error(e); process.exit(1); }); diff --git a/scripts/claim-relayer.ts b/scripts/claim-relayer.ts new file mode 100644 index 0000000..6b579f1 --- /dev/null +++ b/scripts/claim-relayer.ts @@ -0,0 +1,151 @@ +import 'dotenv/config'; +import { ethers } from 'ethers'; +import { getContractConfig } from '@polymarket/clob-client-v2'; + +const PRIVATE_KEY = process.env.POLYMARKET_PRIVATE_KEY!; +const PROXY = process.env.POLYMARKET_PROXY_ADDRESS!; +const RPC = process.env.POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com'; +const RELAYER = 'https://relayer-v2.polymarket.com'; +const MULTISEND = '0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761'; +const DRY_RUN = process.argv.includes('--dry-run'); + +if (!PRIVATE_KEY || !PROXY) { console.error('Missing env'); process.exit(1); } + +const ZERO32 = '0x' + '0'.repeat(64); +const ctfIface = new ethers.Interface([ + 'function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets)' +]); +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)', +]); + +// Pack redeemPositions into a single multiSend item: [op(1)][to(20)][value(32)][dataLen(32)][data] +function encodeMultiSendItem(to: string, data: string): string { + const op = '00'; // CALL + const toHex = to.toLowerCase().replace(/^0x/, '').padStart(40, '0'); + const valueHex = '0'.repeat(64); + const dataBytes = ethers.getBytes(data); + const lenHex = dataBytes.length.toString(16).padStart(64, '0'); + const dataHex = data.replace(/^0x/, ''); + return op + toHex + valueHex + lenHex + dataHex; +} + +async function main() { + const provider = new ethers.JsonRpcProvider(RPC, 137, { staticNetwork: true }); + const wallet = new ethers.Wallet(PRIVATE_KEY, provider); + const contracts = getContractConfig(137); + const CTF = contracts.conditionalTokens; + const COLLATERAL = contracts.collateral; + + console.log('EOA:', wallet.address); + console.log('Proxy(Safe):', PROXY); + console.log('CTF:', CTF, ' Collateral:', COLLATERAL); + + // 1. Query claimable + const res = await fetch(`https://data-api.polymarket.com/positions?user=${PROXY}&sizeThreshold=.01&redeemable=true&limit=100&offset=0`); + const arr: any[] = await res.json(); + const claimable = arr.filter(p => p.curPrice === 1); + console.log(`Claimable position count: ${claimable.length}`); + claimable.forEach(p => console.log(` - ${p.title} | $${p.currentValue.toFixed(4)}`)); + if (!claimable.length) { console.log('Nothing to claim, exiting'); return; } + const total = claimable.reduce((s, p) => s + p.currentValue, 0); + console.log(`Total: $${total.toFixed(4)}`); + + // 2. Single redeemPositions (per the official docs example, operation=0 CALL sent directly to CTF) + const p = claimable[0]; + const data = ctfIface.encodeFunctionData('redeemPositions', [ + COLLATERAL, ZERO32, p.conditionId, [1, 2] + ]); + console.log('redeemPositions data length:', data.length / 2 - 1, 'bytes'); + + // 3. Compute Safe txHash + signature + const safe = new ethers.Contract(PROXY, safeIface, provider); + const nonce: bigint = await safe.nonce(); + console.log('Safe nonce:', nonce.toString()); + const txHash: string = await safe.getTransactionHash( + CTF, 0, data, 0, 0, 0, 0, ethers.ZeroAddress, ethers.ZeroAddress, nonce + ); + console.log('Safe txHash:', txHash); + 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'); + + // 4. Build the request body (per the official docs: operation=0 CALL, to=CTF, no metadata) + const body = { + from: wallet.address, + to: CTF, + proxyWallet: PROXY, + data, + nonce: nonce.toString(), + signature: adjustedSig, + signatureParams: { + gasPrice: '0', + operation: '0', + safeTxnGas: '0', + baseGas: '0', + gasToken: ethers.ZeroAddress, + refundReceiver: ethers.ZeroAddress, + }, + type: 'SAFE', + }; + + if (DRY_RUN) { + console.log('\n--- DRY_RUN body ---'); + console.log(JSON.stringify(body, null, 2)); + return; + } + + // 5. Submit + const RELAYER_API_KEY = process.env.RELAYER_API_KEY!; + const RELAYER_API_KEY_ADDRESS = process.env.RELAYER_API_KEY_ADDRESS!; + if (!RELAYER_API_KEY || !RELAYER_API_KEY_ADDRESS) { + console.error('Missing RELAYER_API_KEY / RELAYER_API_KEY_ADDRESS env'); + process.exit(1); + } + console.log('\nSubmitting to relayer...'); + // Use undici fetch to explicitly preserve header case + const { fetch: undiciFetch } = await import('undici'); + const submitRes: any = await undiciFetch(`${RELAYER}/submit`, { + method: 'POST', + headers: [ + ['Content-Type', 'application/json'], + ['RELAYER_API_KEY', RELAYER_API_KEY], + ['RELAYER_API_KEY_ADDRESS', RELAYER_API_KEY_ADDRESS], + ], + body: JSON.stringify(body), + }); + const submitText = await submitRes.text(); + console.log('Status:', submitRes.status); + console.log('Body:', submitText); + if (submitRes.status === 401) { + // Retry with curl to confirm whether the server really cannot see our headers + console.log('\n[debug] Retrying with curl'); + const curlCmd = `curl -s -i -X POST '${RELAYER}/submit' -H 'Content-Type: application/json' -H 'RELAYER_API_KEY: ${RELAYER_API_KEY}' -H 'RELAYER_API_KEY_ADDRESS: ${RELAYER_API_KEY_ADDRESS}' -d '${JSON.stringify(body)}'`; + const { execSync } = await import('child_process'); + try { + const out = execSync(curlCmd, { encoding: 'utf8' }); + console.log(out); + } catch (e: any) { + console.error(e.message); + } + return; + } + if (!submitRes.ok) { process.exit(1); } + const { transactionID, transactionHash, state } = JSON.parse(submitText); + console.log(`✓ Submitted successfully id:${transactionID} txHash:${transactionHash} state:${state}`); + + // 6. Poll + for (let i = 0; i < 20; i++) { + await new Promise(r => setTimeout(r, 2000)); + const r = await fetch(`${RELAYER}/transaction?id=${transactionID}`); + const j = await r.json(); + console.log(`[${i+1}] state:${j.state} txHash:${j.transactionHash}`); + if (j.state && j.state !== 'STATE_NEW' && j.state !== 'STATE_PENDING') { + console.log('Final state:', j); + break; + } + } +} + +main().catch(e => { console.error('Error:', e); process.exit(1); }); diff --git a/scripts/list-tokens.ts b/scripts/list-tokens.ts new file mode 100644 index 0000000..1dd2fd6 --- /dev/null +++ b/scripts/list-tokens.ts @@ -0,0 +1,66 @@ +import 'dotenv/config'; +import { ethers } from 'ethers'; + +const RPC = 'https://polygon-bor-rpc.publicnode.com'; +const SAFE = process.env.POLYMARKET_PROXY_ADDRESS!; +const CTF = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045'; + +const provider = new ethers.JsonRpcProvider(RPC, 137, { staticNetwork: true }); + +// Scan all tokens held by the Safe via TransferSingle/TransferBatch events +const ctfIface = new ethers.Interface([ + 'event TransferSingle(address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value)', + 'event TransferBatch(address indexed operator, address indexed from, address indexed to, uint256[] ids, uint256[] values)', + 'function balanceOfBatch(address[] accounts, uint256[] ids) view returns (uint256[])', +]); +const ctf = new ethers.Contract(CTF, ctfIface, provider); + +async function main() { + console.log('Safe:', SAFE); + const latest = await provider.getBlockNumber(); + console.log('Current block:', latest); + + // Scan the last 50000 blocks (~30 hours, polygon ~2.3s/block) + const fromBlock = latest - 50000; + console.log(`Scanning ${fromBlock} → ${latest}`); + + const safePadded = ethers.zeroPadValue(SAFE, 32); + const TRANSFER_SINGLE = ctfIface.getEvent('TransferSingle')!.topicHash; + + // Scan in segments + const tokenIds = new Set(); + const STEP = 10000; + for (let from = fromBlock; from <= latest; from += STEP) { + const to = Math.min(from + STEP - 1, latest); + // to = SAFE + const logsIn = await provider.getLogs({ + address: CTF, + topics: [TRANSFER_SINGLE, null, null, safePadded], + fromBlock: from, toBlock: to, + }); + for (const log of logsIn) { + const parsed = ctfIface.parseLog({ topics: [...log.topics], data: log.data }); + if (parsed) tokenIds.add(parsed.args.id.toString()); + } + process.stdout.write(`.`); + } + console.log(`\nFound ${tokenIds.size} distinct tokenIds (received within the last 30h)`); + + if (tokenIds.size === 0) return; + + // Batch query balances + const ids = [...tokenIds]; + const accounts = ids.map(() => SAFE); + const balances: bigint[] = await ctf.balanceOfBatch(accounts, ids); + + let nonZero = 0; + for (let i = 0; i < ids.length; i++) { + if (balances[i] > 0n) { + nonZero++; + console.log(`tokenId=${ids[i]} balance=${ethers.formatUnits(balances[i], 6)}`); + } + } + console.log(`\n=== Total ${nonZero} tokens with balance > 0 ===`); +} + +main().catch(e => console.error(e)); diff --git a/scripts/test-withdraw.ts b/scripts/test-withdraw.ts new file mode 100644 index 0000000..9b25831 --- /dev/null +++ b/scripts/test-withdraw.ts @@ -0,0 +1,198 @@ +/** + * Polymarket Proxy Wallet withdraw test script + * + * Uses Gnosis Safe execTransaction: EOA signs SafeTx EIP-712 hash -> call Proxy to transfer out USDC + * Does not depend on the official Polymarket API, interacts directly with the on-chain Safe + USDC contracts + * + * Usage: + * 1. Fill in CONFIG below (private key / Proxy / target address / withdraw amount) + * 2. tsx scripts/test-withdraw.ts + * + * Safety: DRY_RUN=true by default, only prints, does not send transactions. Change to false after confirming the params are correct. + */ + +import { ethers } from "ethers"; +import { readFileSync, existsSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +// Simple .env loader (no third-party package) +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ENV_FILE = resolve(__dirname, "..", ".env"); +if (existsSync(ENV_FILE)) { + for (const line of readFileSync(ENV_FILE, "utf-8").split("\n")) { + const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/); + if (!m) continue; + let val = m[2]; + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + if (process.env[m[1]] === undefined) process.env[m[1]] = val; + } +} + +// ── Config ────────────────────────────────────────────────── +// Private key and Proxy are read from .env (POLYMARKET_PRIVATE_KEY / POLYMARKET_PROXY_ADDRESS) +// This test script only tests one wallet; for production batch withdraws write a separate script that reads multi-wallet config +const CONFIG = { + // Withdraw target wallet (required) + TO_ADDRESS: "0x__TARGET_ADDRESS__", + // Withdraw amount (USDC, 6 decimals). Use 0.01 for testing + AMOUNT_USDC: "0.01", + // true = only print, do not send on-chain; false = actually send the transaction + DRY_RUN: true, +} as const; + +const EOA_PRIVATE_KEY = process.env.POLYMARKET_PRIVATE_KEY || ""; +const PROXY_ADDRESS = process.env.POLYMARKET_PROXY_ADDRESS || ""; +if (!EOA_PRIVATE_KEY || !PROXY_ADDRESS) { + console.error("✗ .env is missing POLYMARKET_PRIVATE_KEY or POLYMARKET_PROXY_ADDRESS"); + process.exit(1); +} + +// ── Constants ──────────────────────────────────────────────────── +const POLYGON_RPC = "https://polygon-bor-rpc.publicnode.com"; +const POLYGON_CHAIN_ID = 137; +// Polymarket uses USDC.e (PoS bridge USDC), not native USDC +const USDC_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"; +const USDC_DECIMALS = 6; + +// Gnosis Safe ABI (only the parts used) +const SAFE_ABI = [ + "function nonce() view returns (uint256)", + "function getThreshold() view returns (uint256)", + "function getOwners() view returns (address[])", + "function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures) payable returns (bool)", + "function VERSION() view returns (string)", +]; +const ERC20_ABI = [ + "function balanceOf(address) view returns (uint256)", + "function transfer(address,uint256)", + "function decimals() view returns (uint8)", +]; + +// SafeTx EIP-712 type +const EIP712_SAFE_TX_TYPE = { + SafeTx: [ + { type: "address", name: "to" }, + { type: "uint256", name: "value" }, + { type: "bytes", name: "data" }, + { type: "uint8", name: "operation" }, + { type: "uint256", name: "safeTxGas" }, + { type: "uint256", name: "baseGas" }, + { type: "uint256", name: "gasPrice" }, + { type: "address", name: "gasToken" }, + { type: "address", name: "refundReceiver" }, + { type: "uint256", name: "nonce" }, + ], +}; + +async function main() { + console.log("─── Polymarket Proxy withdraw test ───"); + console.log(`DRY_RUN: ${CONFIG.DRY_RUN}`); + + const provider = new ethers.JsonRpcProvider(POLYGON_RPC, POLYGON_CHAIN_ID); + const eoa = new ethers.Wallet(EOA_PRIVATE_KEY, provider); + console.log(`EOA: ${eoa.address}`); + console.log(`Proxy: ${PROXY_ADDRESS}`); + console.log(`To: ${CONFIG.TO_ADDRESS}`); + + // 1. Check Safe status + const safe = new ethers.Contract(PROXY_ADDRESS, SAFE_ABI, provider); + const [owners, threshold, safeNonce] = await Promise.all([ + safe.getOwners() as Promise, + safe.getThreshold() as Promise, + safe.nonce() as Promise, + ]); + console.log(`Owners: ${owners.join(", ")}`); + console.log(`Threshold: ${threshold} Nonce: ${safeNonce}`); + if (!owners.map(o => o.toLowerCase()).includes(eoa.address.toLowerCase())) { + throw new Error("EOA is not an owner of the Proxy, misconfigured"); + } + if (threshold !== 1n) { + throw new Error(`Proxy threshold=${threshold}, this script only supports 1/1 Safe`); + } + + // 2. Check USDC balance + const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider); + const balance = await usdc.balanceOf(PROXY_ADDRESS) as bigint; + const balanceHuman = ethers.formatUnits(balance, USDC_DECIMALS); + console.log(`Proxy USDC balance: ${balanceHuman}`); + + const amountWei = ethers.parseUnits(CONFIG.AMOUNT_USDC, USDC_DECIMALS); + if (amountWei > balance) { + throw new Error(`Insufficient balance: trying to withdraw ${CONFIG.AMOUNT_USDC}, only have ${balanceHuman}`); + } + console.log(`Withdraw amount: ${CONFIG.AMOUNT_USDC} USDC (${amountWei} wei)`); + + // 3. Check EOA MATIC (pays gas) + const maticBalance = await provider.getBalance(eoa.address); + console.log(`EOA MATIC: ${ethers.formatEther(maticBalance)}`); + if (maticBalance < ethers.parseEther("0.01")) { + console.warn("⚠ EOA MATIC < 0.01, may not have enough gas"); + } + + // 4. Build the callData for USDC.transfer(to, amount) + const usdcIface = new ethers.Interface(ERC20_ABI); + const transferData = usdcIface.encodeFunctionData("transfer", [CONFIG.TO_ADDRESS, amountWei]); + console.log(`transferData: ${transferData}`); + + // 5. Assemble SafeTx + const safeTx = { + to: USDC_ADDRESS, + value: 0n, + data: transferData, + operation: 0, // CALL + safeTxGas: 0n, + baseGas: 0n, + gasPrice: 0n, + gasToken: ethers.ZeroAddress, + refundReceiver: ethers.ZeroAddress, + nonce: safeNonce, + }; + + // 6. EIP-712 signature + const domain = { + chainId: POLYGON_CHAIN_ID, + verifyingContract: PROXY_ADDRESS, + }; + const signature = await eoa.signTypedData(domain, EIP712_SAFE_TX_TYPE, safeTx); + console.log(`SafeTx signature: ${signature}`); + + if (CONFIG.DRY_RUN) { + console.log("\n✓ DRY_RUN: all params OK, no transaction sent"); + console.log(" Set CONFIG.DRY_RUN = false and run again to actually withdraw"); + return; + } + + // 7. Send on-chain: call Proxy.execTransaction(...) + const safeWithSigner = safe.connect(eoa) as ethers.Contract; + console.log("\nSending execTransaction…"); + const tx = await safeWithSigner.execTransaction( + safeTx.to, + safeTx.value, + safeTx.data, + safeTx.operation, + safeTx.safeTxGas, + safeTx.baseGas, + safeTx.gasPrice, + safeTx.gasToken, + safeTx.refundReceiver, + signature + ); + console.log(`tx hash: ${tx.hash}`); + console.log("Waiting for confirmation…"); + const rec = await tx.wait(); + if (rec?.status === 1) { + console.log(`✓ Success block=${rec.blockNumber} gas=${rec.gasUsed}`); + const newBal = await usdc.balanceOf(PROXY_ADDRESS) as bigint; + console.log(`Proxy new balance: ${ethers.formatUnits(newBal, USDC_DECIMALS)} USDC`); + } else { + console.log("✗ tx mined but status=0 (execTransaction reverted internally)"); + } +} + +main().catch(err => { + console.error("✗ Failed:", err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..6bff58d --- /dev/null +++ b/server.ts @@ -0,0 +1,6559 @@ +/** + * 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 = { 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; + amount: Record; + shares: Record; // share config for limit strategies (minimum 5) + /** strategy tunable params (e.g. l1's tpDelta/slDiff), indexed by strategyKey -> paramKey -> value */ + params: Record>; + 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>; + amount?: Partial>; + shares?: Partial>; + params?: Partial>; + 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 { + 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 { + 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 { + 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; + const amount = {} as Record; + const shares = {} as Record; + const params = {} as Record>; + 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>; + 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 = {}; + 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(); + +// -- 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; + 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; + 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; +} +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(); +// WS events that arrived before the HTTP response (cached, looked up by orderID after HTTP returns) +const earlyWsCache = new Map(); + +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 { + 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(); + +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 { + 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)[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 { + 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 { + 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 { + 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 { + 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 | 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 | 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 { + 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): 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(); +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): 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): 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, + types: Record, + value: Record + ) => wallet.signTypedData( + domain as ethers.TypedDataDomain, + types as Record, + 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 { + 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(); + +// -- CLOB Client (for ordering) ---------------------------------------- +let clobClient: ClobClient | null = null; + +async function ensureClobClient(): Promise { + 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(), + asks: new Map(), + 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, + kline5m: [] as Array, +}; + +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); } +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, + apiSize: {} as Record, + apiVerified: {} as Record, + confirmedIds: new Set(), + confirmedIdOrder: [] as string[], + lastTradeAt: null as number | null, + lastApiSyncAt: null as number | null, +}; + +// -- Broadcast -------------------------------------------------- +function send(ws: WebSocket, type: string, data: Record): 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): 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(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, 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 { + const perStrategy: Record> = {}; + // observe panel data: collect as long as the strategy implements getObservePanel + const observePanels: Record = {}; + 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 { + 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 = { + 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[]; + 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[])[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 { + 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 { + 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 = {}; + 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 { + 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; + }; + + 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((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 | 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 | null = null; +let marketRenderTimer: ReturnType | null = null; +let marketValidationTimer: ReturnType | 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 { + 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[]) { + 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 { + 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>; + 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(); + 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 { + 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 }; + 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 }).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; + // 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 | undefined): "up" | "down" | null { + const market = ((event?.markets as Record[] | 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 { + 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[]; + const results = slugs.map(slug => { + const event = events.find((e: Record) => e.slug === slug) as Record | 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 | null = null; +let reconnectTimer: ReturnType | 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 { + 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 { + 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 | 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 { + 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 { + 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>; +const tickSizeCache = new Map(); + +/** 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 { + 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 { + 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((_, 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((_, 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 : {}; + 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; + errorMessage?: string; +} + +async function placeOrder(input: PlaceOrderInput): Promise { + 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 { + 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 { + 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(); + 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): 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 { + 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; + // 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 | 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(); + +// -- 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(); +// the set of strategies that have "already attempted to place" within this window (implements "place only once per window") +const strategyLimitWindowMark = new Set(); // value: `${strategyKey}:${windowStart}` +// orderIDs with cancel in progress (prevents the same tick / poll from concurrently invoking the same cancel API) +const cancelInFlight = new Set(); + +// -- 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; +}; +type PendingCancelConfirm = { + orderID: string; + reason: string; + ctx?: OrderCtx; + t0: number; + resolved: boolean; + timer: ReturnType; + 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(); +const pendingOrderConfirm = new Map(); +const pendingCancelConfirm = new Map(); +// 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(); +const earlyCancelConfirmCache = new Map(); +const EARLY_WS_CACHE_TTL_MS = 30_000; +function rememberEarlyWs(map: Map, 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 { + 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 { + 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 { + 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)[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 { + // 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 { + 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(); +/** the set of keys currently being signed, to avoid duplicate triggers within the same tick */ +const presignInflight = new Set(); + +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 { + 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 { + 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["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 { + 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 { + 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)[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; + 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); + 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__PARAM_ 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 { + for (let i = 0; i < PORT_MAX_TRIES; i++) { + const tryPort = PORT_BASE + i; + try { + await new Promise((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((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")); diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..6efd603 --- /dev/null +++ b/start.bat @@ -0,0 +1,38 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" + +if not exist ".env" ( + echo [Error] .env not found. Please copy .env.example and fill in your config: + echo copy .env.example .env + pause + exit /b 1 +) + +if not exist "node_modules\" ( + echo Installing dependencies... + npm install + if errorlevel 1 ( + echo [Error] npm install failed. + pause + exit /b 1 + ) +) + +set "APP_MODE=full" +for /f "tokens=1,* delims==" %%A in (.env) do ( + if /I "%%A"=="APP_MODE" set "APP_MODE=%%B" +) + +echo Starting BTC 5m monitor... +echo Mode: %APP_MODE% +echo Port 3456 in use will auto-fallback to 3457-3465 (see startup log) +echo Press Ctrl+C to stop. +echo. + +REM Probe actual listening port and open browser +if /I not "%APP_MODE%"=="headless" ( + start "" /b cmd /c "for /l %%i in (1,1,10) do (timeout /t 1 /nobreak >nul & for %%P in (3456 3457 3458 3459 3460 3461 3462 3463 3464 3465) do (curl -sf -m 1 http://localhost:%%P/api/state >nul 2>&1 && (start """" http://localhost:%%P & exit /b 0)))" +) + +npx --yes tsx server.ts diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..790b2e9 --- /dev/null +++ b/start.sh @@ -0,0 +1,98 @@ +#!/bin/bash +set -e + +cd "$(dirname "$0")" + +# Check whether .env exists +if [ ! -f .env ]; then + echo "No .env file found. Please copy .env.example first and fill in the config:" + echo " cp .env.example .env" + echo " Then edit .env and fill in POLYMARKET_PRIVATE_KEY and POLYMARKET_PROXY_ADDRESS" + exit 1 +fi + +# Auto-install Node.js (Linux only; macOS usually already has it) +if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then + if [ "$(uname -s)" = "Linux" ]; then + echo "Node.js not detected, auto-installing Node.js 20..." + if ! command -v curl >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y curl + fi + if command -v apt-get >/dev/null 2>&1; then + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + sudo apt-get install -y nodejs + elif command -v yum >/dev/null 2>&1; then + curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash - + sudo yum install -y nodejs + else + echo "Unable to identify the package manager. Please install Node.js 20+ manually and retry" + exit 1 + fi + echo "Node.js installation complete: $(node -v)" + else + echo "Node.js or npm not found. Please install Node.js 20+ first (visit https://nodejs.org)" + exit 1 + fi +fi + +# Install dependencies (on first run, or when package.json/lock is newer than node_modules) +NEED_INSTALL=0 +if [ ! -d node_modules ]; then + NEED_INSTALL=1 +elif [ package.json -nt node_modules ] || [ package-lock.json -nt node_modules ]; then + echo "Dependency updates detected..." + NEED_INSTALL=1 +fi +if [ $NEED_INSTALL -eq 1 ]; then + echo "Installing dependencies..." + npm install + # Update the node_modules timestamp so it won't reinstall next time + touch node_modules +fi + +# When the port is taken, no longer kill the old process; let server.ts auto-try 3457-3465 +if lsof -ti:3456 > /dev/null 2>&1; then + echo "Port 3456 is already in use; server will automatically try the next available port (see startup log below)" +fi + +echo "Starting BTC 5m order book monitor..." +APP_MODE=$(grep -E '^APP_MODE=' .env | tail -n1 | cut -d= -f2 | tr -d '\r' | tr -d '"') +if [ -z "$APP_MODE" ]; then + APP_MODE="full" +fi +echo "Run mode: $APP_MODE" +echo "Press Ctrl+C to exit" +echo "" + +# Clean up background child processes on exit (e.g. the subshell below that opens the browser asynchronously) +# Note: do not trap INT/TERM, otherwise bash handles it first and the signal won't reach the tsx/node process +trap 'kill -- -$$ 2>/dev/null' EXIT + +# Open the browser automatically once the service is ready +# Key: use the .port file written by the server to confirm the port of this startup (avoids opening an old server instance by mistake) +# First record the .port mtime before startup, then read once .port is refreshed (≠ old mtime) +if [ "$APP_MODE" != "headless" ] && [ "$(uname -s)" = "Darwin" ]; then + OLD_PORT_MTIME="" + if [ -f .port ]; then + OLD_PORT_MTIME=$(stat -f %m .port 2>/dev/null || echo "") + fi + ( + for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + sleep 1 + if [ -f .port ]; then + NEW_MTIME=$(stat -f %m .port 2>/dev/null || echo "") + # The file was rewritten by this startup (mtime changed) and the service responds + if [ "$NEW_MTIME" != "$OLD_PORT_MTIME" ]; then + PORT=$(cat .port 2>/dev/null | tr -d '[:space:]') + if [ -n "$PORT" ] && curl -sf -m 1 "http://localhost:$PORT/api/state" > /dev/null 2>&1; then + open "http://localhost:$PORT" >/dev/null 2>&1 || true + exit 0 + fi + fi + fi + done + ) & +fi + +# Use exec so node/tsx directly replaces the bash process (PID unchanged, one less hop, more reliable signaling) +exec npx tsx ./server.ts diff --git a/strategies/STRATEGY-GUIDE.md b/strategies/STRATEGY-GUIDE.md new file mode 100644 index 0000000..3c22ad9 --- /dev/null +++ b/strategies/STRATEGY-GUIDE.md @@ -0,0 +1,504 @@ +# Strategy Development Guide + +This document explains how to add / modify Polymarket up/down strategies in this project. + +--- + +## 1. File naming + +Strategies go under the `strategies/` directory, with filename format: + +``` +.ts +``` + +- **prefix** by strategy type: + - `d` — diff-based (market path) + - `p` — prob-chase (market path) + - `t` — trend arbitrage (market path) + - `l` — limit-order (maker resting orders; see §6A) + - `m` — momentum (reserved) +- **number** increments (e.g. d1.ts, d2.ts, d3.ts) + +The bundled strategies are **d1 / p1 / p2** (market path). Limit-order strategies use the `l` prefix. + +On startup `_runtime/loader.ts` automatically scans and registers them, **with no changes to the main project code**. + +--- + +## 2. Strategy interface + +Each strategy exports a **class implementing the `IStrategy` interface** as the default export. + +Minimal skeleton (see [d1.ts](d1.ts) for a complete example): + +```ts +import type { + IStrategy, StrategyKey, StrategyNumber, + StrategyTickContext, EntrySignal, ExitSignal, StrategyDescription, + StrategyDirection, +} from "./types.js"; + +export default class MyStrategy implements IStrategy { + readonly key: StrategyKey = "x1"; + readonly number: StrategyNumber = 1; + readonly name = "My Strategy"; + + // private state (must be cleared in resetState) + private peakDiff = 0; + + getDescription(): StrategyDescription { + return { + key: this.key, + number: this.number, + name: this.name, + title: "Strategy description", + category: { id: "diff", label: "Diff", color: "#58a6ff" }, + supportedMarkets: ["btc-5m"], // see §3 + lines: [ + { text: "📈 Entry condition description..." }, + ], + }; + } + + updateGuards(_ctx: StrategyTickContext): void { + // maintain cooldown locks, cold-start checks, etc. each tick + } + + checkEntry(ctx: StrategyTickContext): EntrySignal | null { + if (ctx.diffBps != null && ctx.diffBps >= 8) { + return { direction: "up" }; + } + return null; + } + + checkExit(_ctx: StrategyTickContext, _direction: StrategyDirection): ExitSignal { + // usually only return sl; take profit goes via GTC (see §6) + return null; + } + + resetState(): void { + this.peakDiff = 0; + } + + getStatePayload(): Record { + return { peakDiff: this.peakDiff }; + } +} +``` + +--- + +## 3. Market allowlist `supportedMarkets` + +Declare **which markets this strategy supports**. Unsupported markets are not shown by the frontend and skipped during the backend tick. + +```ts +supportedMarkets: ["btc-5m"] // BTC 5m only +supportedMarkets: ["btc-5m", "btc-15m"] // both BTC periods +supportedMarkets: ["eth-5m", "sol-5m"] // ETH and SOL 5m +// omitted / empty array = supports all markets (not recommended; most strategy thresholds are strongly coin-dependent) +``` + +**market key format**: `{symbol}-{period}` +- symbol: `btc` / `eth` / `sol` (see [market-configs.ts](../market-configs.ts)) +- period: `5m` / `15m` + +**Convention**: write a separate strategy file per coin + period. +- Example: BTC 5m uses d1.ts, ETH 5m uses d10.ts, BTC 15m uses d20.ts +- Do not split logic with if-else in the same file; it is hard to maintain + +--- + +## 4. TickContext fields + +`checkEntry` / `checkExit` / `updateGuards` all receive a `StrategyTickContext`: + +| Field | Type | Meaning | +|---|---|---| +| `rem` | number | current window remaining seconds (5m 0~300, 15m 0~900) | +| `upPct` | number\|null | Polymarket up probability, 0-100 integer | +| `dnPct` | number\|null | down probability (= 100 - upPct) | +| `diff` | number\|null | Binance current price - window open price (**absolute USD value**) | +| `diffBps` | number\|null | diff as bps of PTB, **cross-market generic** | +| `prevUpPct` | number\|null | the previous tick's upPct, used to detect threshold crossings | +| `kline1m` | Kline[] | Binance 1m K-lines (latest at the end) | +| `kline5m` | Kline[] | Binance 5m K-lines | +| `marketHoursOnly` | boolean | user config: whether to enter only during US stock market hours | +| `now` | number | current timestamp (ms) | + +### diff vs diffBps — which to use? + +- **Writing a cross-market strategy → use diffBps** (recommended) + - Example: BTC `diff=50` → `diffBps≈7`, SOL `diff=0.05` → `diffBps≈25` + - Thresholds written in bps are cross-market readable: `if (ctx.diffBps >= 10)` means a 0.1% move +- **Writing a BTC-5m-only legacy strategy → use diff** (keep as is) + - Do not casually change the USD thresholds of the legacy d1/d2/m1 etc.; they are already hand-tuned + +### Unit conversion + +- 1% = 100 bps +- 0.1% = 10 bps +- 0.01% = 1 bps +- diffBps is rounded to 2 decimal places + +--- + +## 5. Entry signal `checkEntry` + +```ts +checkEntry(ctx): EntrySignal | null { + // return null for no entry + // return { direction: "up" | "down" } to enter +} +``` + +**When called**: once every 250ms, only when `strategyRuntime.state === "SCANNING"` (the idle scanning period after IDLE). + +**Notes**: +- Do not write state-machine transition logic here — after returning a signal, server.ts handles it itself +- With multiple concurrent strategies, **the first strategy to return non-null wins**, iterated in strategyKeys order +- Data guard: skip when `upPct == null` / `diff == null` (data not ready) + +--- + +## 6. Exit signal `checkExit` + +```ts +checkExit(ctx, direction): ExitSignal { + // return null for no exit + // return { signal: "sl", reason: "..." } for stop loss + // take profit is generally not returned here (see below) +} +``` + +### Take profit goes via GTC limit order (important convention) + +**Why**: Polymarket waives the taker fee (~1.56%) for makers and pays a rebate, so a complete buy+sell using maker orders saves ~3% in fees. + +**Implementation** (market-path strategies, i.e. those that enter via `checkEntry`): +1. The strategy implements the optional method `getMarketTakeProfitPrice(): number | null` returning the absolute target price (0~1): + ```ts + getMarketTakeProfitPrice(): number | null { + return 0.98; // null = no take profit, hold to settlement + } + ``` +2. After the buy is MINED, server.ts automatically places a GTC sell order at that price (via the cond system) using the real filled shares +3. **Do not write a tp branch in `checkExit`**; keep only sl +4. Fallback: GTC minimum is 5 shares; if insufficient or order placement fails, the server automatically falls back to a local market take profit + +> Limit-path strategies (`orderType: "limit"`) do **not** use this method — they manage take profit / stop loss through `getLimitConditionOrder` instead. See §6A. + +### No-take-profit strategies + +The bundled strategies (d1 / p1 / p2) deliberately do not take profit — data proves that once BTC commits to a direction it reaches 99-100% at settlement, so taking profit actually earns less. They hold to settlement and return only an `sl` signal from `checkExit`, so none of them implement `getMarketTakeProfitPrice`. + +--- + +## 6A. Limit-order strategies (`checkLimitOrder` / `checkCancelOrder` / `getLimitConditionOrder`) + +Everything above (§5 `checkEntry`, §6 `checkExit`) is the **market path**: the server takes the order book and buys at market on a signal. A **limit-order strategy** is a separate path — it rests a GTC maker buy order on the book, manages its own cancellation, and on fill places a co-managed take-profit + stop-loss pair. Using maker orders on both legs is what saves the ~3% fee. + +A strategy opts into this path purely by what it implements; the main project code never changes. + +### Opting in + +Set `orderType: "limit"` in `getDescription()` and implement `checkLimitOrder`. The market-path methods become no-ops: + +```ts +getDescription(): StrategyDescription { + return { + key: this.key, number: this.number, name: this.name, + title: "...", + category: { id: "limit-diff", label: "Limit Diff", color: "#3fb950" }, + supportedMarkets: ["btc-5m"], + orderType: "limit", // ← declares this as a limit strategy + lines: [ /* ... */ ], + }; +} + +// market path unused — return nothing +checkEntry(_ctx) { return null; } +checkExit(_ctx, _dir) { return null; } +``` + +(`orderType` may also be `"both"` if a strategy uses the market and limit paths together. Omitted defaults to `"market"`.) + +### The three hooks and when the server calls them + +The server runs a limit tick every cycle. For each enabled limit strategy it follows this state machine: + +| Situation | Hook called | Return | Server action | +|---|---|---|---| +| No active resting order this window | `checkLimitOrder(ctx)` | `LimitOrderSignal` | place a GTC maker buy at the returned price/shares | +| | | `null` | do nothing this tick | +| An order is resting (unfilled / partially filled) | `checkCancelOrder(ctx, order)` | `true` | cancel the resting order | +| | | `false` | leave it on the book | +| A resting order is confirmed filled on-chain (MINED) | `getLimitConditionOrder()` | `{ stopProfit?, stopLoss? }` | place the TP + SL conditional pair for the filled shares | + +Key timing rules the server enforces for you — you do **not** code these: + +- **One order per window** by default. After placing, the window is marked; `checkLimitOrder` won't be called again that window. (Set `limitAllowReplaceAfterCancel = true` to allow re-placing after a cancel — see below.) +- **`checkLimitOrder` and `checkCancelOrder` are mutually exclusive per tick**: the place hook only runs when there is no resting order, the cancel hook only when there is one. +- **Remaining-shares cancellation after a fill is automatic** — your `checkCancelOrder` does not need to handle "cancel the unfilled remainder once partially filled". +- **All resting orders and presign caches are cleared on window switch.** + +### `checkLimitOrder` — place a resting buy + +Called each tick while the strategy has no active order. Return a `LimitOrderSignal` to rest a GTC maker buy, or `null` to wait. + +```ts +checkLimitOrder(ctx: StrategyTickContext): LimitOrderSignal | null { + // decide direction / price / shares from ctx (rem, diff, upPct, dnPct, ...) + if (/* your entry condition */) { + return { + direction: "up", // or "down" + price: 0.50, // 0~1 absolute limit price + shares: this.shares, // minimum 5 (Polymarket maker minimum) + }; + } + return null; +} +``` + +### `checkCancelOrder` — pull the resting buy + +Called each tick while an order is resting. Return `true` to cancel (e.g. the edge that justified the order has decayed, or the window is near its end), `false` to keep resting. The current order's runtime state is passed in: + +```ts +checkCancelOrder(ctx: StrategyTickContext, order: LimitOrderRuntime): boolean { + // order: { direction, price, shares, filledSize, windowStart } + if (/* condition gone */) return true; + return false; +} +``` + +### `getLimitConditionOrder` — TP + SL after fill + +Called once the resting buy is confirmed filled on-chain. Return a take-profit and/or stop-loss spec; the server places them as a **shared group** (triggering either one auto-cancels the other), reusing the same conditional-order infrastructure as manual TP/SL. + +```ts +getLimitConditionOrder() { + return { + // take profit (omit for no TP → hold to settlement): + stopProfit: { pctDelta: 0.05 }, // tp price = entryPrice + 0.05 + // ...or an absolute price instead: { targetPrice: 0.99 } + // stop loss (diff-crossing, market sell): + stopLoss: { diffValue: 10, slippage: 0.15 }, + // buy up → triggers when diff ≤ -diffValue + // buy down→ triggers when diff ≥ +diffValue + }; +} +``` + +- Omit `stopProfit` → no take profit, the filled shares are held to settlement. +- Omit `stopLoss` → no automatic stop loss. +- This interface **takes precedence over** the simpler `getLimitTakeProfitPrice()` (TP-only) fallback; implement one or the other. + +### Optional refinements + +- **`limitAllowReplaceAfterCancel?: boolean`** (default `false`). When `true`, the per-window mark is cleared after a cancel, so `checkLimitOrder` may place a fresh order again later in the same window (useful for full-window strategies that re-arm after a false start). When `false`, one cancel ends the strategy's activity for that window. + +- **`getPresignRequest?(): PresignRequest | null`** — a pure latency optimization. Declare the rem interval / directions / price / shares you expect to use, and the server pre-signs the order package in the background so that when `checkLimitOrder` fires it can `postOrder` immediately and skip the signing round-trip. Your `checkLimitOrder` does not need to know whether the presign hit — it just returns the signal as usual; presigning is invisible to strategy logic and is invalidated on window switch. + +### Minimal limit-strategy skeleton + +```ts +import type { + IStrategy, StrategyKey, StrategyNumber, StrategyDirection, + StrategyTickContext, EntrySignal, ExitSignal, StrategyDescription, + LimitOrderSignal, LimitOrderRuntime, +} from "./types.js"; + +export class MyLimitStrategy implements IStrategy { + readonly key: StrategyKey = "l9"; + readonly number: StrategyNumber = 9; + readonly name = "My Limit Strategy"; + shares = 5; + readonly limitAllowReplaceAfterCancel = true; + + getDescription(): StrategyDescription { + return { + key: this.key, number: this.number, name: this.name, + title: "Limit example", + category: { id: "limit-diff", label: "Limit Diff", color: "#3fb950" }, + supportedMarkets: ["btc-5m"], + orderType: "limit", + lines: [{ text: "..." }], + }; + } + + updateGuards(_ctx: StrategyTickContext): void {} + checkEntry(_ctx: StrategyTickContext): EntrySignal | null { return null; } + checkExit(_ctx: StrategyTickContext, _d: StrategyDirection): ExitSignal { return null; } + + checkLimitOrder(ctx: StrategyTickContext): LimitOrderSignal | null { + // return { direction, price, shares } when conditions are met, else null + return null; + } + + checkCancelOrder(ctx: StrategyTickContext, _order: LimitOrderRuntime): boolean { + // return true to cancel the resting order + return false; + } + + getLimitConditionOrder() { + return { stopProfit: { pctDelta: 0.05 }, stopLoss: { diffValue: 10, slippage: 0.15 } }; + } + + resetState(): void {} + getStatePayload(): Record { return {}; } +} +``` + +> The filename prefix for limit strategies is `l` (see §1). The frontend renders a **shares** input box for `orderType: "limit"` strategies (instead of the amount box used by market strategies). + +--- + +## 7. Strategy lifecycle + +``` +IDLE → SCANNING → BUYING → WAIT_FILL → HOLDING → SELLING → DONE +``` + +Methods called in each state: + +| State | Calls | Description | +|---|---|---| +| SCANNING | `checkEntry` | look for entry opportunities | +| BUYING / WAIT_FILL | — | place order / wait for fill | +| HOLDING | `checkExit` | look for stop loss | +| SELLING | — | closing the position | + +**`updateGuards`**: called every tick (regardless of state), used to maintain the strategy's private state (cooldown locks, consecutive observation counts, etc.). + +**`onEntryFilled`** (optional): called once after a buy fills, used to record entryPrice etc. + +**`resetState`**: called on window switch / strategy switch, clears private fields. + +**`getStatePayload`**: returns the strategy's private state to display on the frontend (e.g. peakDiff). + +--- + +## 8. Data tables (fair-prob / diff-extremes) + +When a strategy needs to look up a table (e.g. the p-series looks up the fair-prob deviation): + +```ts +import { getFairProb } from "./_core/fair-prob.js"; + +const fair = getFairProb(ctx.diff, ctx.rem); +if (fair == null) return null; // current market has no table, safely skip +``` + +### Adding a table for a new market + +Data tables use a `Record` structure; adding a new table changes only one place: + +```ts +// strategies/_core/fair-prob.ts +export const FAIR_PROB_TABLES: Record = { + "btc-5m": BTC_5M, + "eth-5m": ETH_5M, // ← new + "btc-15m": BTC_15M, // ← new +}; +``` + +Generate data: run `python3 backtest-data/analyze.py --symbol --period

` and paste the output in. + +### Guard mechanism + +- Current market has no table → `getFairProb` returns null +- Strategy receives null → does not enter +- Combined with `supportedMarkets`, double protection + +--- + +## 9. Observe panel (optional) + +A strategy can display an observe panel in the frontend's top status bar (e.g. m1's factor scoring): + +```ts +readonly alwaysComputeData = true; // run computeData even when the strategy is disabled + +computeData(ctx: StrategyTickContext): void { + // compute panel data, store to this.xxx +} + +getObservePanel(): ObservePanelData { + return { + title: "Entry factors", + color: "#3fb950", + rows: [ + { type: "score", label: "Momentum", value: this.score, threshold: 60 }, + { type: "direction", label: "Direction", value: "up" }, + ], + }; +} +``` + +The frontend's generic renderer displays it automatically, with no extra frontend code. + +--- + +## 10. New strategy checklist + +After writing a new strategy, self-check against this list: + +- [ ] filename `.ts` is unique +- [ ] `key` matches the filename +- [ ] `number` matches the filename's number and is globally unique +- [ ] implements `getDescription` / `updateGuards` / `checkEntry` / `checkExit` / `resetState` / `getStatePayload` +- [ ] **`supportedMarkets` is declared** (otherwise it shows in all markets but uses the wrong thresholds) +- [ ] **all private state is cleared in `resetState`** +- [ ] data guard: correctly handles when `upPct == null` / `diff == null` / `getFairProb()` returns null +- [ ] cross-market strategy: use `ctx.diffBps` instead of `ctx.diff` +- [ ] take profit goes via GTC: implement `getTargetPrice`, do not write a tp branch in `checkExit` +- [ ] after restarting the service, `/api/strategy/descriptions` shows the new strategy +- [ ] switching to the corresponding market on the frontend shows the strategy toggle +- [ ] after enabling, the logs `[Strategy]` / `[Order]` show entry behavior + +--- + +## 11. Debugging tips + +### Dry-run (no order) test + +Add a temporary `console.log` to the strategy file, start the service but **do not check enable on the frontend** — `updateGuards` / `computeData` are still called (if `alwaysComputeData` is declared), so you can observe scoring without actually placing orders. + +### View live data + +Open the frontend → look at the "Prob Chase" panel at the top for the fair / bias values, or the "Momentum" panel for the factor values. + +### Historical data backtest + +Run `npx tsx backtest/.ts` to replay historical jsonl — for how to write the script, refer to [backtest/diff-extremes.ts](../backtest/diff-extremes.ts). + +### Test a single market in isolation + +Before starting, change `.active-market.json` to `{"key":"eth-5m"}` and restart the service. Or switch via the top dropdown on the frontend. + +--- + +## 12. Common pitfalls + +1. **Forgetting to clear state** — `resetState` not cleaned thoroughly, leaving stale peakDiff and the like across windows. +2. **Using ctx.diff in a cross-market strategy** — a BTC threshold of $35 triggers at $0.35 on ETH, inevitably causing bad entries. **Use diffBps**. +3. **Writing a tp branch in checkExit** — conflicts with GTC and may sell twice. Take profit always via `getTargetPrice`. +4. **Wrong supportedMarkets key** — e.g. `["BTC-5m"]` (uppercase) → never matches, strategy not shown. Must be lowercase `["btc-5m"]`. +5. **Depending on fair-prob but forgetting to check null** — switching to a market without a table makes `getFairProb` return null; not handling it makes NaN comparisons always false (seemingly harmless but hard to spot). +6. **Static field sharing** — using `static` fields shares them across all strategy instances. Use `private` instance fields for private state. + +--- + +## Appendix: related file index + +- [types.ts](types.ts) — interface definitions +- [registry.ts](registry.ts) — registry (usually no need to change) +- [_runtime/loader.ts](_runtime/loader.ts) — auto loader +- [_core/fair-prob.ts](_core/fair-prob.ts) — fair probability table +- [_core/diff-extremes.ts](_core/diff-extremes.ts) — diff extremes table +- [_core/s6-core.ts](_core/s6-core.ts) — momentum scoring shared logic +- [../market-configs.ts](../market-configs.ts) — market config (symbol / period / slug etc.) +- [../server.ts](../server.ts) — main service (strategy scheduling, order placement) +- [../CLAUDE.md](../CLAUDE.md) — project overview diff --git a/strategies/_core/diff-extremes.ts b/strategies/_core/diff-extremes.ts new file mode 100644 index 0000000..85c9809 --- /dev/null +++ b/strategies/_core/diff-extremes.ts @@ -0,0 +1,84 @@ +/** + * diff extremes mapping table — used by adaptive-threshold strategies such as p2 + * + * Data structure: EXTREME_TABLES is Record + * - key shaped like "btc-5m", "eth-5m" + * - each market maintains its own (diff magnitude and distribution differ completely across markets) + * + * Adding a new market table: + * 1. Run the backtest: `npx tsx backtest/diff-extremes.ts --market ` + * 2. Add the generated array to EXTREME_TABLES["sym-period"] + * 3. Restart the service + * + * A market without a table makes getExtremeThreshold() return null, and related strategies automatically no-op + * (combined with the strategy's supportedMarkets allowlist, double protection) + */ + +// Percentiles for each bin (p50/p70/p80/p85/p90/p93/p95/p97/p99) +type PercentileRow = { + p50: number; p70: number; p80: number; p85: number; + p90: number; p93: number; p95: number; p97: number; p99: number; +}; + +type RawData = Array<[string, PercentileRow]>; // [rem bin, percentile row] + +// BTC 5m — statistics based on 6 days of tick data from 2026-04-04 ~ 2026-04-09 +const BTC_5M: RawData = [ + ["0-30", { p50: 32, p70: 56, p80: 77, p85: 91, p90: 115, p93: 136, p95: 167, p97: 200, p99: 303 }], + ["30-60", { p50: 30, p70: 53, p80: 73, p85: 86, p90: 109, p93: 135, p95: 158, p97: 191, p99: 295 }], + ["60-90", { p50: 28, p70: 50, p80: 68, p85: 82, p90: 103, p93: 128, p95: 151, p97: 194, p99: 290 }], + ["90-120", { p50: 26, p70: 48, p80: 66, p85: 79, p90: 99, p93: 125, p95: 150, p97: 192, p99: 288 }], + ["120-150", { p50: 25, p70: 46, p80: 64, p85: 76, p90: 98, p93: 118, p95: 139, p97: 182, p99: 278 }], + ["150-180", { p50: 24, p70: 43, p80: 59, p85: 70, p90: 91, p93: 110, p95: 127, p97: 159, p99: 244 }], + ["180-210", { p50: 21, p70: 38, p80: 53, p85: 64, p90: 80, p93: 97, p95: 115, p97: 147, p99: 210 }], + ["210-240", { p50: 18, p70: 33, p80: 46, p85: 54, p90: 68, p93: 81, p95: 95, p97: 121, p99: 182 }], + ["240-270", { p50: 14, p70: 24, p80: 33, p85: 39, p90: 49, p93: 57, p95: 65, p97: 81, p99: 127 }], + ["270-300", { p50: 8, p70: 17, p80: 23, p85: 28, p90: 35, p93: 41, p95: 48, p97: 59, p99: 89 }], +]; + +// Extreme tables per market — to add a new market, just add an entry here +export const EXTREME_TABLES: Record = { + "btc-5m": BTC_5M, + // "eth-5m": ETH_5M, + // "btc-15m": BTC_15M, +}; + +// Precompiled into a Map, by marketKey → (binKey → PercentileRow) +const COMPILED_MAPS = new Map>(); +for (const [marketKey, raw] of Object.entries(EXTREME_TABLES)) { + const m = new Map(); + for (const [bin, row] of raw) m.set(bin, row); + COMPILED_MAPS.set(marketKey, m); +} + +/** Look up the bin by rem value (one bin per 30 seconds, covering 0-300) */ +export function getExtremeBin(rem: number): string | null { + if (rem < 0 || rem >= 300) return rem >= 300 ? "270-300" : null; + const lo = Math.floor(rem / 30) * 30; + return `${lo}-${lo + 30}`; +} + +/** Query the percentile extreme at a given rem; returns null when the current market has no table */ +export function getExtremeThreshold( + rem: number, + percentile: 50 | 70 | 80 | 85 | 90 | 93 | 95 | 97 | 99, +): number | null { + const map = COMPILED_MAPS.get(activeMarketKey); + if (!map) return null; + const bin = getExtremeBin(rem); + if (!bin) return null; + const row = map.get(bin); + if (!row) return null; + return row[`p${percentile}` as keyof PercentileRow]; +} + +/** Whether the current market has an extreme table */ +export function hasExtremeTable(marketKey?: string): boolean { + return COMPILED_MAPS.has(marketKey ?? activeMarketKey); +} + +// Current active market key (synced by server.ts when switching markets) +let activeMarketKey = "btc-5m"; +export function setActiveMarket(sym: string, period: string): void { + activeMarketKey = `${sym}-${period}`; +} diff --git a/strategies/_core/fair-prob.ts b/strategies/_core/fair-prob.ts new file mode 100644 index 0000000..a6bddde --- /dev/null +++ b/strategies/_core/fair-prob.ts @@ -0,0 +1,441 @@ +/** + * Fair probability mapping table — shared by the p-series strategies + * + * Data structure: FAIR_PROB_TABLES is Record + * - key shaped like "btc-5m", "eth-5m", "btc-15m" + * - each market maintains its own table (the fair-probability distribution differs completely across markets) + * + * Adding a new market table: + * 1. Run the backtest: `python3 backtest-data/analyze.py --symbol --period

` + * 2. Add the generated RawData array to FAIR_PROB_TABLES["sym-period"] + * 3. Restart the service + * + * A market without a table makes getFairProb() return null, and p-series strategies automatically no-op + * (combined with the strategy's supportedMarkets allowlist, double protection) + */ + +type RawData = Array<[number, string, number]>; // [diff bucket, rem bin, upPct median] + +// BTC 5m table (based on 37 days of ~2.5M tick statistics; step=2, updated 2026-05-09) +// How to update: rerun the backtest tool to regenerate and replace the data below +const BTC_5M: RawData = [ + [-140,"0-30",1],[-140,"30-60",1],[-140,"60-120",2],[-140,"120-180",6],[-140,"180-240",10],[-140,"240-300",16], + [-138,"0-30",0],[-138,"30-60",0],[-138,"60-120",2],[-138,"120-180",5],[-138,"180-240",14],[-138,"240-300",15], + [-136,"0-30",0],[-136,"30-60",0],[-136,"60-120",6],[-136,"120-180",10],[-136,"180-240",11],[-136,"240-300",14], + [-134,"0-30",0],[-134,"30-60",1],[-134,"60-120",12],[-134,"120-180",8],[-134,"180-240",10],[-134,"240-300",16], + [-132,"0-30",0],[-132,"30-60",2],[-132,"60-120",5],[-132,"120-180",12],[-132,"180-240",22],[-132,"240-300",29], + [-130,"0-30",0],[-130,"30-60",1],[-130,"60-120",7],[-130,"120-180",9],[-130,"180-240",19],[-130,"240-300",10], + [-128,"0-30",1],[-128,"30-60",7],[-128,"60-120",8],[-128,"120-180",16],[-128,"180-240",14],[-128,"240-300",34], + [-126,"0-30",0],[-126,"30-60",1],[-126,"60-120",3],[-126,"120-180",12],[-126,"180-240",12],[-126,"240-300",41], + [-124,"0-30",0],[-124,"30-60",8],[-124,"60-120",4],[-124,"120-180",9],[-124,"180-240",16],[-124,"240-300",12], + [-122,"0-30",0],[-122,"30-60",0],[-122,"60-120",1],[-122,"120-180",10],[-122,"180-240",15],[-122,"240-300",14], + [-120,"0-30",1],[-120,"30-60",5],[-120,"60-120",2],[-120,"120-180",6],[-120,"180-240",19],[-120,"240-300",18], + [-118,"0-30",0],[-118,"30-60",1],[-118,"60-120",1],[-118,"120-180",10],[-118,"180-240",13],[-118,"240-300",25], + [-116,"0-30",1],[-116,"30-60",6],[-116,"60-120",4],[-116,"120-180",6],[-116,"180-240",17],[-116,"240-300",22], + [-114,"0-30",0],[-114,"30-60",1],[-114,"60-120",5],[-114,"120-180",7],[-114,"180-240",9],[-114,"240-300",25], + [-112,"0-30",1],[-112,"30-60",3],[-112,"60-120",3],[-112,"120-180",5],[-112,"180-240",10],[-112,"240-300",14], + [-110,"0-30",0],[-110,"30-60",0],[-110,"60-120",2],[-110,"120-180",5],[-110,"180-240",10],[-110,"240-300",25], + [-108,"0-30",0],[-108,"30-60",4],[-108,"60-120",3],[-108,"120-180",6],[-108,"180-240",10],[-108,"240-300",31], + [-106,"0-30",0],[-106,"30-60",0],[-106,"60-120",5],[-106,"120-180",7],[-106,"180-240",8],[-106,"240-300",16], + [-104,"0-30",0],[-104,"30-60",1],[-104,"60-120",3],[-104,"120-180",5],[-104,"180-240",14],[-104,"240-300",18], + [-102,"0-30",0],[-102,"30-60",1],[-102,"60-120",5],[-102,"120-180",6],[-102,"180-240",10],[-102,"240-300",3], + [-100,"0-30",0],[-100,"30-60",1],[-100,"60-120",4],[-100,"120-180",6],[-100,"180-240",15],[-100,"240-300",17], + [-98,"0-30",0],[-98,"30-60",1],[-98,"60-120",4],[-98,"120-180",7],[-98,"180-240",10],[-98,"240-300",24], + [-96,"0-30",0],[-96,"30-60",4],[-96,"60-120",6],[-96,"120-180",10],[-96,"180-240",7],[-96,"240-300",21], + [-94,"0-30",0],[-94,"30-60",0],[-94,"60-120",2],[-94,"120-180",5],[-94,"180-240",12],[-94,"240-300",29], + [-92,"0-30",0],[-92,"30-60",2],[-92,"60-120",2],[-92,"120-180",11],[-92,"180-240",15],[-92,"240-300",24], + [-90,"0-30",5],[-90,"30-60",9],[-90,"60-120",3],[-90,"120-180",7],[-90,"180-240",14],[-90,"240-300",20], + [-88,"0-30",2],[-88,"30-60",2],[-88,"60-120",9],[-88,"120-180",9],[-88,"180-240",12],[-88,"240-300",18], + [-86,"0-30",0],[-86,"30-60",1],[-86,"60-120",6],[-86,"120-180",5],[-86,"180-240",20],[-86,"240-300",16], + [-84,"0-30",1],[-84,"30-60",4],[-84,"60-120",8],[-84,"120-180",11],[-84,"180-240",25],[-84,"240-300",23], + [-82,"0-30",3],[-82,"30-60",5],[-82,"60-120",7],[-82,"120-180",11],[-82,"180-240",16],[-82,"240-300",18], + [-80,"0-30",3],[-80,"30-60",5],[-80,"60-120",6],[-80,"120-180",11],[-80,"180-240",22],[-80,"240-300",20], + [-78,"0-30",3],[-78,"30-60",3],[-78,"60-120",6],[-78,"120-180",10],[-78,"180-240",18],[-78,"240-300",28], + [-76,"0-30",1],[-76,"30-60",4],[-76,"60-120",6],[-76,"120-180",13],[-76,"180-240",24],[-76,"240-300",39], + [-74,"0-30",3],[-74,"30-60",4],[-74,"60-120",6],[-74,"120-180",12],[-74,"180-240",16],[-74,"240-300",37], + [-72,"0-30",0],[-72,"30-60",3],[-72,"60-120",10],[-72,"120-180",13],[-72,"180-240",20],[-72,"240-300",31], + [-70,"0-30",2],[-70,"30-60",5],[-70,"60-120",6],[-70,"120-180",11],[-70,"180-240",22],[-70,"240-300",27], + [-68,"0-30",3],[-68,"30-60",5],[-68,"60-120",12],[-68,"120-180",21],[-68,"180-240",19],[-68,"240-300",25], + [-66,"0-30",5],[-66,"30-60",10],[-66,"60-120",9],[-66,"120-180",13],[-66,"180-240",20],[-66,"240-300",30], + [-64,"0-30",4],[-64,"30-60",7],[-64,"60-120",13],[-64,"120-180",14],[-64,"180-240",19],[-64,"240-300",35], + [-62,"0-30",3],[-62,"30-60",8],[-62,"60-120",9],[-62,"120-180",13],[-62,"180-240",21],[-62,"240-300",30], + [-60,"0-30",3],[-60,"30-60",5],[-60,"60-120",12],[-60,"120-180",13],[-60,"180-240",22],[-60,"240-300",33], + [-58,"0-30",1],[-58,"30-60",7],[-58,"60-120",12],[-58,"120-180",19],[-58,"180-240",18],[-58,"240-300",25], + [-56,"0-30",3],[-56,"30-60",6],[-56,"60-120",10],[-56,"120-180",19],[-56,"180-240",22],[-56,"240-300",36], + [-54,"0-30",3],[-54,"30-60",8],[-54,"60-120",8],[-54,"120-180",21],[-54,"180-240",25],[-54,"240-300",32], + [-52,"0-30",8],[-52,"30-60",7],[-52,"60-120",12],[-52,"120-180",21],[-52,"180-240",24],[-52,"240-300",32], + [-50,"0-30",3],[-50,"30-60",5],[-50,"60-120",13],[-50,"120-180",19],[-50,"180-240",21],[-50,"240-300",31], + [-48,"0-30",11],[-48,"30-60",12],[-48,"60-120",13],[-48,"120-180",19],[-48,"180-240",25],[-48,"240-300",36], + [-46,"0-30",4],[-46,"30-60",7],[-46,"60-120",13],[-46,"120-180",17],[-46,"180-240",25],[-46,"240-300",31], + [-44,"0-30",5],[-44,"30-60",8],[-44,"60-120",16],[-44,"120-180",21],[-44,"180-240",23],[-44,"240-300",33], + [-42,"0-30",8],[-42,"30-60",12],[-42,"60-120",18],[-42,"120-180",19],[-42,"180-240",23],[-42,"240-300",31], + [-40,"0-30",7],[-40,"30-60",14],[-40,"60-120",17],[-40,"120-180",23],[-40,"180-240",25],[-40,"240-300",32], + [-38,"0-30",10],[-38,"30-60",15],[-38,"60-120",13],[-38,"120-180",22],[-38,"180-240",29],[-38,"240-300",34], + [-36,"0-30",9],[-36,"30-60",13],[-36,"60-120",18],[-36,"120-180",23],[-36,"180-240",28],[-36,"240-300",36], + [-34,"0-30",7],[-34,"30-60",10],[-34,"60-120",18],[-34,"120-180",21],[-34,"180-240",23],[-34,"240-300",36], + [-32,"0-30",9],[-32,"30-60",13],[-32,"60-120",18],[-32,"120-180",24],[-32,"180-240",30],[-32,"240-300",34], + [-30,"0-30",11],[-30,"30-60",15],[-30,"60-120",21],[-30,"120-180",25],[-30,"180-240",31],[-30,"240-300",40], + [-28,"0-30",13],[-28,"30-60",18],[-28,"60-120",26],[-28,"120-180",27],[-28,"180-240",33],[-28,"240-300",36], + [-26,"0-30",11],[-26,"30-60",17],[-26,"60-120",26],[-26,"120-180",28],[-26,"180-240",32],[-26,"240-300",36], + [-24,"0-30",13],[-24,"30-60",21],[-24,"60-120",27],[-24,"120-180",30],[-24,"180-240",36],[-24,"240-300",39], + [-22,"0-30",14],[-22,"30-60",27],[-22,"60-120",24],[-22,"120-180",31],[-22,"180-240",40],[-22,"240-300",38], + [-20,"0-30",15],[-20,"30-60",23],[-20,"60-120",28],[-20,"120-180",33],[-20,"180-240",35],[-20,"240-300",41], + [-18,"0-30",14],[-18,"30-60",23],[-18,"60-120",28],[-18,"120-180",33],[-18,"180-240",40],[-18,"240-300",40], + [-16,"0-30",16],[-16,"30-60",25],[-16,"60-120",29],[-16,"120-180",38],[-16,"180-240",40],[-16,"240-300",43], + [-14,"0-30",16],[-14,"30-60",20],[-14,"60-120",35],[-14,"120-180",35],[-14,"180-240",38],[-14,"240-300",40], + [-12,"0-30",21],[-12,"30-60",31],[-12,"60-120",35],[-12,"120-180",36],[-12,"180-240",37],[-12,"240-300",44], + [-10,"0-30",22],[-10,"30-60",29],[-10,"60-120",35],[-10,"120-180",42],[-10,"180-240",39],[-10,"240-300",43], + [-8,"0-30",23],[-8,"30-60",32],[-8,"60-120",38],[-8,"120-180",39],[-8,"180-240",42],[-8,"240-300",47], + [-6,"0-30",31],[-6,"30-60",41],[-6,"60-120",38],[-6,"120-180",41],[-6,"180-240",45],[-6,"240-300",46], + [-4,"0-30",32],[-4,"30-60",36],[-4,"60-120",39],[-4,"120-180",42],[-4,"180-240",47],[-4,"240-300",46], + [-2,"0-30",49],[-2,"30-60",50],[-2,"60-120",54],[-2,"120-180",57],[-2,"180-240",53],[-2,"240-300",52], + [0,"0-30",51],[0,"30-60",53],[0,"60-120",54],[0,"120-180",53],[0,"180-240",54],[0,"240-300",53], + [2,"0-30",58],[2,"30-60",60],[2,"60-120",59],[2,"120-180",55],[2,"180-240",55],[2,"240-300",55], + [4,"0-30",66],[4,"30-60",61],[4,"60-120",56],[4,"120-180",53],[4,"180-240",54],[4,"240-300",54], + [6,"0-30",73],[6,"30-60",65],[6,"60-120",62],[6,"120-180",58],[6,"180-240",57],[6,"240-300",55], + [8,"0-30",76],[8,"30-60",69],[8,"60-120",63],[8,"120-180",62],[8,"180-240",58],[8,"240-300",56], + [10,"0-30",80],[10,"30-60",67],[10,"60-120",71],[10,"120-180",63],[10,"180-240",60],[10,"240-300",61], + [12,"0-30",81],[12,"30-60",69],[12,"60-120",65],[12,"120-180",59],[12,"180-240",60],[12,"240-300",60], + [14,"0-30",81],[14,"30-60",74],[14,"60-120",69],[14,"120-180",62],[14,"180-240",63],[14,"240-300",62], + [16,"0-30",86],[16,"30-60",71],[16,"60-120",65],[16,"120-180",65],[16,"180-240",68],[16,"240-300",62], + [18,"0-30",89],[18,"30-60",79],[18,"60-120",71],[18,"120-180",66],[18,"180-240",62],[18,"240-300",65], + [20,"0-30",91],[20,"30-60",84],[20,"60-120",71],[20,"120-180",71],[20,"180-240",65],[20,"240-300",60], + [22,"0-30",86],[22,"30-60",86],[22,"60-120",77],[22,"120-180",71],[22,"180-240",73],[22,"240-300",67], + [24,"0-30",91],[24,"30-60",81],[24,"60-120",77],[24,"120-180",74],[24,"180-240",68],[24,"240-300",64], + [26,"0-30",91],[26,"30-60",89],[26,"60-120",80],[26,"120-180",74],[26,"180-240",69],[26,"240-300",68], + [28,"0-30",89],[28,"30-60",85],[28,"60-120",82],[28,"120-180",77],[28,"180-240",68],[28,"240-300",66], + [30,"0-30",91],[30,"30-60",87],[30,"60-120",80],[30,"120-180",77],[30,"180-240",74],[30,"240-300",71], + [32,"0-30",92],[32,"30-60",82],[32,"60-120",79],[32,"120-180",79],[32,"180-240",74],[32,"240-300",70], + [34,"0-30",88],[34,"30-60",86],[34,"60-120",86],[34,"120-180",83],[34,"180-240",69],[34,"240-300",75], + [36,"0-30",94],[36,"30-60",88],[36,"60-120",86],[36,"120-180",80],[36,"180-240",72],[36,"240-300",69], + [38,"0-30",95],[38,"30-60",86],[38,"60-120",85],[38,"120-180",83],[38,"180-240",73],[38,"240-300",75], + [40,"0-30",97],[40,"30-60",94],[40,"60-120",88],[40,"120-180",81],[40,"180-240",80],[40,"240-300",74], + [42,"0-30",97],[42,"30-60",89],[42,"60-120",85],[42,"120-180",78],[42,"180-240",74],[42,"240-300",73], + [44,"0-30",98],[44,"30-60",92],[44,"60-120",87],[44,"120-180",78],[44,"180-240",75],[44,"240-300",72], + [46,"0-30",97],[46,"30-60",92],[46,"60-120",89],[46,"120-180",77],[46,"180-240",77],[46,"240-300",73], + [48,"0-30",98],[48,"30-60",94],[48,"60-120",90],[48,"120-180",80],[48,"180-240",76],[48,"240-300",70], + [50,"0-30",99],[50,"30-60",95],[50,"60-120",85],[50,"120-180",84],[50,"180-240",76],[50,"240-300",73], + [52,"0-30",99],[52,"30-60",95],[52,"60-120",91],[52,"120-180",85],[52,"180-240",83],[52,"240-300",77], + [54,"0-30",99],[54,"30-60",96],[54,"60-120",89],[54,"120-180",81],[54,"180-240",79],[54,"240-300",69], + [56,"0-30",97],[56,"30-60",95],[56,"60-120",92],[56,"120-180",85],[56,"180-240",82],[56,"240-300",76], + [58,"0-30",98],[58,"30-60",95],[58,"60-120",89],[58,"120-180",85],[58,"180-240",78],[58,"240-300",74], + [60,"0-30",98],[60,"30-60",96],[60,"60-120",90],[60,"120-180",90],[60,"180-240",82],[60,"240-300",77], + [62,"0-30",99],[62,"30-60",95],[62,"60-120",90],[62,"120-180",90],[62,"180-240",79],[62,"240-300",80], + [64,"0-30",99],[64,"30-60",98],[64,"60-120",95],[64,"120-180",86],[64,"180-240",83],[64,"240-300",73], + [66,"0-30",100],[66,"30-60",96],[66,"60-120",94],[66,"120-180",86],[66,"180-240",85],[66,"240-300",70], + [68,"0-30",100],[68,"30-60",98],[68,"60-120",92],[68,"120-180",89],[68,"180-240",84],[68,"240-300",75], + [70,"0-30",99],[70,"30-60",98],[70,"60-120",91],[70,"120-180",87],[70,"180-240",84],[70,"240-300",79], + [72,"0-30",99],[72,"30-60",98],[72,"60-120",97],[72,"120-180",91],[72,"180-240",84],[72,"240-300",71], + [74,"0-30",99],[74,"30-60",99],[74,"60-120",91],[74,"120-180",87],[74,"180-240",81],[74,"240-300",79], + [76,"0-30",99],[76,"30-60",98],[76,"60-120",96],[76,"120-180",90],[76,"180-240",81],[76,"240-300",82], + [78,"0-30",100],[78,"30-60",100],[78,"60-120",96],[78,"120-180",91],[78,"180-240",82],[78,"240-300",79], + [80,"0-30",100],[80,"30-60",99],[80,"60-120",95],[80,"120-180",90],[80,"180-240",85],[80,"240-300",87], + [82,"0-30",100],[82,"30-60",99],[82,"60-120",94],[82,"120-180",92],[82,"180-240",90],[82,"240-300",81], + [84,"0-30",98],[84,"30-60",100],[84,"60-120",96],[84,"120-180",91],[84,"180-240",91],[84,"240-300",80], + [86,"0-30",100],[86,"30-60",100],[86,"60-120",98],[86,"120-180",92],[86,"180-240",88],[86,"240-300",86], + [88,"0-30",100],[88,"30-60",100],[88,"60-120",98],[88,"120-180",89],[88,"180-240",86],[88,"240-300",76], + [90,"0-30",100],[90,"30-60",100],[90,"60-120",96],[90,"120-180",92],[90,"180-240",88],[90,"240-300",77], + [92,"0-30",100],[92,"30-60",100],[92,"60-120",98],[92,"120-180",94],[92,"180-240",87],[92,"240-300",89], + [94,"0-30",100],[94,"30-60",99],[94,"60-120",98],[94,"120-180",90],[94,"180-240",87],[94,"240-300",86], + [96,"0-30",100],[96,"30-60",99],[96,"60-120",98],[96,"120-180",95],[96,"180-240",91],[96,"240-300",84], +]; + +// ETH 5m table (based on 13 days of ~940K tick statistics; step=0.1) +// Bucket design: step 0.1, range ±7 (covers 99% of the diff distribution) +const ETH_5M: RawData = [ + [-7.0,"0-30",18],[-7.0,"30-60",31],[-7.0,"60-120",2],[-7.0,"120-180",9],[-7.0,"180-240",25],[-7.0,"240-300",59], + [-6.9,"0-30",100],[-6.9,"30-60",33],[-6.9,"60-120",5],[-6.9,"120-180",35],[-6.9,"180-240",27],[-6.9,"240-300",0], + [-6.8,"0-30",100],[-6.8,"30-60",100],[-6.8,"60-120",31],[-6.8,"120-180",4],[-6.8,"180-240",11],[-6.8,"240-300",20], + [-6.7,"0-30",26],[-6.7,"30-60",0],[-6.7,"60-120",26],[-6.7,"120-180",43],[-6.7,"180-240",40],[-6.7,"240-300",25], + [-6.6,"0-30",100],[-6.6,"30-60",100],[-6.6,"60-120",9],[-6.6,"120-180",17],[-6.6,"180-240",20],[-6.6,"240-300",0], + [-6.5,"0-30",15],[-6.5,"30-60",0],[-6.5,"60-120",0],[-6.5,"120-180",12],[-6.5,"180-240",43],[-6.5,"240-300",20], + [-6.4,"0-30",28],[-6.4,"30-60",100],[-6.4,"60-120",4],[-6.4,"120-180",23],[-6.4,"180-240",4],[-6.4,"240-300",10], + [-6.3,"0-30",100],[-6.3,"30-60",40],[-6.3,"60-120",24],[-6.3,"120-180",0],[-6.3,"180-240",17],[-6.3,"240-300",60], + [-6.2,"0-30",100],[-6.2,"30-60",60],[-6.2,"60-120",18],[-6.2,"120-180",14],[-6.2,"180-240",0],[-6.2,"240-300",11], + [-6.1,"0-30",100],[-6.1,"30-60",33],[-6.1,"60-120",8],[-6.1,"120-180",26],[-6.1,"180-240",10],[-6.1,"240-300",22], + [-6.0,"0-30",11],[-6.0,"30-60",0],[-6.0,"60-120",30],[-6.0,"120-180",22],[-6.0,"180-240",7],[-6.0,"240-300",0], + [-5.9,"0-30",21],[-5.9,"30-60",50],[-5.9,"60-120",14],[-5.9,"120-180",20],[-5.9,"180-240",10],[-5.9,"240-300",12], + [-5.8,"0-30",19],[-5.8,"30-60",33],[-5.8,"60-120",31],[-5.8,"120-180",19],[-5.8,"180-240",5],[-5.8,"240-300",8], + [-5.7,"0-30",7],[-5.7,"30-60",0],[-5.7,"60-120",0],[-5.7,"120-180",15],[-5.7,"180-240",12],[-5.7,"240-300",8], + [-5.6,"0-30",9],[-5.6,"30-60",0],[-5.6,"60-120",6],[-5.6,"120-180",9],[-5.6,"180-240",24],[-5.6,"240-300",8], + [-5.5,"0-30",0],[-5.5,"30-60",0],[-5.5,"60-120",1],[-5.5,"120-180",5],[-5.5,"180-240",19],[-5.5,"240-300",12], + [-5.4,"0-30",27],[-5.4,"30-60",0],[-5.4,"60-120",5],[-5.4,"120-180",13],[-5.4,"180-240",7],[-5.4,"240-300",18], + [-5.3,"0-30",100],[-5.3,"30-60",0],[-5.3,"60-120",1],[-5.3,"120-180",17],[-5.3,"180-240",20],[-5.3,"240-300",19], + [-5.2,"0-30",64],[-5.2,"30-60",0],[-5.2,"60-120",9],[-5.2,"120-180",8],[-5.2,"180-240",19],[-5.2,"240-300",25], + [-5.1,"0-30",100],[-5.1,"30-60",0],[-5.1,"60-120",4],[-5.1,"120-180",7],[-5.1,"180-240",10],[-5.1,"240-300",33], + [-5.0,"0-30",0],[-5.0,"30-60",6],[-5.0,"60-120",2],[-5.0,"120-180",15],[-5.0,"180-240",7],[-5.0,"240-300",41], + [-4.9,"0-30",25],[-4.9,"30-60",22],[-4.9,"60-120",6],[-4.9,"120-180",9],[-4.9,"180-240",9],[-4.9,"240-300",26], + [-4.8,"0-30",0],[-4.8,"30-60",0],[-4.8,"60-120",3],[-4.8,"120-180",7],[-4.8,"180-240",12],[-4.8,"240-300",27], + [-4.7,"0-30",25],[-4.7,"30-60",17],[-4.7,"60-120",4],[-4.7,"120-180",14],[-4.7,"180-240",27],[-4.7,"240-300",27], + [-4.6,"0-30",11],[-4.6,"30-60",20],[-4.6,"60-120",10],[-4.6,"120-180",4],[-4.6,"180-240",23],[-4.6,"240-300",47], + [-4.5,"0-30",75],[-4.5,"30-60",5],[-4.5,"60-120",17],[-4.5,"120-180",7],[-4.5,"180-240",11],[-4.5,"240-300",19], + [-4.4,"0-30",0],[-4.4,"30-60",0],[-4.4,"60-120",12],[-4.4,"120-180",12],[-4.4,"180-240",22],[-4.4,"240-300",56], + [-4.3,"0-30",86],[-4.3,"30-60",22],[-4.3,"60-120",18],[-4.3,"120-180",10],[-4.3,"180-240",15],[-4.3,"240-300",37], + [-4.2,"0-30",0],[-4.2,"30-60",21],[-4.2,"60-120",8],[-4.2,"120-180",16],[-4.2,"180-240",15],[-4.2,"240-300",32], + [-4.1,"0-30",100],[-4.1,"30-60",8],[-4.1,"60-120",8],[-4.1,"120-180",15],[-4.1,"180-240",19],[-4.1,"240-300",38], + [-4.0,"0-30",56],[-4.0,"30-60",20],[-4.0,"60-120",7],[-4.0,"120-180",12],[-4.0,"180-240",14],[-4.0,"240-300",48], + [-3.9,"0-30",5],[-3.9,"30-60",13],[-3.9,"60-120",9],[-3.9,"120-180",7],[-3.9,"180-240",14],[-3.9,"240-300",37], + [-3.8,"0-30",41],[-3.8,"30-60",10],[-3.8,"60-120",7],[-3.8,"120-180",14],[-3.8,"180-240",12],[-3.8,"240-300",45], + [-3.7,"0-30",67],[-3.7,"30-60",17],[-3.7,"60-120",6],[-3.7,"120-180",8],[-3.7,"180-240",15],[-3.7,"240-300",40], + [-3.6,"0-30",18],[-3.6,"30-60",1],[-3.6,"60-120",7],[-3.6,"120-180",12],[-3.6,"180-240",8],[-3.6,"240-300",30], + [-3.5,"0-30",14],[-3.5,"30-60",20],[-3.5,"60-120",3],[-3.5,"120-180",18],[-3.5,"180-240",15],[-3.5,"240-300",26], + [-3.4,"0-30",44],[-3.4,"30-60",3],[-3.4,"60-120",6],[-3.4,"120-180",14],[-3.4,"180-240",15],[-3.4,"240-300",26], + [-3.3,"0-30",13],[-3.3,"30-60",11],[-3.3,"60-120",8],[-3.3,"120-180",14],[-3.3,"180-240",11],[-3.3,"240-300",27], + [-3.2,"0-30",13],[-3.2,"30-60",0],[-3.2,"60-120",9],[-3.2,"120-180",12],[-3.2,"180-240",16],[-3.2,"240-300",26], + [-3.1,"0-30",31],[-3.1,"30-60",1],[-3.1,"60-120",7],[-3.1,"120-180",13],[-3.1,"180-240",18],[-3.1,"240-300",32], + [-3.0,"0-30",30],[-3.0,"30-60",11],[-3.0,"60-120",13],[-3.0,"120-180",8],[-3.0,"180-240",23],[-3.0,"240-300",29], + [-2.9,"0-30",7],[-2.9,"30-60",6],[-2.9,"60-120",8],[-2.9,"120-180",10],[-2.9,"180-240",12],[-2.9,"240-300",22], + [-2.8,"0-30",19],[-2.8,"30-60",6],[-2.8,"60-120",10],[-2.8,"120-180",21],[-2.8,"180-240",23],[-2.8,"240-300",35], + [-2.7,"0-30",29],[-2.7,"30-60",16],[-2.7,"60-120",11],[-2.7,"120-180",20],[-2.7,"180-240",17],[-2.7,"240-300",46], + [-2.6,"0-30",21],[-2.6,"30-60",19],[-2.6,"60-120",11],[-2.6,"120-180",14],[-2.6,"180-240",15],[-2.6,"240-300",37], + [-2.5,"0-30",5],[-2.5,"30-60",5],[-2.5,"60-120",15],[-2.5,"120-180",19],[-2.5,"180-240",18],[-2.5,"240-300",35], + [-2.4,"0-30",16],[-2.4,"30-60",16],[-2.4,"60-120",12],[-2.4,"120-180",15],[-2.4,"180-240",29],[-2.4,"240-300",39], + [-2.3,"0-30",6],[-2.3,"30-60",14],[-2.3,"60-120",14],[-2.3,"120-180",13],[-2.3,"180-240",21],[-2.3,"240-300",42], + [-2.2,"0-30",30],[-2.2,"30-60",11],[-2.2,"60-120",23],[-2.2,"120-180",25],[-2.2,"180-240",24],[-2.2,"240-300",32], + [-2.1,"0-30",8],[-2.1,"30-60",9],[-2.1,"60-120",14],[-2.1,"120-180",21],[-2.1,"180-240",25],[-2.1,"240-300",37], + [-2.0,"0-30",12],[-2.0,"30-60",9],[-2.0,"60-120",20],[-2.0,"120-180",23],[-2.0,"180-240",28],[-2.0,"240-300",29], + [-1.9,"0-30",6],[-1.9,"30-60",13],[-1.9,"60-120",25],[-1.9,"120-180",23],[-1.9,"180-240",30],[-1.9,"240-300",32], + [-1.8,"0-30",15],[-1.8,"30-60",16],[-1.8,"60-120",18],[-1.8,"120-180",26],[-1.8,"180-240",27],[-1.8,"240-300",36], + [-1.7,"0-30",18],[-1.7,"30-60",13],[-1.7,"60-120",22],[-1.7,"120-180",27],[-1.7,"180-240",34],[-1.7,"240-300",30], + [-1.6,"0-30",12],[-1.6,"30-60",14],[-1.6,"60-120",23],[-1.6,"120-180",26],[-1.6,"180-240",28],[-1.6,"240-300",35], + [-1.5,"0-30",19],[-1.5,"30-60",16],[-1.5,"60-120",21],[-1.5,"120-180",30],[-1.5,"180-240",30],[-1.5,"240-300",38], + [-1.4,"0-30",24],[-1.4,"30-60",23],[-1.4,"60-120",20],[-1.4,"120-180",25],[-1.4,"180-240",29],[-1.4,"240-300",34], + [-1.3,"0-30",21],[-1.3,"30-60",17],[-1.3,"60-120",25],[-1.3,"120-180",29],[-1.3,"180-240",35],[-1.3,"240-300",44], + [-1.2,"0-30",21],[-1.2,"30-60",18],[-1.2,"60-120",24],[-1.2,"120-180",23],[-1.2,"180-240",36],[-1.2,"240-300",39], + [-1.1,"0-30",16],[-1.1,"30-60",18],[-1.1,"60-120",21],[-1.1,"120-180",31],[-1.1,"180-240",42],[-1.1,"240-300",42], + [-1.0,"0-30",16],[-1.0,"30-60",22],[-1.0,"60-120",24],[-1.0,"120-180",32],[-1.0,"180-240",37],[-1.0,"240-300",41], + [-0.9,"0-30",15],[-0.9,"30-60",20],[-0.9,"60-120",29],[-0.9,"120-180",36],[-0.9,"180-240",40],[-0.9,"240-300",45], + [-0.8,"0-30",19],[-0.8,"30-60",21],[-0.8,"60-120",31],[-0.8,"120-180",33],[-0.8,"180-240",41],[-0.8,"240-300",43], + [-0.7,"0-30",20],[-0.7,"30-60",27],[-0.7,"60-120",36],[-0.7,"120-180",38],[-0.7,"180-240",42],[-0.7,"240-300",46], + [-0.6,"0-30",15],[-0.6,"30-60",25],[-0.6,"60-120",36],[-0.6,"120-180",40],[-0.6,"180-240",41],[-0.6,"240-300",46], + [-0.5,"0-30",21],[-0.5,"30-60",27],[-0.5,"60-120",38],[-0.5,"120-180",40],[-0.5,"180-240",45],[-0.5,"240-300",45], + [-0.4,"0-30",27],[-0.4,"30-60",38],[-0.4,"60-120",41],[-0.4,"120-180",42],[-0.4,"180-240",39],[-0.4,"240-300",45], + [-0.3,"0-30",30],[-0.3,"30-60",38],[-0.3,"60-120",43],[-0.3,"120-180",46],[-0.3,"180-240",49],[-0.3,"240-300",47], + [-0.2,"0-30",36],[-0.2,"30-60",47],[-0.2,"60-120",48],[-0.2,"120-180",47],[-0.2,"180-240",48],[-0.2,"240-300",52], + [-0.1,"0-30",47],[-0.1,"30-60",47],[-0.1,"60-120",49],[-0.1,"120-180",52],[-0.1,"180-240",54],[-0.1,"240-300",55], + [0.0,"0-30",56],[0.0,"30-60",54],[0.0,"60-120",55],[0.0,"120-180",56],[0.0,"180-240",56],[0.0,"240-300",56], + [0.1,"0-30",71],[0.1,"30-60",63],[0.1,"60-120",59],[0.1,"120-180",60],[0.1,"180-240",60],[0.1,"240-300",57], + [0.2,"0-30",73],[0.2,"30-60",74],[0.2,"60-120",65],[0.2,"120-180",66],[0.2,"180-240",61],[0.2,"240-300",57], + [0.3,"0-30",88],[0.3,"30-60",75],[0.3,"60-120",66],[0.3,"120-180",68],[0.3,"180-240",64],[0.3,"240-300",58], + [0.4,"0-30",87],[0.4,"30-60",75],[0.4,"60-120",72],[0.4,"120-180",67],[0.4,"180-240",62],[0.4,"240-300",61], + [0.5,"0-30",90],[0.5,"30-60",76],[0.5,"60-120",70],[0.5,"120-180",65],[0.5,"180-240",69],[0.5,"240-300",59], + [0.6,"0-30",87],[0.6,"30-60",83],[0.6,"60-120",77],[0.6,"120-180",70],[0.6,"180-240",67],[0.6,"240-300",64], + [0.7,"0-30",88],[0.7,"30-60",81],[0.7,"60-120",78],[0.7,"120-180",75],[0.7,"180-240",68],[0.7,"240-300",64], + [0.8,"0-30",94],[0.8,"30-60",86],[0.8,"60-120",80],[0.8,"120-180",73],[0.8,"180-240",71],[0.8,"240-300",64], + [0.9,"0-30",93],[0.9,"30-60",81],[0.9,"60-120",82],[0.9,"120-180",77],[0.9,"180-240",70],[0.9,"240-300",67], + [1.0,"0-30",94],[1.0,"30-60",88],[1.0,"60-120",84],[1.0,"120-180",77],[1.0,"180-240",72],[1.0,"240-300",64], + [1.1,"0-30",93],[1.1,"30-60",94],[1.1,"60-120",82],[1.1,"120-180",78],[1.1,"180-240",72],[1.1,"240-300",68], + [1.2,"0-30",96],[1.2,"30-60",90],[1.2,"60-120",78],[1.2,"120-180",82],[1.2,"180-240",71],[1.2,"240-300",68], + [1.3,"0-30",99],[1.3,"30-60",94],[1.3,"60-120",85],[1.3,"120-180",78],[1.3,"180-240",74],[1.3,"240-300",67], + [1.4,"0-30",97],[1.4,"30-60",92],[1.4,"60-120",86],[1.4,"120-180",80],[1.4,"180-240",73],[1.4,"240-300",74], + [1.5,"0-30",99],[1.5,"30-60",98],[1.5,"60-120",91],[1.5,"120-180",81],[1.5,"180-240",77],[1.5,"240-300",74], + [1.6,"0-30",99],[1.6,"30-60",96],[1.6,"60-120",87],[1.6,"120-180",81],[1.6,"180-240",76],[1.6,"240-300",69], + [1.7,"0-30",99],[1.7,"30-60",96],[1.7,"60-120",92],[1.7,"120-180",82],[1.7,"180-240",75],[1.7,"240-300",74], + [1.8,"0-30",97],[1.8,"30-60",97],[1.8,"60-120",93],[1.8,"120-180",80],[1.8,"180-240",79],[1.8,"240-300",76], + [1.9,"0-30",99],[1.9,"30-60",97],[1.9,"60-120",89],[1.9,"120-180",81],[1.9,"180-240",76],[1.9,"240-300",75], + [2.0,"0-30",95],[2.0,"30-60",93],[2.0,"60-120",93],[2.0,"120-180",82],[2.0,"180-240",73],[2.0,"240-300",77], + [2.1,"0-30",91],[2.1,"30-60",93],[2.1,"60-120",95],[2.1,"120-180",84],[2.1,"180-240",85],[2.1,"240-300",75], + [2.2,"0-30",99],[2.2,"30-60",96],[2.2,"60-120",93],[2.2,"120-180",83],[2.2,"180-240",79],[2.2,"240-300",82], + [2.3,"0-30",96],[2.3,"30-60",95],[2.3,"60-120",87],[2.3,"120-180",89],[2.3,"180-240",81],[2.3,"240-300",72], + [2.4,"0-30",97],[2.4,"30-60",93],[2.4,"60-120",91],[2.4,"120-180",84],[2.4,"180-240",84],[2.4,"240-300",79], + [2.5,"0-30",100],[2.5,"30-60",95],[2.5,"60-120",92],[2.5,"120-180",84],[2.5,"180-240",84],[2.5,"240-300",82], + [2.6,"0-30",100],[2.6,"30-60",96],[2.6,"60-120",93],[2.6,"120-180",87],[2.6,"180-240",83],[2.6,"240-300",73], + [2.7,"0-30",99],[2.7,"30-60",97],[2.7,"60-120",98],[2.7,"120-180",89],[2.7,"180-240",84],[2.7,"240-300",72], + [2.8,"0-30",99],[2.8,"30-60",98],[2.8,"60-120",98],[2.8,"120-180",94],[2.8,"180-240",82],[2.8,"240-300",81], + [2.9,"0-30",100],[2.9,"30-60",100],[2.9,"60-120",98],[2.9,"120-180",92],[2.9,"180-240",84],[2.9,"240-300",84], + [3.0,"0-30",100],[3.0,"30-60",98],[3.0,"60-120",96],[3.0,"120-180",95],[3.0,"180-240",87],[3.0,"240-300",83], + [3.1,"0-30",96],[3.1,"30-60",95],[3.1,"60-120",96],[3.1,"120-180",94],[3.1,"180-240",89],[3.1,"240-300",85], + [3.2,"0-30",97],[3.2,"30-60",99],[3.2,"60-120",87],[3.2,"120-180",93],[3.2,"180-240",89],[3.2,"240-300",85], + [3.3,"0-30",97],[3.3,"30-60",93],[3.3,"60-120",95],[3.3,"120-180",96],[3.3,"180-240",86],[3.3,"240-300",85], + [3.4,"0-30",98],[3.4,"30-60",96],[3.4,"60-120",95],[3.4,"120-180",97],[3.4,"180-240",93],[3.4,"240-300",90], + [3.5,"0-30",100],[3.5,"30-60",100],[3.5,"60-120",99],[3.5,"120-180",92],[3.5,"180-240",87],[3.5,"240-300",95], + [3.6,"0-30",100],[3.6,"30-60",100],[3.6,"60-120",96],[3.6,"120-180",90],[3.6,"180-240",89],[3.6,"240-300",90], + [3.7,"0-30",100],[3.7,"30-60",99],[3.7,"60-120",97],[3.7,"120-180",94],[3.7,"180-240",88],[3.7,"240-300",86], + [3.8,"0-30",98],[3.8,"30-60",99],[3.8,"60-120",97],[3.8,"120-180",87],[3.8,"180-240",89],[3.8,"240-300",90], + [3.9,"0-30",98],[3.9,"30-60",97],[3.9,"60-120",96],[3.9,"120-180",95],[3.9,"180-240",86],[3.9,"240-300",95], + [4.0,"0-30",95],[4.0,"30-60",94],[4.0,"60-120",99],[4.0,"120-180",88],[4.0,"180-240",83],[4.0,"240-300",85], + [4.1,"0-30",90],[4.1,"30-60",94],[4.1,"60-120",93],[4.1,"120-180",93],[4.1,"180-240",75],[4.1,"240-300",59], + [4.2,"0-30",99],[4.2,"30-60",99],[4.2,"60-120",94],[4.2,"120-180",95],[4.2,"180-240",90],[4.2,"240-300",73], + [4.3,"0-30",96],[4.3,"30-60",98],[4.3,"60-120",94],[4.3,"120-180",95],[4.3,"180-240",95],[4.3,"240-300",79], + [4.4,"0-30",95],[4.4,"30-60",99],[4.4,"60-120",94],[4.4,"120-180",91],[4.4,"180-240",97],[4.4,"240-300",79], + [4.5,"0-30",97],[4.5,"30-60",97],[4.5,"60-120",99],[4.5,"120-180",98],[4.5,"180-240",90],[4.5,"240-300",83], + [4.6,"0-30",95],[4.6,"30-60",95],[4.6,"60-120",97],[4.6,"120-180",94],[4.6,"180-240",87],[4.6,"240-300",74], + [4.7,"0-30",99],[4.7,"30-60",98],[4.7,"60-120",94],[4.7,"120-180",94],[4.7,"180-240",92],[4.7,"240-300",72], + [4.8,"0-30",100],[4.8,"30-60",100],[4.8,"60-120",98],[4.8,"120-180",91],[4.8,"180-240",90],[4.8,"240-300",96], + [4.9,"0-30",96],[4.9,"30-60",100],[4.9,"60-120",94],[4.9,"120-180",93],[4.9,"180-240",82],[4.9,"240-300",100], + [5.0,"0-30",92],[5.0,"30-60",100],[5.0,"60-120",98],[5.0,"120-180",94],[5.0,"180-240",94],[5.0,"240-300",74], + [5.1,"0-30",100],[5.1,"30-60",97],[5.1,"60-120",99],[5.1,"120-180",81],[5.1,"180-240",79],[5.1,"240-300",55], + [5.2,"0-30",100],[5.2,"30-60",98],[5.2,"60-120",95],[5.2,"120-180",89],[5.2,"180-240",84],[5.2,"240-300",77], + [5.3,"0-30",100],[5.3,"30-60",97],[5.3,"60-120",86],[5.3,"120-180",94],[5.3,"180-240",95],[5.3,"240-300",81], + [5.4,"0-30",100],[5.4,"30-60",96],[5.4,"60-120",80],[5.4,"120-180",98],[5.4,"180-240",89],[5.4,"240-300",100], + [5.5,"0-30",100],[5.5,"30-60",98],[5.5,"60-120",93],[5.5,"120-180",99],[5.5,"180-240",87],[5.5,"240-300",91], + [5.6,"0-30",83],[5.6,"30-60",94],[5.6,"60-120",98],[5.6,"120-180",98],[5.6,"180-240",97],[5.6,"240-300",100], + [5.7,"0-30",100],[5.7,"30-60",95],[5.7,"60-120",98],[5.7,"120-180",100],[5.7,"180-240",100],[5.7,"240-300",86], + [5.8,"0-30",98],[5.8,"30-60",89],[5.8,"60-120",99],[5.8,"120-180",98],[5.8,"180-240",100],[5.8,"240-300",79], + [5.9,"0-30",100],[5.9,"30-60",88],[5.9,"60-120",98],[5.9,"120-180",100],[5.9,"180-240",100],[5.9,"240-300",88], + [6.0,"0-30",96],[6.0,"30-60",89],[6.0,"60-120",96],[6.0,"120-180",90],[6.0,"180-240",98],[6.0,"240-300",89], + [6.1,"0-30",98],[6.1,"30-60",97],[6.1,"60-120",97],[6.1,"120-180",93],[6.1,"180-240",100],[6.1,"240-300",92], + [6.2,"0-30",99],[6.2,"30-60",92],[6.2,"60-120",91],[6.2,"120-180",90],[6.2,"180-240",99],[6.2,"240-300",86], + [6.3,"0-30",100],[6.3,"30-60",90],[6.3,"60-120",94],[6.3,"120-180",100],[6.3,"180-240",100],[6.3,"240-300",94], + [6.4,"0-30",91],[6.4,"30-60",98],[6.4,"60-120",97],[6.4,"120-180",93],[6.4,"180-240",96],[6.4,"240-300",80], + [6.5,"0-30",95],[6.5,"30-60",99],[6.5,"60-120",93],[6.5,"120-180",98],[6.5,"180-240",96],[6.5,"240-300",88], + [6.6,"0-30",99],[6.6,"30-60",99],[6.6,"60-120",98],[6.6,"120-180",100],[6.6,"180-240",100],[6.6,"240-300",92], + [6.7,"0-30",95],[6.7,"30-60",98],[6.7,"60-120",97],[6.7,"120-180",100],[6.7,"180-240",100],[6.7,"240-300",55], + [6.8,"0-30",96],[6.8,"30-60",100],[6.8,"60-120",100],[6.8,"120-180",98],[6.8,"180-240",92],[6.8,"240-300",100], + [6.9,"0-30",95],[6.9,"30-60",97],[6.9,"60-120",100],[6.9,"120-180",100],[6.9,"180-240",100],[6.9,"240-300",100], + [7.0,"0-30",98],[7.0,"30-60",98],[7.0,"60-120",100],[7.0,"120-180",99],[7.0,"180-240",97],[7.0,"240-300",81], +]; + +// SOL 5m table (based on 13 days of ~950K tick statistics; step=0.01) +// Bucket design: step 0.01, range ±0.4 (covers 99% of the diff distribution) +const SOL_5M: RawData = [ + [-0.4,"0-30",100],[-0.4,"30-60",0],[-0.4,"60-120",0],[-0.4,"120-180",7],[-0.4,"180-240",22],[-0.4,"240-300",0], + [-0.39,"0-30",100],[-0.39,"30-60",30],[-0.39,"60-120",0],[-0.39,"120-180",0],[-0.39,"180-240",50],[-0.39,"240-300",0], + [-0.38,"0-30",100],[-0.38,"30-60",51],[-0.38,"60-120",0],[-0.38,"120-180",17],[-0.38,"180-240",71],[-0.38,"240-300",71], + [-0.37,"0-30",37],[-0.37,"30-60",37],[-0.37,"60-120",0],[-0.37,"120-180",21],[-0.37,"180-240",43],[-0.37,"240-300",87], + [-0.36,"0-30",100],[-0.36,"30-60",41],[-0.36,"60-120",0],[-0.36,"120-180",0],[-0.36,"180-240",30],[-0.36,"240-300",75], + [-0.35,"0-30",100],[-0.35,"30-60",100],[-0.35,"60-120",8],[-0.35,"120-180",21],[-0.35,"180-240",59],[-0.35,"240-300",83], + [-0.34,"0-30",88],[-0.34,"30-60",100],[-0.34,"60-120",0],[-0.34,"120-180",28],[-0.34,"180-240",16],[-0.34,"240-300",60], + [-0.33,"0-30",50],[-0.33,"30-60",67],[-0.33,"60-120",2],[-0.33,"120-180",34],[-0.33,"180-240",12],[-0.33,"240-300",11], + [-0.32,"0-30",17],[-0.32,"30-60",38],[-0.32,"60-120",2],[-0.32,"120-180",12],[-0.32,"180-240",17],[-0.32,"240-300",43], + [-0.31,"0-30",38],[-0.31,"30-60",61],[-0.31,"60-120",2],[-0.31,"120-180",19],[-0.31,"180-240",43],[-0.31,"240-300",100], + [-0.3,"0-30",33],[-0.3,"30-60",20],[-0.3,"60-120",2],[-0.3,"120-180",12],[-0.3,"180-240",32],[-0.3,"240-300",75], + [-0.29,"0-30",0],[-0.29,"30-60",18],[-0.29,"60-120",14],[-0.29,"120-180",12],[-0.29,"180-240",36],[-0.29,"240-300",17], + [-0.28,"0-30",50],[-0.28,"30-60",6],[-0.28,"60-120",23],[-0.28,"120-180",17],[-0.28,"180-240",41],[-0.28,"240-300",11], + [-0.27,"0-30",23],[-0.27,"30-60",0],[-0.27,"60-120",26],[-0.27,"120-180",21],[-0.27,"180-240",31],[-0.27,"240-300",10], + [-0.26,"0-30",0],[-0.26,"30-60",0],[-0.26,"60-120",11],[-0.26,"120-180",18],[-0.26,"180-240",30],[-0.26,"240-300",9], + [-0.25,"0-30",0],[-0.25,"30-60",0],[-0.25,"60-120",16],[-0.25,"120-180",16],[-0.25,"180-240",27],[-0.25,"240-300",31], + [-0.24,"0-30",0],[-0.24,"30-60",4],[-0.24,"60-120",19],[-0.24,"120-180",19],[-0.24,"180-240",16],[-0.24,"240-300",29], + [-0.23,"0-30",0],[-0.23,"30-60",0],[-0.23,"60-120",16],[-0.23,"120-180",14],[-0.23,"180-240",18],[-0.23,"240-300",18], + [-0.22,"0-30",30],[-0.22,"30-60",0],[-0.22,"60-120",9],[-0.22,"120-180",14],[-0.22,"180-240",22],[-0.22,"240-300",27], + [-0.21,"0-30",32],[-0.21,"30-60",31],[-0.21,"60-120",14],[-0.21,"120-180",14],[-0.21,"180-240",17],[-0.21,"240-300",38], + [-0.2,"0-30",31],[-0.2,"30-60",34],[-0.2,"60-120",8],[-0.2,"120-180",9],[-0.2,"180-240",10],[-0.2,"240-300",61], + [-0.19,"0-30",23],[-0.19,"30-60",10],[-0.19,"60-120",5],[-0.19,"120-180",8],[-0.19,"180-240",15],[-0.19,"240-300",49], + [-0.18,"0-30",12],[-0.18,"30-60",23],[-0.18,"60-120",4],[-0.18,"120-180",8],[-0.18,"180-240",14],[-0.18,"240-300",50], + [-0.17,"0-30",25],[-0.17,"30-60",1],[-0.17,"60-120",7],[-0.17,"120-180",8],[-0.17,"180-240",9],[-0.17,"240-300",39], + [-0.16,"0-30",13],[-0.16,"30-60",0],[-0.16,"60-120",3],[-0.16,"120-180",7],[-0.16,"180-240",15],[-0.16,"240-300",40], + [-0.15,"0-30",16],[-0.15,"30-60",2],[-0.15,"60-120",8],[-0.15,"120-180",7],[-0.15,"180-240",18],[-0.15,"240-300",33], + [-0.14,"0-30",14],[-0.14,"30-60",6],[-0.14,"60-120",12],[-0.14,"120-180",11],[-0.14,"180-240",17],[-0.14,"240-300",30], + [-0.13,"0-30",14],[-0.13,"30-60",12],[-0.13,"60-120",8],[-0.13,"120-180",17],[-0.13,"180-240",19],[-0.13,"240-300",24], + [-0.12,"0-30",16],[-0.12,"30-60",13],[-0.12,"60-120",9],[-0.12,"120-180",16],[-0.12,"180-240",26],[-0.12,"240-300",25], + [-0.11,"0-30",19],[-0.11,"30-60",8],[-0.11,"60-120",13],[-0.11,"120-180",23],[-0.11,"180-240",25],[-0.11,"240-300",29], + [-0.1,"0-30",18],[-0.1,"30-60",14],[-0.1,"60-120",13],[-0.1,"120-180",26],[-0.1,"180-240",25],[-0.1,"240-300",29], + [-0.09,"0-30",19],[-0.09,"30-60",15],[-0.09,"60-120",15],[-0.09,"120-180",27],[-0.09,"180-240",27],[-0.09,"240-300",23], + [-0.08,"0-30",21],[-0.08,"30-60",11],[-0.08,"60-120",20],[-0.08,"120-180",26],[-0.08,"180-240",27],[-0.08,"240-300",30], + [-0.07,"0-30",21],[-0.07,"30-60",19],[-0.07,"60-120",24],[-0.07,"120-180",27],[-0.07,"180-240",30],[-0.07,"240-300",35], + [-0.06,"0-30",21],[-0.06,"30-60",25],[-0.06,"60-120",27],[-0.06,"120-180",30],[-0.06,"180-240",34],[-0.06,"240-300",39], + [-0.05,"0-30",20],[-0.05,"30-60",20],[-0.05,"60-120",27],[-0.05,"120-180",29],[-0.05,"180-240",33],[-0.05,"240-300",38], + [-0.04,"0-30",22],[-0.04,"30-60",22],[-0.04,"60-120",30],[-0.04,"120-180",36],[-0.04,"180-240",39],[-0.04,"240-300",43], + [-0.03,"0-30",23],[-0.03,"30-60",28],[-0.03,"60-120",34],[-0.03,"120-180",41],[-0.03,"180-240",47],[-0.03,"240-300",48], + [-0.02,"0-30",29],[-0.02,"30-60",35],[-0.02,"60-120",39],[-0.02,"120-180",44],[-0.02,"180-240",48],[-0.02,"240-300",51], + [-0.01,"0-30",38],[-0.01,"30-60",46],[-0.01,"60-120",48],[-0.01,"120-180",51],[-0.01,"180-240",53],[-0.01,"240-300",56], + [0.0,"0-30",58],[0.0,"30-60",56],[0.0,"60-120",59],[0.0,"120-180",57],[0.0,"180-240",57],[0.0,"240-300",57], + [0.01,"0-30",74],[0.01,"30-60",69],[0.01,"60-120",67],[0.01,"120-180",65],[0.01,"180-240",63],[0.01,"240-300",59], + [0.02,"0-30",85],[0.02,"30-60",79],[0.02,"60-120",75],[0.02,"120-180",68],[0.02,"180-240",67],[0.02,"240-300",64], + [0.03,"0-30",91],[0.03,"30-60",85],[0.03,"60-120",79],[0.03,"120-180",75],[0.03,"180-240",70],[0.03,"240-300",67], + [0.04,"0-30",95],[0.04,"30-60",91],[0.04,"60-120",86],[0.04,"120-180",79],[0.04,"180-240",71],[0.04,"240-300",69], + [0.05,"0-30",96],[0.05,"30-60",90],[0.05,"60-120",86],[0.05,"120-180",79],[0.05,"180-240",74],[0.05,"240-300",70], + [0.06,"0-30",98],[0.06,"30-60",94],[0.06,"60-120",86],[0.06,"120-180",80],[0.06,"180-240",77],[0.06,"240-300",72], + [0.07,"0-30",96],[0.07,"30-60",93],[0.07,"60-120",89],[0.07,"120-180",82],[0.07,"180-240",79],[0.07,"240-300",74], + [0.08,"0-30",97],[0.08,"30-60",95],[0.08,"60-120",90],[0.08,"120-180",84],[0.08,"180-240",77],[0.08,"240-300",75], + [0.09,"0-30",97],[0.09,"30-60",93],[0.09,"60-120",92],[0.09,"120-180",88],[0.09,"180-240",79],[0.09,"240-300",76], + [0.1,"0-30",98],[0.1,"30-60",95],[0.1,"60-120",92],[0.1,"120-180",90],[0.1,"180-240",80],[0.1,"240-300",82], + [0.11,"0-30",98],[0.11,"30-60",97],[0.11,"60-120",94],[0.11,"120-180",89],[0.11,"180-240",85],[0.11,"240-300",87], + [0.12,"0-30",99],[0.12,"30-60",98],[0.12,"60-120",95],[0.12,"120-180",91],[0.12,"180-240",82],[0.12,"240-300",82], + [0.13,"0-30",99],[0.13,"30-60",96],[0.13,"60-120",98],[0.13,"120-180",89],[0.13,"180-240",87],[0.13,"240-300",89], + [0.14,"0-30",97],[0.14,"30-60",97],[0.14,"60-120",96],[0.14,"120-180",93],[0.14,"180-240",86],[0.14,"240-300",89], + [0.15,"0-30",99],[0.15,"30-60",97],[0.15,"60-120",93],[0.15,"120-180",92],[0.15,"180-240",89],[0.15,"240-300",79], + [0.16,"0-30",98],[0.16,"30-60",95],[0.16,"60-120",93],[0.16,"120-180",93],[0.16,"180-240",92],[0.16,"240-300",85], + [0.17,"0-30",100],[0.17,"30-60",98],[0.17,"60-120",96],[0.17,"120-180",93],[0.17,"180-240",96],[0.17,"240-300",74], + [0.18,"0-30",100],[0.18,"30-60",98],[0.18,"60-120",99],[0.18,"120-180",95],[0.18,"180-240",97],[0.18,"240-300",71], + [0.19,"0-30",100],[0.19,"30-60",97],[0.19,"60-120",98],[0.19,"120-180",98],[0.19,"180-240",94],[0.19,"240-300",58], + [0.2,"0-30",100],[0.2,"30-60",97],[0.2,"60-120",98],[0.2,"120-180",93],[0.2,"180-240",90],[0.2,"240-300",77], + [0.21,"0-30",98],[0.21,"30-60",98],[0.21,"60-120",97],[0.21,"120-180",95],[0.21,"180-240",81],[0.21,"240-300",77], + [0.22,"0-30",95],[0.22,"30-60",99],[0.22,"60-120",96],[0.22,"120-180",96],[0.22,"180-240",88],[0.22,"240-300",61], + [0.23,"0-30",97],[0.23,"30-60",100],[0.23,"60-120",100],[0.23,"120-180",100],[0.23,"180-240",93],[0.23,"240-300",65], + [0.24,"0-30",98],[0.24,"30-60",98],[0.24,"60-120",100],[0.24,"120-180",100],[0.24,"180-240",95],[0.24,"240-300",83], + [0.25,"0-30",95],[0.25,"30-60",96],[0.25,"60-120",100],[0.25,"120-180",99],[0.25,"180-240",98],[0.25,"240-300",89], + [0.26,"0-30",97],[0.26,"30-60",99],[0.26,"60-120",100],[0.26,"120-180",97],[0.26,"180-240",99],[0.26,"240-300",100], + [0.27,"0-30",100],[0.27,"30-60",99],[0.27,"60-120",100],[0.27,"120-180",97],[0.27,"180-240",100],[0.27,"240-300",96], + [0.28,"0-30",100],[0.28,"30-60",100],[0.28,"60-120",100],[0.28,"120-180",100],[0.28,"180-240",100],[0.28,"240-300",90], + [0.29,"0-30",100],[0.29,"30-60",100],[0.29,"60-120",100],[0.29,"120-180",100],[0.29,"180-240",100],[0.29,"240-300",100], + [0.3,"0-30",100],[0.3,"30-60",100],[0.3,"60-120",100],[0.3,"120-180",100],[0.3,"180-240",100],[0.3,"240-300",100], + [0.31,"0-30",100],[0.31,"30-60",100],[0.31,"60-120",100],[0.31,"120-180",100],[0.31,"180-240",100],[0.31,"240-300",86], + [0.32,"0-30",100],[0.32,"30-60",100],[0.32,"60-120",100],[0.32,"120-180",100],[0.32,"180-240",100],[0.32,"240-300",89], + [0.33,"0-30",100],[0.33,"30-60",100],[0.33,"60-120",100],[0.33,"120-180",100],[0.33,"180-240",100],[0.33,"240-300",100], + [0.34,"0-30",100],[0.34,"30-60",100],[0.34,"60-120",100],[0.34,"120-180",100],[0.34,"180-240",100],[0.34,"240-300",82], + [0.35,"0-30",100],[0.35,"30-60",100],[0.35,"60-120",100],[0.35,"120-180",100],[0.35,"180-240",100],[0.35,"240-300",77], + [0.36,"0-30",100],[0.36,"30-60",100],[0.36,"60-120",100],[0.36,"120-180",100],[0.36,"180-240",100],[0.36,"240-300",33], + [0.37,"0-30",100],[0.37,"30-60",100],[0.37,"60-120",100],[0.37,"120-180",100],[0.37,"180-240",100],[0.37,"240-300",50], + [0.38,"0-30",100],[0.38,"30-60",100],[0.38,"60-120",100],[0.38,"120-180",100],[0.38,"180-240",100],[0.38,"240-300",100], + [0.39,"0-30",100],[0.39,"30-60",100],[0.39,"60-120",100],[0.39,"120-180",100],[0.39,"180-240",100],[0.39,"240-300",100], + [0.4,"0-30",100],[0.4,"30-60",100],[0.4,"60-120",100],[0.4,"120-180",100],[0.4,"180-240",100],[0.4,"240-300",80], +]; + +// Fair probability tables per market — to add a new market, just add an entry here +export const FAIR_PROB_TABLES: Record = { + "btc-5m": BTC_5M, + "eth-5m": ETH_5M, + // "btc-15m": BTC_15M, // to be calibrated + "sol-5m": SOL_5M, +}; + +// Bucket design per market (diff step + range) —— ETH/SOL price magnitudes are far smaller than BTC and need finer buckets +interface BucketSpec { step: number; min: number; max: number; } +const BUCKET_SPECS: Record = { + "btc-5m": { step: 2, min: -140, max: 96 }, + "eth-5m": { step: 0.1, min: -7, max: 7 }, + "sol-5m": { step: 0.01, min: -0.4, max: 0.4 }, +}; + +// Precompile each table into a Map for O(1) lookup +const COMPILED_MAPS = new Map>(); +for (const [marketKey, raw] of Object.entries(FAIR_PROB_TABLES)) { + const m = new Map(); + for (const [diff, rb, prob] of raw) m.set(`${diff},${rb}`, prob); + COMPILED_MAPS.set(marketKey, m); +} + +export function getRemBin(rem: number): string | null { + // rem ≥ 240 all map to the 240-300 bin (handles rem = 300 or transient values right after a window switch) + // Note: all current tables are the 6 bins of the 5m period; adding a 15m table in the future requires extending this function + if (rem >= 240) return "240-300"; + if (rem >= 180) return "180-240"; + if (rem >= 120) return "120-180"; + if (rem >= 60) return "60-120"; + if (rem >= 30) return "30-60"; + if (rem >= 0) return "0-30"; + return null; +} + +/** Look up the fair probability (%) by diff and rem; returns null when the current market has no table */ +export function getFairProb(diff: number, rem: number): number | null { + const map = COMPILED_MAPS.get(activeMarketKey); + if (!map) return null; + const spec = BUCKET_SPECS[activeMarketKey]; + if (!spec) return null; + const rb = getRemBin(rem); + if (!rb) return null; + // round + clamp per the current market's bucket spec + const rawDb = Math.round(diff / spec.step) * spec.step; + const dbClamped = Math.max(spec.min, Math.min(spec.max, rawDb)); + // precision matches the generation side's round(x, 2): integer steps use integers; decimal steps keep 2 places + // (0.5 multiples are also exact at 2 places: 1.0/1.5/2.0...; a future 0.05 step would also match correctly) + const db = spec.step >= 1 ? dbClamped : Math.round(dbClamped * 100) / 100; + return map.get(`${db},${rb}`) ?? null; +} + +/** Whether the current market has a fair-probability table (the frontend can use this to decide whether to enable p-series strategies) */ +export function hasFairProbTable(marketKey?: string): boolean { + return COMPILED_MAPS.has(marketKey ?? activeMarketKey); +} + +// Current active market key (synced by server.ts when switching markets) +let activeMarketKey = "btc-5m"; +export function setActiveMarket(sym: string, period: string): void { + activeMarketKey = `${sym}-${period}`; +} +// Backward compatibility for old calls +export function setActiveSymbol(sym: string): void { + activeMarketKey = `${sym}-5m`; +} diff --git a/strategies/_core/s6-core.ts b/strategies/_core/s6-core.ts new file mode 100644 index 0000000..6428d19 --- /dev/null +++ b/strategies/_core/s6-core.ts @@ -0,0 +1,320 @@ +/** + * Momentum strategy shared core — entry logic + factor computation + * + * Implemented strictly per the original strategy spec, shared by s6/s13: + * 1. Current candle direction + 5-bar 1-minute momentum direction must agree + * 2. 6-factor scoring (RSI 25% / volume 20% / price 20% / candle 15% / consecutive 15% / MA7 5%) + * 3. Bonus when the 5-minute trend agrees (strength * 0.1) + * 4. MA120 long-term trend filter + 5-minute trend filter + * 5. UP threshold 0.55 / DOWN threshold 0.60 + */ + +import type { Kline, StrategyTickContext, StrategyDirection } from "../types.js"; + +// ── Entry parameters ──────────────────────────────────────────────── +export const UP_THRESHOLD = 0.55; +export const DOWN_THRESHOLD = 0.60; +export const WINDOW_MIN_REMAINING = 30; // do not enter when the window's remaining seconds are below this value + +// ── Momentum parameters ──────────────────────────────────────────────── +export const MOMENTUM_BARS = 5; // use the latest 5 1-minute K-lines to judge momentum +export const MOMENTUM_THRESHOLD_PCT = 0.05; // a 0.05% move determines the momentum direction + +// ── K-line indicator computation functions ──────────────────────────── + +export function calcRSI(klines: readonly Kline[], period = 14): number | null { + if (klines.length < period + 1) return null; + let gain = 0, loss = 0; + const start = klines.length - period; + for (let i = start; i < klines.length; i++) { + const change = klines[i].close - klines[i - 1].close; + if (change >= 0) gain += change; else loss -= change; + } + if (loss === 0) return 100; + const rs = gain / loss; + return 100 - 100 / (1 + rs); +} + +export function calcMA(klines: readonly Kline[], period: number): number | null { + if (klines.length < period) return null; + let sum = 0; + for (let i = klines.length - period; i < klines.length; i++) { + sum += klines[i].close; + } + return sum / period; +} + +/** Overall momentum direction of the latest N K-lines */ +export function momentumDirection(klines: readonly Kline[], bars: number, thresholdPct: number): "up" | "down" | "neutral" { + if (klines.length < bars) return "neutral"; + const first = klines[klines.length - bars]; + const last = klines[klines.length - 1]; + const change = (last.close - first.open) / first.open * 100; + if (change > thresholdPct) return "up"; + if (change < -thresholdPct) return "down"; + return "neutral"; +} + +/** Current candle direction (strictly by bullish/bearish close, no body-ratio filter) */ +export function currentCandleDirection(klines: readonly Kline[]): "up" | "down" | "neutral" { + if (!klines.length) return "neutral"; + const k = klines[klines.length - 1]; + if (k.close > k.open) return "up"; + if (k.close < k.open) return "down"; + return "neutral"; +} + +/** Number of consecutive same-direction K-lines */ +export function consecutiveSameDirection(klines: readonly Kline[], dir: "up" | "down"): number { + let count = 0; + for (let i = klines.length - 1; i >= 0; i--) { + const k = klines[i]; + const kDir = k.close > k.open ? "up" : k.close < k.open ? "down" : "neutral"; + if (kDir === dir) count++; + else break; + } + return count; +} + +// ── Individual scoring functions ──────────────────────────── + +export function scoreConsecutive(count: number): number { + return Math.min(count / 3, 1.0); +} + +export function scoreRSI(rsi: number): number { + if (rsi > 70) return 1.0; + if (rsi > 60) return 0.8; + if (rsi > 50) return 0.6; + if (rsi > 40) return 0.4; + return 0.2; +} + +export function scoreVolume(klines: readonly Kline[]): number { + if (klines.length < 20) return 0; + let sum = 0; + for (let i = klines.length - 20; i < klines.length - 1; i++) { + sum += klines[i].volume; + } + const avg = sum / 19; + if (avg === 0) return 0; + const ratio = Math.max(0.1, Math.min(5.0, klines[klines.length - 1].volume / avg)); + if (ratio > 1) return Math.min(ratio / 2, 1.0); + return ratio * 0.5; +} + +export function scorePriceChange(klines: readonly Kline[], bars: number): number { + if (klines.length < bars + 1) return 0; + const from = klines[klines.length - 1 - bars].close; + const to = klines[klines.length - 1].close; + const changePct = Math.abs((to - from) / from * 100); + return Math.min(changePct / 0.1, 1.0); +} + +export function scoreCandle(klines: readonly Kline[]): number { + if (!klines.length) return 0; + const k = klines[klines.length - 1]; + const range = k.high - k.low; + if (range === 0) return 0; + const body = Math.abs(k.close - k.open); + const upperWick = k.high - Math.max(k.open, k.close); + const lowerWick = Math.min(k.open, k.close) - k.low; + const bodyRatio = body / range; + const wickPenalty = (upperWick + lowerWick) / range; + return Math.max(0, Math.min(1, bodyRatio - wickPenalty * 0.3)); +} + +export function scoreMA7(klines: readonly Kline[], dir: "up" | "down"): number { + const ma7 = calcMA(klines, 7); + if (ma7 == null) return 0; + const price = klines[klines.length - 1].close; + if (dir === "up") return price > ma7 ? 1.0 : 0; + return price < ma7 ? 1.0 : 0; +} + +// ── 5-minute trend analysis ─────────────────────────────────────────── + +export interface Trend5m { + direction: "up" | "down" | "neutral"; + strength: number; +} + +export function analyze5mTrend(klines5m: readonly Kline[]): Trend5m { + if (klines5m.length < 20) return { direction: "neutral", strength: 0 }; + + const ma5 = calcMA(klines5m, 5); + const ma10 = calcMA(klines5m, 10); + const ma20 = calcMA(klines5m, 20); + if (ma5 == null || ma10 == null || ma20 == null) return { direction: "neutral", strength: 0 }; + + let upVotes = 0, downVotes = 0; + + // a. MA structure + if (ma5 > ma10 && ma10 > ma20) upVotes++; + else if (ma5 < ma10 && ma10 < ma20) downVotes++; + + // b. momentum of the latest 5 bars + const mom = momentumDirection(klines5m, 5, 0.05); + if (mom === "up") upVotes++; + else if (mom === "down") downVotes++; + + // c. consecutive same-direction + const consecUp = consecutiveSameDirection(klines5m, "up"); + const consecDown = consecutiveSameDirection(klines5m, "down"); + if (consecUp >= 2) upVotes++; + if (consecDown >= 2) downVotes++; + + const total = upVotes + downVotes; + if (total === 0) return { direction: "neutral", strength: 0 }; + if (upVotes > downVotes) return { direction: "up", strength: upVotes / 3 }; + if (downVotes > upVotes) return { direction: "down", strength: downVotes / 3 }; + return { direction: "neutral", strength: 0 }; +} + +// ── Long-term trend (MA120) ──────────────────────────────────────── + +export function longTermTrend(klines1m: readonly Kline[]): "up" | "down" | "neutral" { + const ma120 = calcMA(klines1m, 120); + if (ma120 == null || !klines1m.length) return "neutral"; + const price = klines1m[klines1m.length - 1].close; + const diffPct = (price - ma120) / ma120 * 100; + if (diffPct > 1) return "up"; + if (diffPct < -1) return "down"; + return "neutral"; +} + +// ── Factor aggregation ──────────────────────────────────────────────── + +export interface S6Factors { + currDir: "up" | "down" | "neutral"; + momDir: "up" | "down" | "neutral"; + rsi: number | null; + consecutive: number; + scRsi: number; + scVolume: number; + scPriceChange: number; + scCandle: number; + scMa7: number; + scConsecutive: number; + totalScore: number; + threshold: number; + longTrend: "up" | "down" | "neutral"; + trend5mDir: "up" | "down" | "neutral"; + trend5mStrength: number; + dataReady: boolean; +} + +/** Compute a factor snapshot (including current-direction scoring) */ +export function computeFactors(ctx: StrategyTickContext): S6Factors { + const { kline1m, kline5m } = ctx; + const dataReady = kline1m.length >= 120 && kline5m.length >= 20; + + const currDir = currentCandleDirection(kline1m); + const momDir = momentumDirection(kline1m, MOMENTUM_BARS, MOMENTUM_THRESHOLD_PCT); + const rsi = calcRSI(kline1m, 14); + const dir: "up" | "down" = currDir !== "neutral" ? currDir : "up"; + + const consecutive = consecutiveSameDirection(kline1m, dir); + const scConsecutive = scoreConsecutive(consecutive); + const scRsi = rsi != null ? scoreRSI(rsi) : 0; + const scVolume = scoreVolume(kline1m); + const scPriceChange = scorePriceChange(kline1m, MOMENTUM_BARS); + const scCandle = scoreCandle(kline1m); + const scMa7 = scoreMA7(kline1m, dir); + const longTrend = longTermTrend(kline1m); + const trend5m = analyze5mTrend(kline5m); + + let totalScore = + scConsecutive * 0.15 + + scRsi * 0.25 + + scVolume * 0.20 + + scPriceChange * 0.20 + + scCandle * 0.15 + + scMa7 * 0.05; + if (trend5m.direction === dir) totalScore += trend5m.strength * 0.1; + + return { + currDir, momDir, rsi, consecutive, + scRsi, scVolume, scPriceChange, scCandle, scMa7, scConsecutive, + totalScore: Math.round(totalScore * 1000) / 1000, + threshold: dir === "up" ? UP_THRESHOLD : DOWN_THRESHOLD, + longTrend, + trend5mDir: trend5m.direction, + trend5mStrength: Math.round(trend5m.strength * 100) / 100, + dataReady, + }; +} + +/** + * Determine whether it is currently US stock market hours (rough filter only, covering both DST and standard time). + * Monday~Friday UTC 13:30 - 21:00 (covers all of ET 9:30-16:00) + * Weekends are treated as closed all day. + */ +export function isUSMarketOpen(nowMs = Date.now()): boolean { + const d = new Date(nowMs); + const day = d.getUTCDay(); // 0=Sunday, 6=Saturday + if (day === 0 || day === 6) return false; + const minutesUTC = d.getUTCHours() * 60 + d.getUTCMinutes(); + return minutesUTC >= 13 * 60 + 30 && minutesUTC < 21 * 60; +} + +/** Check entry conditions (strictly per the original strategy) */ +export function checkMomentumEntry( + ctx: StrategyTickContext, + minRem: number = WINDOW_MIN_REMAINING, +): { direction: StrategyDirection; entryScore: number } | null { + const { rem, kline1m, kline5m, marketHoursOnly } = ctx; + if (rem <= minRem) return null; + if (kline1m.length < 120) return null; + if (kline5m.length < 20) return null; + // US market hours filter + if (marketHoursOnly && !isUSMarketOpen()) return null; + + // 1. current candle direction + const currDir = currentCandleDirection(kline1m); + if (currDir === "neutral") return null; + + // 2. 5-bar momentum direction + const momDir = momentumDirection(kline1m, MOMENTUM_BARS, MOMENTUM_THRESHOLD_PCT); + if (momDir !== currDir) return null; + + const dir: StrategyDirection = currDir; + + // 3. long-term trend filter + const longTrend = longTermTrend(kline1m); + if (dir === "up" && longTrend === "down") return null; + if (dir === "down" && longTrend === "up") return null; + + // 4. 5-minute trend filter + const trend5m = analyze5mTrend(kline5m); + if (trend5m.strength > 0.3) { + if (dir === "up" && trend5m.direction === "down") return null; + if (dir === "down" && trend5m.direction === "up") return null; + } + + // 5. 6-factor scoring + const rsi = calcRSI(kline1m, 14); + if (rsi == null) return null; + + const consecutive = consecutiveSameDirection(kline1m, dir); + const scConsecutive = scoreConsecutive(consecutive); + const scRsi = scoreRSI(rsi); + const scVolume = scoreVolume(kline1m); + const scPriceChange = scorePriceChange(kline1m, MOMENTUM_BARS); + const scCandle = scoreCandle(kline1m); + const scMa7 = scoreMA7(kline1m, dir); + + let totalScore = + scConsecutive * 0.15 + + scRsi * 0.25 + + scVolume * 0.20 + + scPriceChange * 0.20 + + scCandle * 0.15 + + scMa7 * 0.05; + if (trend5m.direction === dir) totalScore += trend5m.strength * 0.1; + + const threshold = dir === "up" ? UP_THRESHOLD : DOWN_THRESHOLD; + if (totalScore < threshold) return null; + + return { direction: dir, entryScore: totalScore }; +} diff --git a/strategies/_runtime/loader.ts b/strategies/_runtime/loader.ts new file mode 100644 index 0000000..235d5fd --- /dev/null +++ b/strategies/_runtime/loader.ts @@ -0,0 +1,132 @@ +/** + * Strategy dynamic loader (plugin-ization core) + * + * On startup, automatically scans the following directories: + * - strategies/*.ts main project built-in strategies + * - strategies/extensions/* optional extension strategies (can be gitignored) + * + * As long as the filename matches sN.ts (s1 ~ s999), it is automatically imported and registered. + * The main project code needs no changes anywhere, including types.ts / registry.ts. + */ + +import { readdirSync, existsSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath, pathToFileURL } from "url"; +import type { IStrategy } from "../types.js"; +import { __setStrategyKeys } from "../types.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const STRATEGIES_DIR = resolve(__dirname, ".."); +const EXTENSIONS_DIR = resolve(__dirname, "..", "extensions"); + +/** Recognize strategy filenames: .ts (e.g. s1.ts, d1.ts, p1.ts, m1.ts, le1.ts) */ +/** Excludes the _core / _runtime / extensions subdirectory names, and shared modules like s6-core.ts (containing `-`) */ +const STRATEGY_FILE_REGEX = /^([a-z]+)(\d+)\.ts$/; + +interface LoadedFile { + filePath: string; + prefix: string; // letter prefix, used for sorting + number: number; // numeric part + source: "core" | "ext"; +} + +function scanDir(dir: string, source: "core" | "ext"): LoadedFile[] { + if (!existsSync(dir)) return []; + const files: LoadedFile[] = []; + for (const f of readdirSync(dir)) { + const match = f.match(STRATEGY_FILE_REGEX); + if (!match) continue; + files.push({ + filePath: resolve(dir, f), + prefix: match[1], + number: parseInt(match[2], 10), + source, + }); + } + return files; +} + +const strategies: Map = new Map(); +let loaded = false; + +/** Called on startup to dynamically load all strategy files */ +export async function loadAllStrategies(): Promise { + if (loaded) return [...strategies.values()]; + + const files = [ + ...scanDir(STRATEGIES_DIR, "core"), + ...scanDir(EXTENSIONS_DIR, "ext"), + ].sort((a, b) => a.number - b.number); + + const instances: IStrategy[] = []; + for (const f of files) { + try { + const url = pathToFileURL(f.filePath).href; + const mod = await import(url); + // Supports two export styles: + // 1) Old-style class: `export class S1Enhanced implements IStrategy`, using `new ClassName()` + // 2) New style: `export default defineStrategy({...})` or `export default ` + let instance: IStrategy | null = null; + + if (mod.default) { + // New style: default export + if (typeof mod.default === "function") { + instance = new mod.default(); + } else { + instance = mod.default as IStrategy; + } + } else { + // Old style: find the first class export + for (const key of Object.keys(mod)) { + const v = mod[key]; + if (typeof v === "function" && /^[A-Z]/.test(key)) { + instance = new v(); + break; + } + } + } + + if (!instance || typeof instance.checkEntry !== "function") { + console.warn(`[StrategyLoader] ${f.filePath} no valid strategy instance found, skipping`); + continue; + } + + strategies.set(instance.key, instance); + instances.push(instance); + } catch (err) { + console.warn(`[StrategyLoader] failed to load ${f.filePath}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + // Sort by key: first by letter prefix (d s.key), + instances.map(s => s.number), + ); + + loaded = true; + console.log(`[StrategyLoader] loaded ${instances.length} strategies: ${instances.map(s => s.key).join(", ")}`); + return instances; +} + +export function getStrategy(key: string): IStrategy | undefined { + return strategies.get(key); +} + +function sortByKey(a: IStrategy, b: IStrategy): number { + const [, aPrefix = "", aNum = "0"] = a.key.match(/^([a-z]+)(\d+)?$/) ?? []; + const [, bPrefix = "", bNum = "0"] = b.key.match(/^([a-z]+)(\d+)?$/) ?? []; + if (aPrefix !== bPrefix) return aPrefix.localeCompare(bPrefix); + return parseInt(aNum, 10) - parseInt(bNum, 10); +} + +export function getAllStrategies(): IStrategy[] { + return [...strategies.values()].sort(sortByKey); +} + +export function getAllStrategyKeys(): string[] { + return getAllStrategies().map(s => s.key); +} diff --git a/strategies/d1.ts b/strategies/d1.ts new file mode 100644 index 0000000..0487603 --- /dev/null +++ b/strategies/d1.ts @@ -0,0 +1,68 @@ +/** + * Strategy 3 · Tail-Scalp — large-diff entry at the tail of the window + */ + +import type { + IStrategy, StrategyKey, StrategyNumber, StrategyDirection, + StrategyTickContext, EntrySignal, ExitSignal, StrategyDescription, +} from "./types.js"; + +const WINDOW_MAX_REMAINING = 30; +const ENTRY_DIFF = 40; +const ENTRY_PROB_CAP = 80; +const STOP_LOSS_DIFF = 0; + +export class D1Sweep implements IStrategy { + readonly key: StrategyKey = "d1"; + readonly number: StrategyNumber = 1; + readonly name = "Tail-Scalp"; + + getDescription(): StrategyDescription { + return { + key: this.key, + number: this.number, + name: this.name, + title: "Diff 1 · Tail-Scalp", + category: { id: "diff", label: "Diff", color: "#58a6ff" }, + supportedMarkets: ["btc-5m"], + lines: [ + { text: `⏱ Checked when remaining ${WINDOW_MAX_REMAINING}s~0s (the very tail, highest win rate)` }, + { text: `📈 Buy up: diff >+${ENTRY_DIFF} and up probability <${ENTRY_PROB_CAP}%` }, + { text: `📉 Buy down: diff <-${ENTRY_DIFF} and down probability <${ENTRY_PROB_CAP}%` }, + { text: `Stop loss: diff crosses 0 (buy up diff≤0 / buy down diff≥0)`, color: "#f85149", marginTop: true }, + { text: "No take profit, hold to window end", color: "#3fb950" }, + { text: "Backtest 24 days: ~7 trades/day / 86% win rate / $0.70 per trade (passed all 5 validations)", color: "#888", marginTop: true }, + ], + }; + } + + updateGuards(_ctx: StrategyTickContext): void {} + + checkEntry(ctx: StrategyTickContext): EntrySignal | null { + const { rem, upPct, dnPct, diff } = ctx; + if (upPct == null || dnPct == null || diff == null) return null; + if (rem > WINDOW_MAX_REMAINING || rem <= 0) return null; + + if (diff > ENTRY_DIFF && upPct < ENTRY_PROB_CAP) return { direction: "up" }; + if (diff < -ENTRY_DIFF && dnPct < ENTRY_PROB_CAP) return { direction: "down" }; + return null; + } + + checkExit(ctx: StrategyTickContext, direction: StrategyDirection): ExitSignal { + const { diff } = ctx; + if (diff == null) return null; + if (direction === "up" && diff <= STOP_LOSS_DIFF) { + return { signal: "sl", reason: `stop loss diff=${Math.round(diff)}≤${STOP_LOSS_DIFF}` }; + } + if (direction === "down" && diff >= -STOP_LOSS_DIFF) { + return { signal: "sl", reason: `stop loss diff=${Math.round(diff)}≥${-STOP_LOSS_DIFF}` }; + } + return null; + } + + resetState(): void {} + + getStatePayload(): Record { + return {}; + } +} diff --git a/strategies/p1.ts b/strategies/p1.ts new file mode 100644 index 0000000..49675c1 --- /dev/null +++ b/strategies/p1.ts @@ -0,0 +1,135 @@ +/** + * Strategy 5 · Prob Chase — when diff crosses, the probability is too low; hold to window settlement + * + * Core logic: at the moment diff breaks the threshold, if the probability has not caught up yet (deviates from the historical fair probability), + * it means the market is reacting slowly, so enter and buy. + * + * Exit: no take profit, no timeout; only stop loss when diff crosses to the reverse ±5; otherwise hold to window-end settlement. + */ + +import type { + IStrategy, StrategyKey, StrategyNumber, StrategyDirection, + StrategyTickContext, EntrySignal, ExitSignal, StrategyDescription, +} from "./types.js"; +import { getFairProb } from "./_core/fair-prob.js"; + +// ── Entry parameters ──────────────────────────────────────────────── +const ENTRY_DIFF = 25; // check the deviation when diff crosses this threshold +const ENTRY_BIAS_MIN = 10; // enter only when the probability deviation is at least this many percentage points (fair probability - actual probability ≥ 10) +const WINDOW_MAX_REMAINING = 90; // entry scan start: remaining ≤90s +const WINDOW_MIN_REMAINING = 30; // entry scan end: remaining ≤30s + +// ── Exit parameters ──────────────────────────────────────────────── +const STOP_LOSS_DIFF = 5; // cross -5 stop loss: buy up diff≤-5 / buy down diff≥5 + +interface S5State { + lastDiff: number | null; + entryBias: number; // deviation value at entry + entryTs: number; // entry timestamp +} + +function createState(): S5State { + return { + lastDiff: null, + entryBias: 0, + entryTs: 0, + }; +} + +export class P1ProbChase implements IStrategy { + readonly key: StrategyKey = "p1"; + readonly number: StrategyNumber = 1; + readonly name = "Prob Chase"; + + private s: S5State = createState(); + + getDescription(): StrategyDescription { + return { + key: this.key, + number: this.number, + name: this.name, + title: "Prob Chase 1", + category: { id: "prob-chase", label: "Prob Chase", color: "#f0a500" }, + supportedMarkets: ["btc-5m"], + lines: [ + { text: `⏱ Checked when remaining ${WINDOW_MAX_REMAINING}s~${WINDOW_MIN_REMAINING}s` }, + { text: `📈 Enter when diff crosses ±${ENTRY_DIFF} and the probability deviation ≥${ENTRY_BIAS_MIN}%` }, + { text: "deviation = historical fair probability - current probability (probability has not caught up with diff)" }, + { text: `Stop loss: buy up diff≤-${STOP_LOSS_DIFF} / buy down diff≥${STOP_LOSS_DIFF}`, color: "#f85149", marginTop: true }, + { text: "No take profit, no timeout; hold to window end and let settlement decide", color: "#f0a500" }, + { text: "Fair probability judged from a diff+rem 2D mapping table", color: "#888", marginTop: true }, + ], + }; + } + + updateGuards(_ctx: StrategyTickContext): void {} + + checkEntry(ctx: StrategyTickContext): EntrySignal | null { + const { rem, upPct, dnPct, diff } = ctx; + if (upPct == null || dnPct == null || diff == null) return null; + if (rem > WINDOW_MAX_REMAINING || rem <= WINDOW_MIN_REMAINING) return null; + + const lastDiff = this.s.lastDiff; + if (lastDiff == null) return null; + + // buy-up crossing + if (lastDiff <= ENTRY_DIFF && diff > ENTRY_DIFF) { + const fair = getFairProb(diff, rem); + if (fair != null && fair - upPct >= ENTRY_BIAS_MIN) { + this.s.entryBias = fair - upPct; + return { direction: "up" }; + } + } + + // buy-down crossing + if (lastDiff >= -ENTRY_DIFF && diff < -ENTRY_DIFF) { + const fair = getFairProb(diff, rem); + if (fair != null) { + const fairDn = 100 - fair; + const bias = fairDn - dnPct; + if (bias >= ENTRY_BIAS_MIN) { + this.s.entryBias = bias; + return { direction: "down" }; + } + } + } + + return null; + } + + onEntryFilled(ctx: StrategyTickContext, _direction: StrategyDirection): void { + this.s.entryTs = ctx.now; + } + + checkExit(ctx: StrategyTickContext, direction: StrategyDirection): ExitSignal { + const { diff } = ctx; + if (diff == null) return null; + + // cross -5 stop loss: buy up diff≤-5 / buy down diff≥5 + if (direction === "up" && diff <= -STOP_LOSS_DIFF) { + return { signal: "sl", reason: `reverse-cross stop loss diff ${Math.round(diff)}≤-${STOP_LOSS_DIFF}` }; + } + if (direction === "down" && diff >= STOP_LOSS_DIFF) { + return { signal: "sl", reason: `reverse-cross stop loss diff ${Math.round(diff)}≥${STOP_LOSS_DIFF}` }; + } + + // no take profit, no timeout, no forced close → return null, hold to window-end settlement + return null; + } + + finalizeTick(diff: number | null): void { + this.s.lastDiff = diff; + } + + resetState(): void { + this.s = createState(); + } + + getStatePayload(): Record { + return { + lastDiff: this.s.lastDiff, + entryBias: this.s.entryBias, + entryTs: this.s.entryTs, + }; + } +} diff --git a/strategies/p2.ts b/strategies/p2.ts new file mode 100644 index 0000000..9bda547 --- /dev/null +++ b/strategies/p2.ts @@ -0,0 +1,137 @@ +/** + * Strategy p2 · Prob Chase · End-Game Crossing version + * + * Entry (checked each tick): + * - 30 < rem ≤ 90 (window remaining 90s ~ 30s) + * - diff crosses ±25 (previous tick within ±25 / current tick crosses out) + * - entry-direction current probability < 65% (market has not caught up) + * - entry-direction deviation = historical fair probability - current probability ≥ 10% + * + * Exit: + * - no take profit + * - stop loss: buy up diff ≤ -5 / buy down diff ≥ 5 + * - no timeout; with no stop loss, hold to window end and let settlement decide + */ + +import type { + IStrategy, StrategyKey, StrategyNumber, StrategyDirection, + StrategyTickContext, EntrySignal, ExitSignal, StrategyDescription, +} from "./types.js"; +import { getFairProb } from "./_core/fair-prob.js"; + +const REM_MAX = 90; +const REM_MIN = 30; +const ENTRY_DIFF = 25; +const ENTRY_BIAS_MIN = 10; +const ENTRY_PCT_MAX = 65; +const SL_DIFF = 5; + +interface P2State { + lastDiff: number | null; + entryBias: number; + entryTs: number; +} + +function createState(): P2State { + return { lastDiff: null, entryBias: 0, entryTs: 0 }; +} + +export class P2ProbChaseTail implements IStrategy { + readonly key: StrategyKey = "p2"; + readonly number: StrategyNumber = 2; + readonly name = "Prob Chase · End-Game Crossing"; + + private s: P2State = createState(); + + getDescription(): StrategyDescription { + return { + key: this.key, + number: this.number, + name: this.name, + title: "Prob Chase 2 · End-Game Crossing", + category: { id: "prob-chase", label: "Prob Chase", color: "#f0a500" }, + supportedMarkets: ["btc-5m"], + lines: [ + { text: `⏱ Checked when remaining ${REM_MIN}s ~ ${REM_MAX}s` }, + { text: `📈 Enter when diff crosses ±${ENTRY_DIFF} and deviation ≥ ${ENTRY_BIAS_MIN}%` }, + { text: "deviation = historical fair probability - current probability (probability has not caught up with diff)" }, + { text: `🔒 Entry-direction current probability < ${ENTRY_PCT_MAX}% (enter only when the market has not caught up)` }, + { text: `Stop loss: buy up diff≤-${SL_DIFF} / buy down diff≥${SL_DIFF}`, color: "#f85149", marginTop: true }, + { text: "No take profit, no timeout; hold to window end and let settlement decide", color: "#3fb950" }, + { text: "Fair probability judged from a diff+rem 2D mapping table", color: "#888", marginTop: true }, + ], + }; + } + + updateGuards(_ctx: StrategyTickContext): void {} + + checkEntry(ctx: StrategyTickContext): EntrySignal | null { + const { rem, upPct, dnPct, diff } = ctx; + if (upPct == null || dnPct == null || diff == null) return null; + if (rem > REM_MAX || rem <= REM_MIN) return null; + + const lastDiff = this.s.lastDiff; + if (lastDiff == null) return null; + + // cross up over +ENTRY_DIFF → buy up + if (lastDiff <= ENTRY_DIFF && diff > ENTRY_DIFF && upPct < ENTRY_PCT_MAX) { + const fair = getFairProb(diff, rem); + if (fair != null) { + const upBias = fair - upPct; + if (upBias >= ENTRY_BIAS_MIN) { + this.s.entryBias = upBias; + return { direction: "up" }; + } + } + } + + // cross down below -ENTRY_DIFF → buy down + if (lastDiff >= -ENTRY_DIFF && diff < -ENTRY_DIFF && dnPct < ENTRY_PCT_MAX) { + const fair = getFairProb(diff, rem); + if (fair != null) { + const fairDn = 100 - fair; + const dnBias = fairDn - dnPct; + if (dnBias >= ENTRY_BIAS_MIN) { + this.s.entryBias = dnBias; + return { direction: "down" }; + } + } + } + + return null; + } + + onEntryFilled(ctx: StrategyTickContext, _direction: StrategyDirection): void { + this.s.entryTs = ctx.now; + } + + checkExit(ctx: StrategyTickContext, direction: StrategyDirection): ExitSignal { + const { diff } = ctx; + if (diff == null) return null; + + if (direction === "up" && diff <= -SL_DIFF) { + return { signal: "sl", reason: `reverse cross diff=${Math.round(diff)} ≤ -${SL_DIFF}` }; + } + if (direction === "down" && diff >= SL_DIFF) { + return { signal: "sl", reason: `reverse cross diff=${Math.round(diff)} ≥ +${SL_DIFF}` }; + } + return null; + } + + /** Called externally at the end of each tick to record lastDiff for crossing detection */ + finalizeTick(diff: number | null): void { + this.s.lastDiff = diff; + } + + resetState(): void { + this.s = createState(); + } + + getStatePayload(): Record { + return { + entryBias: this.s.entryBias, + entryTs: this.s.entryTs, + lastDiff: this.s.lastDiff, + }; + } +} diff --git a/strategies/registry.ts b/strategies/registry.ts new file mode 100644 index 0000000..fbf19b7 --- /dev/null +++ b/strategies/registry.ts @@ -0,0 +1,41 @@ +/** + * Strategy registry —— public interface layer + * + * After plugin-ization, the actual loading logic lives in `_runtime/loader.ts`. + * This file is kept as the public interface so server.ts's imports do not need to change. + * + * Startup flow: server.ts awaits initStrategies() on startup, after which other modules can make calls. + */ + +import type { IStrategy, StrategyDescription } from "./types.js"; +import { + loadAllStrategies, + getStrategy as _getStrategy, + getAllStrategies as _getAllStrategies, + getAllStrategyKeys as _getAllStrategyKeys, +} from "./_runtime/loader.js"; + +let ready = false; + +/** Must be called once on startup (server.ts awaits it before server.listen) */ +export async function initStrategies(): Promise { + if (ready) return; + await loadAllStrategies(); + ready = true; +} + +export function getStrategy(key: string): IStrategy | undefined { + return _getStrategy(key); +} + +export function getAllStrategies(): IStrategy[] { + return _getAllStrategies(); +} + +export function getAllStrategyKeys(): string[] { + return _getAllStrategyKeys(); +} + +export function getAllDescriptions(): StrategyDescription[] { + return _getAllStrategies().map(s => s.getDescription()); +} diff --git a/strategies/types.ts b/strategies/types.ts new file mode 100644 index 0000000..769dd5a --- /dev/null +++ b/strategies/types.ts @@ -0,0 +1,291 @@ +/** + * Shared types for strategy modules + */ + +// ── Strategy types (after plugin-ization: strategies are self-governing, no hard-coded list) ─────────── +// Add a strategy: create a new sN.ts under strategies/ or strategies/extensions/ +// Remove a strategy: delete the corresponding sN.ts +// The main project server.ts / index.html need no changes at all +export type StrategyKey = string; // e.g. "s1" "s6" "s13" +export type StrategyNumber = number; // e.g. 1 6 13 + +// Populated at runtime by the loader. Uses a mutable array + in-place splice, so that the +// binding obtained via import sees the latest content (any array method like .map/.filter/.forEach works normally) +export const ALL_STRATEGY_KEYS: StrategyKey[] = []; +export const ALL_STRATEGY_NUMBERS: StrategyNumber[] = []; +export function __setStrategyKeys(keys: readonly StrategyKey[], numbers: readonly StrategyNumber[]): void { + ALL_STRATEGY_KEYS.splice(0, ALL_STRATEGY_KEYS.length, ...keys); + ALL_STRATEGY_NUMBERS.splice(0, ALL_STRATEGY_NUMBERS.length, ...numbers); +} + +export type StrategyDirection = "up" | "down"; +export type StrategyLifecycleState = + | "IDLE" + | "SCANNING" + | "BUYING" + | "WAIT_FILL" + | "RECONCILING_FILL" + | "HOLDING" + | "SELLING" + | "WAIT_SELL_FILL" + | "DONE"; + +/** Binance K-line */ +export interface Kline { + openTime: number; + open: number; + high: number; + low: number; + close: number; + volume: number; + closed: boolean; +} + +/** Read-only market snapshot passed to strategies each tick */ +export interface StrategyTickContext { + rem: number; + upPct: number | null; + dnPct: number | null; + diff: number | null; // Binance current price - window open price (absolute USD value), strongly correlated with the coin's price magnitude + diffBps: number | null; // diff as a fraction of the window open price in bps (diff/PTB*10000), cross-coin generic + volPct: number | null; // 30-second rolling amplitude percentage = (max - min) / min * 100; null within the first 30s of startup / when data is insufficient + now: number; + prevUpPct: number | null; + kline1m: readonly Kline[]; // Binance 1-minute K-lines (latest at the end) + kline5m: readonly Kline[]; // Binance 5-minute K-lines + marketHoursOnly: boolean; // whether momentum strategies only enter during US stock market hours +} + +/** Strategy entry signal */ +export interface EntrySignal { + direction: StrategyDirection; +} + +/** Limit open-order signal (used by checkLimitOrder) */ +export interface LimitOrderSignal { + direction: StrategyDirection; + /** Order price (0-1, absolute price) */ + price: number; + /** Shares (minimum 5, limited by Polymarket maker minimum) */ + shares: number; +} + +/** Presign request (strategy declares the limit-order parameters that need presigning) + * + * How it works (server implementation): + * - When rem ∈ [remMin, remMax], the server asynchronously createOrder in the background and caches the signed package + * - On trigger checkLimitOrder returns a LimitOrderSignal; if the server hits the presign cache it skips signing and postOrder directly + * - All presign caches are cleared on window switch + * - Conservative failure handling: a failed presign is not retried; if the cache is empty on trigger it falls back to live signing (the original path) + */ +export interface PresignRequest { + /** Which rem interval to presign in (e.g. [152, 160] corresponds to elapsed 140~148s) */ + remMin: number; + remMax: number; + /** Directions to presign (e.g. ["up", "down"] for both sides or ["up"] for one side) */ + directions: StrategyDirection[]; + /** Limit price (0~1 absolute price) */ + price: number; + /** Shares (minimum 5) */ + shares: number; +} + +/** Limit-order runtime state (passed to checkCancelOrder) */ +export interface LimitOrderRuntime { + direction: StrategyDirection; + price: number; + shares: number; + filledSize: number; + windowStart: number; +} + +/** Strategy exit signal */ +export interface ExitSignalResult { + signal: "tp" | "sl"; + reason: string; +} + +export type ExitSignal = ExitSignalResult | null; + +/** Description line for the frontend hover tooltip */ +export interface StrategyDescriptionLine { + text: string; + color?: string; + marginTop?: boolean; +} + +/** Strategy category (for frontend grouped display) */ +export interface StrategyCategory { + id: string; // category id, e.g. "momentum" + label: string; // display name, e.g. "Momentum" + color: string; // category primary color, e.g. "#3fb950" +} + +/** A single row of the observe panel (generic rendering) */ +export type ObserveRow = + | { type: "score"; label: string; value: number | null; threshold?: number; unit?: string } + | { type: "direction"; label: string; value: "up" | "down" | "neutral" | null; extra?: string } + | { type: "text"; label: string; value: string | number | null; color?: string } + | { type: "separator" }; + +/** Strategy observe panel (generic display area in the top status bar) */ +export interface ObservePanelData { + title: string; // panel title, e.g. "Entry factors" + color?: string; // title color + rows: ObserveRow[]; // data rows +} + +/** Strategy description (used by the frontend to dynamically generate the UI) */ +export interface StrategyDescription { + key: StrategyKey; + number: StrategyNumber; + name: string; + title: string; + lines: StrategyDescriptionLine[]; + /** Frontend category (defaults to "Uncategorized" if omitted) */ + category?: StrategyCategory; + /** List of supported markets (market key, e.g. ["btc-5m", "btc-15m"]). Omitted or empty array = all markets */ + supportedMarkets?: string[]; + /** Order type: market (market only, frontend shows amount box) / limit (limit only, shows shares box) / both (shows both). Defaults to "market" if omitted */ + orderType?: "market" | "limit" | "both"; + /** Tunable parameter declarations (frontend renders extra input boxes accordingly). Each item has key/label/default value/minimum/step */ + tunableParams?: TunableParam[]; + /** Parameter groups (optional; frontend renders by group with separators between groups). Each group lists the parameter keys it contains. + * Parameters not specified are automatically placed in a trailing "Other" group. If paramGroups is omitted entirely, parameters are rendered flat. */ + paramGroups?: StrategyParamGroup[]; +} + +export interface StrategyParamGroup { + /** Group display name (e.g. "Trigger" "Position" "Session") */ + label: string; + /** List of parameter keys contained in this group (must appear in tunableParams) */ + params: string[]; + /** Optional: group color (side bar / label color) */ + color?: string; +} + +/** Strategy tunable parameter definition (frontend renders input box, server persists to strategyConfig.params) */ +export interface TunableParam { + /** Parameter key (e.g. "tpDelta" "slDiff"), matching the strategy instance field name */ + key: string; + /** Display name (e.g. "Take profit" "Stop loss diff") */ + label: string; + /** Default value (used for the frontend's first render) */ + defaultValue: number; + /** Minimum (inclusive) */ + min: number; + /** Maximum (inclusive); unbounded if omitted */ + max?: number; + /** Step (input step) */ + step: number; + /** Unit hint (shown to the right of the input box, e.g. "¢" "USD") */ + unit?: string; + /** hover tooltip */ + title?: string; +} + +/** Strategy interface — every strategy must implement it */ +export interface IStrategy { + readonly key: StrategyKey; + readonly number: StrategyNumber; + readonly name: string; + + /** Return the frontend hover description */ + getDescription(): StrategyDescription; + + /** Update internal guard state (cooldown locks, etc.) each tick, called before checkEntry */ + updateGuards(ctx: StrategyTickContext): void; + + /** Check entry conditions (called during the SCANNING phase) */ + checkEntry(ctx: StrategyTickContext): EntrySignal | null; + + /** Check exit conditions (called during the HOLDING phase) */ + checkExit(ctx: StrategyTickContext, direction: StrategyDirection): ExitSignal; + + /** Reset the strategy's private state on window switch */ + resetState(): void; + + /** Serialize the strategy's private state for broadcasting to the frontend */ + getStatePayload(): Record; + + /** Notify the strategy that it has entered a position (called after a buy fills) */ + onEntryFilled?(ctx: StrategyTickContext, direction: StrategyDirection): void; + + // ── Plugin extension points (all optional) ──────────────────────────── + + /** Whether the strategy needs to "compute data every tick even when disabled" (e.g. s6's factor panel) */ + readonly alwaysComputeData?: boolean; + + /** Compute data each tick (called when alwaysComputeData=true, regardless of whether the strategy is enabled) */ + computeData?(ctx: StrategyTickContext): void; + + /** Return observe panel data (the frontend's generic renderer will render this) */ + getObservePanel?(): ObservePanelData | null; + + // ── Limit-order strategy extension points (only implemented by strategies with orderType=limit/both) ── + + /** + * Check whether a limit order needs to be placed (called each tick, only when this strategy has no active open order) + * Returns LimitOrderSignal → server will place a GTC limit order + * Returns null → skip this tick + */ + checkLimitOrder?(ctx: StrategyTickContext): LimitOrderSignal | null; + + /** + * Check whether an already-placed order needs to be canceled (called each tick, only when there is an active open order) + * Returns true → server cancels the order + * + * Note: the strategy does not need to manage "whether to cancel the remainder after a fill" — the server cancels automatically + */ + checkCancelOrder?(ctx: StrategyTickContext, order: LimitOrderRuntime): boolean; + + /** + * Market strategy: the absolute target price (0~1) for the GTC sell take-profit placed after entry + * - Returns a number → server places a sell @ targetPrice via the cond system after MINED + * - Returns null / not implemented → no take profit placed, exit controlled by checkExit + * Note: stop loss still goes through checkExit's sl signal (market sell), unaffected by this interface + */ + getMarketTakeProfitPrice?(): number | null; + + /** + * Limit strategy: declare presign requirements (not implemented = no presigning needed) + * - Returns PresignRequest → server pre-signs and caches in the background when rem ∈ [remMin, remMax] + * - When checkLimitOrder triggers, the server preferentially sends using the presign package; a hit = skip the signing latency + * - All presign packages are invalidated on window switch + * - Conservative on failure: a failed presign is not retried; if the cache is empty on trigger it falls back to live signing + * + * Note: strategy code does not need to be aware of whether the presign hit; it only needs to return a LimitOrderSignal. + * Presigning is purely a server-side latency optimization. + */ + getPresignRequest?(): PresignRequest | null; + + /** + * Limit strategy: whether placing a new order is allowed in this window after a cancel (default false, only one order per window) + * - false (default): no further order after cancel, wait for the next window + * - true: the window marker is cleared after cancel, and a new order may be placed on the next tick if conditions are met + */ + readonly limitAllowReplaceAfterCancel?: boolean; + + /** + * The price (0~1 absolute price) for the take-profit sell order placed after a limit maker order is confirmed filled on-chain + * - Returns a number → server immediately places a same-direction GTC sell order after MINED (partial fills are placed in batches) + * - Returns null / not implemented → no take profit placed; filled shares are held to settlement + * + * Note: mutually exclusive with getLimitConditionOrder. If the strategy implements getLimitConditionOrder, + * the server preferentially uses the cond system (TP+SL managed in a shared group) and ignores this interface. + */ + getLimitTakeProfitPrice?(): number | null; + + /** + * Create a conditional order (take profit + stop loss, both directions) after a limit maker order is confirmed filled on-chain + * - Reuses the manual conditional-order infrastructure (cond-tp / cond-sl); TP/SL share a groupId and clean each other up + * - stopProfit.pctDelta: take-profit price = entryPrice + pctDelta (e.g. entry=0.50, delta=0.05 → tp=0.55) + * - stopProfit.targetPrice: take-profit absolute price (e.g. 0.99); choose one of this or pctDelta + * - stopLoss.diffValue: triggered by diff crossing (buy up: diff ≤ -diffValue / buy down: diff ≥ diffValue) + * - Returns null / not implemented → falls back to the getLimitTakeProfitPrice path + */ + getLimitConditionOrder?(): { + stopProfit?: { pctDelta?: number; targetPrice?: number }; + stopLoss?: { diffValue?: number; slippage?: number }; + } | null; +} diff --git a/terminal.css b/terminal.css new file mode 100644 index 0000000..8151fef --- /dev/null +++ b/terminal.css @@ -0,0 +1,1300 @@ +/* + * BTC 5m Terminal UI + * Standalone visual layer: only covers typography, layering, color, and responsiveness; does not change any business JS bindings. + */ + +:root { + --terminal-bg: #04070b; + --terminal-ink: #f2f7ff; + --terminal-muted: #9aa8b8; + --terminal-faint: #5f6f83; + --terminal-line: rgba(151, 166, 188, 0.15); + --terminal-line-strong: rgba(151, 166, 188, 0.28); + --terminal-card: rgba(9, 15, 24, 0.78); + --terminal-card-strong: rgba(13, 22, 34, 0.94); + --terminal-card-soft: rgba(255, 255, 255, 0.045); + --terminal-green: #2ee6a6; + --terminal-red: #ff6474; + --terminal-amber: #f5bd4f; + --terminal-blue: #62bdff; + --terminal-cyan: #41e8ff; + --terminal-radius: 20px; + --terminal-radius-lg: 28px; + --terminal-shadow: 0 22px 70px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.055); + --terminal-shadow-soft: 0 14px 44px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.045); + --terminal-font: "Avenir Next", "DIN Alternate", "PingFang SC", "Microsoft YaHei", sans-serif; + --terminal-num: "JetBrains Mono", "SF Mono", "Fira Code", "Cascadia Code", monospace; +} + +html { + background: var(--terminal-bg); +} + +body { + min-width: 0; + color: var(--terminal-ink); + font-family: var(--terminal-font); + letter-spacing: 0.01em; + background: + radial-gradient(circle at 8% -8%, rgba(98, 189, 255, 0.24), transparent 30rem), + radial-gradient(circle at 92% 2%, rgba(46, 230, 166, 0.16), transparent 34rem), + radial-gradient(circle at 55% 110%, rgba(245, 189, 79, 0.11), transparent 24rem), + linear-gradient(135deg, #04070b 0%, #07111a 48%, #060a10 100%); +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(255, 255, 255, 0.022) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.018) 1px, transparent 1px); + background-size: 44px 44px; + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.9), transparent 76%); +} + +button, +input { + font-family: inherit; +} + +input { + color-scheme: dark; +} + +.up { color: var(--terminal-green) !important; } +.down { color: var(--terminal-red) !important; } +.yellow { color: var(--terminal-amber) !important; } +.gray { color: var(--terminal-faint) !important; } + +::-webkit-scrollbar { + width: 9px; + height: 9px; +} + +::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.028); +} + +::-webkit-scrollbar-thumb { + background: rgba(151, 166, 188, 0.26); + border: 2px solid rgba(4, 7, 11, 0.85); + border-radius: 999px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(151, 166, 188, 0.42); +} + +#header, +#price-bar, +#strat-bar, +#observe-bar, +#main-wrap, +#pm-pnl, +#app-footer { + width: calc(100% - 32px); + max-width: 1880px; + margin-left: auto !important; + margin-right: auto !important; +} + +/* Top Command Bar */ +#header { + position: sticky; + top: 12px; + z-index: 5000; + margin-top: 14px !important; + padding: 12px 16px !important; + gap: 12px 14px !important; + border: 1px solid var(--terminal-line) !important; + border-radius: var(--terminal-radius-lg) !important; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.072), rgba(255, 255, 255, 0.022)), + rgba(7, 12, 20, 0.96) !important; + box-shadow: var(--terminal-shadow); + backdrop-filter: none; + overflow: visible !important; +} + +#header:has(.result-dot:hover), +#header:has(.order-latency-trigger:hover) { + z-index: 8000; +} + +#header .window-info { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--terminal-ink); + font-size: 16px !important; + font-weight: 900 !important; + letter-spacing: 0.02em; +} + +#header .window-info::before { + content: ""; + width: 10px; + height: 32px; + flex: 0 0 auto; + border-radius: 999px; + background: linear-gradient(180deg, var(--terminal-green), var(--terminal-blue)); + box-shadow: 0 0 24px rgba(46, 230, 166, 0.44); +} + +#header .window-info span { + color: var(--terminal-amber) !important; +} + +#app-version { + padding: 2px 7px; + border: 1px solid rgba(151, 166, 188, 0.13); + border-radius: 999px; + background: rgba(255, 255, 255, 0.065); + color: var(--terminal-faint) !important; +} + +#header > a { + padding: 6px 10px; + border: 1px solid rgba(245, 189, 79, 0.24); + border-radius: 999px; + background: rgba(245, 189, 79, 0.08); + color: var(--terminal-amber) !important; +} + +#recent-results { + gap: 7px !important; + margin-left: auto; +} + +.result-dot { + width: 24px !important; + height: 24px !important; + border: 1px solid var(--terminal-line); + background: rgba(255, 255, 255, 0.055); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.result-dot.up { + background: linear-gradient(135deg, rgba(46, 230, 166, 0.94), rgba(14, 128, 94, 0.94)) !important; +} + +.result-dot.down { + background: linear-gradient(135deg, rgba(255, 100, 116, 0.96), rgba(138, 38, 52, 0.96)) !important; +} + +#conn-dot { + width: 10px !important; + height: 10px !important; + background: var(--terminal-green) !important; + box-shadow: 0 0 0 5px rgba(46, 230, 166, 0.12), 0 0 22px rgba(46, 230, 166, 0.76) !important; +} + +#conn-dot.disconnected { + background: var(--terminal-red) !important; + box-shadow: 0 0 0 5px rgba(255, 100, 116, 0.12), 0 0 20px rgba(255, 100, 116, 0.74) !important; +} + +#ws-status { + gap: 7px !important; + flex-wrap: wrap; +} + +.ws-light, +#kline-status { + min-height: 26px; + padding: 5px 9px; + gap: 6px; + border: 1px solid rgba(151, 166, 188, 0.12); + border-radius: 999px; + background: rgba(255, 255, 255, 0.045); + color: var(--terminal-muted) !important; + font-size: 11px !important; + white-space: nowrap; +} + +.ws-light .dot { + width: 7px !important; + height: 7px !important; + background: var(--terminal-red); + box-shadow: 0 0 10px rgba(255, 100, 116, 0.75); +} + +.ws-light.on .dot, +.ws-light.http-fast .dot { + background: var(--terminal-green) !important; + box-shadow: 0 0 12px rgba(46, 230, 166, 0.78) !important; +} + +.ws-light.http-slow .dot { + background: var(--terminal-amber) !important; + box-shadow: 0 0 12px rgba(245, 189, 79, 0.72) !important; +} + +.mode-toggle { + margin-left: 0 !important; + padding: 5px 6px 5px 11px !important; + border-color: var(--terminal-line) !important; + background: rgba(255, 255, 255, 0.045) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.mode-toggle-label { + color: var(--terminal-muted) !important; +} + +.mode-toggle-btn { + border-color: rgba(151, 166, 188, 0.22) !important; + background: rgba(255, 255, 255, 0.08) !important; +} + +.mode-toggle-btn::after { + background: #d7e4f2 !important; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.45); +} + +.mode-toggle-btn.on { + border-color: rgba(46, 230, 166, 0.65) !important; + background: rgba(46, 230, 166, 0.18) !important; +} + +.mode-toggle-btn.on::after { + background: var(--terminal-green) !important; +} + +/* Decision Strip */ +#price-bar { + margin-top: 14px !important; + padding: 0 !important; + display: grid !important; + grid-template-columns: minmax(190px, 1.25fr) minmax(190px, 1.25fr) repeat(3, minmax(145px, 0.82fr)) minmax(132px, 0.72fr); + gap: 12px !important; + border: 0 !important; + background: transparent !important; +} + +.price-item { + position: relative; + min-height: 104px; + padding: 17px 18px !important; + justify-content: space-between; + overflow: hidden; + border: 1px solid var(--terminal-line); + border-radius: var(--terminal-radius); + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.088), rgba(255, 255, 255, 0.018)), + var(--terminal-card) !important; + box-shadow: var(--terminal-shadow-soft); +} + +.price-item::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(120deg, rgba(255, 255, 255, 0.08), transparent 34%), + radial-gradient(circle at 85% 18%, rgba(98, 189, 255, 0.13), transparent 36%); +} + +.price-item:nth-child(1), +.price-item:nth-child(2) { + min-height: 122px; +} + +.price-item:nth-child(1)::before { + background: + linear-gradient(120deg, rgba(245, 189, 79, 0.13), transparent 38%), + radial-gradient(circle at 84% 22%, rgba(245, 189, 79, 0.18), transparent 36%); +} + +.price-item:nth-child(2)::before { + background: + linear-gradient(120deg, rgba(46, 230, 166, 0.11), transparent 38%), + radial-gradient(circle at 84% 22%, rgba(65, 232, 255, 0.15), transparent 36%); +} + +.price-label, +.compact-label { + position: relative; + z-index: 1; + color: var(--terminal-faint) !important; + font-size: 10px !important; + font-weight: 900; + letter-spacing: 0.14em !important; +} + +.price-val, +#btc-diff, +#btc-diff-bn, +#btc-vol { + position: relative; + z-index: 1; + color: var(--terminal-ink); + font-family: var(--terminal-num); + font-size: clamp(21px, 1.7vw, 28px) !important; + font-weight: 900 !important; + letter-spacing: -0.045em; +} + +.price-item:nth-child(1) .price-val, +.price-item:nth-child(2) .price-val { + font-size: clamp(26px, 2.1vw, 36px) !important; +} + +#remaining { + color: var(--terminal-amber) !important; + text-shadow: 0 0 24px rgba(245, 189, 79, 0.35); +} + +#remaining.ending { + color: var(--terminal-red) !important; + text-shadow: 0 0 24px rgba(255, 100, 116, 0.55); +} + +/* Strategy And Observability */ +#strat-bar, +#observe-bar { + position: relative; + margin-top: 12px !important; + border: 1px solid var(--terminal-line) !important; + border-radius: var(--terminal-radius) !important; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.062), rgba(255, 255, 255, 0.018)), + rgba(8, 14, 23, 0.96) !important; + box-shadow: var(--terminal-shadow-soft); + backdrop-filter: none; +} + +#strat-bar { + padding: 14px 16px !important; + z-index: 4000; + overflow: visible !important; +} + +#strat-bar:has(.strat-tip:hover), +#strat-bar:has(.tg-help-trigger:hover), +#strat-bar:has(#tg-config-body[style*="display: flex"]), +#strat-bar:has(.strat-popup.open) { + z-index: 9000; +} + +#observe-bar { + padding: 10px 14px !important; + color: var(--terminal-muted) !important; + z-index: 200; +} + +.strat-row { + position: relative; + z-index: 1; + align-items: center; + padding: 4px 0; +} + +.strat-row:has(.strat-tip:hover), +.strat-row:has(.tip-box:hover), +.strat-row:has(.strat-popup.open) { + z-index: 100010; +} + +.strat-group { + position: relative; + z-index: 1; +} + +.strat-group:has(.strat-tip:hover), +.strat-group:has(.tip-box:hover) { + z-index: 100011; +} + +.strat-group label { + position: relative; + z-index: 1; + padding: 6px 10px; + border: 1px solid rgba(151, 166, 188, 0.12); + border-radius: 999px; + background: rgba(255, 255, 255, 0.035); + color: var(--terminal-muted); + transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease; +} + +.strat-group label:hover { + z-index: 100012; + transform: translateY(-1px); + border-color: rgba(245, 189, 79, 0.32); + background: rgba(245, 189, 79, 0.06); +} + +.strat-group input[type=checkbox] + .strat-tip::before { + color: rgba(151, 166, 188, 0.42); +} + +.strat-group input[type=checkbox]:checked + .strat-tip::before, +.strat-group label:has(input:checked) { + color: var(--terminal-amber) !important; +} + +.strat-amt { + height: 26px; + border-radius: 9px !important; +} + +#strat-status { + min-height: 30px; + padding: 6px 11px; + border: 1px solid rgba(245, 189, 79, 0.18); + border-radius: 999px; + background: rgba(245, 189, 79, 0.075); +} + +#strat-state-text { + font-weight: 900 !important; + letter-spacing: 0.02em; +} + +#strat-state-text.idle { color: var(--terminal-faint) !important; } +#strat-state-text.scan { color: var(--terminal-muted) !important; } +#strat-state-text.active { color: var(--terminal-amber) !important; } +#strat-state-text.hold { color: var(--terminal-green) !important; } +#strat-state-text.done { color: var(--terminal-blue) !important; } +#strat-state-text.fail { color: var(--terminal-red) !important; } + +.strat-divider { + background: var(--terminal-line-strong) !important; +} + +/* Workbench */ +#main-wrap { + position: relative; + z-index: 100; + margin-top: 14px !important; + padding: 0 !important; + display: grid !important; + grid-template-columns: minmax(0, 1fr) 380px; + gap: 14px !important; + align-items: start; +} + +#main-wrap:has(.allowance-wrap:hover) { + z-index: 7000; +} + +#book, +#order-panel, +#pm-pnl { + position: relative; + border: 1px solid var(--terminal-line) !important; + border-radius: var(--terminal-radius-lg) !important; + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.065), rgba(255, 255, 255, 0.018)), + rgba(7, 12, 20, 0.96) !important; + box-shadow: var(--terminal-shadow); + backdrop-filter: none; +} + +#book { + z-index: 10; + padding: 0 !important; + overflow: hidden; +} + +#chart-toggle, +#book-toggle { + margin: 0 !important; + padding: 15px 17px !important; + border-bottom: 1px solid var(--terminal-line) !important; + background: rgba(255, 255, 255, 0.018); +} + +#chart-toggle:hover, +#book-toggle:hover { + border-radius: 0 !important; + background: rgba(98, 189, 255, 0.075) !important; +} + +#chart-toggle .toggle-label, +#book-toggle .toggle-label { + color: var(--terminal-muted) !important; + font-size: 11px !important; + font-weight: 900; + letter-spacing: 0.17em !important; +} + +#chart-toggle .toggle-label::before, +#book-toggle .toggle-label::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + margin-right: 8px; + border-radius: 50%; + background: var(--terminal-blue); + box-shadow: 0 0 12px rgba(98, 189, 255, 0.72); +} + +#book-toggle .toggle-label::before { + background: var(--terminal-amber); + box-shadow: 0 0 12px rgba(245, 189, 79, 0.72); +} + +#chart-toggle .toggle-arrow, +#book-toggle .toggle-arrow { + color: var(--terminal-muted) !important; +} + +#chart-collapsible { + max-height: 620px !important; +} + +#chart-collapsible.collapsed { + max-height: 0 !important; +} + +#price-chart { + margin: 12px !important; + overflow: hidden; + border: 1px solid rgba(151, 166, 188, 0.12) !important; + border-radius: 22px; + background: + radial-gradient(circle at 68% 14%, rgba(98, 189, 255, 0.13), transparent 34%), + linear-gradient(180deg, rgba(7, 12, 20, 0.92), rgba(4, 7, 11, 0.94)) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); +} + +#price-chart canvas { + height: min(54vh, 560px) !important; + min-height: 400px; +} + +#book-collapsible { + max-height: 720px !important; + padding: 0 12px 14px; +} + +#book-collapsible.collapsed { + max-height: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +#momentum-bar, +#spread-bar { + border-color: var(--terminal-line) !important; + color: var(--terminal-muted) !important; +} + +#momentum-bar { + margin-top: 10px; + padding: 9px 10px !important; + border: 1px solid var(--terminal-line); + border-radius: 15px; + background: rgba(255, 255, 255, 0.035); +} + +.mom-label, +.mom-name { + color: var(--terminal-faint) !important; +} + +.mom-val { + font-family: var(--terminal-num); +} + +.mom-val.up { color: var(--terminal-green) !important; } +.mom-val.down { color: var(--terminal-red) !important; } +.mom-val.flat { color: var(--terminal-faint) !important; } + +.book-header { + margin-top: 8px; + padding: 9px 10px !important; + color: var(--terminal-faint) !important; + font-weight: 900; +} + +.order-row { + min-height: 27px; + align-items: center; + margin: 2px 0; + padding: 5px 10px !important; + border: 1px solid transparent; + border-radius: 11px !important; + background: rgba(255, 255, 255, 0.025); + font-family: var(--terminal-num); +} + +.order-row:hover { + border-color: rgba(151, 166, 188, 0.16); + background: rgba(255, 255, 255, 0.05); +} + +.order-row.ask .depth-bar { + background: linear-gradient(90deg, transparent, rgba(255, 100, 116, 0.58)) !important; + opacity: 0.2 !important; +} + +.order-row.bid .depth-bar { + background: linear-gradient(90deg, rgba(46, 230, 166, 0.58), transparent) !important; + opacity: 0.2 !important; +} + +.order-row .qty { + color: var(--terminal-muted) !important; +} + +#spread-bar { + margin: 8px 0 !important; + padding: 10px !important; + border: 1px solid var(--terminal-line) !important; + border-radius: 15px; + background: rgba(255, 255, 255, 0.035); +} + +#prob-bar { + padding: 12px 2px 0 !important; +} + +.prob-label { + color: var(--terminal-muted) !important; +} + +.prob-val { + font-family: var(--terminal-num); + font-size: 24px !important; +} + +#prob-track { + height: 10px !important; + border: 1px solid rgba(151, 166, 188, 0.12); + border-radius: 999px; + background: rgba(255, 255, 255, 0.06) !important; +} + +#prob-fill-up { + background: linear-gradient(90deg, var(--terminal-green), var(--terminal-cyan), var(--terminal-amber)) !important; + box-shadow: 0 0 18px rgba(65, 232, 255, 0.25); +} + +/* Order Panel */ +#order-panel { + position: sticky; + top: 94px; + z-index: 60; + width: auto !important; + padding: 16px !important; + gap: 12px !important; + overflow: visible !important; +} + +#order-panel:has(.allowance-wrap:hover) { + z-index: 7001; +} + +#order-panel .panel-title { + color: var(--terminal-ink) !important; + font-size: 15px !important; + font-weight: 900; + letter-spacing: 0.14em !important; +} + +#order-panel > div:first-child { + padding-bottom: 10px; + border-bottom: 1px solid var(--terminal-line); +} + +#account-block { + overflow: visible !important; + padding: 12px !important; + border: 1px solid rgba(151, 166, 188, 0.14); + border-radius: 19px !important; + background: rgba(255, 255, 255, 0.04) !important; +} + +.acct-item { + gap: 8px; + min-height: 29px; +} + +.acct-label, +.acct-sub, +#sync-time { + color: var(--terminal-faint) !important; +} + +.acct-val { + font-family: var(--terminal-num); + color: var(--terminal-ink); +} + +.verified-tag { + border: 1px solid rgba(151, 166, 188, 0.14); + border-radius: 999px !important; + background: rgba(255, 255, 255, 0.045) !important; + color: var(--terminal-faint) !important; +} + +.verified-tag.ok { + border-color: rgba(46, 230, 166, 0.28); + background: rgba(46, 230, 166, 0.1) !important; + color: var(--terminal-green) !important; +} + +.btn-group { + gap: 9px !important; +} + +.tog-btn, +.side-btn, +.quick-amt-btn, +.pm-refresh-btn, +.max-btn, +.step-btn, +#clear-all-btn, +#confirm-btn, +#limit-confirm-btn { + border-radius: 14px !important; + transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; +} + +.tog-btn, +.side-btn, +.quick-amt-btn { + border-color: rgba(151, 166, 188, 0.18) !important; + background: rgba(255, 255, 255, 0.055) !important; + color: var(--terminal-muted) !important; +} + +.tog-btn:hover, +.side-btn:hover, +.quick-amt-btn:hover, +.pm-refresh-btn:hover, +.max-btn:hover, +.step-btn:hover { + transform: translateY(-1px); +} + +.tog-btn.active { + border-color: rgba(98, 189, 255, 0.55) !important; + background: rgba(98, 189, 255, 0.13) !important; + color: var(--terminal-blue) !important; + box-shadow: 0 0 0 3px rgba(98, 189, 255, 0.08); +} + +.tog-btn.active-up { + border-color: rgba(46, 230, 166, 0.54) !important; + background: rgba(46, 230, 166, 0.13) !important; + color: var(--terminal-green) !important; +} + +.tog-btn.active-down { + border-color: rgba(255, 100, 116, 0.54) !important; + background: rgba(255, 100, 116, 0.13) !important; + color: var(--terminal-red) !important; +} + +.side-btn.active-buy { + border-color: rgba(98, 189, 255, 0.5) !important; + background: linear-gradient(135deg, rgba(98, 189, 255, 0.28), rgba(98, 189, 255, 0.09)) !important; + color: #c7eaff !important; +} + +.side-btn.active-sell { + border-color: rgba(245, 189, 79, 0.5) !important; + background: linear-gradient(135deg, rgba(245, 189, 79, 0.3), rgba(245, 189, 79, 0.08)) !important; + color: #ffe4aa !important; +} + +.dir-pct, +#amount-unit { + font-family: var(--terminal-num); +} + +#amount-unit { + color: var(--terminal-faint) !important; +} + +.input-row { + border-color: rgba(151, 166, 188, 0.18) !important; + border-radius: 15px !important; + background: rgba(255, 255, 255, 0.04) !important; +} + +.input-row:focus-within { + border-color: rgba(98, 189, 255, 0.65) !important; + box-shadow: 0 0 0 3px rgba(98, 189, 255, 0.1); +} + +.input-row input { + background: transparent !important; + color: var(--terminal-ink) !important; + font-family: var(--terminal-num); +} + +.step-btn, +.max-btn { + background: rgba(255, 255, 255, 0.055) !important; + color: var(--terminal-muted) !important; +} + +.quick-amt-btn { + min-height: 28px; +} + +.quick-amt-btn:hover, +.quick-amt-btn.active { + border-color: rgba(245, 189, 79, 0.5) !important; + background: rgba(245, 189, 79, 0.1) !important; + color: var(--terminal-amber) !important; +} + +#est-row, +#pos-summary, +#cond-panel, +#slippage-row, +#manual-limit-list { + border: 1px solid rgba(151, 166, 188, 0.12) !important; + border-radius: 15px !important; + background: rgba(255, 255, 255, 0.035) !important; +} + +#est-row { + padding: 9px 11px !important; + color: var(--terminal-muted) !important; +} + +#est-row span { + color: var(--terminal-ink) !important; + font-family: var(--terminal-num); +} + +#confirm-btn, +#limit-confirm-btn { + min-height: 52px; + border: 1px solid rgba(151, 166, 188, 0.16) !important; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.035)) !important; + color: var(--terminal-muted) !important; + letter-spacing: 0.03em; +} + +#confirm-btn.ready-buy, +#limit-confirm-btn.ready-buy { + border-color: rgba(46, 230, 166, 0.45) !important; + background: linear-gradient(135deg, rgba(46, 230, 166, 0.96), rgba(18, 129, 91, 0.96)) !important; + color: #04130e !important; + box-shadow: 0 14px 38px rgba(46, 230, 166, 0.22); +} + +#confirm-btn.ready-sell, +#limit-confirm-btn.ready-sell { + border-color: rgba(245, 189, 79, 0.48) !important; + background: linear-gradient(135deg, rgba(245, 189, 79, 0.96), rgba(164, 88, 28, 0.96)) !important; + color: #170d04 !important; + box-shadow: 0 14px 38px rgba(245, 189, 79, 0.2); +} + +#confirm-btn:not(:disabled):hover, +#limit-confirm-btn:not(:disabled):hover, +#clear-all-btn:hover:not(:disabled) { + transform: translateY(-1px); +} + +#clear-all-btn { + border-color: rgba(255, 100, 116, 0.3) !important; + background: rgba(255, 100, 116, 0.08) !important; + color: var(--terminal-red) !important; +} + +#clear-all-btn:hover:not(:disabled) { + border-color: rgba(255, 100, 116, 0.5) !important; + background: rgba(255, 100, 116, 0.13) !important; +} + +#cond-panel, +#slippage-row { + padding: 9px 10px; +} + +#cond-head { + color: var(--terminal-muted) !important; +} + +#cond-body { + border-left-color: var(--terminal-line) !important; +} + +/* Floating Surfaces */ +.result-dot .dot-tip, +.allowance-tooltip, +.strat-tip .tip-box, +.tg-help-tip, +.order-latency-tip, +.order-latency-tip > div, +#tg-config-body { + z-index: 100000 !important; + border-color: var(--terminal-line-strong) !important; + background: rgba(7, 12, 20, 0.96) !important; + box-shadow: 0 22px 70px rgba(0, 0, 0, 0.56) !important; + backdrop-filter: none; +} + +.result-dot, +.allowance-wrap, +.strat-tip, +.tg-help-trigger, +.order-latency-trigger { + position: relative; +} + +.result-dot:hover, +.allowance-wrap:hover, +.strat-tip:hover, +.tg-help-trigger:hover, +.order-latency-trigger:hover { + z-index: 100001; +} + +.result-dot .dot-tip, +.allowance-tooltip, +.strat-tip .tip-box { + z-index: 100002 !important; +} + +#tg-config-body { + width: min(460px, calc(100vw - 32px)) !important; + max-height: min(620px, calc(100vh - 120px)); + overflow-y: auto; + overscroll-behavior: contain; + z-index: 100003 !important; +} + +#tg-config-panel { + position: relative; + z-index: 100004; +} + +.tg-help-tip { + z-index: 100005 !important; + pointer-events: auto; +} + +#claim-tip { + position: relative; +} + +#claim-tip:hover, +#claim-tip:has(.tg-help-tip:hover) { + z-index: 100010 !important; +} + +#claim-tip::after { + left: auto !important; + right: 0 !important; + width: min(340px, calc(100vw - 32px)); + height: 18px !important; + top: 100% !important; + z-index: 100009; +} + +#claim-tip .tg-help-tip { + z-index: 100011 !important; +} + +#tg-bot-token, +#tg-chat-id, +#vol-threshold, +.strat-amt, +#max-round-entries, +#strat-slippage, +#tg-interval, +#cond-tp-pct, +#cond-sl-pct, +#cond-sl-slippage, +#slippage-input { + border-color: rgba(151, 166, 188, 0.22) !important; + background: rgba(255, 255, 255, 0.05) !important; + color: var(--terminal-ink) !important; +} + +#backtest-btn, +#tg-config-body button, +#cond-cancel-all-btn { + border-color: rgba(151, 166, 188, 0.22) !important; + background: rgba(255, 255, 255, 0.06) !important; + color: var(--terminal-muted) !important; +} + +/* PnL Table */ +#pm-pnl { + margin-top: 14px !important; + margin-bottom: 16px !important; + padding: 14px !important; + overflow-x: auto; +} + +#pm-pnl .hist-title { + margin-bottom: 10px !important; + color: var(--terminal-muted) !important; + font-size: 12px !important; + font-weight: 900; + letter-spacing: 0.14em !important; +} + +#pm-pnl .pm-total { + color: var(--terminal-muted) !important; + font-family: var(--terminal-num); +} + +#pm-pnl .pm-total .val-pos { color: var(--terminal-green) !important; } +#pm-pnl .pm-total .val-neg { color: var(--terminal-red) !important; } + +.pm-refresh-btn { + padding: 6px 12px !important; + border-color: rgba(151, 166, 188, 0.18) !important; + background: rgba(255, 255, 255, 0.055) !important; + color: var(--terminal-muted) !important; +} + +.pm-refresh-btn:hover { + border-color: rgba(98, 189, 255, 0.5) !important; + background: rgba(98, 189, 255, 0.12) !important; + color: #d9f1ff !important; +} + +.pm-countdown { + color: var(--terminal-faint) !important; +} + +#pm-pnl-list { + gap: 5px !important; + height: min(42vh, 420px) !important; +} + +.pm-item { + min-width: 920px; + padding: 7px 9px !important; + border: 1px solid rgba(151, 166, 188, 0.08); + border-radius: 12px !important; + background: rgba(255, 255, 255, 0.035) !important; + color: var(--terminal-muted) !important; + font-family: var(--terminal-num); +} + +.pm-item:hover { + border-color: rgba(151, 166, 188, 0.17); + background: rgba(255, 255, 255, 0.055) !important; +} + +.pm-header { + border-color: var(--terminal-line) !important; + background: transparent !important; +} + +.pm-header > span { + color: var(--terminal-faint) !important; +} + +.pm-item .pm-time, +.pm-item .pm-detail, +.pm-item .pm-title, +.pm-item .pm-amount .pm-amount-label, +.pm-item .pm-pos-pnl .pm-pos-pnl-label { + color: var(--terminal-faint) !important; +} + +.pm-item .pm-kind.buy, +.pm-item .pm-outcome.up { + background: rgba(46, 230, 166, 0.12) !important; + color: var(--terminal-green) !important; +} + +.pm-item .pm-kind.sell, +.pm-item .pm-outcome.down { + background: rgba(255, 100, 116, 0.12) !important; + color: var(--terminal-red) !important; +} + +.pm-item .pm-kind.claim { + background: rgba(245, 189, 79, 0.13) !important; + color: var(--terminal-amber) !important; +} + +.pm-item .pm-source { + background: rgba(98, 189, 255, 0.12) !important; + color: var(--terminal-blue) !important; +} + +.pm-item .pm-source.manual { + background: rgba(245, 189, 79, 0.13) !important; + color: var(--terminal-amber) !important; +} + +#app-footer { + margin-top: 0 !important; + margin-bottom: 16px !important; + border: 1px solid var(--terminal-line) !important; + border-radius: 16px; + background: rgba(255, 255, 255, 0.025); + color: var(--terminal-faint) !important; +} + +/* Low Data Mode */ +body.low-data-mode { + background: + radial-gradient(circle at 50% -10%, rgba(98, 189, 255, 0.18), transparent 30rem), + linear-gradient(135deg, #04070b, #071018 58%, #06080c) !important; +} + +body.low-data-mode #header, +body.low-data-mode #price-bar, +body.low-data-mode #strat-bar, +body.low-data-mode #observe-bar { + width: min(980px, calc(100% - 32px)) !important; + max-width: none; +} + +body.low-data-mode #header { + padding: 12px 16px !important; +} + +body.low-data-mode #price-bar { + display: block !important; + margin-top: 12px !important; + border: 1px solid var(--terminal-line) !important; + border-radius: var(--terminal-radius-lg) !important; + background: var(--terminal-card-strong) !important; + box-shadow: var(--terminal-shadow); +} + +body.low-data-mode #low-data-hero { + padding: 24px 24px 10px !important; +} + +body.low-data-mode #low-data-detail { + padding: 10px 24px 18px !important; + border-top-color: var(--terminal-line) !important; + color: var(--terminal-muted) !important; +} + +/* Responsive */ +@media (max-width: 1320px) { + #price-bar { + grid-template-columns: repeat(3, minmax(180px, 1fr)); + } + + #main-wrap { + grid-template-columns: 1fr !important; + } + + #order-panel { + position: static; + width: 100% !important; + } +} + +@media (max-width: 860px) { + #header, + #price-bar, + #strat-bar, + #observe-bar, + #main-wrap, + #pm-pnl, + #app-footer { + width: calc(100% - 20px); + } + + #header { + position: static; + margin-top: 10px !important; + border-radius: 20px !important; + } + + #header .window-info { + width: 100%; + } + + #recent-results { + margin-left: 0; + order: 3; + } + + #ws-status { + order: 5; + width: 100%; + } + + .mode-toggle { + order: 4; + } + + #price-bar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .price-item, + .price-item:nth-child(1), + .price-item:nth-child(2) { + min-height: 90px; + padding: 14px !important; + } + + #price-chart canvas { + min-height: 320px; + height: 44vh !important; + } + + #momentum-bar { + flex-direction: column; + } + + .mom-group { + width: 100%; + } + + #prob-bar { + gap: 12px !important; + } +} + +@media (max-width: 560px) { + body { + font-size: 12px; + } + + #price-bar { + grid-template-columns: 1fr; + } + + #book-collapsible { + padding: 0 8px 10px; + } + + #price-chart { + margin: 8px !important; + } + + #price-chart canvas { + min-height: 280px; + } + + #order-panel { + padding: 12px !important; + } + + .tog-btn, + .side-btn { + font-size: 13px !important; + } + + #low-data-hero { + flex-direction: column; + align-items: flex-start !important; + } + + body.low-data-mode #header, + body.low-data-mode #price-bar, + body.low-data-mode #strat-bar, + body.low-data-mode #observe-bar { + width: calc(100% - 20px) !important; + } +} diff --git a/tg-push.ts b/tg-push.ts new file mode 100644 index 0000000..8125b0d --- /dev/null +++ b/tg-push.ts @@ -0,0 +1,115 @@ +/** + * Telegram push module + * + * Features: + * - Scheduled push of account status (balance, PnL, enabled strategies, latest 5 trades) + * - Bot Token / Chat ID / frequency configurable from the frontend + * - Config persisted to .tg-config.json + */ + +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TG_CONFIG_FILE = resolve(__dirname, ".tg-config.json"); + +export interface TgConfig { + enabled: boolean; // Master switch: when off, no push is sent at all + botToken: string; + chatId: string; + intervalMinutes: number; // Scheduled push frequency (minutes), minimum 5 + scheduledEnabled: boolean; // Whether to enable scheduled push + postTradeEnabled: boolean; // Whether to enable the "10 minutes after buy MINED" push +} + +const DEFAULT_CONFIG: TgConfig = { + enabled: false, + botToken: "", + chatId: "", + intervalMinutes: 60, + scheduledEnabled: true, + postTradeEnabled: false, +}; + +export function loadTgConfig(): TgConfig { + try { + if (!existsSync(TG_CONFIG_FILE)) return { ...DEFAULT_CONFIG }; + const raw = JSON.parse(readFileSync(TG_CONFIG_FILE, "utf-8")); + return { + enabled: !!raw.enabled, + botToken: typeof raw.botToken === "string" ? raw.botToken : "", + chatId: typeof raw.chatId === "string" ? raw.chatId : "", + intervalMinutes: typeof raw.intervalMinutes === "number" && raw.intervalMinutes >= 5 + ? raw.intervalMinutes + : 60, + // Backward compatibility: when an old config lacks these two fields, scheduled push defaults to on (keeps original behavior), post-trade push defaults to off + scheduledEnabled: typeof raw.scheduledEnabled === "boolean" ? raw.scheduledEnabled : true, + postTradeEnabled: typeof raw.postTradeEnabled === "boolean" ? raw.postTradeEnabled : false, + }; + } catch (err) { + console.warn(`[TG] Failed to load config: ${err instanceof Error ? err.message : String(err)}`); + return { ...DEFAULT_CONFIG }; + } +} + +export function saveTgConfig(cfg: TgConfig): void { + try { + writeFileSync(TG_CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", "utf-8"); + } catch (err) { + console.warn(`[TG] Failed to save config: ${err instanceof Error ? err.message : String(err)}`); + } +} + +/** Auto-detect Chat ID via getUpdates (only available after the user has sent the bot a message) */ +export async function autoDetectChatId(botToken: string): Promise<{ ok: boolean; chatId?: string; error?: string }> { + if (!botToken) return { ok: false, error: "Bot Token is empty" }; + const url = `https://api.telegram.org/bot${botToken}/getUpdates`; + try { + const res = await fetch(url); + const data = await res.json() as { ok: boolean; result?: Array<{ message?: { chat?: { id?: number } } }>; description?: string }; + if (!data.ok) { + return { ok: false, error: data.description || `HTTP ${res.status}` }; + } + const results = data.result || []; + if (results.length === 0) { + return { ok: false, error: "No messages found. Please first send the bot a message in Telegram (e.g. /start), then try again" }; + } + // Take the chat.id of the last message + for (let i = results.length - 1; i >= 0; i--) { + const id = results[i].message?.chat?.id; + if (typeof id === "number") { + return { ok: true, chatId: String(id) }; + } + } + return { ok: false, error: "chat.id not found in messages" }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** Send a text message to Telegram */ +export async function sendTgMessage(cfg: TgConfig, text: string): Promise<{ ok: boolean; error?: string }> { + if (!cfg.botToken || !cfg.chatId) { + return { ok: false, error: "Bot Token or Chat ID not configured" }; + } + const url = `https://api.telegram.org/bot${cfg.botToken}/sendMessage`; + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: cfg.chatId, + text, + disable_web_page_preview: true, + }), + }); + const data = await res.json() as { ok: boolean; description?: string }; + if (!data.ok) { + return { ok: false, error: data.description || `HTTP ${res.status}` }; + } + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/tunnels/tunnel-arl-2t4.sh b/tunnels/tunnel-arl-2t4.sh new file mode 100755 index 0000000..7c71f5f --- /dev/null +++ b/tunnels/tunnel-arl-2t4.sh @@ -0,0 +1,147 @@ +#!/bin/bash +# Polymarket VPS SSH tunnel management (autossh auto-reconnect) +# +# Usage: +# ./tunnel.sh foreground start (default; Ctrl+C to exit = close tunnel; output shown directly) +# ./tunnel.sh bg background start (tunnel keeps running after closing the terminal, log at /tmp/pm-tunnel.log) +# ./tunnel.sh down close the background tunnel +# ./tunnel.sh status check running status +# ./tunnel.sh logs tail the background log +# +# Multiple accounts: edit the PORTS array below and add more ports + +# ── Config ───────────────────────────────────────────── +PEM="$HOME/Desktop/arl.pem" +HOST=ubuntu@34.249.106.114 +PORTS=(3556 3557 3558 3559 3560 3561 3562) # 6 accounts + +# ── Internal ───────────────────────────────────────────── +# LOG generated from the script filename (avoids multiple scripts sharing one log file) +SCRIPT_NAME=$(basename "$0" .sh) +LOG="/tmp/${SCRIPT_NAME}.log" +TAG="autossh-pm-${HOST//[@.]/_}" # use host as the identifier, for pkill / pgrep + +# Assemble port-forwarding arguments +PORT_ARGS="" +for p in "${PORTS[@]}"; do + PORT_ARGS="$PORT_ARGS -L $p:localhost:$p" +done + +cmd="${1:-up}" + +# Common: check dependencies + config +check_prereq() { + if ! command -v autossh >/dev/null 2>&1; then + echo "❌ autossh not installed, install it first:" + echo " brew install autossh" + exit 1 + fi + if [ ! -f "$PEM" ]; then + echo "❌ pem file does not exist: $PEM" + exit 1 + fi +} + +case "$cmd" in + up|fg) + # Foreground run: Ctrl+C closes the tunnel directly, output shown in real time + if pgrep -f "autossh.*$HOST" >/dev/null 2>&1; then + echo "⚠ Already running in the background pid=$(pgrep -f "autossh.*$HOST" | head -1)" + echo " Run ./tunnel.sh down first, then restart in foreground; or ./tunnel.sh logs to view the background log" + exit 1 + fi + check_prereq + echo "✓ Starting tunnel (foreground mode, Ctrl+C to exit)" + echo " HOST: $HOST" + echo " Ports: ${PORTS[*]}" + echo "" + # No -f: foreground run; no log file, straight to stdout + exec autossh -M 0 -N \ + -i "$PEM" \ + $PORT_ARGS \ + "$HOST" \ + -o "ServerAliveInterval=30" \ + -o "ServerAliveCountMax=3" \ + -o "ExitOnForwardFailure=yes" \ + -o "TCPKeepAlive=yes" \ + -o "StrictHostKeyChecking=no" \ + -o "UserKnownHostsFile=/dev/null" + ;; + + bg) + # Background run: closing the terminal has no effect, log written to file + if pgrep -f "autossh.*$HOST" >/dev/null 2>&1; then + echo "✓ Already running (pid=$(pgrep -f "autossh.*$HOST" | tr '\n' ' '))" + exit 0 + fi + check_prereq + AUTOSSH_LOGFILE="$LOG" \ + autossh -M 0 -f -N \ + -i "$PEM" \ + $PORT_ARGS \ + "$HOST" \ + -E "$LOG" \ + -o "ServerAliveInterval=30" \ + -o "ServerAliveCountMax=3" \ + -o "ExitOnForwardFailure=yes" \ + -o "TCPKeepAlive=yes" \ + -o "StrictHostKeyChecking=no" \ + -o "UserKnownHostsFile=/dev/null" + sleep 1 + pid=$(pgrep -f "autossh.*$HOST" | head -1) + if [ -n "$pid" ]; then + echo "✓ Tunnel started (background) pid=$pid" + echo " Local ports: ${PORTS[*]}" + echo " Log: $LOG (./tunnel.sh logs to view)" + else + echo "⚠ No process found after autossh started; it may have failed immediately, see $LOG" + fi + ;; + + down) + if pgrep -f "autossh.*$HOST" >/dev/null 2>&1; then + pkill -f "autossh.*$HOST" + pkill -f "ssh.*$HOST" 2>/dev/null + sleep 0.3 + echo "✓ Tunnel closed" + else + echo "Not running" + fi + ;; + + status) + pid=$(pgrep -f "autossh.*$HOST" | head -1) + if [ -n "$pid" ]; then + echo "✓ Running pid=$pid" + echo " Ports: ${PORTS[*]}" + # Test whether each port is actually reachable + for p in "${PORTS[@]}"; do + if curl -sf -m 2 "http://localhost:$p/api/state" >/dev/null 2>&1; then + echo " $p: ✓ reachable" + else + echo " $p: ✗ unreachable (tunnel up but remote server not started?)" + fi + done + else + echo "✗ Not running" + fi + ;; + + logs) + if [ -f "$LOG" ]; then + tail -f "$LOG" + else + echo "No log yet (tunnel has not run)" + fi + ;; + + *) + echo "Usage: $0 {up|bg|down|status|logs}" + echo " up foreground start (default; Ctrl+C to exit = close tunnel)" + echo " bg background start (closing the terminal has no effect)" + echo " down close the background tunnel" + echo " status check running status + port connectivity" + echo " logs tail the background log" + exit 1 + ;; +esac diff --git a/tunnels/tunnel-arl-large.bat b/tunnels/tunnel-arl-large.bat new file mode 100644 index 0000000..036959b --- /dev/null +++ b/tunnels/tunnel-arl-large.bat @@ -0,0 +1,5 @@ +@echo off +REM Double-click this file to start the tunnel (no need to change the Windows execution policy) +cd /d "%~dp0" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0tunnel-arl-large.ps1" +pause diff --git a/tunnels/tunnel-arl-large.ps1 b/tunnels/tunnel-arl-large.ps1 new file mode 100644 index 0000000..2e57128 --- /dev/null +++ b/tunnels/tunnel-arl-large.ps1 @@ -0,0 +1,61 @@ +# Polymarket VPS SSH tunnel (PowerShell version, auto-reconnect) +# +# Usage: +# Right-click the .ps1 → Run with PowerShell (foreground mode, closing the window = closing the tunnel) +# Or run in PowerShell: .\tunnel-arl-large.ps1 +# +# The first run may be blocked by the execution policy; open PowerShell as administrator and run once: +# Set-ExecutionPolicy -Scope CurrentUser RemoteSigned + +# ── Config (matches the .sh version) ───────────────────────────── +$PEM = "$env:USERPROFILE\Desktop\arl.pem" +$HOST_ = "ubuntu@52.51.231.134" +$PORTS = @(3556, 3557, 3558, 3559, 3560, 3561) + +# ── Internal ──────────────────────────────────────────────── + +# Assemble port-forwarding arguments +$portArgs = @() +foreach ($p in $PORTS) { + $portArgs += "-L" + $portArgs += "${p}:localhost:${p}" +} + +# pem file check +if (-not (Test-Path $PEM)) { + Write-Host "❌ pem file does not exist: $PEM" -ForegroundColor Red + exit 1 +} + +# Windows OpenSSH requires correct pem file permissions (owner-only); +# if permissions were not set before, ssh reports "WARNING: UNPROTECTED PRIVATE KEY FILE" and refuses to connect +# Auto chmod is skipped here (icacls on Windows is too complex); if you hit an error, please: +# 1. Right-click the pem file → Properties → Security → Advanced +# 2. Disable inheritance → remove other users → keep only yourself +# 3. For a detailed guide, search "windows ssh permissions are too open" + +Write-Host "✓ Starting tunnel (foreground mode, Ctrl+C to exit)" +Write-Host " HOST: $HOST_" +Write-Host " Ports: $($PORTS -join ' ')" +Write-Host "" + +# Reconnect loop: ssh exits → wait 3 seconds → reconnect +$attempt = 0 +while ($true) { + $attempt++ + Write-Host "[$([DateTime]::Now.ToString('HH:mm:ss'))] Attempting connection (count $attempt)..." -ForegroundColor Cyan + & ssh -N ` + -i $PEM ` + @portArgs ` + $HOST_ ` + -o "ServerAliveInterval=30" ` + -o "ServerAliveCountMax=3" ` + -o "ExitOnForwardFailure=yes" ` + -o "TCPKeepAlive=yes" ` + -o "StrictHostKeyChecking=no" ` + -o "UserKnownHostsFile=NUL" + + $exitCode = $LASTEXITCODE + Write-Host "[$([DateTime]::Now.ToString('HH:mm:ss'))] Tunnel disconnected (exit=$exitCode), reconnecting in 3 seconds..." -ForegroundColor Yellow + Start-Sleep -Seconds 3 +} diff --git a/tunnels/tunnel-arl-ot.sh b/tunnels/tunnel-arl-ot.sh new file mode 100755 index 0000000..7fdd41a --- /dev/null +++ b/tunnels/tunnel-arl-ot.sh @@ -0,0 +1,147 @@ +#!/bin/bash +# Polymarket VPS SSH tunnel management (autossh auto-reconnect) +# +# Usage: +# ./tunnel.sh foreground start (default; Ctrl+C to exit = close tunnel; output shown directly) +# ./tunnel.sh bg background start (tunnel keeps running after closing the terminal, log at /tmp/pm-tunnel.log) +# ./tunnel.sh down close the background tunnel +# ./tunnel.sh status check running status +# ./tunnel.sh logs tail the background log +# +# Multiple accounts: edit the PORTS array below and add more ports + +# ── Config ───────────────────────────────────────────── +PEM="$HOME/Desktop/arl.pem" +HOST=ubuntu@34.244.122.101 +PORTS=(5000 5001 5002 5003) # 4 accounts + +# ── Internal ───────────────────────────────────────────── +# LOG generated from the script filename (avoids multiple scripts sharing one log file) +SCRIPT_NAME=$(basename "$0" .sh) +LOG="/tmp/${SCRIPT_NAME}.log" +TAG="autossh-pm-${HOST//[@.]/_}" # use host as the identifier, for pkill / pgrep + +# Assemble port-forwarding arguments +PORT_ARGS="" +for p in "${PORTS[@]}"; do + PORT_ARGS="$PORT_ARGS -L $p:localhost:$p" +done + +cmd="${1:-up}" + +# Common: check dependencies + config +check_prereq() { + if ! command -v autossh >/dev/null 2>&1; then + echo "❌ autossh not installed, install it first:" + echo " brew install autossh" + exit 1 + fi + if [ ! -f "$PEM" ]; then + echo "❌ pem file does not exist: $PEM" + exit 1 + fi +} + +case "$cmd" in + up|fg) + # Foreground run: Ctrl+C closes the tunnel directly, output shown in real time + if pgrep -f "autossh.*$HOST" >/dev/null 2>&1; then + echo "⚠ Already running in the background pid=$(pgrep -f "autossh.*$HOST" | head -1)" + echo " Run ./tunnel.sh down first, then restart in foreground; or ./tunnel.sh logs to view the background log" + exit 1 + fi + check_prereq + echo "✓ Starting tunnel (foreground mode, Ctrl+C to exit)" + echo " HOST: $HOST" + echo " Ports: ${PORTS[*]}" + echo "" + # No -f: foreground run; no log file, straight to stdout + exec autossh -M 0 -N \ + -i "$PEM" \ + $PORT_ARGS \ + "$HOST" \ + -o "ServerAliveInterval=30" \ + -o "ServerAliveCountMax=3" \ + -o "ExitOnForwardFailure=yes" \ + -o "TCPKeepAlive=yes" \ + -o "StrictHostKeyChecking=no" \ + -o "UserKnownHostsFile=/dev/null" + ;; + + bg) + # Background run: closing the terminal has no effect, log written to file + if pgrep -f "autossh.*$HOST" >/dev/null 2>&1; then + echo "✓ Already running (pid=$(pgrep -f "autossh.*$HOST" | tr '\n' ' '))" + exit 0 + fi + check_prereq + AUTOSSH_LOGFILE="$LOG" \ + autossh -M 0 -f -N \ + -i "$PEM" \ + $PORT_ARGS \ + "$HOST" \ + -E "$LOG" \ + -o "ServerAliveInterval=30" \ + -o "ServerAliveCountMax=3" \ + -o "ExitOnForwardFailure=yes" \ + -o "TCPKeepAlive=yes" \ + -o "StrictHostKeyChecking=no" \ + -o "UserKnownHostsFile=/dev/null" + sleep 1 + pid=$(pgrep -f "autossh.*$HOST" | head -1) + if [ -n "$pid" ]; then + echo "✓ Tunnel started (background) pid=$pid" + echo " Local ports: ${PORTS[*]}" + echo " Log: $LOG (./tunnel.sh logs to view)" + else + echo "⚠ No process found after autossh started; it may have failed immediately, see $LOG" + fi + ;; + + down) + if pgrep -f "autossh.*$HOST" >/dev/null 2>&1; then + pkill -f "autossh.*$HOST" + pkill -f "ssh.*$HOST" 2>/dev/null + sleep 0.3 + echo "✓ Tunnel closed" + else + echo "Not running" + fi + ;; + + status) + pid=$(pgrep -f "autossh.*$HOST" | head -1) + if [ -n "$pid" ]; then + echo "✓ Running pid=$pid" + echo " Ports: ${PORTS[*]}" + # Test whether each port is actually reachable + for p in "${PORTS[@]}"; do + if curl -sf -m 2 "http://localhost:$p/api/state" >/dev/null 2>&1; then + echo " $p: ✓ reachable" + else + echo " $p: ✗ unreachable (tunnel up but remote server not started?)" + fi + done + else + echo "✗ Not running" + fi + ;; + + logs) + if [ -f "$LOG" ]; then + tail -f "$LOG" + else + echo "No log yet (tunnel has not run)" + fi + ;; + + *) + echo "Usage: $0 {up|bg|down|status|logs}" + echo " up foreground start (default; Ctrl+C to exit = close tunnel)" + echo " bg background start (closing the terminal has no effect)" + echo " down close the background tunnel" + echo " status check running status + port connectivity" + echo " logs tail the background log" + exit 1 + ;; +esac diff --git a/tunnels/tunnel-arl-t4.bat b/tunnels/tunnel-arl-t4.bat new file mode 100644 index 0000000..b101233 --- /dev/null +++ b/tunnels/tunnel-arl-t4.bat @@ -0,0 +1,5 @@ +@echo off +REM Double-click this file to start the tunnel (no need to change the Windows execution policy) +cd /d "%~dp0" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0tunnel-arl-t4.ps1" +pause diff --git a/tunnels/tunnel-arl-t4.ps1 b/tunnels/tunnel-arl-t4.ps1 new file mode 100644 index 0000000..60cfbe5 --- /dev/null +++ b/tunnels/tunnel-arl-t4.ps1 @@ -0,0 +1,61 @@ +# Polymarket VPS SSH tunnel (PowerShell version, auto-reconnect) +# +# Usage: +# Right-click the .ps1 → Run with PowerShell (foreground mode, closing the window = closing the tunnel) +# Or run in PowerShell: .\tunnel-arl-large.ps1 +# +# The first run may be blocked by the execution policy; open PowerShell as administrator and run once: +# Set-ExecutionPolicy -Scope CurrentUser RemoteSigned + +# ── Config (matches the .sh version) ───────────────────────────── +$PEM = "$env:USERPROFILE\Desktop\arl.pem" +$HOST_ = "ubuntu@3.255.100.208" +$PORTS = @(3456, 3457, 3458) + +# ── Internal ──────────────────────────────────────────────── + +# Assemble port-forwarding arguments +$portArgs = @() +foreach ($p in $PORTS) { + $portArgs += "-L" + $portArgs += "${p}:localhost:${p}" +} + +# pem file check +if (-not (Test-Path $PEM)) { + Write-Host "❌ pem file does not exist: $PEM" -ForegroundColor Red + exit 1 +} + +# Windows OpenSSH requires correct pem file permissions (owner-only); +# if permissions were not set before, ssh reports "WARNING: UNPROTECTED PRIVATE KEY FILE" and refuses to connect +# Auto chmod is skipped here (icacls on Windows is too complex); if you hit an error, please: +# 1. Right-click the pem file → Properties → Security → Advanced +# 2. Disable inheritance → remove other users → keep only yourself +# 3. For a detailed guide, search "windows ssh permissions are too open" + +Write-Host "✓ Starting tunnel (foreground mode, Ctrl+C to exit)" +Write-Host " HOST: $HOST_" +Write-Host " Ports: $($PORTS -join ' ')" +Write-Host "" + +# Reconnect loop: ssh exits → wait 3 seconds → reconnect +$attempt = 0 +while ($true) { + $attempt++ + Write-Host "[$([DateTime]::Now.ToString('HH:mm:ss'))] Attempting connection (count $attempt)..." -ForegroundColor Cyan + & ssh -N ` + -i $PEM ` + @portArgs ` + $HOST_ ` + -o "ServerAliveInterval=30" ` + -o "ServerAliveCountMax=3" ` + -o "ExitOnForwardFailure=yes" ` + -o "TCPKeepAlive=yes" ` + -o "StrictHostKeyChecking=no" ` + -o "UserKnownHostsFile=NUL" + + $exitCode = $LASTEXITCODE + Write-Host "[$([DateTime]::Now.ToString('HH:mm:ss'))] Tunnel disconnected (exit=$exitCode), reconnecting in 3 seconds..." -ForegroundColor Yellow + Start-Sleep -Seconds 3 +} diff --git a/tunnels/tunnel-arl-t4.sh b/tunnels/tunnel-arl-t4.sh new file mode 100755 index 0000000..9e9147c --- /dev/null +++ b/tunnels/tunnel-arl-t4.sh @@ -0,0 +1,147 @@ +#!/bin/bash +# Polymarket VPS SSH tunnel management (autossh auto-reconnect) +# +# Usage: +# ./tunnel.sh foreground start (default; Ctrl+C to exit = close tunnel; output shown directly) +# ./tunnel.sh bg background start (tunnel keeps running after closing the terminal, log at /tmp/pm-tunnel.log) +# ./tunnel.sh down close the background tunnel +# ./tunnel.sh status check running status +# ./tunnel.sh logs tail the background log +# +# Multiple accounts: edit the PORTS array below and add more ports + +# ── Config ───────────────────────────────────────────── +PEM="$HOME/Desktop/arl.pem" +HOST=ubuntu@3.255.100.208 +PORTS=(3456 3457 3458 3459 3460) # 3 accounts + +# ── Internal ───────────────────────────────────────────── +# LOG generated from the script filename (avoids multiple scripts sharing one log file) +SCRIPT_NAME=$(basename "$0" .sh) +LOG="/tmp/${SCRIPT_NAME}.log" +TAG="autossh-pm-${HOST//[@.]/_}" # use host as the identifier, for pkill / pgrep + +# Assemble port-forwarding arguments +PORT_ARGS="" +for p in "${PORTS[@]}"; do + PORT_ARGS="$PORT_ARGS -L $p:localhost:$p" +done + +cmd="${1:-up}" + +# Common: check dependencies + config +check_prereq() { + if ! command -v autossh >/dev/null 2>&1; then + echo "❌ autossh not installed, install it first:" + echo " brew install autossh" + exit 1 + fi + if [ ! -f "$PEM" ]; then + echo "❌ pem file does not exist: $PEM" + exit 1 + fi +} + +case "$cmd" in + up|fg) + # Foreground run: Ctrl+C closes the tunnel directly, output shown in real time + if pgrep -f "autossh.*$HOST" >/dev/null 2>&1; then + echo "⚠ Already running in the background pid=$(pgrep -f "autossh.*$HOST" | head -1)" + echo " Run ./tunnel.sh down first, then restart in foreground; or ./tunnel.sh logs to view the background log" + exit 1 + fi + check_prereq + echo "✓ Starting tunnel (foreground mode, Ctrl+C to exit)" + echo " HOST: $HOST" + echo " Ports: ${PORTS[*]}" + echo "" + # No -f: foreground run; no log file, straight to stdout + exec autossh -M 0 -N \ + -i "$PEM" \ + $PORT_ARGS \ + "$HOST" \ + -o "ServerAliveInterval=30" \ + -o "ServerAliveCountMax=3" \ + -o "ExitOnForwardFailure=yes" \ + -o "TCPKeepAlive=yes" \ + -o "StrictHostKeyChecking=no" \ + -o "UserKnownHostsFile=/dev/null" + ;; + + bg) + # Background run: closing the terminal has no effect, log written to file + if pgrep -f "autossh.*$HOST" >/dev/null 2>&1; then + echo "✓ Already running (pid=$(pgrep -f "autossh.*$HOST" | tr '\n' ' '))" + exit 0 + fi + check_prereq + AUTOSSH_LOGFILE="$LOG" \ + autossh -M 0 -f -N \ + -i "$PEM" \ + $PORT_ARGS \ + "$HOST" \ + -E "$LOG" \ + -o "ServerAliveInterval=30" \ + -o "ServerAliveCountMax=3" \ + -o "ExitOnForwardFailure=yes" \ + -o "TCPKeepAlive=yes" \ + -o "StrictHostKeyChecking=no" \ + -o "UserKnownHostsFile=/dev/null" + sleep 1 + pid=$(pgrep -f "autossh.*$HOST" | head -1) + if [ -n "$pid" ]; then + echo "✓ Tunnel started (background) pid=$pid" + echo " Local ports: ${PORTS[*]}" + echo " Log: $LOG (./tunnel.sh logs to view)" + else + echo "⚠ No process found after autossh started; it may have failed immediately, see $LOG" + fi + ;; + + down) + if pgrep -f "autossh.*$HOST" >/dev/null 2>&1; then + pkill -f "autossh.*$HOST" + pkill -f "ssh.*$HOST" 2>/dev/null + sleep 0.3 + echo "✓ Tunnel closed" + else + echo "Not running" + fi + ;; + + status) + pid=$(pgrep -f "autossh.*$HOST" | head -1) + if [ -n "$pid" ]; then + echo "✓ Running pid=$pid" + echo " Ports: ${PORTS[*]}" + # Test whether each port is actually reachable + for p in "${PORTS[@]}"; do + if curl -sf -m 2 "http://localhost:$p/api/state" >/dev/null 2>&1; then + echo " $p: ✓ reachable" + else + echo " $p: ✗ unreachable (tunnel up but remote server not started?)" + fi + done + else + echo "✗ Not running" + fi + ;; + + logs) + if [ -f "$LOG" ]; then + tail -f "$LOG" + else + echo "No log yet (tunnel has not run)" + fi + ;; + + *) + echo "Usage: $0 {up|bg|down|status|logs}" + echo " up foreground start (default; Ctrl+C to exit = close tunnel)" + echo " bg background start (closing the terminal has no effect)" + echo " down close the background tunnel" + echo " status check running status + port connectivity" + echo " logs tail the background log" + exit 1 + ;; +esac