Initial commit
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
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 (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: 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.
|
||||
@@ -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/ 目录下运行
|
||||
@@ -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}%")
|
||||
@@ -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
@@ -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"
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+5
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Generated
+2799
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+115
@@ -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) };
|
||||
}
|
||||
}
|
||||
Executable
+147
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
Executable
+147
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
Executable
+147
@@ -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
|
||||
Reference in New Issue
Block a user