Initial commit

This commit is contained in:
doge-8
2026-05-31 13:49:36 +08:00
commit cd988d9c3a
55 changed files with 27001 additions and 0 deletions
+19
View File
@@ -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
+50
View File
@@ -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
+21
View File
@@ -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.
+286
View File
@@ -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_<KEY>_ENABLED` / `STRATEGY_<KEY>_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 `<letter-prefix><number>.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.
+104
View File
@@ -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/ 目录下运行
+701
View File
@@ -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}%")
View File
+105
View File
@@ -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<string, ethers.JsonRpcProvider>();
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<ethers.TransactionReceipt | null> {
try {
const rpc = getProvider(url);
return await Promise.race([
rpc.getTransactionReceipt(txHash),
new Promise<null>((_, 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<number | null> {
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
}
Executable
+53
View File
@@ -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"
+739
View File
@@ -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<string, string>; // price → size
asks: Map<string, string>;
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<MarketSymbol, SymbolState>();
const marketStates = new Map<MarketKey, MarketState>();
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<T extends { t: number }>(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<string, unknown> };
const stream = raw.stream;
const payload = raw.data;
if (!stream || !payload) return;
if (stream.endsWith("@aggTrade")) {
const p = payload as { p?: string; T?: number };
const price = parseFloat(p.p ?? "");
const t = p.T ?? Date.now();
if (!price) return;
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<string, string>[]) {
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<MarketInfo | null> {
const slug = `${config.slugPrefix}-${windowStart}`;
try {
const res = await fetch(`${GAMMA_URL}/events?slug=${slug}`);
const events = await res.json() as Record<string, unknown>[];
if (!events?.length) return null;
const event = events[0];
const market = ((event.markets || []) as Record<string, unknown>[])[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<number | null> {
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<void> {
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<string, string[]>();
for (const f of files) {
const date = f.slice(0, 10);
if (!byDate.has(date)) byDate.set(date, []);
byDate.get(date)!.push(f);
}
const dates = [...byDate.keys()].sort();
if (dates.length <= 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<void> {
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);
});
+128
View File
@@ -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
```
+183
View File
@@ -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)
+66
View File
@@ -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<v8::internal::Factory>::NewRawOneByteString(int, v8::internal::AllocationType) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node]
8: 0x1009cb4c8 v8::internal::FactoryBase<v8::internal::Factory>::NewStringFromOneByte(v8::base::Vector<unsigned char const>, v8::internal::AllocationType) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node]
9: 0x100b0dee0 v8::internal::JsonStringifier::Stringify(v8::internal::Handle<v8::internal::Object>, v8::internal::Handle<v8::internal::Object>, v8::internal::Handle<v8::internal::Object>) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node]
10: 0x100b0dc50 v8::internal::JsonStringify(v8::internal::Isolate*, v8::internal::Handle<v8::internal::Object>, v8::internal::Handle<v8::internal::Object>, v8::internal::Handle<v8::internal::Object>) [/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::Object>, v8::internal::Handle<v8::internal::Object>, int, v8::internal::Handle<v8::internal::Object>*) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node]
30: 0x1007f2930 v8::Function::Call(v8::Isolate*, v8::Local<v8::Context>, v8::Local<v8::Value>, int, v8::Local<v8::Value>*) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node]
31: 0x100505cec node::InternalMakeCallback(node::Environment*, v8::Local<v8::Object>, v8::Local<v8::Object>, v8::Local<v8::Function>, int, v8::Local<v8::Value>*, node::async_context, v8::Local<v8::Value>) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node]
32: 0x10051b1cc node::AsyncWrap::MakeCallback(v8::Local<v8::Function>, int, v8::Local<v8::Value>*) [/Users/yuepin/.nvm/versions/node/v23.11.0/bin/node]
33: 0x1007324b0 node::StreamBase::CallJSOnreadMethod(long, v8::Local<v8::ArrayBuffer>, 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]
Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+6619
View File
File diff suppressed because it is too large Load Diff
+110
View File
@@ -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<MarketSymbol, Omit<MarketConfig, "key" | "period" | "periodSeconds" | "displayName" | "slugPrefix" | "cryptoPriceVariant">> = {
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<MarketPeriod, { seconds: number; periodLabel: string; cryptoPriceVariant: string }> = {
// 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<MarketSymbol, string> = { btc: "BTC", eth: "ETH", sol: "SOL" };
function buildMarkets(): Record<MarketKey, MarketConfig> {
const out = {} as Record<MarketKey, MarketConfig>;
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<MarketKey, MarketConfig> = 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;
}
+678
View File
@@ -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<string, unknown> {
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<number, { state: any; at: number }>();
async function fetchAccountState(port: number, timeoutMs = 8000): Promise<FetchStateResult> {
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<string, BalanceSnapshot>; // 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<BalanceSnapshot> {
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<void> {
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<void> {
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<string> {
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<void> {
// 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 <account name> — 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 <account name>\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<void> {
// 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<number>();
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<string, unknown>;
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<string, unknown>;
// 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);
});
+1691
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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
+303
View File
@@ -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 <account name> 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<string, unknown> = {
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<string, unknown> = {
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<void> {
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> | void,
private readonly onCallback?: (ctx: CallbackContext) => Promise<void> | 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<void> {
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<void> {
return new Promise(resolve => {
this.timer = setTimeout(() => { this.timer = null; resolve(); }, ms);
});
}
}
+2799
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+654
View File
@@ -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<T>(url: string): Promise<T> {
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<T>(path: string, extraQs: string = ""): Promise<T[]> {
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<T[]>(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<PmTrade[]> {
return fetchPaged<PmTrade>(`activity?user=${proxy}&type=TRADE`);
}
export async function fetchAllRedeems(proxy: string): Promise<PmRedeem[]> {
// Polymarket returns many empty redeem records with size=0 (multi-direction split noise from the same tx), filter them out
const all = await fetchPaged<PmRedeem>(`activity?user=${proxy}&type=REDEEM`);
return all.filter(r => (r.usdcSize > 0) || (r.size > 0));
}
export async function fetchAllPositions(proxy: string): Promise<PmPosition[]> {
return fetchPaged<PmPosition>(`positions?user=${proxy}`);
}
/** Incremental fetch: only data with timestamp > sinceSec */
export async function fetchTradesSince(proxy: string, sinceSec: number): Promise<PmTrade[]> {
// 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<PmTrade[]>(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<PmRedeem[]> {
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<PmRedeem[]>(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<string, string>,
): PositionSummary[] {
type Key = string;
const mk = (c: string, o: string): Key => `${c}::${o}`;
const groups = new Map<Key, PositionSummary>();
// 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<string, PmRedeem[]>();
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<string, string> {
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<string, string>).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<string, string>): void {
try {
const obj: Record<string, string> = {};
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<string, string> = 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<void> {
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<boolean> {
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<string, WinRow>();
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;
}
}
+54
View File
@@ -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));
+64
View File
@@ -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));
+84
View File
@@ -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));
+54
View File
@@ -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));
+45
View File
@@ -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<string>();
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));
+110
View File
@@ -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); });
+151
View File
@@ -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); });
+66
View File
@@ -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<string>();
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));
+198
View File
@@ -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<string[]>,
safe.getThreshold() as Promise<bigint>,
safe.nonce() as Promise<bigint>,
]);
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);
});
+6559
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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
Executable
+98
View File
@@ -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
+504
View File
@@ -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:
```
<prefix><number>.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<string, unknown> {
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<string, unknown> { 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<MarketKey, RawData>` structure; adding a new table changes only one place:
```ts
// strategies/_core/fair-prob.ts
export const FAIR_PROB_TABLES: Record<string, RawData> = {
"btc-5m": BTC_5M,
"eth-5m": ETH_5M, // ← new
"btc-15m": BTC_15M, // ← new
};
```
Generate data: run `python3 backtest-data/analyze.py --symbol <sym> --period <p>` 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 `<prefix><number>.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/<your-script>.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
+84
View File
@@ -0,0 +1,84 @@
/**
* diff extremes mapping table used by adaptive-threshold strategies such as p2
*
* Data structure: EXTREME_TABLES is Record<MarketKey, RawData>
* - 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 <key>`
* 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<string, RawData> = {
"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<string, Map<string, PercentileRow>>();
for (const [marketKey, raw] of Object.entries(EXTREME_TABLES)) {
const m = new Map<string, PercentileRow>();
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}`;
}
+441
View File
@@ -0,0 +1,441 @@
/**
* Fair probability mapping table shared by the p-series strategies
*
* Data structure: FAIR_PROB_TABLES is Record<MarketKey, RawData>
* - 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 <sym> --period <p>`
* 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<string, RawData> = {
"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<string, BucketSpec> = {
"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<string, Map<string, number>>();
for (const [marketKey, raw] of Object.entries(FAIR_PROB_TABLES)) {
const m = new Map<string, number>();
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`;
}
+320
View File
@@ -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 };
}
+132
View File
@@ -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: <letter prefix><number>.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<string, IStrategy> = new Map();
let loaded = false;
/** Called on startup to dynamically load all strategy files */
export async function loadAllStrategies(): Promise<IStrategy[]> {
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 <IStrategy instance>`
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<l<p<t), then by number (d1 < d2)
instances.sort(sortByKey);
// Sync to types.ts global arrays (for backward compatibility with old code)
__setStrategyKeys(
instances.map(s => 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);
}
+68
View File
@@ -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<string, unknown> {
return {};
}
}
+135
View File
@@ -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<string, unknown> {
return {
lastDiff: this.s.lastDiff,
entryBias: this.s.entryBias,
entryTs: this.s.entryTs,
};
}
}
+137
View File
@@ -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<string, unknown> {
return {
entryBias: this.s.entryBias,
entryTs: this.s.entryTs,
lastDiff: this.s.lastDiff,
};
}
}
+41
View File
@@ -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<void> {
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());
}
+291
View File
@@ -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<string, unknown>;
/** 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;
}
+1300
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -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) };
}
}
+147
View File
@@ -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
+5
View File
@@ -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
+61
View File
@@ -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
}
+147
View File
@@ -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
+5
View File
@@ -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
+61
View File
@@ -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
}
+147
View File
@@ -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