@@ -0,0 +1,7 @@
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
linker = "musl-gcc"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
# =============================================================================
|
||||
# poly_bot 环境配置
|
||||
# Environment Configuration
|
||||
#
|
||||
# 复制为 .env 后填写必填项:
|
||||
# Copy to .env and fill required fields:
|
||||
# cp .env.example .env
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# [1] 账户认证
|
||||
# Account & Authentication REQUIRED
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Polymarket 私钥(64 位 hex,可带或不带 0x 前缀)
|
||||
# Private key (64-char hex, with or without 0x prefix)
|
||||
# 邮箱/Magic 账号: https://reveal.magic.link/polymarket
|
||||
# Email/Magic: https://reveal.magic.link/polymarket
|
||||
# 浏览器钱包: 导出对应 EOA 私钥
|
||||
# Browser wallet: export your EOA private key
|
||||
|
||||
POLYMARKET_PRIVATE_KEY=
|
||||
|
||||
# 资金托管地址(Settings 里显示的 Address,非 EOA 签名地址)
|
||||
# Funder address from Polymarket Settings (NOT your EOA signer address)
|
||||
# https://polymarket.com/settings?tab=builder → Address
|
||||
# 邮箱与浏览器钱包在 V2 下通常均为 deposit wallet,填 Settings 地址即可
|
||||
# For V2, both email and browser accounts usually use the deposit wallet from Settings
|
||||
|
||||
POLYMARKET_PROXY_ADDRESS=
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# [2] Builder API REQUIRED for Merge
|
||||
# Merge / Redeem 前必须填写
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# 在 Polymarket → Settings → Builders → Create New 获取
|
||||
# Get from Polymarket → Settings → Builders → Create New
|
||||
POLY_BUILDER_API_KEY=
|
||||
POLY_BUILDER_SECRET=
|
||||
POLY_BUILDER_PASSPHRASE=
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# [3] CLOB 接口
|
||||
# CLOB API optional
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# API 地址 — 使用 https://clob.polymarket.com(不要用 clob-v2.polymarket.com)
|
||||
# API endpoint — use https://clob.polymarket.com (NOT clob-v2.polymarket.com)
|
||||
CLOB_API_URL=https://clob.polymarket.com
|
||||
|
||||
# 签名类型 — 按「资金托管钱包类型」选择,与登录方式(邮箱/钱包)无必然对应
|
||||
# Signature type — based on funder wallet type, NOT login method (email vs browser)
|
||||
#
|
||||
# Poly1271 | deposit | 3 — V2 deposit wallet(默认;邮箱/Magic 与浏览器钱包均适用)
|
||||
# Poly1271 | deposit | 3 — V2 deposit wallet (default; email/Magic and browser wallet)
|
||||
#
|
||||
# Proxy | magic | email — 仅 V1 旧 Magic 代理(Settings 地址 = ProxyFactory 推导地址)
|
||||
# Proxy | magic | email — legacy Magic proxy only (Settings addr = ProxyFactory derive)
|
||||
#
|
||||
# GnosisSafe | safe — Gnosis Safe
|
||||
# Eoa | 0 — 纯 EOA 直连(无需 POLYMARKET_PROXY_ADDRESS)
|
||||
# Eoa | 0 — direct EOA (no POLYMARKET_PROXY_ADDRESS)
|
||||
#
|
||||
# 若 Proxy 下单报 "please use the deposit wallet flow" → 改用 Poly1271
|
||||
# If Proxy orders fail with "please use the deposit wallet flow" → use Poly1271
|
||||
SIGNATURE_TYPE=Poly1271
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# [4] 链上 & Relayer
|
||||
# On-chain & Relayer optional
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
RELAYER_URL=https://relayer-v2.polymarket.com
|
||||
|
||||
# Merge / Redeem 输出代币(默认 pUSD)
|
||||
# Output token after Merge or Redeem (default: pUSD)
|
||||
MERGE_OUTPUT_TOKEN=pUSD
|
||||
|
||||
# Merge 后自动 wrap 为 pUSD
|
||||
# Auto-wrap merge proceeds to pUSD via Collateral Onramp
|
||||
MERGE_WRAP_TO_PUSD=true
|
||||
REDEEM_OUTPUT_TOKEN=pUSD
|
||||
|
||||
# Polygon RPC(不填则使用内置公共节点)
|
||||
# Polygon RPC (uses built-in public node if unset)
|
||||
# RPC_URL=https://polygon-bor-rpc.publicnode.com
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# [5] 市场发现
|
||||
# Market Discovery optional
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# 监控的加密货币符号,逗号分隔
|
||||
# Comma-separated crypto symbols to monitor
|
||||
CRYPTO_SYMBOLS=btc,eth
|
||||
|
||||
# 距下一窗口结束前多少秒刷新市场
|
||||
# Seconds before next window ends to refresh markets
|
||||
MARKET_REFRESH_ADVANCE_SECS=5
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# [6] 套利 & 下单
|
||||
# Arbitrage & Orders optional
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# 最小利润阈值(0.001 = 0.1%)
|
||||
# Min profit threshold (0.001 = 0.1%)
|
||||
MIN_PROFIT_THRESHOLD=0.001
|
||||
|
||||
# 单笔最大下单量(USDC / pUSD)
|
||||
# Max order size per trade (USDC / pUSD)
|
||||
MAX_ORDER_SIZE_USDC=10.0
|
||||
|
||||
# 执行条件: yes + no <= 1 - spread(如 spread=0.01 时在 0.99 执行)
|
||||
# Execute when yes + no <= 1 - spread (e.g. spread=0.01 triggers at 0.99)
|
||||
ARBITRAGE_EXECUTION_SPREAD=0.03
|
||||
|
||||
# YES / NO 价格下限,0 = 不限制
|
||||
# Min YES/NO price; 0 = no filter
|
||||
MIN_YES_PRICE_THRESHOLD=0.2
|
||||
MIN_NO_PRICE_THRESHOLD=0.2
|
||||
|
||||
# 滑点 [first, second]: 下降侧用 second,上涨/持平用 first
|
||||
# Slippage [first, second]: use second for declining side, first for rising/flat
|
||||
SLIPPAGE=0.01,0.01
|
||||
|
||||
# 订单类型: GTC | GTD | FOK | FAK
|
||||
# Order type: GTC | GTD | FOK | FAK
|
||||
ARBITRAGE_ORDER_TYPE=GTD
|
||||
|
||||
# GTD 订单过期时间(秒),仅 ARBITRAGE_ORDER_TYPE=GTD 时生效
|
||||
# GTD order expiry in seconds (only when ARBITRAGE_ORDER_TYPE=GTD)
|
||||
GTD_EXPIRATION_SECS=300
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# [7] 风控 & 收尾
|
||||
# Risk & Wind-down optional
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# 每轮最大风险敞口(USDC)
|
||||
# Max exposure per round (USDC)
|
||||
RISK_MAX_EXPOSURE_USDC=1000.0
|
||||
|
||||
# 持仓不平衡阈值(0.1 = 10%)
|
||||
# Position imbalance threshold (0.1 = 10%)
|
||||
RISK_IMBALANCE_THRESHOLD=0.1
|
||||
|
||||
# 对冲止盈 / 止损比例
|
||||
# Hedge take-profit / stop-loss ratio
|
||||
HEDGE_TAKE_PROFIT_PCT=0.05
|
||||
HEDGE_STOP_LOSS_PCT=0.05
|
||||
|
||||
# 市场结束前 N 分钟停止套利,0 = 不限制
|
||||
# Stop arbitrage N minutes before market end; 0 = no limit
|
||||
STOP_ARBITRAGE_BEFORE_END_MINUTES=2
|
||||
|
||||
# 窗口结束前收尾(取消挂单 → Merge → 市价卖剩余),0 = 关闭
|
||||
# Wind-down before window end (cancel orders → Merge → market sell remainder); 0 = disabled
|
||||
WIND_DOWN_BEFORE_WINDOW_END_MINUTES=1
|
||||
|
||||
# 收尾时单腿卖出限价
|
||||
# Limit price for single-leg sell during wind-down
|
||||
WIND_DOWN_SELL_PRICE=0.01
|
||||
|
||||
# 定时 Merge 间隔(分钟),0 = 关闭
|
||||
# Scheduled Merge interval in minutes; 0 = disabled
|
||||
MERGE_INTERVAL_MINUTES=1
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# [8] 持仓同步
|
||||
# Position Sync optional
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# 持仓同步间隔(秒)
|
||||
# Position sync interval in seconds
|
||||
POSITION_SYNC_INTERVAL_SECS=10
|
||||
|
||||
# 仓位平衡检查间隔(秒)
|
||||
# Position balance check interval in seconds
|
||||
POSITION_BALANCE_INTERVAL_SECS=60
|
||||
|
||||
# 不平衡阈值
|
||||
# Imbalance threshold to trigger balance action
|
||||
POSITION_BALANCE_THRESHOLD=2.0
|
||||
|
||||
# 最小总持仓要求
|
||||
# Min total position required to run balance check
|
||||
POSITION_BALANCE_MIN_TOTAL=5.0
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# [9] 日志 & 界面
|
||||
# Logging & UI optional
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# 日志级别: trace | debug | info | warn | error
|
||||
# Log level: trace | debug | info | warn | error
|
||||
RUST_LOG=info
|
||||
|
||||
# 日志文件路径
|
||||
# Log file path
|
||||
# LOG_FILE=bot.log
|
||||
|
||||
# 启用终端 UI
|
||||
# Enable terminal dashboard UI
|
||||
# TUI_ENABLED=true
|
||||
|
||||
# 纯文本日志(关闭 TUI 着色)
|
||||
# Plain-text logs (disable TUI styling)
|
||||
# PLAIN_LOGS=false
|
||||
@@ -0,0 +1,130 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
BINARY_NAME: polypulse
|
||||
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# musl static binaries: run on older Linux without newer GLIBC
|
||||
- target: x86_64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
archive: tar.gz
|
||||
musl: true
|
||||
- target: aarch64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
archive: tar.gz
|
||||
use_cross: true
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
archive: tar.gz
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-latest
|
||||
archive: tar.gz
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
archive: zip
|
||||
- target: aarch64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
archive: zip
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install musl toolchain
|
||||
if: matrix.musl == true
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y musl-tools
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.target }}-cargo-
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use_cross == true
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cross
|
||||
|
||||
- name: Build release binary (cross)
|
||||
if: matrix.use_cross == true
|
||||
run: cross build --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Build release binary
|
||||
if: matrix.use_cross != true
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Prepare release package (Unix)
|
||||
if: matrix.archive == 'tar.gz'
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PACKAGE="${BINARY_NAME}-${VERSION}-${{ matrix.target }}"
|
||||
mkdir -p "dist/${PACKAGE}"
|
||||
cp "target/${{ matrix.target }}/release/${BINARY_NAME}" "dist/${PACKAGE}/"
|
||||
cp .env.example README.md README.zh-CN.md "dist/${PACKAGE}/"
|
||||
tar -czf "dist/${PACKAGE}.tar.gz" -C dist "${PACKAGE}"
|
||||
|
||||
- name: Prepare release package (Windows)
|
||||
if: matrix.archive == 'zip'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$Version = "${{ github.ref_name }}".TrimStart('v')
|
||||
$Package = "${{ env.BINARY_NAME }}-${Version}-${{ matrix.target }}"
|
||||
New-Item -ItemType Directory -Force -Path "dist/$Package" | Out-Null
|
||||
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe" "dist/$Package/"
|
||||
Copy-Item .env.example, README.md, README.zh-CN.md "dist/$Package/"
|
||||
Compress-Archive -Path "dist/$Package" -DestinationPath "dist/$Package.zip"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.BINARY_NAME }}-${{ matrix.target }}
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: dist/*
|
||||
@@ -0,0 +1,6 @@
|
||||
/target
|
||||
.env
|
||||
bot.log
|
||||
.idea
|
||||
_vendor
|
||||
Cargo.lock
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "polypulse"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
default-run = "polypulse"
|
||||
|
||||
[dependencies]
|
||||
polymarket-client-sdk = { version = "0.4.1", features = ["clob", "ctf", "data", "gamma", "ws", "tracing"] }
|
||||
polymarket-client-sdk-v2 = { package = "polymarket_client_sdk_v2", version = "0.6.0-canary.1", features = ["clob"] }
|
||||
tokio = { version = "1.49", features = ["full"] }
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
rust_decimal = "1.39"
|
||||
rust_decimal_macros = "1.39"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
dotenvy = "0.15"
|
||||
text-pad-core = "0.1"
|
||||
alloy = { version = "1.3", default-features = false, features = [
|
||||
"signer-local",
|
||||
"signers",
|
||||
"reqwest",
|
||||
"reqwest-rustls-tls",
|
||||
"providers",
|
||||
"contract",
|
||||
] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dashmap = "6.1"
|
||||
futures = "0.3"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
rand = "0.8"
|
||||
@@ -0,0 +1,104 @@
|
||||
# Polymarket-5min-bot
|
||||
|
||||
**English** | [中文](./README.zh-CN.md)
|
||||
|
||||
**Contact**:[smith123_lee](https://t.me/smith123_lee)
|
||||
|
||||
> Rust arbitrage bot for [Polymarket](https://polymarket.com) crypto “Up or Down” 5-minute prediction markets.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## How It Works
|
||||
|
||||
Polymarket Up/Down markets open a new 5-minute window (UTC) every cycle. Each market has YES and NO outcome tokens.
|
||||
|
||||
In theory, holding equal amounts of YES + NO redeems for 1 USDC at settlement, so:
|
||||
|
||||
```
|
||||
YES best ask + NO best ask < 1 → arbitrage opportunity
|
||||
```
|
||||
|
||||
The bot roughly follows these steps:
|
||||
|
||||
1. **Market discovery** — Finds current 5-minute Up/Down markets for configured symbols (e.g. btc, eth).
|
||||
2. **Order book monitoring** — Subscribes to CLOB order books and tracks YES + NO combined price in real time.
|
||||
3. **Arbitrage execution** — Buys YES and NO when the combined price falls below your threshold; slippage, size limits, and execution spread are configurable.
|
||||
4. **Merge** — When holding both YES and NO, merges on-chain into USDC/pUSD to reduce position risk.
|
||||
5. **Wind-down** — Near window end, can auto-cancel orders, merge, and market-sell remaining single-leg positions.
|
||||
|
||||
> This bot connects to live markets and real funds. Understand the risks before use.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Pre-built binary
|
||||
|
||||
If you don't want to compile from source, use the pre-built executable from **[Releases](../../releases/latest)**:
|
||||
|
||||
1. Download the package for your OS/arch from Releases (Linux builds use `*-linux-musl` for broad compatibility on older distros)
|
||||
2. Copy `.env.example` to `.env` and fill in required fields
|
||||
3. Run:
|
||||
- Linux / macOS: `./polypulse`
|
||||
- Windows: `polypulse.exe`
|
||||
|
||||
### Build from source
|
||||
|
||||
Requires [Rust](https://rustup.rs).
|
||||
|
||||
```bash
|
||||
cp .env.example .env # fill required fields in .env, then run
|
||||
cargo run
|
||||
```
|
||||
|
||||
See `.env.example` for full options, grouped as `[1]`–`[9]`: earlier sections are more important.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `POLYMARKET_PRIVATE_KEY` | Signer private key. Email/Magic: [reveal.magic.link/polymarket](https://reveal.magic.link/polymarket); browser wallet: export your EOA key |
|
||||
| `POLYMARKET_PROXY_ADDRESS` | Funder address from Settings (not your EOA) — [polymarket.com/settings](https://polymarket.com/settings) |
|
||||
|
||||
### Signature type `SIGNATURE_TYPE`
|
||||
|
||||
Choose based on **funder wallet type in Settings**, not whether you registered with email or a browser wallet:
|
||||
|
||||
| Value | When to use |
|
||||
|-------|-------------|
|
||||
| `Poly1271` (**default**) | V2 deposit wallet — email/Magic and browser-wallet accounts |
|
||||
| `Proxy` | Legacy V1 Magic proxy only (Settings address must match ProxyFactory CREATE2 derive from your EOA) |
|
||||
| `GnosisSafe` | Gnosis Safe multisig |
|
||||
| `Eoa` | Direct EOA trading — no `POLYMARKET_PROXY_ADDRESS` needed |
|
||||
|
||||
**Rule of thumb:** keep the default `Poly1271`. If `Proxy` orders fail with `please use the deposit wallet flow`, your account uses the V2 deposit wallet — switch back to `Poly1271` (no need to change private key or proxy address).
|
||||
|
||||
### Required for Merge (when scheduled Merge or wind-down is enabled)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `POLY_BUILDER_API_KEY` | Builder API key |
|
||||
| `POLY_BUILDER_SECRET` | Builder API secret |
|
||||
| `POLY_BUILDER_PASSPHRASE` | Builder API passphrase |
|
||||
|
||||
Get these from Polymarket → Settings → Builder.
|
||||
|
||||
### Common options
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CRYPTO_SYMBOLS` | `btc,eth,sol,xrp` | Symbols to monitor, comma-separated |
|
||||
| `ARBITRAGE_EXECUTION_SPREAD` | `0.01` | Execute when `yes + no <= 1 - spread` |
|
||||
| `MAX_ORDER_SIZE_USDC` | `100.0` | Max order size per trade |
|
||||
| `RISK_MAX_EXPOSURE_USDC` | `1000.0` | Max exposure per round |
|
||||
| `MERGE_INTERVAL_MINUTES` | `0` | Scheduled Merge interval (minutes); `0` = disabled |
|
||||
| `WIND_DOWN_BEFORE_WINDOW_END_MINUTES` | `0` | Wind-down before window end (minutes); `0` = disabled |
|
||||
| `RUST_LOG` | `info` | Log level |
|
||||
|
||||
Other settings (CLOB URL, signature type, slippage, order type, position sync, etc.) have sensible defaults. See `.env.example` for the full list with bilingual comments.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This software is for learning and research only — not investment advice. Crypto and prediction markets carry significant risk, including loss of funds. Assess your own risk and comply with Polymarket’s terms and applicable laws.
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
# Polymarket-5min-bot
|
||||
|
||||
[English](./README.md) | **中文**
|
||||
|
||||
**联系方式**:[smith123_lee](https://t.me/smith123_lee)
|
||||
|
||||
> 面向 [Polymarket](https://polymarket.com) 加密货币「Up or Down」5 分钟预测市场的 Rust 套利机器人。
|
||||
|
||||
account运行界面
|
||||
|
||||
## 基本原理
|
||||
|
||||
Polymarket 的 Up/Down 市场每 5 分钟(UTC)开一个新窗口,每个市场有 YES 和 NO 两个结果代币。
|
||||
|
||||
理论上,持有等量 YES + NO 可在结算时兑换 1 USDC,因此:
|
||||
|
||||
```
|
||||
YES 卖一价 + NO 卖一价 < 1 → 存在套利空间
|
||||
```
|
||||
|
||||
机器人大致流程如下:
|
||||
|
||||
1. **发现市场** — 按配置的币种(如 btc、eth)自动查找当前 5 分钟窗口对应的 Up/Down 市场。
|
||||
2. **监控订单簿** — 订阅 CLOB 订单簿,实时计算 YES + NO 的合计价格。
|
||||
3. **执行套利** — 当合计价格低于阈值时,同时买入 YES 和 NO;可通过滑点、单笔上限、执行价差等参数控制下单行为。
|
||||
4. **Merge 回收** — 若同时持有 YES 和 NO,可链上 Merge 合并为 USDC/pUSD,减少持仓风险。
|
||||
5. **窗口收尾** — 接近窗口结束时,可自动取消挂单、Merge、并卖出剩余单腿仓位。
|
||||
|
||||
> 本程序连接真实市场与真实资金,使用前请充分理解风险。
|
||||
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
|
||||
### 预编译可执行文件
|
||||
|
||||
如果你不会编译代码,请直接使用 **[Releases](../../releases/latest)** 中提供的预编译可执行文件:
|
||||
|
||||
1. 从 Releases 下载对应系统/架构的安装包(Linux 请选 `*-linux-musl`,兼容较旧发行版)
|
||||
2. 复制 `.env.example` 为 `.env`,并填写必填项
|
||||
3. 运行:
|
||||
- Linux / macOS:`./polypulse`
|
||||
- Windows:`polypulse.exe`
|
||||
|
||||
|
||||
|
||||
### 从源码编译
|
||||
|
||||
需要先安装 [Rust](https://rustup.rs)。
|
||||
|
||||
```bash
|
||||
cp .env.example .env # 填写 .env 配置文件的必填项后启动
|
||||
cargo run # 运行程序
|
||||
```
|
||||
|
||||
详细参数说明见 `.env.example`,按 `[1]` ~ `[9]` 分区排列:越靠前越重要。
|
||||
|
||||
## 基础配置
|
||||
|
||||
|
||||
|
||||
### 必填
|
||||
|
||||
|
||||
| 变量 | 说明 |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `POLYMARKET_PRIVATE_KEY` | 签名私钥。邮箱/Magic 从 [reveal.magic.link/polymarket](https://reveal.magic.link/polymarket) 导出;浏览器钱包导出对应 EOA 私钥 |
|
||||
| `POLYMARKET_PROXY_ADDRESS` | 资金托管地址(Settings 里的 Address,非 EOA),见 [polymarket.com/settings](https://polymarket.com/settings) |
|
||||
|
||||
|
||||
|
||||
|
||||
### 签名类型 `SIGNATURE_TYPE`
|
||||
|
||||
按 **Settings 里资金钱包的类型** 选择,与「邮箱还是浏览器钱包注册」无必然对应:
|
||||
|
||||
|
||||
| 值 | 适用场景 |
|
||||
| ------------------ | -------------------------------------------------------- |
|
||||
| `Poly1271`(**默认**) | V2 deposit wallet;邮箱/Magic 与浏览器钱包授权账号均适用 |
|
||||
| `Proxy` | 仅 V1 旧 Magic 代理(Settings 地址须等于 ProxyFactory 从 EOA 推导的地址) |
|
||||
| `GnosisSafe` | Gnosis Safe 多签 |
|
||||
| `Eoa` | 纯 EOA 直连,无需 `POLYMARKET_PROXY_ADDRESS` |
|
||||
|
||||
|
||||
**判断方法:** 保持默认 `Poly1271` 即可。若误设 `Proxy` 且下单报 `please use the deposit wallet flow`,说明账号已走 V2 deposit wallet,应改回 `Poly1271`(私钥与 `POLYMARKET_PROXY_ADDRESS` 无需改动)。
|
||||
|
||||
### Merge 所需(启用定时 Merge 或收尾时必填)
|
||||
|
||||
|
||||
| 变量 | 说明 |
|
||||
| ------------------------- | ---------------------- |
|
||||
| `POLY_BUILDER_API_KEY` | Builder API Key |
|
||||
| `POLY_BUILDER_SECRET` | Builder API Secret |
|
||||
| `POLY_BUILDER_PASSPHRASE` | Builder API Passphrase |
|
||||
|
||||
|
||||
以上三项在 Polymarket → Settings → Builder 获取。
|
||||
|
||||
### 常用可选项
|
||||
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| ------------------------------------- | ----------------- | --------------------------------- |
|
||||
| `CRYPTO_SYMBOLS` | `btc,eth,sol,xrp` | 监控的币种,逗号分隔 |
|
||||
| `ARBITRAGE_EXECUTION_SPREAD` | `0.01` | 执行阈值:`yes + no <= 1 - spread` 时下单 |
|
||||
| `MAX_ORDER_SIZE_USDC` | `100.0` | 单笔最大下单量 |
|
||||
| `RISK_MAX_EXPOSURE_USDC` | `1000.0` | 每轮最大风险敞口 |
|
||||
| `MERGE_INTERVAL_MINUTES` | `0` | 定时 Merge 间隔(分钟),`0` 为关闭 |
|
||||
| `WIND_DOWN_BEFORE_WINDOW_END_MINUTES` | `0` | 窗口结束前收尾(分钟),`0` 为关闭 |
|
||||
| `RUST_LOG` | `info` | 日志级别 |
|
||||
|
||||
|
||||
其余参数(CLOB 地址、签名类型、滑点、订单类型、持仓同步等)均有合理默认值,一般无需修改。完整列表与注释见 `.env.example`。
|
||||
|
||||
## 免责声明
|
||||
|
||||
本软件仅供学习与研究,不构成任何投资建议。加密货币与预测市场存在较高风险,可能导致资金损失。使用前请自行评估风险,并遵守 Polymarket 服务条款及当地法律法规。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,135 @@
|
||||
//! Shared CTF adapter approval helpers for pUSD on-chain ops (merge, redeem).
|
||||
|
||||
use alloy::primitives::{keccak256, Address, B256, U256};
|
||||
use alloy::providers::Provider;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
use crate::deposit_wallet_relay::{
|
||||
relayer_execute_deposit_wallet_calldata, use_deposit_wallet_relayer,
|
||||
};
|
||||
use crate::proxy_relay::{relayer_execute_proxy_calldata, CTF_POLYGON};
|
||||
|
||||
use alloy::sol;
|
||||
sol! {
|
||||
#[sol(rpc)]
|
||||
interface IERC1155Approval {
|
||||
function isApprovedForAll(address account, address operator) external view returns (bool);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_set_approval_for_all(operator: Address, approved: bool) -> Vec<u8> {
|
||||
let sel = &keccak256(b"setApprovalForAll(address,bool)")[..4];
|
||||
let mut out = Vec::from(sel);
|
||||
out.extend_from_slice(&[0u8; 12]);
|
||||
out.extend_from_slice(operator.as_slice());
|
||||
out.extend_from_slice(&U256::from(approved as u8).to_be_bytes::<32>());
|
||||
out
|
||||
}
|
||||
|
||||
pub async fn is_adapter_approved<P: Provider>(
|
||||
provider: &P,
|
||||
ctf: Address,
|
||||
owner: Address,
|
||||
adapter: Address,
|
||||
) -> Result<bool> {
|
||||
let ctf_contract = IERC1155Approval::new(ctf, provider);
|
||||
ctf_contract
|
||||
.isApprovedForAll(owner, adapter)
|
||||
.call()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("isApprovedForAll failed: {}", e))
|
||||
}
|
||||
|
||||
pub async fn wait_relayer_tx<P: Provider>(provider: &P, tx_hash: &str) -> Result<()> {
|
||||
let hash: B256 = tx_hash
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("invalid tx hash {}: {}", tx_hash, e))?;
|
||||
for _ in 0..60 {
|
||||
if let Some(receipt) = provider.get_transaction_receipt(hash).await? {
|
||||
if !receipt.status() {
|
||||
anyhow::bail!("Relayer tx reverted on-chain: {}", tx_hash);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
anyhow::bail!("Timed out waiting for relayer tx: {}", tx_hash);
|
||||
}
|
||||
|
||||
async fn submit_adapter_approval<P: Provider>(
|
||||
_provider: &P,
|
||||
wallet: Address,
|
||||
adapter: Address,
|
||||
signer: &impl alloy::signers::Signer,
|
||||
builder_key: &str,
|
||||
builder_secret: &str,
|
||||
builder_passphrase: &str,
|
||||
relayer_url: &str,
|
||||
) -> Result<String> {
|
||||
let approve_calldata = encode_set_approval_for_all(adapter, true);
|
||||
if use_deposit_wallet_relayer() {
|
||||
relayer_execute_deposit_wallet_calldata(
|
||||
&approve_calldata,
|
||||
CTF_POLYGON,
|
||||
wallet,
|
||||
signer,
|
||||
builder_key,
|
||||
builder_secret,
|
||||
builder_passphrase,
|
||||
relayer_url,
|
||||
"Approve CTF adapter",
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
relayer_execute_proxy_calldata(
|
||||
&approve_calldata,
|
||||
CTF_POLYGON,
|
||||
wallet,
|
||||
signer,
|
||||
builder_key,
|
||||
builder_secret,
|
||||
builder_passphrase,
|
||||
relayer_url,
|
||||
"Approve CTF adapter",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ensure_adapter_approved<P: Provider>(
|
||||
provider: &P,
|
||||
wallet: Address,
|
||||
adapter: Address,
|
||||
signer: &impl alloy::signers::Signer,
|
||||
builder_key: &str,
|
||||
builder_secret: &str,
|
||||
builder_passphrase: &str,
|
||||
relayer_url: &str,
|
||||
) -> Result<()> {
|
||||
if is_adapter_approved(provider, CTF_POLYGON, wallet, adapter).await? {
|
||||
return Ok(());
|
||||
}
|
||||
info!("pUSD adapter {:?} 未授权,提交 setApprovalForAll …", adapter);
|
||||
let tx = submit_adapter_approval(
|
||||
provider,
|
||||
wallet,
|
||||
adapter,
|
||||
signer,
|
||||
builder_key,
|
||||
builder_secret,
|
||||
builder_passphrase,
|
||||
relayer_url,
|
||||
)
|
||||
.await?;
|
||||
wait_relayer_tx(provider, &tx).await?;
|
||||
if !is_adapter_approved(provider, CTF_POLYGON, wallet, adapter).await? {
|
||||
anyhow::bail!(
|
||||
"setApprovalForAll 已提交 ({}) 但链上仍未授权,请稍后重试",
|
||||
tx
|
||||
);
|
||||
}
|
||||
info!("✅ CTF adapter 已授权");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Polymarket V2 CLOB client factory (shared by main bot and test binaries).
|
||||
|
||||
use anyhow::Result;
|
||||
use alloy::signers::local::LocalSigner;
|
||||
use alloy::signers::Signer as _;
|
||||
use polymarket_client_sdk_v2::clob::types::SignatureType;
|
||||
use polymarket_client_sdk_v2::clob::{Client, Config as ClobConfig};
|
||||
use polymarket_client_sdk_v2::types::Address;
|
||||
use polymarket_client_sdk_v2::POLYGON;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub const CLOB_API_URL_DEFAULT: &str = "https://clob.polymarket.com";
|
||||
|
||||
pub type AuthenticatedClobClient = Client<
|
||||
polymarket_client_sdk_v2::auth::state::Authenticated<
|
||||
polymarket_client_sdk_v2::auth::Normal,
|
||||
>,
|
||||
>;
|
||||
|
||||
/// Parse V2 CLOB signature type from env string.
|
||||
///
|
||||
/// Most V2 accounts (email/Magic and browser wallet) use `Poly1271` (deposit wallet).
|
||||
/// Legacy `Proxy` applies only when Settings funder equals ProxyFactory CREATE2 derive.
|
||||
pub fn parse_signature_type(s: &str) -> SignatureType {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"proxy" | "magic" | "email" => SignatureType::Proxy,
|
||||
"gnosissafe" | "safe" => SignatureType::GnosisSafe,
|
||||
"poly1271" | "deposit" | "deposit_wallet" | "3" => SignatureType::Poly1271,
|
||||
"eoa" | "0" => SignatureType::Eoa,
|
||||
_ => SignatureType::Poly1271,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an authenticated V2 CLOB client (EIP-712 domain v2 / pUSD).
|
||||
pub async fn create_authenticated_clob_client(
|
||||
private_key: &str,
|
||||
clob_api_url: &str,
|
||||
funder_address: Option<Address>,
|
||||
signature_type: SignatureType,
|
||||
) -> Result<AuthenticatedClobClient> {
|
||||
if !matches!(signature_type, SignatureType::Eoa) && funder_address.is_none() {
|
||||
anyhow::bail!(
|
||||
"POLYMARKET_PROXY_ADDRESS (deposit wallet / proxy) is required for {:?} orders",
|
||||
signature_type
|
||||
);
|
||||
}
|
||||
|
||||
let signer = LocalSigner::from_str(private_key)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid private key: {}", e))?
|
||||
.with_chain_id(Some(POLYGON));
|
||||
|
||||
let clob_config = ClobConfig::builder().use_server_time(true).build();
|
||||
let mut auth_builder = Client::new(clob_api_url, clob_config)?
|
||||
.authentication_builder(&signer);
|
||||
|
||||
if let Some(funder) = funder_address {
|
||||
auth_builder = auth_builder
|
||||
.funder(funder)
|
||||
.signature_type(signature_type);
|
||||
}
|
||||
|
||||
auth_builder
|
||||
.authenticate()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("CLOB V2 auth failed: {}", e))
|
||||
}
|
||||
|
||||
/// Parse proxy/deposit wallet address from v1 SDK Address string representation.
|
||||
pub fn v1_address_to_v2(addr: polymarket_client_sdk::types::Address) -> Address {
|
||||
addr.to_string().parse().expect("valid address")
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
use anyhow::Result;
|
||||
use polymarket_client_sdk_v2::clob::types::OrderType;
|
||||
use std::env;
|
||||
|
||||
use polymarket_client_sdk::types::Address;
|
||||
|
||||
use crate::trading::CLOB_API_URL_DEFAULT;
|
||||
|
||||
/// Parse arbitrage order type: GTC, GTD, FOK, FAK; case-insensitive; invalid/unknown defaults to GTD.
|
||||
fn parse_arbitrage_order_type(s: &str) -> OrderType {
|
||||
match s.trim().to_uppercase().as_str() {
|
||||
"GTC" => OrderType::GTC,
|
||||
"GTD" => OrderType::GTD,
|
||||
"FOK" => OrderType::FOK,
|
||||
"FAK" => OrderType::FAK,
|
||||
_ => OrderType::GTD,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse slippage array: comma-separated, e.g. "-0.02,0.0".
|
||||
/// Index 0=up/flat side, 1=down-only side. Single value used for both. Default "0,0.01".
|
||||
fn parse_slippage(s: &str) -> [f64; 2] {
|
||||
let parts: Vec<f64> = s
|
||||
.split(',')
|
||||
.map(|x| x.trim().parse().unwrap_or(0.0))
|
||||
.collect();
|
||||
match parts.len() {
|
||||
0 => [0.0, 0.01],
|
||||
1 => [parts[0], parts[0]],
|
||||
_ => [parts[0], parts[1]],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub private_key: String,
|
||||
pub proxy_address: Option<Address>, // Funder from Settings (deposit wallet or legacy proxy)
|
||||
/// V2 CLOB API base URL (default https://clob.polymarket.com — do not use clob-v2 host)
|
||||
pub clob_api_url: String,
|
||||
/// CLOB signature type: Poly1271 | Proxy | GnosisSafe | Eoa (see SIGNATURE_TYPE)
|
||||
pub signature_type: String,
|
||||
pub min_profit_threshold: f64,
|
||||
pub max_order_size_usdc: f64,
|
||||
pub crypto_symbols: Vec<String>,
|
||||
pub market_refresh_advance_secs: u64,
|
||||
pub risk_max_exposure_usdc: f64,
|
||||
pub risk_imbalance_threshold: f64,
|
||||
pub hedge_take_profit_pct: f64, // Hedge take-profit % (e.g. 0.05 = 5%)
|
||||
pub hedge_stop_loss_pct: f64, // Hedge stop-loss % (e.g. 0.05 = 5%)
|
||||
pub arbitrage_execution_spread: f64, // Execute arbitrage when yes+no <= 1 - this spread
|
||||
/// Slippage [first, second]: down-only uses second, up/flat uses first. e.g. "-0.02,0.0"
|
||||
pub slippage: [f64; 2],
|
||||
pub gtd_expiration_secs: u64, // GTD order expiry (seconds), default 300 (5 min); only when arbitrage_order_type=GTD
|
||||
/// Order type for arbitrage: GTC (good till cancel), GTD (with gtd_expiration_secs), FOK (fill or kill), FAK (fill and kill remainder)
|
||||
pub arbitrage_order_type: OrderType,
|
||||
pub stop_arbitrage_before_end_minutes: u64, // Stop arbitrage N minutes before market end, default 0 (no stop)
|
||||
/// Scheduled Merge interval (minutes); 0 = disabled. CONDITION_ID from current window markets like orderbook.
|
||||
pub merge_interval_minutes: u64,
|
||||
/// YES price threshold: only execute arbitrage when YES >= this, default 0.0 (no limit)
|
||||
pub min_yes_price_threshold: f64,
|
||||
/// NO price threshold: only execute arbitrage when NO >= this, default 0.0 (no limit)
|
||||
pub min_no_price_threshold: f64,
|
||||
/// Position sync interval (seconds), default 10 (fetch from API, overwrite local cache)
|
||||
pub position_sync_interval_secs: u64,
|
||||
/// Position balance check interval (seconds), default 60
|
||||
pub position_balance_interval_secs: u64,
|
||||
/// Imbalance threshold: cancel orders only when position diff >= this, default 2.0
|
||||
pub position_balance_threshold: f64,
|
||||
/// Min total position: run balance only when total >= this, default 5.0
|
||||
pub position_balance_min_total: f64,
|
||||
/// Wind-down before window end: minutes before 5min window end to trigger (cancel→Merge→market sell rest). 0=disabled.
|
||||
pub wind_down_before_window_end_minutes: u64,
|
||||
/// Limit price for one-sided leg sells during wind-down (aim for fast fill), default 0.01
|
||||
pub wind_down_sell_price: f64,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
// Parse proxy_address (optional)
|
||||
let proxy_address: Option<Address> = env::var("POLYMARKET_PROXY_ADDRESS")
|
||||
.ok()
|
||||
.and_then(|addr| addr.trim().parse().ok());
|
||||
|
||||
Ok(Config {
|
||||
private_key: env::var("POLYMARKET_PRIVATE_KEY")
|
||||
.expect("POLYMARKET_PRIVATE_KEY must be set")
|
||||
.trim()
|
||||
.to_string(),
|
||||
proxy_address,
|
||||
clob_api_url: env::var("CLOB_API_URL")
|
||||
.unwrap_or_else(|_| CLOB_API_URL_DEFAULT.to_string()),
|
||||
signature_type: env::var("SIGNATURE_TYPE").unwrap_or_else(|_| "Poly1271".to_string()),
|
||||
min_profit_threshold: env::var("MIN_PROFIT_THRESHOLD")
|
||||
.unwrap_or_else(|_| "0.001".to_string())
|
||||
.parse()
|
||||
.unwrap_or(0.001),
|
||||
max_order_size_usdc: env::var("MAX_ORDER_SIZE_USDC")
|
||||
.unwrap_or_else(|_| "100.0".to_string())
|
||||
.parse()
|
||||
.unwrap_or(100.0),
|
||||
crypto_symbols: env::var("CRYPTO_SYMBOLS")
|
||||
.unwrap_or_else(|_| "btc,eth,xrp,sol".to_string())
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.collect(),
|
||||
market_refresh_advance_secs: env::var("MARKET_REFRESH_ADVANCE_SECS")
|
||||
.unwrap_or_else(|_| "5".to_string())
|
||||
.parse()
|
||||
.unwrap_or(5),
|
||||
risk_max_exposure_usdc: env::var("RISK_MAX_EXPOSURE_USDC")
|
||||
.unwrap_or_else(|_| "1000.0".to_string())
|
||||
.parse()
|
||||
.unwrap_or(1000.0),
|
||||
risk_imbalance_threshold: env::var("RISK_IMBALANCE_THRESHOLD")
|
||||
.unwrap_or_else(|_| "0.1".to_string())
|
||||
.parse()
|
||||
.unwrap_or(0.1),
|
||||
hedge_take_profit_pct: env::var("HEDGE_TAKE_PROFIT_PCT")
|
||||
.unwrap_or_else(|_| "0.05".to_string())
|
||||
.parse()
|
||||
.unwrap_or(0.05), // default 5% take-profit
|
||||
hedge_stop_loss_pct: env::var("HEDGE_STOP_LOSS_PCT")
|
||||
.unwrap_or_else(|_| "0.05".to_string())
|
||||
.parse()
|
||||
.unwrap_or(0.05), // default 5% stop-loss
|
||||
arbitrage_execution_spread: env::var("ARBITRAGE_EXECUTION_SPREAD")
|
||||
.unwrap_or_else(|_| "0.01".to_string())
|
||||
.parse()
|
||||
.unwrap_or(0.01), // default 0.01
|
||||
slippage: parse_slippage(&env::var("SLIPPAGE").unwrap_or_else(|_| "0,0.01".to_string())),
|
||||
gtd_expiration_secs: env::var("GTD_EXPIRATION_SECS")
|
||||
.unwrap_or_else(|_| "300".to_string())
|
||||
.parse()
|
||||
.unwrap_or(300), // default 300s (5 min)
|
||||
arbitrage_order_type: parse_arbitrage_order_type(
|
||||
&env::var("ARBITRAGE_ORDER_TYPE").unwrap_or_else(|_| "GTD".to_string()),
|
||||
),
|
||||
stop_arbitrage_before_end_minutes: env::var("STOP_ARBITRAGE_BEFORE_END_MINUTES")
|
||||
.unwrap_or_else(|_| "0".to_string())
|
||||
.parse()
|
||||
.unwrap_or(0), // default 0 (no stop)
|
||||
merge_interval_minutes: env::var("MERGE_INTERVAL_MINUTES")
|
||||
.unwrap_or_else(|_| "0".to_string())
|
||||
.parse()
|
||||
.unwrap_or(0), // 0=disabled
|
||||
min_yes_price_threshold: env::var("MIN_YES_PRICE_THRESHOLD")
|
||||
.unwrap_or_else(|_| "0.0".to_string())
|
||||
.parse()
|
||||
.unwrap_or(0.0), // default 0.0 (no limit)
|
||||
min_no_price_threshold: env::var("MIN_NO_PRICE_THRESHOLD")
|
||||
.unwrap_or_else(|_| "0.0".to_string())
|
||||
.parse()
|
||||
.unwrap_or(0.0), // default 0.0 (no limit)
|
||||
position_sync_interval_secs: env::var("POSITION_SYNC_INTERVAL_SECS")
|
||||
.unwrap_or_else(|_| "10".to_string())
|
||||
.parse()
|
||||
.unwrap_or(10), // default 10s
|
||||
position_balance_interval_secs: env::var("POSITION_BALANCE_INTERVAL_SECS")
|
||||
.unwrap_or_else(|_| "60".to_string())
|
||||
.parse()
|
||||
.unwrap_or(60), // default 60s
|
||||
position_balance_threshold: env::var("POSITION_BALANCE_THRESHOLD")
|
||||
.unwrap_or_else(|_| "2.0".to_string())
|
||||
.parse()
|
||||
.unwrap_or(2.0), // default 2.0
|
||||
position_balance_min_total: env::var("POSITION_BALANCE_MIN_TOTAL")
|
||||
.unwrap_or_else(|_| "5.0".to_string())
|
||||
.parse()
|
||||
.unwrap_or(5.0), // default 5.0
|
||||
wind_down_before_window_end_minutes: env::var("WIND_DOWN_BEFORE_WINDOW_END_MINUTES")
|
||||
.unwrap_or_else(|_| "0".to_string())
|
||||
.parse()
|
||||
.unwrap_or(0), // 0=disabled
|
||||
wind_down_sell_price: env::var("WIND_DOWN_SELL_PRICE")
|
||||
.unwrap_or_else(|_| "0.01".to_string())
|
||||
.parse()
|
||||
.unwrap_or(0.01), // default 0.01
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Polymarket V2 deposit wallet relayer (`WALLET` batch) for on-chain ops.
|
||||
|
||||
use std::env;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use alloy::primitives::{Address, Bytes, U256};
|
||||
use alloy::sol_types::{eip712_domain, SolStruct};
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
use crate::proxy_relay::{relayer_submit_authed, to_hex_0x, RELAYER_URL_DEFAULT};
|
||||
|
||||
/// Polygon mainnet deposit wallet factory (Polymarket docs).
|
||||
pub const DEPOSIT_WALLET_FACTORY: Address = {
|
||||
use polymarket_client_sdk::types::address;
|
||||
address!("0x00000000000Fb5C9ADea0298D729A0CB3823Cc07")
|
||||
};
|
||||
|
||||
const RELAYER_GET_NONCE: &str = "/nonce";
|
||||
const WALLET_BATCH_DEADLINE_SECS: u64 = 600;
|
||||
|
||||
alloy::sol! {
|
||||
struct Call {
|
||||
address target;
|
||||
uint256 value;
|
||||
bytes data;
|
||||
}
|
||||
struct Batch {
|
||||
address wallet;
|
||||
uint256 nonce;
|
||||
uint256 deadline;
|
||||
Call[] calls;
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `SIGNATURE_TYPE` indicates V2 deposit wallet (Poly1271).
|
||||
pub fn use_deposit_wallet_relayer() -> bool {
|
||||
match env::var("SIGNATURE_TYPE") {
|
||||
Ok(v) => {
|
||||
let s = v.trim().to_lowercase();
|
||||
s == "poly1271" || s == "deposit" || s == "deposit_wallet" || s == "3"
|
||||
}
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_wallet_nonce(relayer_url: &str, owner: Address) -> Result<U256> {
|
||||
let client = reqwest::Client::new();
|
||||
let base = relayer_url.trim_end_matches('/');
|
||||
let url = format!(
|
||||
"{}{}?address={:#x}&type=WALLET",
|
||||
base, RELAYER_GET_NONCE, owner
|
||||
);
|
||||
let resp = client.get(&url).send().await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("GET /nonce (WALLET) failed status={} body={}", status, text);
|
||||
}
|
||||
let j: serde_json::Value = serde_json::from_str(&text)?;
|
||||
let nonce = j
|
||||
.get("nonce")
|
||||
.and_then(|v| {
|
||||
v.as_str()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| v.as_u64())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
Ok(U256::from(nonce))
|
||||
}
|
||||
|
||||
async fn sign_deposit_wallet_batch(
|
||||
signer: &impl alloy::signers::Signer,
|
||||
chain_id: u64,
|
||||
deposit_wallet: Address,
|
||||
nonce: U256,
|
||||
deadline: U256,
|
||||
calls: Vec<(Address, U256, Vec<u8>)>,
|
||||
) -> Result<String> {
|
||||
let domain = eip712_domain! {
|
||||
name: "DepositWallet",
|
||||
version: "1",
|
||||
chain_id: chain_id,
|
||||
verifying_contract: deposit_wallet,
|
||||
};
|
||||
let batch_calls: Vec<Call> = calls
|
||||
.into_iter()
|
||||
.map(|(target, value, data)| Call {
|
||||
target,
|
||||
value,
|
||||
data: Bytes::from(data),
|
||||
})
|
||||
.collect();
|
||||
let batch = Batch {
|
||||
wallet: deposit_wallet,
|
||||
nonce,
|
||||
deadline,
|
||||
calls: batch_calls,
|
||||
};
|
||||
let hash = batch.eip712_signing_hash(&domain);
|
||||
let sig = signer
|
||||
.sign_hash(&hash)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("deposit wallet batch sign failed: {}", e))?;
|
||||
let mut sig_bytes = sig.as_bytes().to_vec();
|
||||
if sig_bytes.len() == 65 && (sig_bytes[64] == 0 || sig_bytes[64] == 1) {
|
||||
sig_bytes[64] += 27;
|
||||
}
|
||||
Ok(to_hex_0x(&sig_bytes))
|
||||
}
|
||||
|
||||
/// Execute one or more calls on a deposit wallet via relayer `WALLET` batch.
|
||||
pub async fn relayer_execute_deposit_wallet_calls(
|
||||
calls: &[(Address, Vec<u8>)],
|
||||
deposit_wallet: Address,
|
||||
signer: &impl alloy::signers::Signer,
|
||||
builder_key: &str,
|
||||
builder_secret: &str,
|
||||
builder_passphrase: &str,
|
||||
relayer_url: &str,
|
||||
metadata: &str,
|
||||
) -> Result<String> {
|
||||
if calls.is_empty() {
|
||||
anyhow::bail!("relayer_execute_deposit_wallet_calls: empty calls");
|
||||
}
|
||||
let relayer_url = if relayer_url.is_empty() {
|
||||
RELAYER_URL_DEFAULT
|
||||
} else {
|
||||
relayer_url
|
||||
};
|
||||
let owner = signer.address();
|
||||
let chain_id = signer.chain_id().unwrap_or(137);
|
||||
let nonce = get_wallet_nonce(relayer_url, owner).await?;
|
||||
let deadline = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)?
|
||||
.as_secs()
|
||||
+ WALLET_BATCH_DEADLINE_SECS;
|
||||
|
||||
let typed_calls: Vec<(Address, U256, Vec<u8>)> = calls
|
||||
.iter()
|
||||
.map(|(target, data)| (*target, U256::ZERO, data.clone()))
|
||||
.collect();
|
||||
let signature = sign_deposit_wallet_batch(
|
||||
signer,
|
||||
chain_id,
|
||||
deposit_wallet,
|
||||
nonce,
|
||||
U256::from(deadline),
|
||||
typed_calls,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let calls_json: Vec<serde_json::Value> = calls
|
||||
.iter()
|
||||
.map(|(target, data)| {
|
||||
serde_json::json!({
|
||||
"target": format!("{:#x}", target),
|
||||
"value": "0",
|
||||
"data": to_hex_0x(data),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"type": "WALLET",
|
||||
"from": format!("{:#x}", owner),
|
||||
"to": format!("{:#x}", DEPOSIT_WALLET_FACTORY),
|
||||
"nonce": nonce.to_string(),
|
||||
"signature": signature,
|
||||
"metadata": metadata,
|
||||
"depositWalletParams": {
|
||||
"depositWallet": format!("{:#x}", deposit_wallet),
|
||||
"deadline": deadline.to_string(),
|
||||
"calls": calls_json,
|
||||
}
|
||||
});
|
||||
|
||||
info!(
|
||||
"Relayer WALLET batch | wallet={:?} | calls={} | nonce={}",
|
||||
deposit_wallet,
|
||||
calls.len(),
|
||||
nonce
|
||||
);
|
||||
|
||||
relayer_submit_authed(
|
||||
body,
|
||||
builder_key,
|
||||
builder_secret,
|
||||
builder_passphrase,
|
||||
relayer_url,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn relayer_execute_deposit_wallet_calldata(
|
||||
calldata: &[u8],
|
||||
target: Address,
|
||||
deposit_wallet: Address,
|
||||
signer: &impl alloy::signers::Signer,
|
||||
builder_key: &str,
|
||||
builder_secret: &str,
|
||||
builder_passphrase: &str,
|
||||
relayer_url: &str,
|
||||
metadata: &str,
|
||||
) -> Result<String> {
|
||||
relayer_execute_deposit_wallet_calls(
|
||||
&[(target, calldata.to_vec())],
|
||||
deposit_wallet,
|
||||
signer,
|
||||
builder_key,
|
||||
builder_secret,
|
||||
builder_passphrase,
|
||||
relayer_url,
|
||||
metadata,
|
||||
)
|
||||
.await
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
//! polypulse library: shared modules for the main binary and test binaries.
|
||||
|
||||
pub mod clob_v2;
|
||||
mod adapter_auth;
|
||||
mod deposit_wallet_relay;
|
||||
mod proxy_relay;
|
||||
mod pusd_wrap;
|
||||
mod wallet_kind;
|
||||
|
||||
pub use clob_v2::{
|
||||
create_authenticated_clob_client, parse_signature_type, v1_address_to_v2,
|
||||
AuthenticatedClobClient, CLOB_API_URL_DEFAULT,
|
||||
};
|
||||
|
||||
pub use proxy_relay::{
|
||||
CTF_COLLATERAL_ADAPTER, CTF_POLYGON, NEG_RISK_ADAPTER, NEG_RISK_COLLATERAL_ADAPTER,
|
||||
PROXY_MERGE_PUSD_GAS, PUSD_POLYGON, RPC_URL_DEFAULT, USDC_POLYGON,
|
||||
};
|
||||
|
||||
pub mod merge;
|
||||
pub mod positions;
|
||||
pub mod redeem;
|
||||
pub mod ui;
|
||||
+1022
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use polymarket_client_sdk::gamma::{Client, types::request::MarketsRequest};
|
||||
use polymarket_client_sdk::types::{B256, U256};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// 5-minute window duration in seconds (for main etc. to compute window_end)
|
||||
pub const FIVE_MIN_SECS: i64 = 300;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MarketInfo {
|
||||
pub market_id: B256,
|
||||
pub slug: String,
|
||||
pub yes_token_id: U256,
|
||||
pub no_token_id: U256,
|
||||
pub title: String,
|
||||
pub end_date: DateTime<Utc>,
|
||||
pub crypto_symbol: String,
|
||||
}
|
||||
|
||||
pub struct MarketDiscoverer {
|
||||
gamma_client: Client,
|
||||
crypto_symbols: Vec<String>,
|
||||
}
|
||||
|
||||
impl MarketDiscoverer {
|
||||
pub fn new(crypto_symbols: Vec<String>) -> Self {
|
||||
Self {
|
||||
gamma_client: Client::default(),
|
||||
crypto_symbols,
|
||||
}
|
||||
}
|
||||
|
||||
/// Current 5-minute window start timestamp (UTC)
|
||||
/// Window aligned to 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55
|
||||
pub fn calculate_current_window_timestamp(now: DateTime<Utc>) -> i64 {
|
||||
let ts = now.timestamp();
|
||||
(ts / FIVE_MIN_SECS) * FIVE_MIN_SECS
|
||||
}
|
||||
|
||||
/// Next 5-minute window start timestamp (UTC)
|
||||
pub fn calculate_next_window_timestamp(now: DateTime<Utc>) -> i64 {
|
||||
let ts = now.timestamp();
|
||||
((ts / FIVE_MIN_SECS) + 1) * FIVE_MIN_SECS
|
||||
}
|
||||
|
||||
/// Generate market slugs, e.g. btc-updown-5m-1770972300
|
||||
pub fn generate_market_slugs(&self, timestamp: i64) -> Vec<String> {
|
||||
self.crypto_symbols
|
||||
.iter()
|
||||
.map(|symbol| format!("{}-updown-5m-{}", symbol, timestamp))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fetch 5-minute markets for given timestamp
|
||||
pub async fn get_markets_for_timestamp(&self, timestamp: i64) -> Result<Vec<MarketInfo>> {
|
||||
// Generate slugs for all crypto symbols
|
||||
let slugs = self.generate_market_slugs(timestamp);
|
||||
|
||||
info!(timestamp, slug_count = slugs.len(), "Querying markets");
|
||||
|
||||
// Batch query Gamma API
|
||||
let request = MarketsRequest::builder()
|
||||
.slug(slugs.clone())
|
||||
.build();
|
||||
|
||||
match self.gamma_client.markets(&request).await {
|
||||
Ok(markets) => {
|
||||
// Filter and parse markets
|
||||
let valid_markets: Vec<MarketInfo> = markets
|
||||
.into_iter()
|
||||
.filter_map(|market| self.parse_market(market))
|
||||
.collect();
|
||||
|
||||
info!(count = valid_markets.len(), "Found valid markets");
|
||||
Ok(valid_markets)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, timestamp = timestamp, "Market query failed, markets may not exist yet");
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse market, extract YES and NO token_ids
|
||||
fn parse_market(&self, market: polymarket_client_sdk::gamma::types::response::Market) -> Option<MarketInfo> {
|
||||
// Check market is active, orderbook enabled and accepting orders
|
||||
if !market.active.unwrap_or(false)
|
||||
|| !market.enable_order_book.unwrap_or(false)
|
||||
|| !market.accepting_orders.unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check outcomes are ["Up", "Down"]
|
||||
let outcomes = market.outcomes.as_ref()?;
|
||||
|
||||
if outcomes.len() != 2
|
||||
|| !outcomes.contains(&"Up".to_string())
|
||||
|| !outcomes.contains(&"Down".to_string()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Get clobTokenIds
|
||||
let token_ids = market.clob_token_ids.as_ref()?;
|
||||
|
||||
if token_ids.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// First is Up token_id, second is Down
|
||||
let yes_token_id = token_ids[0];
|
||||
let no_token_id = token_ids[1];
|
||||
|
||||
// Get conditionId
|
||||
let market_id = market.condition_id?;
|
||||
|
||||
// Extract crypto symbol from slug
|
||||
let slug = market.slug.as_ref()?;
|
||||
let crypto_symbol = slug
|
||||
.split('-')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Get endDate
|
||||
let end_date = market.end_date?;
|
||||
|
||||
Some(MarketInfo {
|
||||
market_id,
|
||||
slug: slug.clone(),
|
||||
yes_token_id,
|
||||
no_token_id,
|
||||
title: market.question.unwrap_or_default(),
|
||||
end_date,
|
||||
crypto_symbol,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod discoverer;
|
||||
pub mod scheduler;
|
||||
|
||||
pub use discoverer::*;
|
||||
pub use scheduler::*;
|
||||
@@ -0,0 +1,117 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use super::discoverer::{MarketDiscoverer, MarketInfo};
|
||||
|
||||
pub struct MarketScheduler {
|
||||
discoverer: MarketDiscoverer,
|
||||
refresh_advance_secs: u64,
|
||||
}
|
||||
|
||||
impl MarketScheduler {
|
||||
pub fn new(discoverer: MarketDiscoverer, refresh_advance_secs: u64) -> Self {
|
||||
Self {
|
||||
discoverer,
|
||||
refresh_advance_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate wait time until next 5-minute window
|
||||
pub fn calculate_wait_time(&self, now: DateTime<Utc>) -> Duration {
|
||||
let next_window_ts = MarketDiscoverer::calculate_next_window_timestamp(now);
|
||||
let next_window = DateTime::from_timestamp(next_window_ts, 0)
|
||||
.expect("Invalid timestamp");
|
||||
|
||||
// Query a few seconds early so markets are created
|
||||
let wait_duration = next_window
|
||||
.signed_duration_since(now)
|
||||
.to_std()
|
||||
.unwrap_or(Duration::ZERO)
|
||||
.saturating_sub(Duration::from_secs(self.refresh_advance_secs));
|
||||
|
||||
wait_duration.max(Duration::ZERO)
|
||||
}
|
||||
|
||||
/// Fetch markets for current window immediately, or wait for next on failure
|
||||
pub async fn get_markets_immediately_or_wait(&self) -> Result<Vec<MarketInfo>> {
|
||||
// Try to fetch current window markets first
|
||||
let now = Utc::now();
|
||||
let current_timestamp = MarketDiscoverer::calculate_current_window_timestamp(now);
|
||||
let next_timestamp = MarketDiscoverer::calculate_next_window_timestamp(now);
|
||||
|
||||
// If current and next window same (shouldn't happen for 5m), use wait logic
|
||||
if current_timestamp == next_timestamp {
|
||||
return self.wait_for_next_window().await;
|
||||
}
|
||||
|
||||
info!("Fetching markets for current window");
|
||||
match self.discoverer.get_markets_for_timestamp(current_timestamp).await {
|
||||
Ok(markets) => {
|
||||
if !markets.is_empty() {
|
||||
info!(count = markets.len(), "Found markets for current window");
|
||||
return Ok(markets);
|
||||
}
|
||||
// No markets: maybe not created yet; retry with short interval (5m markets usually ready in seconds)
|
||||
// Calling wait_for_next_window would skip to next boundary and miss this window
|
||||
const RETRY_SECS: u64 = 2;
|
||||
const MAX_RETRY_SECS: u64 = 90; // Max retry ~90s
|
||||
let mut elapsed = 0u64;
|
||||
while elapsed < MAX_RETRY_SECS {
|
||||
info!("Current window empty, retrying in {}s (waited {}s)", RETRY_SECS, elapsed);
|
||||
sleep(Duration::from_secs(RETRY_SECS)).await;
|
||||
elapsed += RETRY_SECS;
|
||||
match self.discoverer.get_markets_for_timestamp(current_timestamp).await {
|
||||
Ok(markets) if !markets.is_empty() => {
|
||||
info!(count = markets.len(), "Retry succeeded, found markets");
|
||||
return Ok(markets);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Retry timed out, wait for next window
|
||||
warn!("No markets after {}s retry, waiting for next window", MAX_RETRY_SECS);
|
||||
self.wait_for_next_window().await
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Failed to fetch current window markets, waiting for next");
|
||||
self.wait_for_next_window().await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for next 5-minute window and fetch markets
|
||||
pub async fn wait_for_next_window(&self) -> Result<Vec<MarketInfo>> {
|
||||
loop {
|
||||
let wait_time = self.calculate_wait_time(Utc::now());
|
||||
if wait_time > Duration::ZERO {
|
||||
info!(
|
||||
wait_secs = wait_time.as_secs(),
|
||||
"Waiting for next 5-minute window"
|
||||
);
|
||||
sleep(wait_time).await;
|
||||
}
|
||||
|
||||
// Query current window markets
|
||||
let now = Utc::now();
|
||||
let timestamp = MarketDiscoverer::calculate_current_window_timestamp(now);
|
||||
match self.discoverer.get_markets_for_timestamp(timestamp).await {
|
||||
Ok(markets) => {
|
||||
if !markets.is_empty() {
|
||||
info!(count = markets.len(), "Found new markets");
|
||||
return Ok(markets);
|
||||
}
|
||||
// Markets not created yet, wait and retry
|
||||
info!("Markets not created yet, waiting to retry...");
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "Failed to fetch markets, retrying...");
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+634
@@ -0,0 +1,634 @@
|
||||
//! CTF Merge module: merge equal YES/NO tokens back to pUSD (V2) or USDC.e (legacy).
|
||||
//!
|
||||
//! Supports **Gnosis Safe** (execTransaction), **Magic/Email** (PROXY relayer),
|
||||
//! and **V2 deposit wallet** (WALLET batch relayer when `SIGNATURE_TYPE=Poly1271`).
|
||||
//! V2 默认经 CollateralAdapter merge 为 pUSD;USDC.e 抵押持仓 merge 后可自动 wrap 为 pUSD(`MERGE_WRAP_TO_PUSD`,默认开启)。
|
||||
//! 设 `MERGE_OUTPUT_TOKEN=USDC.e` 可走 legacy 路径。
|
||||
//! Merge amount is automatically `min(YES_balance, NO_balance)`.
|
||||
|
||||
use std::env;
|
||||
|
||||
use alloy::primitives::{keccak256, Address, B256, U256};
|
||||
use alloy::providers::{Provider, ProviderBuilder};
|
||||
use alloy::signers::local::LocalSigner;
|
||||
use alloy::signers::Signer as _;
|
||||
use anyhow::Result;
|
||||
use polymarket_client_sdk::ctf::types::{CollectionIdRequest, MergePositionsRequest, PositionIdRequest};
|
||||
use polymarket_client_sdk::ctf::Client;
|
||||
use polymarket_client_sdk::{contract_config, POLYGON};
|
||||
use std::str::FromStr as _;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::adapter_auth::{ensure_adapter_approved, encode_set_approval_for_all, wait_relayer_tx};
|
||||
use crate::deposit_wallet_relay::relayer_execute_deposit_wallet_calldata;
|
||||
use crate::pusd_wrap::{merge_wrap_to_pusd, wrap_usdce_to_pusd};
|
||||
use crate::wallet_kind::{classify_wallet, WalletKind};
|
||||
use crate::proxy_relay::{
|
||||
self, derive_proxy_wallet, relayer_execute_proxy_calldata, IGnosisSafe, CTF_COLLATERAL_ADAPTER,
|
||||
CTF_POLYGON, PROXY_FACTORY, PROXY_MERGE_PUSD_GAS, PUSD_POLYGON, RELAYER_URL_DEFAULT,
|
||||
RPC_URL_DEFAULT, USDC_POLYGON,
|
||||
};
|
||||
|
||||
use alloy::sol;
|
||||
sol! {
|
||||
#[sol(rpc)]
|
||||
interface IERC1155Balance {
|
||||
function balanceOf(address account, uint256 id) external view returns (uint256);
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_to_pusd() -> bool {
|
||||
match env::var("MERGE_OUTPUT_TOKEN") {
|
||||
Ok(v) => {
|
||||
let s = v.trim().to_lowercase();
|
||||
s != "usdc.e" && s != "usdc"
|
||||
}
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_collateral() -> Address {
|
||||
if merge_to_pusd() {
|
||||
PUSD_POLYGON
|
||||
} else {
|
||||
USDC_POLYGON
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_target_for_collateral(ctf: Address, collateral: Address) -> Address {
|
||||
if collateral == PUSD_POLYGON && merge_to_pusd() {
|
||||
CTF_COLLATERAL_ADAPTER
|
||||
} else {
|
||||
ctf
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_gas_for_collateral(collateral: Address) -> Option<u64> {
|
||||
if collateral == PUSD_POLYGON && merge_to_pusd() {
|
||||
Some(PROXY_MERGE_PUSD_GAS)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
struct ResolvedMerge {
|
||||
collateral: Address,
|
||||
yes_id: U256,
|
||||
no_id: U256,
|
||||
b_yes: U256,
|
||||
b_no: U256,
|
||||
}
|
||||
|
||||
async fn binary_position_ids(
|
||||
client: &Client<impl Provider + Clone>,
|
||||
condition_id: B256,
|
||||
collateral: Address,
|
||||
) -> Result<(U256, U256)> {
|
||||
let req_col_yes = CollectionIdRequest::builder()
|
||||
.parent_collection_id(B256::ZERO)
|
||||
.condition_id(condition_id)
|
||||
.index_set(U256::from(1))
|
||||
.build();
|
||||
let req_col_no = CollectionIdRequest::builder()
|
||||
.parent_collection_id(B256::ZERO)
|
||||
.condition_id(condition_id)
|
||||
.index_set(U256::from(2))
|
||||
.build();
|
||||
let col_yes = client.collection_id(&req_col_yes).await?;
|
||||
let col_no = client.collection_id(&req_col_no).await?;
|
||||
let pos_yes = client
|
||||
.position_id(
|
||||
&PositionIdRequest::builder()
|
||||
.collateral_token(collateral)
|
||||
.collection_id(col_yes.collection_id)
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
let pos_no = client
|
||||
.position_id(
|
||||
&PositionIdRequest::builder()
|
||||
.collateral_token(collateral)
|
||||
.collection_id(col_no.collection_id)
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
Ok((pos_yes.position_id, pos_no.position_id))
|
||||
}
|
||||
|
||||
async fn resolve_merge_balances(
|
||||
client: &Client<impl Provider + Clone>,
|
||||
prov_read: &impl Provider,
|
||||
wallet: Address,
|
||||
condition_id: B256,
|
||||
asset_hint: Option<(U256, U256)>,
|
||||
) -> Result<ResolvedMerge> {
|
||||
let collaterals = if merge_to_pusd() {
|
||||
[PUSD_POLYGON, USDC_POLYGON]
|
||||
} else {
|
||||
[USDC_POLYGON, PUSD_POLYGON]
|
||||
};
|
||||
|
||||
if let Some((yes_id, no_id)) = asset_hint {
|
||||
let b_yes = erc1155_balance(prov_read, wallet, yes_id).await?;
|
||||
let b_no = erc1155_balance(prov_read, wallet, no_id).await?;
|
||||
if b_yes > 0 && b_no > 0 {
|
||||
for &collateral in &collaterals {
|
||||
let (py, pn) = binary_position_ids(client, condition_id, collateral).await?;
|
||||
if py == yes_id && pn == no_id {
|
||||
return Ok(ResolvedMerge {
|
||||
collateral,
|
||||
yes_id,
|
||||
no_id,
|
||||
b_yes,
|
||||
b_no,
|
||||
});
|
||||
}
|
||||
}
|
||||
warn!(
|
||||
"API asset IDs have balance but don't match computed position IDs; using preferred collateral {:?}",
|
||||
merge_collateral()
|
||||
);
|
||||
return Ok(ResolvedMerge {
|
||||
collateral: merge_collateral(),
|
||||
yes_id,
|
||||
no_id,
|
||||
b_yes,
|
||||
b_no,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for &collateral in &collaterals {
|
||||
let (yes_id, no_id) = binary_position_ids(client, condition_id, collateral).await?;
|
||||
let b_yes = erc1155_balance(prov_read, wallet, yes_id).await?;
|
||||
let b_no = erc1155_balance(prov_read, wallet, no_id).await?;
|
||||
if b_yes > 0 && b_no > 0 {
|
||||
if collateral == USDC_POLYGON && merge_to_pusd() && merge_wrap_to_pusd() {
|
||||
info!("链上持仓为 USDC.e 抵押,merge 后将自动 wrap 为 pUSD");
|
||||
} else if collateral == USDC_POLYGON && merge_to_pusd() {
|
||||
warn!("链上持仓为 USDC.e 抵押 outcome token,merge 产出 USDC.e(MERGE_WRAP_TO_PUSD=0)");
|
||||
}
|
||||
return Ok(ResolvedMerge {
|
||||
collateral,
|
||||
yes_id,
|
||||
no_id,
|
||||
b_yes,
|
||||
b_no,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let (pref_yes, pref_no) = binary_position_ids(client, condition_id, merge_collateral()).await?;
|
||||
let pref_by = erc1155_balance(prov_read, wallet, pref_yes).await.unwrap_or(U256::ZERO);
|
||||
let pref_bn = erc1155_balance(prov_read, wallet, pref_no).await.unwrap_or(U256::ZERO);
|
||||
if let Some((yes_id, no_id)) = asset_hint {
|
||||
let hint_yes = erc1155_balance(prov_read, wallet, yes_id)
|
||||
.await
|
||||
.unwrap_or(U256::ZERO);
|
||||
let hint_no = erc1155_balance(prov_read, wallet, no_id)
|
||||
.await
|
||||
.unwrap_or(U256::ZERO);
|
||||
anyhow::bail!(
|
||||
"No mergeable shares: computed YES={} NO={} | API assets yes={} no={} (balances {}/{}) | wallet={:?}",
|
||||
pref_by,
|
||||
pref_bn,
|
||||
yes_id,
|
||||
no_id,
|
||||
hint_yes,
|
||||
hint_no,
|
||||
wallet
|
||||
);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"No mergeable shares: YES={} NO={} (token ids {} / {}), wallet={:?}",
|
||||
pref_by,
|
||||
pref_bn,
|
||||
pref_yes,
|
||||
pref_no,
|
||||
wallet
|
||||
);
|
||||
}
|
||||
|
||||
fn encode_merge_calldata(req: &MergePositionsRequest) -> Vec<u8> {
|
||||
let sel = &keccak256(b"mergePositions(address,bytes32,bytes32,uint256[],uint256)")[..4];
|
||||
let mut out = Vec::from(sel);
|
||||
out.extend_from_slice(&[0u8; 12]);
|
||||
out.extend_from_slice(req.collateral_token.as_slice());
|
||||
out.extend_from_slice(req.parent_collection_id.as_slice());
|
||||
out.extend_from_slice(req.condition_id.as_slice());
|
||||
out.extend_from_slice(&U256::from(160u64).to_be_bytes::<32>());
|
||||
out.extend_from_slice(&req.amount.to_be_bytes::<32>());
|
||||
out.extend_from_slice(&U256::from(req.partition.len()).to_be_bytes::<32>());
|
||||
for p in &req.partition {
|
||||
out.extend_from_slice(&p.to_be_bytes::<32>());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Result of a successful merge: on-chain tx hash and verified merged share amount (6-decimal raw).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MergeResult {
|
||||
pub tx_hash: String,
|
||||
pub merged_amount: U256,
|
||||
}
|
||||
|
||||
async fn verify_merged<P: Provider>(
|
||||
provider: &P,
|
||||
wallet: Address,
|
||||
pos_yes: U256,
|
||||
pos_no: U256,
|
||||
before_yes: U256,
|
||||
before_no: U256,
|
||||
expected: U256,
|
||||
) -> Result<()> {
|
||||
let after_yes = erc1155_balance(provider, wallet, pos_yes).await?;
|
||||
let after_no = erc1155_balance(provider, wallet, pos_no).await?;
|
||||
let merged_yes = before_yes.saturating_sub(after_yes);
|
||||
let merged_no = before_no.saturating_sub(after_no);
|
||||
if merged_yes == U256::ZERO || merged_no == U256::ZERO {
|
||||
anyhow::bail!(
|
||||
"Merge 后 YES/NO 余额未减少 (YES before={} after={} | NO before={} after={}),链上 merge 可能失败",
|
||||
before_yes,
|
||||
after_yes,
|
||||
before_no,
|
||||
after_no
|
||||
);
|
||||
}
|
||||
let actual = merged_yes.min(merged_no);
|
||||
if actual < expected {
|
||||
warn!(
|
||||
"Merge 实际数量 {} 小于预期 {},以链上为准",
|
||||
actual, expected
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn erc1155_balance<P: Provider>(
|
||||
provider: &P,
|
||||
account: Address,
|
||||
token_id: U256,
|
||||
) -> Result<U256> {
|
||||
let erc1155 = IERC1155Balance::new(CTF_POLYGON, provider);
|
||||
erc1155
|
||||
.balanceOf(account, token_id)
|
||||
.call()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("balanceOf failed: {}", e))
|
||||
}
|
||||
|
||||
/// Shorten long 0x-prefixed hex for logs (reused for compatibility).
|
||||
pub fn short_hex(s: &str) -> String {
|
||||
proxy_relay::short_hex(s)
|
||||
}
|
||||
|
||||
async fn relayer_merge(
|
||||
wallet_kind: WalletKind,
|
||||
merge_calldata: &[u8],
|
||||
merge_to: Address,
|
||||
wallet: Address,
|
||||
signer: &impl alloy::signers::Signer,
|
||||
builder_key: &str,
|
||||
builder_secret: &str,
|
||||
builder_passphrase: &str,
|
||||
relayer_url: &str,
|
||||
gas_limit: Option<u64>,
|
||||
) -> Result<String> {
|
||||
match wallet_kind {
|
||||
WalletKind::DepositWallet => {
|
||||
relayer_execute_deposit_wallet_calldata(
|
||||
merge_calldata,
|
||||
merge_to,
|
||||
wallet,
|
||||
signer,
|
||||
builder_key,
|
||||
builder_secret,
|
||||
builder_passphrase,
|
||||
relayer_url,
|
||||
"Merge positions",
|
||||
)
|
||||
.await
|
||||
}
|
||||
WalletKind::MagicProxy => {
|
||||
relayer_execute_proxy_calldata(
|
||||
merge_calldata,
|
||||
merge_to,
|
||||
wallet,
|
||||
signer,
|
||||
builder_key,
|
||||
builder_secret,
|
||||
builder_passphrase,
|
||||
relayer_url,
|
||||
"Merge positions",
|
||||
gas_limit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
WalletKind::GnosisSafe => {
|
||||
anyhow::bail!("relayer_merge called with GnosisSafe wallet kind");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn safe_exec_call<P: Provider>(
|
||||
safe: &IGnosisSafe::IGnosisSafeInstance<P>,
|
||||
signer: &impl alloy::signers::Signer,
|
||||
to: Address,
|
||||
calldata: Vec<u8>,
|
||||
) -> Result<B256> {
|
||||
let nonce: U256 = safe
|
||||
.nonce()
|
||||
.call()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read Safe nonce: {}", e))?;
|
||||
let tx_hash_data = safe
|
||||
.encodeTransactionData(
|
||||
to,
|
||||
U256::ZERO,
|
||||
calldata.clone().into(),
|
||||
0u8,
|
||||
U256::ZERO,
|
||||
U256::ZERO,
|
||||
U256::ZERO,
|
||||
Address::ZERO,
|
||||
Address::ZERO,
|
||||
nonce,
|
||||
)
|
||||
.call()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Safe.encodeTransactionData failed: {}", e))?
|
||||
.0;
|
||||
let tx_hash = keccak256(tx_hash_data.as_ref());
|
||||
let sig = signer
|
||||
.sign_hash(&tx_hash)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Signing failed: {}", e))?;
|
||||
let mut sig_bytes = sig.as_bytes().to_vec();
|
||||
if sig_bytes.len() == 65 && (sig_bytes[64] == 0 || sig_bytes[64] == 1) {
|
||||
sig_bytes[64] += 27;
|
||||
}
|
||||
let pending = safe
|
||||
.execTransaction(
|
||||
to,
|
||||
U256::ZERO,
|
||||
calldata.into(),
|
||||
0u8,
|
||||
U256::ZERO,
|
||||
U256::ZERO,
|
||||
U256::ZERO,
|
||||
Address::ZERO,
|
||||
Address::ZERO,
|
||||
sig_bytes.into(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Safe.execTransaction failed: {}", e))?;
|
||||
let tx_hash_out = *pending.tx_hash();
|
||||
let receipt = pending
|
||||
.get_receipt()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed waiting for receipt: {}", e))?;
|
||||
if !receipt.status() {
|
||||
anyhow::bail!("Safe tx reverted: {:#x}", tx_hash_out);
|
||||
}
|
||||
Ok(tx_hash_out)
|
||||
}
|
||||
|
||||
async fn maybe_wrap_merge_output<P: Provider>(
|
||||
provider: &P,
|
||||
prov_read: &impl Provider,
|
||||
wallet: Address,
|
||||
collateral: Address,
|
||||
merged_amount: U256,
|
||||
wallet_kind: WalletKind,
|
||||
safe: Option<&IGnosisSafe::IGnosisSafeInstance<P>>,
|
||||
signer: &impl alloy::signers::Signer,
|
||||
builder: Option<(&str, &str, &str)>,
|
||||
relayer_url: &str,
|
||||
) -> Result<()> {
|
||||
if collateral != USDC_POLYGON || !merge_to_pusd() || !merge_wrap_to_pusd() {
|
||||
return Ok(());
|
||||
}
|
||||
let (bk, bs, bp) = match builder {
|
||||
Some((k, s, p)) => (Some(k), Some(s), Some(p)),
|
||||
None => (None, None, None),
|
||||
};
|
||||
wrap_usdce_to_pusd(
|
||||
provider,
|
||||
prov_read,
|
||||
wallet,
|
||||
merged_amount,
|
||||
wallet_kind,
|
||||
safe,
|
||||
signer,
|
||||
bk,
|
||||
bs,
|
||||
bp,
|
||||
relayer_url,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Merge maximum available YES+NO to pUSD (default) or USDC.e for given `condition_id` on `wallet`.
|
||||
/// Pass `asset_hint` (yes_asset, no_asset) from Data API when available for accurate on-chain lookup.
|
||||
pub async fn merge_max(
|
||||
condition_id: B256,
|
||||
wallet: Address,
|
||||
private_key: &str,
|
||||
rpc_url: Option<&str>,
|
||||
asset_hint: Option<(U256, U256)>,
|
||||
) -> Result<MergeResult> {
|
||||
let rpc = rpc_url.unwrap_or(RPC_URL_DEFAULT);
|
||||
let chain = POLYGON;
|
||||
let signer = LocalSigner::from_str(private_key)?.with_chain_id(Some(chain));
|
||||
let eoa = signer.address();
|
||||
|
||||
let output = if merge_to_pusd() { "pUSD" } else { "USDC.e" };
|
||||
|
||||
let provider = ProviderBuilder::new().wallet(signer.clone()).connect(rpc).await?;
|
||||
let client = Client::new(provider.clone(), chain)?;
|
||||
let config = contract_config(chain, false).ok_or_else(|| anyhow::anyhow!("Unsupported chain_id: {}", chain))?;
|
||||
let prov_read = ProviderBuilder::new().connect(rpc).await?;
|
||||
let ctf = config.conditional_tokens;
|
||||
|
||||
let resolved = resolve_merge_balances(&client, &prov_read, wallet, condition_id, asset_hint).await?;
|
||||
let ResolvedMerge {
|
||||
collateral,
|
||||
yes_id: pos_yes_id,
|
||||
no_id: pos_no_id,
|
||||
b_yes,
|
||||
b_no,
|
||||
} = resolved;
|
||||
|
||||
let merge_amount = b_yes.min(b_no);
|
||||
if merge_amount == U256::ZERO {
|
||||
anyhow::bail!("No mergeable shares: YES={} NO={}, at least one is 0.", b_yes, b_no);
|
||||
}
|
||||
info!(
|
||||
"🔄 Merge amount: {} ({}) | wallet={:?} | collateral={:?} | target={}",
|
||||
merge_amount,
|
||||
merge_amount / U256::from(1_000_000),
|
||||
wallet,
|
||||
collateral,
|
||||
output
|
||||
);
|
||||
|
||||
let merge_req = MergePositionsRequest::for_binary_market(collateral, condition_id, merge_amount);
|
||||
let merge_calldata = encode_merge_calldata(&merge_req);
|
||||
let merge_to = merge_target_for_collateral(ctf, collateral);
|
||||
let gas_limit = merge_gas_for_collateral(collateral);
|
||||
|
||||
let code = provider.get_code_at(wallet).await.unwrap_or_default();
|
||||
let wallet_kind = classify_wallet(code.len());
|
||||
|
||||
match wallet_kind {
|
||||
WalletKind::DepositWallet | WalletKind::MagicProxy => {
|
||||
if matches!(wallet_kind, WalletKind::MagicProxy) {
|
||||
let derived = derive_proxy_wallet(eoa, PROXY_FACTORY);
|
||||
let try_anyway = env::var("MERGE_TRY_ANYWAY")
|
||||
.map(|s| s.trim() == "1" || s.trim().eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
if derived != wallet {
|
||||
if !try_anyway {
|
||||
anyhow::bail!(
|
||||
"POLYMARKET_PROXY_ADDRESS ({:?}) does not match ProxyFactory CREATE2 derive ({:?}). \
|
||||
Use Polymarket web merge or set MERGE_TRY_ANYWAY=1 to force.",
|
||||
wallet,
|
||||
derived
|
||||
);
|
||||
}
|
||||
warn!("MERGE_TRY_ANYWAY=1: derive != proxy, still sending Relayer request.");
|
||||
}
|
||||
}
|
||||
let builder_key = env::var("POLY_BUILDER_API_KEY").ok();
|
||||
let builder_secret = env::var("POLY_BUILDER_SECRET").ok();
|
||||
let builder_passphrase = env::var("POLY_BUILDER_PASSPHRASE").ok();
|
||||
let relayer_url =
|
||||
env::var("RELAYER_URL").unwrap_or_else(|_| RELAYER_URL_DEFAULT.to_string());
|
||||
match (
|
||||
builder_key.as_deref(),
|
||||
builder_secret.as_deref(),
|
||||
builder_passphrase.as_deref(),
|
||||
) {
|
||||
(Some(k), Some(s), Some(p)) => {
|
||||
if collateral == PUSD_POLYGON && merge_to_pusd() {
|
||||
ensure_adapter_approved(
|
||||
&provider,
|
||||
wallet,
|
||||
CTF_COLLATERAL_ADAPTER,
|
||||
&signer,
|
||||
k,
|
||||
s,
|
||||
p,
|
||||
&relayer_url,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let out = relayer_merge(
|
||||
wallet_kind,
|
||||
&merge_calldata,
|
||||
merge_to,
|
||||
wallet,
|
||||
&signer,
|
||||
k,
|
||||
s,
|
||||
p,
|
||||
&relayer_url,
|
||||
gas_limit,
|
||||
)
|
||||
.await?;
|
||||
wait_relayer_tx(&provider, &out).await?;
|
||||
verify_merged(
|
||||
&prov_read,
|
||||
wallet,
|
||||
pos_yes_id,
|
||||
pos_no_id,
|
||||
b_yes,
|
||||
b_no,
|
||||
merge_amount,
|
||||
)
|
||||
.await?;
|
||||
let after_yes = erc1155_balance(&prov_read, wallet, pos_yes_id).await?;
|
||||
let after_no = erc1155_balance(&prov_read, wallet, pos_no_id).await?;
|
||||
let merged_amount = b_yes
|
||||
.saturating_sub(after_yes)
|
||||
.min(b_no.saturating_sub(after_no));
|
||||
maybe_wrap_merge_output(
|
||||
&provider,
|
||||
&prov_read,
|
||||
wallet,
|
||||
collateral,
|
||||
merged_amount,
|
||||
wallet_kind,
|
||||
None,
|
||||
&signer,
|
||||
Some((k, s, p)),
|
||||
&relayer_url,
|
||||
)
|
||||
.await?;
|
||||
info!("✅ Relayer merge confirmed: {} | merged={}", out, merged_amount);
|
||||
return Ok(MergeResult {
|
||||
tx_hash: out,
|
||||
merged_amount,
|
||||
});
|
||||
}
|
||||
_ => anyhow::bail!(
|
||||
"Relayer merge requires POLY_BUILDER_API_KEY, POLY_BUILDER_SECRET, POLY_BUILDER_PASSPHRASE.",
|
||||
),
|
||||
}
|
||||
}
|
||||
WalletKind::GnosisSafe => {}
|
||||
}
|
||||
|
||||
let safe = IGnosisSafe::new(wallet, provider.clone());
|
||||
if collateral == PUSD_POLYGON
|
||||
&& merge_to_pusd()
|
||||
&& !crate::adapter_auth::is_adapter_approved(
|
||||
&provider,
|
||||
CTF_POLYGON,
|
||||
wallet,
|
||||
CTF_COLLATERAL_ADAPTER,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let approve_calldata = encode_set_approval_for_all(CTF_COLLATERAL_ADAPTER, true);
|
||||
let tx = safe_exec_call(&safe, &signer, CTF_POLYGON, approve_calldata).await?;
|
||||
info!("✅ Safe setApprovalForAll tx: {:#x}", tx);
|
||||
}
|
||||
|
||||
let tx_hash_out = safe_exec_call(&safe, &signer, merge_to, merge_calldata).await?;
|
||||
verify_merged(
|
||||
&prov_read,
|
||||
wallet,
|
||||
pos_yes_id,
|
||||
pos_no_id,
|
||||
b_yes,
|
||||
b_no,
|
||||
merge_amount,
|
||||
)
|
||||
.await?;
|
||||
let after_yes = erc1155_balance(&prov_read, wallet, pos_yes_id).await?;
|
||||
let after_no = erc1155_balance(&prov_read, wallet, pos_no_id).await?;
|
||||
let merged_amount = b_yes
|
||||
.saturating_sub(after_yes)
|
||||
.min(b_no.saturating_sub(after_no));
|
||||
let relayer_url = env::var("RELAYER_URL").unwrap_or_else(|_| RELAYER_URL_DEFAULT.to_string());
|
||||
maybe_wrap_merge_output(
|
||||
&provider,
|
||||
&prov_read,
|
||||
wallet,
|
||||
collateral,
|
||||
merged_amount,
|
||||
wallet_kind,
|
||||
Some(&safe),
|
||||
&signer,
|
||||
None,
|
||||
&relayer_url,
|
||||
)
|
||||
.await?;
|
||||
info!("✅ Merge success (Safe) tx: {:#x} | merged={}", tx_hash_out, merged_amount);
|
||||
Ok(MergeResult {
|
||||
tx_hash: format!("{:#x}", tx_hash_out),
|
||||
merged_amount,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use polymarket_client_sdk::clob::ws::types::response::BookUpdate;
|
||||
use polymarket_client_sdk::types::{B256, Decimal, U256};
|
||||
use rust_decimal_macros::dec;
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArbitrageOpportunity {
|
||||
pub market_id: B256,
|
||||
pub yes_token_id: U256,
|
||||
pub no_token_id: U256,
|
||||
pub yes_ask_price: Decimal,
|
||||
pub no_ask_price: Decimal,
|
||||
pub total_cost: Decimal,
|
||||
pub profit_percentage: Decimal,
|
||||
pub yes_size: Decimal,
|
||||
pub no_size: Decimal,
|
||||
}
|
||||
|
||||
pub struct ArbitrageDetector {
|
||||
min_profit_threshold: Decimal,
|
||||
max_depth: usize, // Max depth to probe
|
||||
min_order_value_usd: Decimal, // Min order value (USD)
|
||||
}
|
||||
|
||||
impl ArbitrageDetector {
|
||||
pub fn new(min_profit_threshold: f64) -> Self {
|
||||
Self {
|
||||
min_profit_threshold: Decimal::try_from(min_profit_threshold)
|
||||
.unwrap_or(dec!(0.001)),
|
||||
max_depth: 10, // Max 10 levels
|
||||
min_order_value_usd: dec!(1.0), // Min $1 per order
|
||||
}
|
||||
}
|
||||
|
||||
/// Select prices: best ask only. Returns (yes_ask, no_ask, size, profit_pct, total_price).
|
||||
/// Executor will: compare prices, add slippage, create orders.
|
||||
fn find_best_opportunity(
|
||||
&self,
|
||||
yes_book: &BookUpdate,
|
||||
no_book: &BookUpdate,
|
||||
) -> Option<(Decimal, Decimal, Decimal, Decimal, Decimal)> {
|
||||
// asks last element is best ask (lowest sell)
|
||||
let yes_best = yes_book.asks.last()?;
|
||||
let no_best = no_book.asks.last()?;
|
||||
|
||||
let yes_price = yes_best.price.round_dp(2);
|
||||
let no_price = no_best.price.round_dp(2);
|
||||
let total_price = yes_price + no_price;
|
||||
|
||||
if total_price > dec!(1.0) {
|
||||
return None; // Best ask total > 1, no arb
|
||||
}
|
||||
|
||||
// Available size at best ask: min of both, floor to 2 decimals
|
||||
let raw_size = yes_best.size.min(no_best.size);
|
||||
let final_size = if raw_size.is_zero() {
|
||||
dec!(0.01)
|
||||
} else {
|
||||
(raw_size * dec!(100.0)).floor() / dec!(100.0)
|
||||
};
|
||||
|
||||
let yes_order_value = yes_price * final_size;
|
||||
let no_order_value = no_price * final_size;
|
||||
if yes_order_value < self.min_order_value_usd || no_order_value < self.min_order_value_usd {
|
||||
return None;
|
||||
}
|
||||
|
||||
let profit_pct = (dec!(1.0) - total_price) * dec!(100.0);
|
||||
Some((yes_price, no_price, final_size, profit_pct, total_price))
|
||||
}
|
||||
|
||||
|
||||
/// Print order depth (debug level to avoid log spam)
|
||||
fn print_orderbook_depth(
|
||||
&self,
|
||||
yes_book: &BookUpdate,
|
||||
no_book: &BookUpdate,
|
||||
yes_final_price: Decimal,
|
||||
no_final_price: Decimal,
|
||||
yes_final_size: Decimal,
|
||||
no_final_size: Decimal,
|
||||
) {
|
||||
let yes_asks = &yes_book.asks;
|
||||
let yes_depth_str: Vec<String> = yes_asks
|
||||
.iter()
|
||||
.rev()
|
||||
.take(5)
|
||||
.map(|level| {
|
||||
let m = if (level.price - yes_final_price).abs() < dec!(0.001) { "←" } else { "" };
|
||||
format!("{:.2}@{:.2}{}", level.price, level.size, m)
|
||||
})
|
||||
.collect();
|
||||
let no_asks = &no_book.asks;
|
||||
let no_depth_str: Vec<String> = no_asks
|
||||
.iter()
|
||||
.rev()
|
||||
.take(5)
|
||||
.map(|level| {
|
||||
let m = if (level.price - no_final_price).abs() < dec!(0.001) { "←" } else { "" };
|
||||
format!("{:.2}@{:.2}{}", level.price, level.size, m)
|
||||
})
|
||||
.collect();
|
||||
debug!(
|
||||
yes_depth = yes_depth_str.join(", "),
|
||||
no_depth = no_depth_str.join(", "),
|
||||
"Order depth"
|
||||
);
|
||||
// Level selection log moved to executor; prints slippage-adjusted prices
|
||||
}
|
||||
|
||||
/// Check if orderbook has arbitrage opportunity
|
||||
pub fn check_arbitrage(
|
||||
&self,
|
||||
yes_book: &BookUpdate,
|
||||
no_book: &BookUpdate,
|
||||
market_id: &B256,
|
||||
) -> Option<ArbitrageOpportunity> {
|
||||
// Select best ask first; executor: compare prices, add slippage, create orders
|
||||
let (yes_ask, no_ask, final_size, net_profit_pct, total_price) =
|
||||
self.find_best_opportunity(yes_book, no_book)?;
|
||||
|
||||
self.print_orderbook_depth(yes_book, no_book, yes_ask, no_ask, final_size, final_size);
|
||||
|
||||
debug!(
|
||||
market_id = %market_id,
|
||||
yes_price = %yes_ask,
|
||||
no_price = %no_ask,
|
||||
total_price = %total_price,
|
||||
net_profit_pct = %net_profit_pct,
|
||||
order_size = %final_size,
|
||||
"Arbitrage opportunity (best ask)"
|
||||
);
|
||||
|
||||
Some(ArbitrageOpportunity {
|
||||
market_id: *market_id,
|
||||
yes_token_id: yes_book.asset_id,
|
||||
no_token_id: no_book.asset_id,
|
||||
yes_ask_price: yes_ask,
|
||||
no_ask_price: no_ask,
|
||||
total_cost: total_price * final_size,
|
||||
profit_percentage: net_profit_pct,
|
||||
yes_size: final_size,
|
||||
no_size: final_size,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod arbitrage;
|
||||
pub mod orderbook;
|
||||
|
||||
pub use arbitrage::*;
|
||||
pub use orderbook::*;
|
||||
@@ -0,0 +1,163 @@
|
||||
use anyhow::Result;
|
||||
use dashmap::DashMap;
|
||||
use futures::Stream;
|
||||
use futures::StreamExt;
|
||||
use polymarket_client_sdk::clob::ws::{Client as WsClient, types::response::BookUpdate};
|
||||
use polymarket_client_sdk::types::{B256, U256};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::market::MarketInfo;
|
||||
|
||||
/// Shorten B256 for logs: 0x + first 8 hex, e.g. 0xb91126b7..
|
||||
#[inline]
|
||||
fn short_b256(b: &B256) -> String {
|
||||
let s = format!("{b}");
|
||||
if s.len() > 12 { format!("{}..", &s[..10]) } else { s }
|
||||
}
|
||||
|
||||
/// Shorten U256 for logs: last 8 digits, e.g. ..67033653
|
||||
#[inline]
|
||||
fn short_u256(u: &U256) -> String {
|
||||
let s = format!("{u}");
|
||||
if s.len() > 12 {
|
||||
format!("..{}", &s[s.len().saturating_sub(8)..])
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OrderBookMonitor {
|
||||
ws_client: WsClient,
|
||||
books: DashMap<U256, BookUpdate>,
|
||||
market_map: HashMap<B256, (U256, U256)>, // market_id -> (yes_token_id, no_token_id)
|
||||
}
|
||||
|
||||
pub struct OrderBookPair {
|
||||
pub yes_book: BookUpdate,
|
||||
pub no_book: BookUpdate,
|
||||
pub market_id: B256,
|
||||
}
|
||||
|
||||
impl OrderBookMonitor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
// Use unauthenticated client: orderbook is public, no auth needed
|
||||
// Only user data (orders, trades) requires auth
|
||||
ws_client: WsClient::default(),
|
||||
books: DashMap::new(),
|
||||
market_map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to new market
|
||||
pub fn subscribe_market(&mut self, market: &MarketInfo) -> Result<()> {
|
||||
// Record market mapping
|
||||
self.market_map.insert(
|
||||
market.market_id,
|
||||
(market.yes_token_id, market.no_token_id),
|
||||
);
|
||||
|
||||
info!(
|
||||
market_id = short_b256(&market.market_id),
|
||||
yes = short_u256(&market.yes_token_id),
|
||||
no = short_u256(&market.no_token_id),
|
||||
"Subscribe to market orderbook"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create orderbook subscription stream
|
||||
///
|
||||
/// Note: Orderbook uses unauthenticated WebSocket; orderbook data is public.
|
||||
/// Only user data (order status, trade history) needs auth.
|
||||
pub fn create_orderbook_stream(
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<BookUpdate>> + Send + '_>>> {
|
||||
// Collect all token_ids to subscribe
|
||||
let token_ids: Vec<U256> = self
|
||||
.market_map
|
||||
.values()
|
||||
.flat_map(|(yes, no)| [*yes, *no])
|
||||
.collect();
|
||||
|
||||
if token_ids.is_empty() {
|
||||
return Err(anyhow::anyhow!("No markets to subscribe"));
|
||||
}
|
||||
|
||||
info!(token_count = token_ids.len(), "Creating orderbook stream (unauthenticated)");
|
||||
|
||||
// subscribe_orderbook does not need auth
|
||||
let stream = self.ws_client.subscribe_orderbook(token_ids)?;
|
||||
// Convert SDK Error to anyhow::Error
|
||||
let stream = stream.map(|result| result.map_err(|e| anyhow::anyhow!("{}", e)));
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
/// Handle orderbook update
|
||||
pub fn handle_book_update(&self, book: BookUpdate) -> Option<OrderBookPair> {
|
||||
|
||||
// Print top 5 bid/ask (debug)
|
||||
if !book.bids.is_empty() {
|
||||
let top_bids: Vec<String> = book.bids.iter()
|
||||
.take(5)
|
||||
.map(|b| format!("{}@{}", b.size, b.price))
|
||||
.collect();
|
||||
debug!(
|
||||
asset_id = %book.asset_id,
|
||||
"Top 5 bids: {}",
|
||||
top_bids.join(", ")
|
||||
);
|
||||
}
|
||||
if !book.asks.is_empty() {
|
||||
let top_asks: Vec<String> = book.asks.iter()
|
||||
.take(5)
|
||||
.map(|a| format!("{}@{}", a.size, a.price))
|
||||
.collect();
|
||||
debug!(
|
||||
asset_id = short_u256(&book.asset_id),
|
||||
"Top 5 asks: {}",
|
||||
top_asks.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
// Update orderbook cache
|
||||
self.books.insert(book.asset_id, book.clone());
|
||||
|
||||
// Find which market this token belongs to; either side update returns OrderBookPair for arbitrage
|
||||
for (market_id, (yes_token, no_token)) in &self.market_map {
|
||||
if book.asset_id == *yes_token {
|
||||
if let Some(no_book) = self.books.get(no_token) {
|
||||
return Some(OrderBookPair {
|
||||
yes_book: book.clone(),
|
||||
no_book: no_book.clone(),
|
||||
market_id: *market_id,
|
||||
});
|
||||
}
|
||||
} else if book.asset_id == *no_token {
|
||||
if let Some(yes_book) = self.books.get(yes_token) {
|
||||
return Some(OrderBookPair {
|
||||
yes_book: yes_book.clone(),
|
||||
no_book: book.clone(),
|
||||
market_id: *market_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get orderbook if present
|
||||
pub fn get_book(&self, token_id: U256) -> Option<BookUpdate> {
|
||||
self.books.get(&token_id).map(|b| b.clone())
|
||||
}
|
||||
|
||||
/// Clear all subscriptions
|
||||
pub fn clear(&mut self) {
|
||||
self.books.clear();
|
||||
self.market_map.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Fetch user positions (Data API)
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use polymarket_client_sdk::data::types::request::PositionsRequest;
|
||||
use polymarket_client_sdk::data::Client;
|
||||
use polymarket_client_sdk::types::Address;
|
||||
|
||||
/// Position structure from Data API, re-exported for callers
|
||||
pub use polymarket_client_sdk::data::types::response::Position;
|
||||
|
||||
/// Read user address from `POLYMARKET_PROXY_ADDRESS`, call Data API for current open positions.
|
||||
///
|
||||
/// # Environment variables
|
||||
///
|
||||
/// - `POLYMARKET_PROXY_ADDRESS`: Required, Polymarket proxy wallet address (or EOA)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - `POLYMARKET_PROXY_ADDRESS` not set
|
||||
/// - Invalid address format
|
||||
/// - Data API call failed
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// use polypulse::positions::{get_positions, Position};
|
||||
///
|
||||
/// let positions = get_positions().await?;
|
||||
/// for p in positions {
|
||||
/// println!("{}: {} @ {}", p.title, p.size, p.cur_price);
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn get_positions() -> Result<Vec<Position>> {
|
||||
dotenvy::dotenv().ok();
|
||||
let addr = std::env::var("POLYMARKET_PROXY_ADDRESS")
|
||||
.context("POLYMARKET_PROXY_ADDRESS not set")?
|
||||
.trim()
|
||||
.to_string();
|
||||
let user: Address = addr
|
||||
.parse()
|
||||
.context("POLYMARKET_PROXY_ADDRESS invalid format")?;
|
||||
let client = Client::default();
|
||||
let req = PositionsRequest::builder().user(user).build();
|
||||
client.positions(&req).await.context("Failed to fetch positions")
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Proxy wallet + Relayer/Safe shared infrastructure for merge, redeem, withdraw.
|
||||
//!
|
||||
//! Relayer requests (/relay-payload, /submit), proxy call encoding, Gnosis Safe interface and signing.
|
||||
|
||||
use std::env;
|
||||
|
||||
use alloy::primitives::{keccak256, Address, B256, Bytes, U256};
|
||||
use alloy::sol_types::SolCall;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
use polymarket_client_sdk::types::address;
|
||||
|
||||
use alloy::sol;
|
||||
sol! {
|
||||
#[sol(rpc)]
|
||||
interface IGnosisSafe {
|
||||
function nonce() external view returns (uint256);
|
||||
function encodeTransactionData(
|
||||
address to,
|
||||
uint256 value,
|
||||
bytes memory data,
|
||||
uint8 operation,
|
||||
uint256 safeTxGas,
|
||||
uint256 baseGas,
|
||||
uint256 gasPrice,
|
||||
address gasToken,
|
||||
address refundReceiver,
|
||||
uint256 _nonce
|
||||
) external view returns (bytes memory);
|
||||
function execTransaction(
|
||||
address to,
|
||||
uint256 value,
|
||||
bytes memory data,
|
||||
uint8 operation,
|
||||
uint256 safeTxGas,
|
||||
uint256 baseGas,
|
||||
uint256 gasPrice,
|
||||
address gasToken,
|
||||
address refundReceiver,
|
||||
bytes memory signatures
|
||||
) external payable returns (bool success);
|
||||
}
|
||||
}
|
||||
|
||||
sol! {
|
||||
struct ProxyCallTuple {
|
||||
uint8 typeCode;
|
||||
address to;
|
||||
uint256 value;
|
||||
bytes data;
|
||||
}
|
||||
function proxy(ProxyCallTuple[] calls) external payable returns (bytes[] returnValues);
|
||||
}
|
||||
|
||||
pub const RPC_URL_DEFAULT: &str = "https://polygon-bor-rpc.publicnode.com";
|
||||
pub const RELAYER_URL_DEFAULT: &str = "https://relayer-v2.polymarket.com";
|
||||
/// USDC.e(bridged),V1 及旧持仓抵押品
|
||||
pub const USDC_POLYGON: Address = address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174");
|
||||
/// pUSD(Polymarket USD),V2 抵押品
|
||||
pub const PUSD_POLYGON: Address = address!("0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB");
|
||||
/// CTF(Conditional Tokens Framework)
|
||||
pub const CTF_POLYGON: Address = address!("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045");
|
||||
/// NegRisk 适配器(V1 赎回 USDC.e)
|
||||
pub const NEG_RISK_ADAPTER: Address = address!("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296");
|
||||
/// V2 标准市场抵押品适配器(赎回/merge 为 pUSD)
|
||||
pub const CTF_COLLATERAL_ADAPTER: Address = address!("0xAdA100Db00Ca00073811820692005400218FcE1f");
|
||||
/// V2 Collateral Onramp:USDC.e → pUSD wrap
|
||||
pub const COLLATERAL_ONRAMP: Address = address!("0x93070a847efEf7F70739046A929D47a521F5B8ee");
|
||||
/// V2 NegRisk 抵押品适配器(赎回为 pUSD)
|
||||
pub const NEG_RISK_COLLATERAL_ADAPTER: Address = address!("0xadA2005600Dec949baf300f4C6120000bDB6eAab");
|
||||
|
||||
const RELAYER_GET_RELAY_PAYLOAD: &str = "/relay-payload";
|
||||
const RELAYER_SUBMIT: &str = "/submit";
|
||||
|
||||
pub const PROXY_FACTORY: Address = address!("0xaB45c5A4B0c941a2F231C04C3f49182e1A254052");
|
||||
const RELAY_HUB: Address = address!("0xD216153c06E857cD7f72665E0aF1d7D82172F494");
|
||||
const PROXY_INIT_CODE_HASH: [u8; 32] = [
|
||||
0xd2, 0x1d, 0xf8, 0xdc, 0x65, 0x88, 0x0a, 0x86, 0x06, 0xf0, 0x9f, 0xe0, 0xce, 0x3d, 0xf9, 0xb8,
|
||||
0x86, 0x92, 0x87, 0xab, 0x0b, 0x05, 0x8b, 0xe0, 0x5a, 0xa9, 0xe8, 0xaf, 0x63, 0x30, 0xa0, 0x0b,
|
||||
];
|
||||
pub const PROXY_DEFAULT_GAS: u64 = 160_000;
|
||||
/// pUSD CollateralAdapter redeem 实测约需 370k+ gas
|
||||
pub const PROXY_REDEEM_PUSD_GAS: u64 = 450_000;
|
||||
/// legacy CTF redeem 实测约需 166k gas
|
||||
pub const PROXY_REDEEM_LEGACY_GAS: u64 = 220_000;
|
||||
/// pUSD CollateralAdapter merge 实测约需 370k+ gas
|
||||
pub const PROXY_MERGE_PUSD_GAS: u64 = 450_000;
|
||||
|
||||
/// Shorten long 0x-prefixed hex to `0x` + first 8 + `..` + last 6 for logs.
|
||||
pub fn short_hex(s: &str) -> String {
|
||||
let hex = s.strip_prefix("0x").unwrap_or(s);
|
||||
if hex.len() > 14 {
|
||||
let lo = hex.len().saturating_sub(6);
|
||||
format!("0x{}..{}", &hex[..8.min(hex.len())], &hex[lo..])
|
||||
} else {
|
||||
format!("0x{}", hex)
|
||||
}
|
||||
}
|
||||
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub(crate) fn derive_proxy_wallet(eoa: Address, proxy_factory: Address) -> Address {
|
||||
let salt = keccak256(eoa.as_slice());
|
||||
let mut buf = [0u8; 1 + 20 + 32 + 32];
|
||||
buf[0] = 0xff;
|
||||
buf[1..21].copy_from_slice(proxy_factory.as_slice());
|
||||
buf[21..53].copy_from_slice(salt.as_slice());
|
||||
buf[53..85].copy_from_slice(&PROXY_INIT_CODE_HASH);
|
||||
let h = keccak256(buf);
|
||||
Address::from_slice(&h.as_slice()[12..32])
|
||||
}
|
||||
|
||||
pub(crate) fn to_hex_0x(b: &[u8]) -> String {
|
||||
const HEX: &[u8] = b"0123456789abcdef";
|
||||
let mut s = String::with_capacity(2 + b.len() * 2);
|
||||
s.push_str("0x");
|
||||
for &x in b {
|
||||
s.push(HEX[(x >> 4) as usize] as char);
|
||||
s.push(HEX[(x & 0xf) as usize] as char);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn build_hmac_signature(secret: &[u8], timestamp: u64, method: &str, path: &str, body: &str) -> String {
|
||||
let msg = format!("{}{}{}{}", timestamp, method, path, body);
|
||||
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC key");
|
||||
mac.update(msg.as_bytes());
|
||||
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
|
||||
sig.replace('+', "-").replace('/', "_")
|
||||
}
|
||||
|
||||
pub(crate) async fn get_relay_payload(client: &reqwest::Client, base: &str, eoa: Address) -> Result<(Address, String)> {
|
||||
let url = format!("{}{}", base.trim_end_matches('/'), RELAYER_GET_RELAY_PAYLOAD);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.query(&[("address", format!("{:#x}", eoa)), ("type", "PROXY".to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("GET /relay-payload failed status={} body={}", status, text);
|
||||
}
|
||||
let j: serde_json::Value = serde_json::from_str(&text)?;
|
||||
let addr = j.get("address").and_then(|v| v.as_str()).ok_or_else(|| anyhow::anyhow!("relay-payload missing address"))?;
|
||||
let nonce = j
|
||||
.get("nonce")
|
||||
.map(|v| {
|
||||
v.as_str()
|
||||
.map(String::from)
|
||||
.or_else(|| v.as_u64().map(|n| n.to_string()))
|
||||
.unwrap_or_else(|| "0".into())
|
||||
})
|
||||
.unwrap_or_else(|| "0".into());
|
||||
let relay = addr.trim().parse::<Address>().map_err(|e| anyhow::anyhow!("Failed to parse relay address: {}", e))?;
|
||||
Ok((relay, nonce.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn encode_proxy_call(target: Address, data: &[u8]) -> Vec<u8> {
|
||||
encode_proxy_calls(&[(target, data)])
|
||||
}
|
||||
|
||||
pub(crate) fn encode_proxy_calls(calls: &[(Address, &[u8])]) -> Vec<u8> {
|
||||
let tuples: Vec<ProxyCallTuple> = calls
|
||||
.iter()
|
||||
.map(|(to, data)| ProxyCallTuple {
|
||||
typeCode: 1u8,
|
||||
to: *to,
|
||||
value: U256::ZERO,
|
||||
data: Bytes::from(data.to_vec()),
|
||||
})
|
||||
.collect();
|
||||
proxyCall { calls: tuples }.abi_encode().to_vec()
|
||||
}
|
||||
|
||||
pub(crate) fn create_struct_hash(
|
||||
from: Address,
|
||||
to: Address,
|
||||
data: &[u8],
|
||||
tx_fee: u64,
|
||||
gas_price: u64,
|
||||
gas_limit: u64,
|
||||
nonce: &str,
|
||||
relay_hub: Address,
|
||||
relay: Address,
|
||||
) -> B256 {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(b"rlx:");
|
||||
buf.extend_from_slice(from.as_slice());
|
||||
buf.extend_from_slice(to.as_slice());
|
||||
buf.extend_from_slice(data);
|
||||
buf.extend_from_slice(&U256::from(tx_fee).to_be_bytes::<32>());
|
||||
buf.extend_from_slice(&U256::from(gas_price).to_be_bytes::<32>());
|
||||
buf.extend_from_slice(&U256::from(gas_limit).to_be_bytes::<32>());
|
||||
let n: u64 = nonce.parse().unwrap_or(0);
|
||||
buf.extend_from_slice(&U256::from(n).to_be_bytes::<32>());
|
||||
buf.extend_from_slice(relay_hub.as_slice());
|
||||
buf.extend_from_slice(relay.as_slice());
|
||||
keccak256(buf)
|
||||
}
|
||||
|
||||
pub(crate) fn eip191_hash(struct_hash: B256) -> B256 {
|
||||
let mut msg = b"\x19Ethereum Signed Message:\n32".to_vec();
|
||||
msg.extend_from_slice(struct_hash.as_slice());
|
||||
keccak256(msg)
|
||||
}
|
||||
|
||||
/// Execute one or more proxy calls via Relayer (gasless). Called by merge/redeem/withdraw.
|
||||
pub(crate) async fn relayer_execute_proxy_calldata(
|
||||
calldata: &[u8],
|
||||
target_address: Address,
|
||||
proxy_wallet: Address,
|
||||
signer: &impl alloy::signers::Signer,
|
||||
builder_key: &str,
|
||||
builder_secret: &str,
|
||||
builder_passphrase: &str,
|
||||
relayer_url: &str,
|
||||
metadata: &str,
|
||||
gas_limit: Option<u64>,
|
||||
) -> Result<String> {
|
||||
relayer_execute_proxy_calls(
|
||||
&[(target_address, calldata.to_vec())],
|
||||
proxy_wallet,
|
||||
signer,
|
||||
builder_key,
|
||||
builder_secret,
|
||||
builder_passphrase,
|
||||
relayer_url,
|
||||
metadata,
|
||||
gas_limit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn relayer_execute_proxy_calls(
|
||||
calls: &[(Address, Vec<u8>)],
|
||||
proxy_wallet: Address,
|
||||
signer: &impl alloy::signers::Signer,
|
||||
builder_key: &str,
|
||||
builder_secret: &str,
|
||||
builder_passphrase: &str,
|
||||
relayer_url: &str,
|
||||
metadata: &str,
|
||||
gas_limit: Option<u64>,
|
||||
) -> Result<String> {
|
||||
if calls.is_empty() {
|
||||
anyhow::bail!("relayer_execute_proxy_calls: empty calls");
|
||||
}
|
||||
let client = reqwest::Client::new();
|
||||
let eoa = signer.address();
|
||||
let base = relayer_url.trim_end_matches('/');
|
||||
|
||||
let (relay, nonce) = get_relay_payload(&client, base, eoa).await?;
|
||||
let call_refs: Vec<(Address, &[u8])> = calls.iter().map(|(a, d)| (*a, d.as_slice())).collect();
|
||||
let proxy_data = encode_proxy_calls(&call_refs);
|
||||
let base_gas = gas_limit.unwrap_or_else(|| {
|
||||
env::var("MERGE_PROXY_GAS_LIMIT")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(PROXY_DEFAULT_GAS)
|
||||
});
|
||||
let gas_limit = base_gas.saturating_mul(calls.len() as u64).max(base_gas);
|
||||
|
||||
if env::var("MERGE_PROXY_TO").map(|s| s.trim().eq_ignore_ascii_case("PROXY_WALLET")).unwrap_or(false) {
|
||||
info!("ℹ️ MERGE_PROXY_TO=PROXY_WALLET ignored, using to=PROXY_FACTORY");
|
||||
}
|
||||
let to = PROXY_FACTORY;
|
||||
let struct_hash = create_struct_hash(eoa, to, &proxy_data, 0, 0, gas_limit, &nonce, RELAY_HUB, relay);
|
||||
let to_sign = eip191_hash(struct_hash);
|
||||
let sig = signer.sign_hash(&to_sign).await.map_err(|e| anyhow::anyhow!("EOA signing failed: {}", e))?;
|
||||
let mut sig_bytes = sig.as_bytes().to_vec();
|
||||
if sig_bytes.len() == 65 && (sig_bytes[64] == 0 || sig_bytes[64] == 1) {
|
||||
sig_bytes[64] += 27;
|
||||
}
|
||||
let signature_hex = to_hex_0x(&sig_bytes);
|
||||
|
||||
let signature_params = serde_json::json!({
|
||||
"gasPrice": "0",
|
||||
"gasLimit": gas_limit.to_string(),
|
||||
"relayerFee": "0",
|
||||
"relayHub": format!("{:#x}", RELAY_HUB),
|
||||
"relay": format!("{:#x}", relay)
|
||||
});
|
||||
let body = serde_json::json!({
|
||||
"from": format!("{:#x}", eoa),
|
||||
"to": format!("{:#x}", to),
|
||||
"proxyWallet": format!("{:#x}", proxy_wallet),
|
||||
"data": to_hex_0x(&proxy_data),
|
||||
"nonce": nonce,
|
||||
"signature": signature_hex,
|
||||
"signatureParams": signature_params,
|
||||
"type": "PROXY",
|
||||
"metadata": metadata
|
||||
});
|
||||
let body_str = serde_json::to_string(&body)?;
|
||||
|
||||
let path = RELAYER_SUBMIT;
|
||||
let method = "POST";
|
||||
let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_millis() as u64;
|
||||
let secret_b64 = builder_secret
|
||||
.trim()
|
||||
.replace('-', "+")
|
||||
.replace('_', "/");
|
||||
let secret_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(&secret_b64)
|
||||
.map_err(|e| anyhow::anyhow!("POLY_BUILDER_SECRET base64 decode failed: {}", e))?;
|
||||
let sig_hmac = build_hmac_signature(&secret_bytes, timestamp, method, path, &body_str);
|
||||
|
||||
let url = format!("{}{}", base, path);
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("POLY_BUILDER_API_KEY", builder_key.trim())
|
||||
.header("POLY_BUILDER_TIMESTAMP", timestamp.to_string())
|
||||
.header("POLY_BUILDER_PASSPHRASE", builder_passphrase.trim())
|
||||
.header("POLY_BUILDER_SIGNATURE", sig_hmac)
|
||||
.body(body_str)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Relayer request failed status={} body={}", status, text);
|
||||
}
|
||||
parse_relayer_tx_hash(&text)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_relayer_tx_hash(text: &str) -> Result<String> {
|
||||
let json: serde_json::Value = serde_json::from_str(text)?;
|
||||
let hash = json
|
||||
.get("transactionHash")
|
||||
.or_else(|| json.get("transaction_hash"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
Ok(hash.unwrap_or_else(|| text.to_string()))
|
||||
}
|
||||
|
||||
/// Submit a pre-built relayer JSON body with builder HMAC auth.
|
||||
pub(crate) async fn relayer_submit_authed(
|
||||
body: serde_json::Value,
|
||||
builder_key: &str,
|
||||
builder_secret: &str,
|
||||
builder_passphrase: &str,
|
||||
relayer_url: &str,
|
||||
) -> Result<String> {
|
||||
let client = reqwest::Client::new();
|
||||
let base = relayer_url.trim_end_matches('/');
|
||||
let path = RELAYER_SUBMIT;
|
||||
let method = "POST";
|
||||
let body_str = serde_json::to_string(&body)?;
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_millis() as u64;
|
||||
let secret_b64 = builder_secret
|
||||
.trim()
|
||||
.replace('-', "+")
|
||||
.replace('_', "/");
|
||||
let secret_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(&secret_b64)
|
||||
.map_err(|e| anyhow::anyhow!("POLY_BUILDER_SECRET base64 decode failed: {}", e))?;
|
||||
let sig_hmac = build_hmac_signature(&secret_bytes, timestamp, method, path, &body_str);
|
||||
|
||||
let url = format!("{}{}", base, path);
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("POLY_BUILDER_API_KEY", builder_key.trim())
|
||||
.header("POLY_BUILDER_TIMESTAMP", timestamp.to_string())
|
||||
.header("POLY_BUILDER_PASSPHRASE", builder_passphrase.trim())
|
||||
.header("POLY_BUILDER_SIGNATURE", sig_hmac)
|
||||
.body(body_str)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Relayer request failed status={} body={}", status, text);
|
||||
}
|
||||
parse_relayer_tx_hash(&text)
|
||||
}
|
||||
|
||||
// IGnosisSafe from sol! above, used by merge/redeem/withdraw via crate::proxy_relay::IGnosisSafe
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Wrap USDC.e → pUSD via Collateral Onramp after legacy-collateral merge.
|
||||
|
||||
use std::env;
|
||||
|
||||
use alloy::primitives::{keccak256, Address, U256};
|
||||
use alloy::providers::Provider;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
use crate::deposit_wallet_relay::relayer_execute_deposit_wallet_calls;
|
||||
use crate::wallet_kind::WalletKind;
|
||||
use crate::proxy_relay::{
|
||||
relayer_execute_proxy_calls, IGnosisSafe, COLLATERAL_ONRAMP, PUSD_POLYGON, USDC_POLYGON,
|
||||
};
|
||||
|
||||
use alloy::sol;
|
||||
sol! {
|
||||
#[sol(rpc)]
|
||||
interface IERC20 {
|
||||
function balanceOf(address account) external view returns (uint256);
|
||||
function allowance(address owner, address spender) external view returns (uint256);
|
||||
}
|
||||
}
|
||||
|
||||
/// True when merge output should be wrapped to pUSD (default on when `MERGE_OUTPUT_TOKEN=pUSD`).
|
||||
pub fn merge_wrap_to_pusd() -> bool {
|
||||
match env::var("MERGE_WRAP_TO_PUSD") {
|
||||
Ok(v) => {
|
||||
let s = v.trim().to_lowercase();
|
||||
s != "0" && s != "false" && s != "no" && s != "off"
|
||||
}
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_erc20_approve(spender: Address, amount: U256) -> Vec<u8> {
|
||||
let sel = &keccak256(b"approve(address,uint256)")[..4];
|
||||
let mut out = Vec::from(sel);
|
||||
out.extend_from_slice(&[0u8; 12]);
|
||||
out.extend_from_slice(spender.as_slice());
|
||||
out.extend_from_slice(&amount.to_be_bytes::<32>());
|
||||
out
|
||||
}
|
||||
|
||||
pub fn encode_onramp_wrap(recipient: Address, amount: U256) -> Vec<u8> {
|
||||
let sel = &keccak256(b"wrap(address,address,uint256)")[..4];
|
||||
let mut out = Vec::from(sel);
|
||||
out.extend_from_slice(&[0u8; 12]);
|
||||
out.extend_from_slice(USDC_POLYGON.as_slice());
|
||||
out.extend_from_slice(&[0u8; 12]);
|
||||
out.extend_from_slice(recipient.as_slice());
|
||||
out.extend_from_slice(&amount.to_be_bytes::<32>());
|
||||
out
|
||||
}
|
||||
|
||||
async fn erc20_balance<P: Provider>(provider: &P, owner: Address, token: Address) -> Result<U256> {
|
||||
IERC20::new(token, provider)
|
||||
.balanceOf(owner)
|
||||
.call()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("ERC20 balanceOf failed: {}", e))
|
||||
}
|
||||
|
||||
async fn erc20_allowance<P: Provider>(
|
||||
provider: &P,
|
||||
owner: Address,
|
||||
token: Address,
|
||||
spender: Address,
|
||||
) -> Result<U256> {
|
||||
IERC20::new(token, provider)
|
||||
.allowance(owner, spender)
|
||||
.call()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("ERC20 allowance failed: {}", e))
|
||||
}
|
||||
|
||||
async fn safe_exec_call<P: Provider>(
|
||||
safe: &IGnosisSafe::IGnosisSafeInstance<P>,
|
||||
signer: &impl alloy::signers::Signer,
|
||||
to: Address,
|
||||
calldata: Vec<u8>,
|
||||
) -> Result<alloy::primitives::B256> {
|
||||
use alloy::primitives::keccak256;
|
||||
let nonce: U256 = safe
|
||||
.nonce()
|
||||
.call()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read Safe nonce: {}", e))?;
|
||||
let tx_hash_data = safe
|
||||
.encodeTransactionData(
|
||||
to,
|
||||
U256::ZERO,
|
||||
calldata.clone().into(),
|
||||
0u8,
|
||||
U256::ZERO,
|
||||
U256::ZERO,
|
||||
U256::ZERO,
|
||||
Address::ZERO,
|
||||
Address::ZERO,
|
||||
nonce,
|
||||
)
|
||||
.call()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Safe.encodeTransactionData failed: {}", e))?
|
||||
.0;
|
||||
let tx_hash = keccak256(tx_hash_data.as_ref());
|
||||
let sig = signer
|
||||
.sign_hash(&tx_hash)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Signing failed: {}", e))?;
|
||||
let mut sig_bytes = sig.as_bytes().to_vec();
|
||||
if sig_bytes.len() == 65 && (sig_bytes[64] == 0 || sig_bytes[64] == 1) {
|
||||
sig_bytes[64] += 27;
|
||||
}
|
||||
let pending = safe
|
||||
.execTransaction(
|
||||
to,
|
||||
U256::ZERO,
|
||||
calldata.into(),
|
||||
0u8,
|
||||
U256::ZERO,
|
||||
U256::ZERO,
|
||||
U256::ZERO,
|
||||
Address::ZERO,
|
||||
Address::ZERO,
|
||||
sig_bytes.into(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Safe.execTransaction failed: {}", e))?;
|
||||
let tx_hash_out = *pending.tx_hash();
|
||||
let receipt = pending
|
||||
.get_receipt()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed waiting for receipt: {}", e))?;
|
||||
if !receipt.status() {
|
||||
anyhow::bail!("Safe tx reverted: {:#x}", tx_hash_out);
|
||||
}
|
||||
Ok(tx_hash_out)
|
||||
}
|
||||
|
||||
async fn relayer_wrap_calls(
|
||||
wallet_kind: WalletKind,
|
||||
wallet: Address,
|
||||
calls: Vec<(Address, Vec<u8>)>,
|
||||
signer: &impl alloy::signers::Signer,
|
||||
builder_key: &str,
|
||||
builder_secret: &str,
|
||||
builder_passphrase: &str,
|
||||
relayer_url: &str,
|
||||
) -> Result<String> {
|
||||
match wallet_kind {
|
||||
WalletKind::DepositWallet => {
|
||||
relayer_execute_deposit_wallet_calls(
|
||||
&calls,
|
||||
wallet,
|
||||
signer,
|
||||
builder_key,
|
||||
builder_secret,
|
||||
builder_passphrase,
|
||||
relayer_url,
|
||||
"Wrap USDC.e to pUSD",
|
||||
)
|
||||
.await
|
||||
}
|
||||
WalletKind::MagicProxy => {
|
||||
relayer_execute_proxy_calls(
|
||||
&calls,
|
||||
wallet,
|
||||
signer,
|
||||
builder_key,
|
||||
builder_secret,
|
||||
builder_passphrase,
|
||||
relayer_url,
|
||||
"Wrap USDC.e to pUSD",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
WalletKind::GnosisSafe => {
|
||||
anyhow::bail!("relayer_wrap_calls: unexpected GnosisSafe");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After a USDC.e-collateral merge, wrap `amount` USDC.e to pUSD in the deposit wallet.
|
||||
pub async fn wrap_usdce_to_pusd<P: Provider>(
|
||||
provider: &P,
|
||||
prov_read: &impl Provider,
|
||||
wallet: Address,
|
||||
amount: U256,
|
||||
wallet_kind: WalletKind,
|
||||
safe: Option<&IGnosisSafe::IGnosisSafeInstance<P>>,
|
||||
signer: &impl alloy::signers::Signer,
|
||||
builder_key: Option<&str>,
|
||||
builder_secret: Option<&str>,
|
||||
builder_passphrase: Option<&str>,
|
||||
relayer_url: &str,
|
||||
) -> Result<()> {
|
||||
if amount == U256::ZERO {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let usdc_before = erc20_balance(prov_read, wallet, USDC_POLYGON).await?;
|
||||
if usdc_before < amount {
|
||||
anyhow::bail!(
|
||||
"wrap 需要 {} USDC.e,钱包余额仅 {}",
|
||||
amount,
|
||||
usdc_before
|
||||
);
|
||||
}
|
||||
let pusd_before = erc20_balance(prov_read, wallet, PUSD_POLYGON).await?;
|
||||
info!(
|
||||
"🔄 Wrap USDC.e → pUSD | amount={} ({}) | wallet={:?}",
|
||||
amount,
|
||||
amount / U256::from(1_000_000),
|
||||
wallet
|
||||
);
|
||||
|
||||
match wallet_kind {
|
||||
WalletKind::GnosisSafe => {
|
||||
let safe = safe.ok_or_else(|| anyhow::anyhow!("Safe instance required for wrap"))?;
|
||||
let allowance = erc20_allowance(prov_read, wallet, USDC_POLYGON, COLLATERAL_ONRAMP).await?;
|
||||
if allowance < amount {
|
||||
let approve = encode_erc20_approve(COLLATERAL_ONRAMP, amount);
|
||||
let tx = safe_exec_call(safe, signer, USDC_POLYGON, approve).await?;
|
||||
info!("✅ Safe USDC.e approve for Onramp: {:#x}", tx);
|
||||
}
|
||||
let wrap_calldata = encode_onramp_wrap(wallet, amount);
|
||||
let tx = safe_exec_call(safe, signer, COLLATERAL_ONRAMP, wrap_calldata).await?;
|
||||
info!("✅ Safe wrap tx: {:#x}", tx);
|
||||
}
|
||||
WalletKind::DepositWallet | WalletKind::MagicProxy => {
|
||||
let (k, s, p) = match (builder_key, builder_secret, builder_passphrase) {
|
||||
(Some(k), Some(s), Some(p)) => (k, s, p),
|
||||
_ => anyhow::bail!("Wrap via relayer requires POLY_BUILDER_* credentials"),
|
||||
};
|
||||
let allowance = erc20_allowance(prov_read, wallet, USDC_POLYGON, COLLATERAL_ONRAMP).await?;
|
||||
let mut calls: Vec<(Address, Vec<u8>)> = Vec::new();
|
||||
if allowance < amount {
|
||||
calls.push((
|
||||
USDC_POLYGON,
|
||||
encode_erc20_approve(COLLATERAL_ONRAMP, amount),
|
||||
));
|
||||
}
|
||||
calls.push((COLLATERAL_ONRAMP, encode_onramp_wrap(wallet, amount)));
|
||||
let tx = relayer_wrap_calls(
|
||||
wallet_kind,
|
||||
wallet,
|
||||
calls,
|
||||
signer,
|
||||
k,
|
||||
s,
|
||||
p,
|
||||
relayer_url,
|
||||
)
|
||||
.await?;
|
||||
crate::adapter_auth::wait_relayer_tx(provider, &tx).await?;
|
||||
info!("✅ Relayer wrap confirmed: {}", tx);
|
||||
}
|
||||
}
|
||||
|
||||
let pusd_after = erc20_balance(prov_read, wallet, PUSD_POLYGON).await?;
|
||||
if pusd_after < pusd_before + amount {
|
||||
anyhow::bail!(
|
||||
"wrap 后 pUSD 余额未增加 (before={} after={} expected +{})",
|
||||
pusd_before,
|
||||
pusd_after,
|
||||
amount
|
||||
);
|
||||
}
|
||||
info!(
|
||||
"✅ Wrapped to pUSD | +{} (wallet pUSD balance={})",
|
||||
amount,
|
||||
pusd_after
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
//! CTF Redeem: redeem winner tokens for settled markets to pUSD (V2) or USDC.e (legacy).
|
||||
//!
|
||||
//! Supports **Gnosis Safe** (execTransaction) and **Magic/Email EIP-1167** (Polymarket Relayer).
|
||||
//! V2 默认经 CollateralAdapter 赎回为 pUSD;设 `REDEEM_OUTPUT_TOKEN=USDC.e` 可走 legacy 路径。
|
||||
//! pUSD 首次赎回需对 CTF 执行 `setApprovalForAll(adapter, true)`,本模块会自动处理。
|
||||
|
||||
use std::env;
|
||||
|
||||
use alloy::primitives::{keccak256, Address, B256, U256};
|
||||
use alloy::providers::{Provider, ProviderBuilder};
|
||||
use alloy::signers::local::LocalSigner;
|
||||
use alloy::signers::Signer as _;
|
||||
use anyhow::Result;
|
||||
use polymarket_client_sdk::{contract_config, POLYGON};
|
||||
use std::str::FromStr as _;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use alloy::sol;
|
||||
sol! {
|
||||
#[sol(rpc)]
|
||||
interface IERC1155 {
|
||||
function balanceOf(address account, uint256 id) external view returns (uint256);
|
||||
}
|
||||
}
|
||||
|
||||
use crate::adapter_auth::{ensure_adapter_approved, encode_set_approval_for_all, wait_relayer_tx};
|
||||
use crate::deposit_wallet_relay::{
|
||||
relayer_execute_deposit_wallet_calldata, use_deposit_wallet_relayer,
|
||||
};
|
||||
use crate::proxy_relay::{
|
||||
derive_proxy_wallet, relayer_execute_proxy_calldata, IGnosisSafe, CTF_COLLATERAL_ADAPTER,
|
||||
CTF_POLYGON, NEG_RISK_ADAPTER, NEG_RISK_COLLATERAL_ADAPTER, PROXY_FACTORY,
|
||||
PROXY_REDEEM_LEGACY_GAS, PROXY_REDEEM_PUSD_GAS, RELAYER_URL_DEFAULT, RPC_URL_DEFAULT,
|
||||
USDC_POLYGON, PUSD_POLYGON,
|
||||
};
|
||||
|
||||
const PARENT_COLLECTION_ID: B256 = B256::ZERO;
|
||||
|
||||
fn encode_redeem_calldata_4arg(collateral_token: Address, condition_id: B256) -> Vec<u8> {
|
||||
let sel = &keccak256(b"redeemPositions(address,bytes32,bytes32,uint256[])")[..4];
|
||||
let mut out = Vec::from(sel);
|
||||
out.extend_from_slice(&[0u8; 12]);
|
||||
out.extend_from_slice(collateral_token.as_slice());
|
||||
out.extend_from_slice(PARENT_COLLECTION_ID.as_slice());
|
||||
out.extend_from_slice(condition_id.as_slice());
|
||||
out.extend_from_slice(&U256::from(128u64).to_be_bytes::<32>());
|
||||
out.extend_from_slice(&U256::from(2u64).to_be_bytes::<32>());
|
||||
out.extend_from_slice(&U256::from(1u64).to_be_bytes::<32>());
|
||||
out.extend_from_slice(&U256::from(2u64).to_be_bytes::<32>());
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_redeem_calldata_neg_risk_legacy(condition_id: B256, amounts: [U256; 2]) -> Vec<u8> {
|
||||
let sel = &keccak256(b"redeemPositions(bytes32,uint256[])")[..4];
|
||||
let mut out = Vec::from(sel);
|
||||
out.extend_from_slice(condition_id.as_slice());
|
||||
out.extend_from_slice(&U256::from(128u64).to_be_bytes::<32>());
|
||||
out.extend_from_slice(&U256::from(2u64).to_be_bytes::<32>());
|
||||
out.extend_from_slice(&amounts[0].to_be_bytes::<32>());
|
||||
out.extend_from_slice(&amounts[1].to_be_bytes::<32>());
|
||||
out
|
||||
}
|
||||
|
||||
fn redeem_to_pusd() -> bool {
|
||||
match env::var("REDEEM_OUTPUT_TOKEN") {
|
||||
Ok(v) => {
|
||||
let s = v.trim().to_lowercase();
|
||||
s != "usdc.e" && s != "usdc"
|
||||
}
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn pusd_adapter(neg_risk: bool) -> Address {
|
||||
if neg_risk {
|
||||
NEG_RISK_COLLATERAL_ADAPTER
|
||||
} else {
|
||||
CTF_COLLATERAL_ADAPTER
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_redeem_call(
|
||||
neg_risk: bool,
|
||||
condition_id: B256,
|
||||
outcome_index: Option<i32>,
|
||||
size_raw: Option<U256>,
|
||||
) -> Result<(Address, Vec<u8>)> {
|
||||
if redeem_to_pusd() {
|
||||
let target = pusd_adapter(neg_risk);
|
||||
let calldata = encode_redeem_calldata_4arg(PUSD_POLYGON, condition_id);
|
||||
return Ok((target, calldata));
|
||||
}
|
||||
|
||||
if neg_risk {
|
||||
let idx = outcome_index.ok_or_else(|| {
|
||||
anyhow::anyhow!("NegRisk legacy USDC.e 赎回需要 outcome_index(或设 REDEEM_OUTPUT_TOKEN=pUSD)")
|
||||
})?;
|
||||
let size = size_raw.ok_or_else(|| {
|
||||
anyhow::anyhow!("NegRisk legacy USDC.e 赎回需要持仓 size(或设 REDEEM_OUTPUT_TOKEN=pUSD)")
|
||||
})?;
|
||||
let mut amounts = [U256::ZERO, U256::ZERO];
|
||||
if idx == 0 || idx == 1 {
|
||||
amounts[idx as usize] = size;
|
||||
} else {
|
||||
anyhow::bail!("无效的 outcome_index: {}", idx);
|
||||
}
|
||||
let calldata = encode_redeem_calldata_neg_risk_legacy(condition_id, amounts);
|
||||
return Ok((NEG_RISK_ADAPTER, calldata));
|
||||
}
|
||||
|
||||
let config = contract_config(POLYGON, false)
|
||||
.ok_or_else(|| anyhow::anyhow!("Unsupported chain_id: {}", POLYGON))?;
|
||||
let calldata = encode_redeem_calldata_4arg(USDC_POLYGON, condition_id);
|
||||
Ok((config.conditional_tokens, calldata))
|
||||
}
|
||||
|
||||
async fn outcome_balance<P: Provider>(provider: &P, proxy: Address, asset: U256) -> Result<U256> {
|
||||
let ctf = IERC1155::new(CTF_POLYGON, provider);
|
||||
ctf
|
||||
.balanceOf(proxy, asset)
|
||||
.call()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("balanceOf failed: {}", e))
|
||||
}
|
||||
|
||||
async fn verify_redeemed<P: Provider>(
|
||||
provider: &P,
|
||||
proxy: Address,
|
||||
assets: &[U256],
|
||||
before: &[U256],
|
||||
) -> Result<()> {
|
||||
for (asset, prev) in assets.iter().zip(before.iter()) {
|
||||
let after = outcome_balance(provider, proxy, *asset).await?;
|
||||
if after >= *prev {
|
||||
anyhow::bail!(
|
||||
"赎回后 outcome token {} 余额未减少 (before={} after={}),Relayer 可能 gas 不足或链上 redeem 失败",
|
||||
asset,
|
||||
prev,
|
||||
after
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn redeem_gas_limit() -> u64 {
|
||||
if redeem_to_pusd() {
|
||||
PROXY_REDEEM_PUSD_GAS
|
||||
} else {
|
||||
PROXY_REDEEM_LEGACY_GAS
|
||||
}
|
||||
}
|
||||
|
||||
fn use_relayer_by_config(code_len: usize) -> bool {
|
||||
if use_deposit_wallet_relayer() {
|
||||
return true;
|
||||
}
|
||||
let s = match env::var("SIGNATURE_TYPE") {
|
||||
Ok(v) => v.trim().to_lowercase(),
|
||||
Err(_) => return code_len < 150,
|
||||
};
|
||||
if s == "proxy" {
|
||||
return true;
|
||||
}
|
||||
if s == "gnosissafe" || s == "safe" {
|
||||
return false;
|
||||
}
|
||||
code_len < 150
|
||||
}
|
||||
|
||||
async fn relayer_redeem_calldata(
|
||||
redeem_data: &[u8],
|
||||
redeem_to: Address,
|
||||
wallet: Address,
|
||||
signer: &impl alloy::signers::Signer,
|
||||
builder_key: &str,
|
||||
builder_secret: &str,
|
||||
builder_passphrase: &str,
|
||||
relayer_url: &str,
|
||||
gas_limit: Option<u64>,
|
||||
) -> Result<String> {
|
||||
if use_deposit_wallet_relayer() {
|
||||
relayer_execute_deposit_wallet_calldata(
|
||||
redeem_data,
|
||||
redeem_to,
|
||||
wallet,
|
||||
signer,
|
||||
builder_key,
|
||||
builder_secret,
|
||||
builder_passphrase,
|
||||
relayer_url,
|
||||
"Redeem positions",
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
relayer_execute_proxy_calldata(
|
||||
redeem_data,
|
||||
redeem_to,
|
||||
wallet,
|
||||
signer,
|
||||
builder_key,
|
||||
builder_secret,
|
||||
builder_passphrase,
|
||||
relayer_url,
|
||||
"Redeem positions",
|
||||
gas_limit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
async fn safe_exec_call<P: Provider>(
|
||||
safe: &IGnosisSafe::IGnosisSafeInstance<P>,
|
||||
signer: &impl alloy::signers::Signer,
|
||||
to: Address,
|
||||
calldata: Vec<u8>,
|
||||
) -> Result<B256> {
|
||||
let nonce: U256 = safe
|
||||
.nonce()
|
||||
.call()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read Safe nonce: {}", e))?;
|
||||
let tx_hash_data = safe
|
||||
.encodeTransactionData(
|
||||
to,
|
||||
U256::ZERO,
|
||||
calldata.clone().into(),
|
||||
0u8,
|
||||
U256::ZERO,
|
||||
U256::ZERO,
|
||||
U256::ZERO,
|
||||
Address::ZERO,
|
||||
Address::ZERO,
|
||||
nonce,
|
||||
)
|
||||
.call()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Safe.encodeTransactionData failed: {}", e))?
|
||||
.0;
|
||||
let tx_hash = keccak256(tx_hash_data.as_ref());
|
||||
let sig = signer
|
||||
.sign_hash(&tx_hash)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Signing failed: {}", e))?;
|
||||
let mut sig_bytes = sig.as_bytes().to_vec();
|
||||
if sig_bytes.len() == 65 && (sig_bytes[64] == 0 || sig_bytes[64] == 1) {
|
||||
sig_bytes[64] += 27;
|
||||
}
|
||||
let pending = safe
|
||||
.execTransaction(
|
||||
to,
|
||||
U256::ZERO,
|
||||
calldata.into(),
|
||||
0u8,
|
||||
U256::ZERO,
|
||||
U256::ZERO,
|
||||
U256::ZERO,
|
||||
Address::ZERO,
|
||||
Address::ZERO,
|
||||
sig_bytes.into(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Safe.execTransaction failed: {}", e))?;
|
||||
let tx_hash_out = *pending.tx_hash();
|
||||
let receipt = pending
|
||||
.get_receipt()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed waiting for receipt: {}", e))?;
|
||||
if !receipt.status() {
|
||||
anyhow::bail!("Safe tx reverted: {:#x}", tx_hash_out);
|
||||
}
|
||||
Ok(tx_hash_out)
|
||||
}
|
||||
|
||||
async fn build_redeem_calls<P: Provider>(
|
||||
provider: &P,
|
||||
proxy: Address,
|
||||
neg_risk: bool,
|
||||
redeem_target: Address,
|
||||
redeem_calldata: Vec<u8>,
|
||||
) -> Result<Vec<(Address, Vec<u8>)>> {
|
||||
let _ = (provider, proxy, neg_risk);
|
||||
Ok(vec![(redeem_target, redeem_calldata)])
|
||||
}
|
||||
|
||||
/// Redeem winner tokens for `condition_id` on `proxy`.
|
||||
/// `verify_assets`: 赎回前后校验这些 outcome token 余额是否减少(可为空跳过)。
|
||||
pub async fn redeem_one(
|
||||
condition_id: B256,
|
||||
neg_risk: bool,
|
||||
proxy: Address,
|
||||
private_key: &str,
|
||||
rpc_url: Option<&str>,
|
||||
outcome_index: Option<i32>,
|
||||
size_raw: Option<U256>,
|
||||
verify_assets: &[U256],
|
||||
) -> Result<String> {
|
||||
let rpc = rpc_url.unwrap_or(RPC_URL_DEFAULT);
|
||||
let chain = POLYGON;
|
||||
let signer = LocalSigner::from_str(private_key)?.with_chain_id(Some(chain));
|
||||
let wallet = signer.address();
|
||||
|
||||
let (redeem_target, redeem_calldata) =
|
||||
resolve_redeem_call(neg_risk, condition_id, outcome_index, size_raw)?;
|
||||
|
||||
let output = if redeem_to_pusd() { "pUSD" } else { "USDC.e" };
|
||||
info!(
|
||||
"Redeem {:?} | neg_risk={} | target={:?} | output={}",
|
||||
condition_id, neg_risk, redeem_target, output
|
||||
);
|
||||
|
||||
let provider = ProviderBuilder::new().wallet(signer.clone()).connect(rpc).await?;
|
||||
let mut balances_before = Vec::new();
|
||||
for asset in verify_assets {
|
||||
balances_before.push(outcome_balance(&provider, proxy, *asset).await?);
|
||||
}
|
||||
let gas_limit = Some(redeem_gas_limit());
|
||||
info!("Relayer redeem gasLimit={}", gas_limit.unwrap_or(PROXY_REDEEM_PUSD_GAS));
|
||||
let calls = build_redeem_calls(&provider, proxy, neg_risk, redeem_target, redeem_calldata).await?;
|
||||
let code = provider.get_code_at(proxy).await.unwrap_or_default();
|
||||
let use_relayer = use_relayer_by_config(code.len());
|
||||
|
||||
if use_relayer {
|
||||
if !use_deposit_wallet_relayer() {
|
||||
let derived = derive_proxy_wallet(wallet, PROXY_FACTORY);
|
||||
let try_anyway = env::var("MERGE_TRY_ANYWAY")
|
||||
.map(|s| s.trim() == "1" || s.trim().eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
if derived != proxy && !try_anyway {
|
||||
anyhow::bail!(
|
||||
"POLYMARKET_PROXY_ADDRESS ({:?}) does not match ProxyFactory derive ({:?}). Set MERGE_TRY_ANYWAY=1 to force.",
|
||||
proxy, derived
|
||||
);
|
||||
}
|
||||
if derived != proxy {
|
||||
warn!("MERGE_TRY_ANYWAY=1: derive != proxy, still sending Relayer request.");
|
||||
}
|
||||
}
|
||||
let builder_key = env::var("POLY_BUILDER_API_KEY").ok();
|
||||
let builder_secret = env::var("POLY_BUILDER_SECRET").ok();
|
||||
let builder_passphrase = env::var("POLY_BUILDER_PASSPHRASE").ok();
|
||||
let relayer_url = env::var("RELAYER_URL").unwrap_or_else(|_| RELAYER_URL_DEFAULT.to_string());
|
||||
match (builder_key.as_deref(), builder_secret.as_deref(), builder_passphrase.as_deref()) {
|
||||
(Some(k), Some(s), Some(p)) => {
|
||||
if redeem_to_pusd() {
|
||||
ensure_adapter_approved(
|
||||
&provider,
|
||||
proxy,
|
||||
pusd_adapter(neg_risk),
|
||||
&signer,
|
||||
k,
|
||||
s,
|
||||
p,
|
||||
&relayer_url,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let (redeem_to, redeem_data) = calls
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing redeem call"))?;
|
||||
let out = relayer_redeem_calldata(
|
||||
&redeem_data,
|
||||
redeem_to,
|
||||
proxy,
|
||||
&signer,
|
||||
k,
|
||||
s,
|
||||
p,
|
||||
&relayer_url,
|
||||
gas_limit,
|
||||
)
|
||||
.await?;
|
||||
wait_relayer_tx(&provider, &out).await?;
|
||||
if !verify_assets.is_empty() {
|
||||
verify_redeemed(&provider, proxy, verify_assets, &balances_before).await?;
|
||||
}
|
||||
info!("✅ Relayer redeem confirmed: {}", out);
|
||||
return Ok(out);
|
||||
}
|
||||
_ => anyhow::bail!(
|
||||
"Magic/Email requires POLY_BUILDER_API_KEY, POLY_BUILDER_SECRET, POLY_BUILDER_PASSPHRASE.",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
let safe = IGnosisSafe::new(proxy, provider.clone());
|
||||
if redeem_to_pusd() {
|
||||
let adapter = pusd_adapter(neg_risk);
|
||||
if !crate::adapter_auth::is_adapter_approved(&provider, CTF_POLYGON, proxy, adapter).await? {
|
||||
let approve_calldata = encode_set_approval_for_all(adapter, true);
|
||||
let tx = safe_exec_call(&safe, &signer, CTF_POLYGON, approve_calldata).await?;
|
||||
info!("✅ Safe setApprovalForAll tx: {:#x}", tx);
|
||||
}
|
||||
}
|
||||
let mut last_tx = B256::ZERO;
|
||||
for (to, calldata) in calls {
|
||||
last_tx = safe_exec_call(&safe, &signer, to, calldata).await?;
|
||||
info!("✅ Safe redeem tx: {:#x}", last_tx);
|
||||
}
|
||||
if !verify_assets.is_empty() {
|
||||
verify_redeemed(&provider, proxy, verify_assets, &balances_before).await?;
|
||||
}
|
||||
Ok(format!("{:#x}", last_tx))
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
use anyhow::Result;
|
||||
use alloy::signers::Signer;
|
||||
use alloy::signers::local::LocalSigner;
|
||||
use dashmap::DashMap;
|
||||
use polymarket_client_sdk::clob::ws::types::response::BookUpdate;
|
||||
use polymarket_client_sdk::types::{Address, Decimal, U256};
|
||||
use polymarket_client_sdk_v2::clob::types::{OrderType, Side};
|
||||
use polymarket_client_sdk_v2::POLYGON;
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
use rust_decimal_macros::dec;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
|
||||
use super::positions::PositionTracker;
|
||||
use super::recovery::RecoveryAction;
|
||||
use crate::trading::AuthenticatedClobClient;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HedgePosition {
|
||||
pub token_id: U256,
|
||||
pub opposite_token_id: U256, // Opposite side token_id (for diff calc)
|
||||
pub amount: Decimal,
|
||||
pub entry_price: Decimal, // Buy price (best ask)
|
||||
pub take_profit_price: Decimal, // Take-profit price
|
||||
pub stop_loss_price: Decimal, // Stop-loss price
|
||||
pub pair_id: String,
|
||||
pub market_display: String, // Market display name (e.g. "btc prediction")
|
||||
pub order_id: Option<String>, // GTC order ID if placed
|
||||
pub pending_sell_amount: Decimal, // Pending sell amount
|
||||
}
|
||||
|
||||
pub struct HedgeMonitor {
|
||||
client: AuthenticatedClobClient,
|
||||
private_key: String,
|
||||
proxy_address: Option<Address>,
|
||||
positions: DashMap<String, HedgePosition>, // pair_id -> position
|
||||
position_tracker: Arc<PositionTracker>, // For exposure updates
|
||||
}
|
||||
|
||||
impl HedgeMonitor {
|
||||
pub fn new(
|
||||
client: AuthenticatedClobClient,
|
||||
private_key: String,
|
||||
proxy_address: Option<Address>,
|
||||
position_tracker: Arc<PositionTracker>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client,
|
||||
private_key,
|
||||
proxy_address,
|
||||
positions: DashMap::new(),
|
||||
position_tracker,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add hedge position to monitor
|
||||
pub fn add_position(&self, action: &RecoveryAction) -> Result<()> {
|
||||
if let RecoveryAction::MonitorForExit {
|
||||
token_id,
|
||||
opposite_token_id,
|
||||
amount,
|
||||
entry_price,
|
||||
take_profit_pct,
|
||||
stop_loss_pct,
|
||||
pair_id,
|
||||
market_display,
|
||||
} = action
|
||||
{
|
||||
// Compute take-profit and stop-loss prices
|
||||
let take_profit_price = *entry_price * (dec!(1.0) + *take_profit_pct);
|
||||
let stop_loss_price = *entry_price * (dec!(1.0) - *stop_loss_pct);
|
||||
|
||||
info!(
|
||||
"🛡️ Start hedge monitor | market:{} | pos:{} | entry:{:.4} | TP:{:.4} | SL:{:.4}",
|
||||
market_display,
|
||||
amount,
|
||||
entry_price,
|
||||
take_profit_price,
|
||||
stop_loss_price
|
||||
);
|
||||
|
||||
let position = HedgePosition {
|
||||
token_id: *token_id,
|
||||
opposite_token_id: *opposite_token_id,
|
||||
amount: *amount,
|
||||
entry_price: *entry_price,
|
||||
take_profit_price,
|
||||
stop_loss_price,
|
||||
pair_id: pair_id.clone(),
|
||||
market_display: market_display.clone(),
|
||||
order_id: None,
|
||||
pending_sell_amount: dec!(0),
|
||||
};
|
||||
|
||||
self.positions.insert(pair_id.clone(), position);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update entry_price from orderbook best ask
|
||||
pub fn update_entry_price(&self, pair_id: &str, entry_price: Decimal) {
|
||||
if let Some(mut pos) = self.positions.get_mut(pair_id) {
|
||||
let old_entry = pos.entry_price;
|
||||
pos.entry_price = entry_price;
|
||||
// Recompute take-profit and stop-loss
|
||||
let take_profit_pct = (pos.take_profit_price - old_entry) / old_entry;
|
||||
let stop_loss_pct = (old_entry - pos.stop_loss_price) / old_entry;
|
||||
pos.take_profit_price = entry_price * (dec!(1.0) + take_profit_pct);
|
||||
pos.stop_loss_price = entry_price * (dec!(1.0) - stop_loss_pct);
|
||||
|
||||
info!(
|
||||
pair_id = %pair_id,
|
||||
old_entry = %old_entry,
|
||||
new_entry = %entry_price,
|
||||
take_profit_price = %pos.take_profit_price,
|
||||
stop_loss_price = %pos.stop_loss_price,
|
||||
"Update buy price"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check orderbook; sell if take-profit or stop-loss hit
|
||||
pub async fn check_and_execute(&self, book: &BookUpdate) -> Result<()> {
|
||||
// Best bid (last in bids, bids are price descending)
|
||||
let best_bid = book.bids.last();
|
||||
let best_bid_price = match best_bid {
|
||||
Some(bid) => bid.price,
|
||||
None => return Ok(()), // No bids, cannot sell
|
||||
};
|
||||
|
||||
// Find positions to check
|
||||
let positions_to_check: Vec<(String, HedgePosition)> = self
|
||||
.positions
|
||||
.iter()
|
||||
.filter(|entry| entry.value().token_id == book.asset_id)
|
||||
.map(|entry| (entry.key().clone(), entry.value().clone()))
|
||||
.collect();
|
||||
|
||||
for (pair_id, position) in positions_to_check {
|
||||
// Check if GTC order exists; if so, repost at latest price
|
||||
if let Some(ref order_id) = position.order_id {
|
||||
let pending_amount = position.pending_sell_amount;
|
||||
if pending_amount > dec!(0) {
|
||||
// Unfilled order; repost at latest price
|
||||
info!(
|
||||
"🔄 Unfilled order | market:{} | order_id:{} | remain:{} | repost at {:.4}",
|
||||
position.market_display,
|
||||
&order_id[..16],
|
||||
pending_amount,
|
||||
best_bid_price
|
||||
);
|
||||
// Clear old order_id before repost
|
||||
if let Some(mut pos) = self.positions.get_mut(&pair_id) {
|
||||
pos.order_id = None;
|
||||
}
|
||||
// Proceed with sell logic using pending_amount
|
||||
} else {
|
||||
// Order submitted but pending_amount=0, possibly in progress; skip
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check take-profit or stop-loss
|
||||
let (should_sell, reason) = if best_bid_price >= position.take_profit_price {
|
||||
let profit_pct = ((best_bid_price - position.entry_price) / position.entry_price * dec!(100.0)).to_f64().unwrap_or(0.0);
|
||||
(true, format!("Take-profit ({:.2}%)", profit_pct))
|
||||
} else if best_bid_price <= position.stop_loss_price {
|
||||
let loss_pct = ((position.entry_price - best_bid_price) / position.entry_price * dec!(100.0)).to_f64().unwrap_or(0.0);
|
||||
(true, format!("Stop-loss ({:.2}%)", loss_pct))
|
||||
} else {
|
||||
(false, String::new())
|
||||
};
|
||||
|
||||
if should_sell {
|
||||
// Get current and opposite positions
|
||||
let current_position = self.position_tracker.get_position(position.token_id);
|
||||
let opposite_position = self.position_tracker.get_position(position.opposite_token_id);
|
||||
|
||||
// Diff: current - opposite
|
||||
let difference = current_position - opposite_position;
|
||||
|
||||
// If diff <= 0, opposite covers; no sell
|
||||
if difference <= dec!(0) {
|
||||
info!(
|
||||
"⏸️ No sell needed | market:{} | pos:{} | opposite:{} | diff:{} | opposite covers",
|
||||
position.market_display,
|
||||
current_position,
|
||||
opposite_position,
|
||||
difference
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine sell amount
|
||||
let sell_amount = if position.order_id.is_some() && position.pending_sell_amount > dec!(0) {
|
||||
// Use pending_sell_amount if unfilled order exists
|
||||
position.pending_sell_amount
|
||||
} else {
|
||||
// Else use diff
|
||||
difference
|
||||
};
|
||||
|
||||
// Diff > 0; sell diff amount
|
||||
info!(
|
||||
"✅ {} hit | market:{} | bid:{:.4} | entry:{:.4} | pos:{} | opposite:{} | diff:{} | sell:{}",
|
||||
reason,
|
||||
position.market_display,
|
||||
best_bid_price,
|
||||
position.entry_price,
|
||||
current_position,
|
||||
opposite_position,
|
||||
difference,
|
||||
sell_amount
|
||||
);
|
||||
|
||||
// Sell via GTC order
|
||||
// Spawn async to avoid blocking main loop
|
||||
let position_clone = position.clone();
|
||||
let pair_id_clone = pair_id.clone();
|
||||
let position_tracker = self.position_tracker.clone();
|
||||
let positions = self.positions.clone();
|
||||
let client = self.client.clone();
|
||||
let private_key = self.private_key.clone();
|
||||
|
||||
// Mark processing to avoid duplicate orders
|
||||
if let Some((_, mut pos)) = self.positions.remove(&pair_id) {
|
||||
pos.order_id = Some("processing".to_string());
|
||||
self.positions.insert(pair_id.clone(), pos);
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Recreate signer (cannot use self in spawn)
|
||||
let signer = match LocalSigner::from_str(&private_key) {
|
||||
Ok(s) => s.with_chain_id(Some(POLYGON)),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ Create signer failed | market:{} | err:{}",
|
||||
position_clone.market_display,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Execute sell
|
||||
match Self::execute_sell_order(
|
||||
&client,
|
||||
&signer,
|
||||
&position_clone,
|
||||
best_bid_price,
|
||||
sell_amount,
|
||||
).await {
|
||||
Ok((order_id, filled, remaining)) => {
|
||||
// Update position, mark order placed
|
||||
let order_id_short = order_id[..16].to_string();
|
||||
if let Some((_, mut pos)) = positions.remove(&pair_id_clone) {
|
||||
if remaining > dec!(0) {
|
||||
// Partial fill; save order_id
|
||||
pos.order_id = Some(order_id);
|
||||
pos.pending_sell_amount = remaining;
|
||||
info!("🔒 Position order_id updated | market:{} | id:{} | remain:{}",
|
||||
position_clone.market_display, order_id_short, remaining);
|
||||
} else {
|
||||
// Full fill; clear order_id
|
||||
pos.order_id = None;
|
||||
pos.pending_sell_amount = dec!(0);
|
||||
info!("✅ Sell order fully filled | market:{} | id:{} | filled:{}",
|
||||
position_clone.market_display, order_id_short, filled);
|
||||
}
|
||||
positions.insert(pair_id_clone.clone(), pos);
|
||||
} else {
|
||||
warn!("⚠️ Position not found | pair_id:{}", pair_id_clone);
|
||||
}
|
||||
|
||||
// Only update position and exposure for actual fills
|
||||
if filled > dec!(0) {
|
||||
info!("📊 Updating position | market:{} | reduce:{}",
|
||||
position_clone.market_display, filled);
|
||||
position_tracker.update_position(position_clone.token_id, -filled);
|
||||
info!("📊 Position update done | market:{}", position_clone.market_display);
|
||||
|
||||
// Update exposure cost
|
||||
info!("💰 Updating exposure | market:{} | entry:{} | sell:{}",
|
||||
position_clone.market_display,
|
||||
position_clone.entry_price,
|
||||
filled);
|
||||
position_tracker.update_exposure_cost(
|
||||
position_clone.token_id,
|
||||
position_clone.entry_price,
|
||||
-filled,
|
||||
);
|
||||
info!("💰 Exposure update done | market:{}", position_clone.market_display);
|
||||
|
||||
// Compute exposure
|
||||
let current_exposure = position_tracker.calculate_exposure();
|
||||
info!(
|
||||
"📉 Exposure updated | market:{} | sold:{} | exposure:{:.2} USD",
|
||||
position_clone.market_display,
|
||||
filled,
|
||||
current_exposure
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ Sell order failed | market:{} | price:{:.4} | err:{}",
|
||||
position_clone.market_display,
|
||||
best_bid_price,
|
||||
e
|
||||
);
|
||||
// On failure, clear processing
|
||||
if let Some(mut pos) = positions.get_mut(&pair_id_clone) {
|
||||
pos.order_id = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute actual sell amount (with fee)
|
||||
fn calculate_sell_amount(&self, position: &HedgePosition) -> Decimal {
|
||||
self.calculate_sell_amount_with_size(position, position.amount)
|
||||
}
|
||||
|
||||
/// Compute actual sell amount for given size (with fee)
|
||||
fn calculate_sell_amount_with_size(&self, position: &HedgePosition, base_amount: Decimal) -> Decimal {
|
||||
// Compute fee
|
||||
let p = position.entry_price.to_f64().unwrap_or(0.0);
|
||||
let c = 100.0;
|
||||
let fee_rate = 0.25;
|
||||
let exponent = 2.0;
|
||||
|
||||
let base = p * (1.0 - p);
|
||||
let fee_value = c * fee_rate * base.powf(exponent);
|
||||
let fee_decimal = Decimal::try_from(fee_value).unwrap_or(dec!(0));
|
||||
|
||||
// Compute available amount
|
||||
let available_amount = if fee_decimal >= dec!(100.0) {
|
||||
dec!(0.01)
|
||||
} else {
|
||||
let multiplier = (dec!(100.0) - fee_decimal) / dec!(100.0);
|
||||
base_amount * multiplier
|
||||
};
|
||||
|
||||
// Floor to 2 decimals
|
||||
let floored_size = (available_amount * dec!(100.0)).floor() / dec!(100.0);
|
||||
|
||||
if floored_size.is_zero() {
|
||||
dec!(0.01)
|
||||
} else {
|
||||
floored_size
|
||||
}
|
||||
}
|
||||
|
||||
/// Static: compute actual sell amount for given size (with fee)
|
||||
fn calculate_sell_amount_static(position: &HedgePosition, base_amount: Decimal) -> Decimal {
|
||||
// Compute fee
|
||||
let p = position.entry_price.to_f64().unwrap_or(0.0);
|
||||
let c = 100.0;
|
||||
let fee_rate = 0.25;
|
||||
let exponent = 2.0;
|
||||
|
||||
let base = p * (1.0 - p);
|
||||
let fee_value = c * fee_rate * base.powf(exponent);
|
||||
let fee_decimal = Decimal::try_from(fee_value).unwrap_or(dec!(0));
|
||||
|
||||
// Compute available amount
|
||||
let available_amount = if fee_decimal >= dec!(100.0) {
|
||||
dec!(0.01)
|
||||
} else {
|
||||
let multiplier = (dec!(100.0) - fee_decimal) / dec!(100.0);
|
||||
base_amount * multiplier
|
||||
};
|
||||
|
||||
// Floor to 2 decimals
|
||||
let floored_size = (available_amount * dec!(100.0)).floor() / dec!(100.0);
|
||||
|
||||
if floored_size.is_zero() {
|
||||
dec!(0.01)
|
||||
} else {
|
||||
floored_size
|
||||
}
|
||||
}
|
||||
|
||||
/// Static: execute sell order
|
||||
async fn execute_sell_order(
|
||||
client: &AuthenticatedClobClient,
|
||||
signer: &impl Signer<alloy::primitives::Signature>,
|
||||
position: &HedgePosition,
|
||||
price: Decimal,
|
||||
size: Decimal,
|
||||
) -> Result<(String, Decimal, Decimal)> {
|
||||
// Compute fee
|
||||
let p = position.entry_price.to_f64().unwrap_or(0.0);
|
||||
let c = 100.0;
|
||||
let fee_rate = 0.25;
|
||||
let exponent = 2.0;
|
||||
|
||||
let base = p * (1.0 - p);
|
||||
let fee_value = c * fee_rate * base.powf(exponent);
|
||||
let fee_decimal = Decimal::try_from(fee_value).unwrap_or(dec!(0));
|
||||
|
||||
// Compute available amount
|
||||
let available_amount = if fee_decimal >= dec!(100.0) {
|
||||
dec!(0.01)
|
||||
} else {
|
||||
let multiplier = (dec!(100.0) - fee_decimal) / dec!(100.0);
|
||||
size * multiplier
|
||||
};
|
||||
|
||||
// Floor to 2 decimals
|
||||
let floored_size = (available_amount * dec!(100.0)).floor() / dec!(100.0);
|
||||
let order_size = if floored_size.is_zero() {
|
||||
dec!(0.01)
|
||||
} else {
|
||||
floored_size
|
||||
};
|
||||
|
||||
info!(
|
||||
"💰 Sell amount | market:{} | base:{:.2} | entry:{:.4} | fee:{:.2}% | avail:{:.2} | order:{:.2}",
|
||||
position.market_display,
|
||||
size,
|
||||
position.entry_price,
|
||||
fee_decimal,
|
||||
available_amount,
|
||||
order_size
|
||||
);
|
||||
|
||||
// Build GTC sell order
|
||||
let sell_order = client
|
||||
.limit_order()
|
||||
.token_id(position.token_id)
|
||||
.side(Side::Sell)
|
||||
.price(price)
|
||||
.size(order_size)
|
||||
.order_type(OrderType::GTC)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
// Sign order
|
||||
let signed_order = client.sign(signer, sell_order).await?;
|
||||
|
||||
// Submit order
|
||||
let result = client.post_order(signed_order).await?;
|
||||
|
||||
if !result.success {
|
||||
let error_msg = result.error_msg.as_deref().unwrap_or("unknown error");
|
||||
return Err(anyhow::anyhow!("GTC sell order failed: {}", error_msg));
|
||||
}
|
||||
|
||||
// Check immediate fill
|
||||
let filled = result.taking_amount;
|
||||
let remaining = order_size - filled;
|
||||
|
||||
if filled > dec!(0) {
|
||||
info!(
|
||||
"💰 Sell order partial fill | market:{} | id:{} | filled:{} | remain:{}",
|
||||
position.market_display,
|
||||
&result.order_id[..16],
|
||||
filled,
|
||||
remaining
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"📋 Sell order posted (no immediate fill) | market:{} | id:{} | size:{} | price:{:.4}",
|
||||
position.market_display,
|
||||
&result.order_id[..16],
|
||||
order_size,
|
||||
price
|
||||
);
|
||||
}
|
||||
|
||||
Ok((result.order_id, filled, remaining))
|
||||
}
|
||||
|
||||
/// Sell via GTC order; size: optional, else position.amount
|
||||
async fn sell_with_gtc(
|
||||
&self,
|
||||
position: &HedgePosition,
|
||||
price: Decimal,
|
||||
size: Option<Decimal>,
|
||||
) -> Result<(String, Decimal, Decimal)> {
|
||||
let signer = LocalSigner::from_str(&self.private_key)?
|
||||
.with_chain_id(Some(POLYGON));
|
||||
|
||||
// Compute fee
|
||||
// fee = c * fee_rate * (p * (1-p))^exponent; p=entry_price, c=100
|
||||
let p = position.entry_price.to_f64().unwrap_or(0.0);
|
||||
let c = 100.0;
|
||||
let fee_rate = 0.25;
|
||||
let exponent = 2.0;
|
||||
|
||||
// Fee ratio (0-1.56)
|
||||
let base = p * (1.0 - p);
|
||||
let fee_value = c * fee_rate * base.powf(exponent);
|
||||
|
||||
// To Decimal
|
||||
let fee_decimal = Decimal::try_from(fee_value).unwrap_or(dec!(0));
|
||||
|
||||
// Use size or position.amount
|
||||
let base_amount = size.unwrap_or(position.amount);
|
||||
|
||||
// Available = filled size * (100 - Fee) / 100
|
||||
// If Fee >= 100, use min tradeable unit
|
||||
let available_amount = if fee_decimal >= dec!(100.0) {
|
||||
dec!(0.01) // Edge case: min unit
|
||||
} else {
|
||||
// Normal: available = filled * (100 - Fee) / 100
|
||||
let multiplier = (dec!(100.0) - fee_decimal) / dec!(100.0);
|
||||
base_amount * multiplier
|
||||
};
|
||||
|
||||
let floored_size = (available_amount * dec!(100.0)).floor() / dec!(100.0);
|
||||
let order_size = if floored_size.is_zero() {
|
||||
dec!(0.01)
|
||||
} else {
|
||||
floored_size
|
||||
};
|
||||
|
||||
info!(
|
||||
"💰 Sell amount | market:{} | base:{:.2} | entry:{:.4} | fee:{:.2}% | avail:{:.2} | order:{:.2}",
|
||||
position.market_display,
|
||||
base_amount,
|
||||
position.entry_price,
|
||||
fee_decimal,
|
||||
available_amount,
|
||||
order_size
|
||||
);
|
||||
|
||||
// Build GTC sell order
|
||||
let sell_order = self
|
||||
.client
|
||||
.limit_order()
|
||||
.token_id(position.token_id)
|
||||
.side(Side::Sell)
|
||||
.price(price)
|
||||
.size(order_size)
|
||||
.order_type(OrderType::GTC)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
// Sign order
|
||||
let signed_order = self.client.sign(&signer, sell_order).await?;
|
||||
|
||||
// Submit order
|
||||
let result = self.client.post_order(signed_order).await?;
|
||||
|
||||
if !result.success {
|
||||
let error_msg = result.error_msg.as_deref().unwrap_or("unknown error");
|
||||
return Err(anyhow::anyhow!("GTC sell order failed: {}", error_msg));
|
||||
}
|
||||
|
||||
// Check immediate fill
|
||||
let filled = result.taking_amount;
|
||||
let remaining = order_size - filled;
|
||||
|
||||
if filled > dec!(0) {
|
||||
info!(
|
||||
"💰 Sell order partial fill | market:{} | id:{} | filled:{} | remain:{}",
|
||||
position.market_display,
|
||||
&result.order_id[..16],
|
||||
filled,
|
||||
remaining
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"📋 Sell order posted (no immediate fill) | market:{} | id:{} | size:{} | price:{:.4}",
|
||||
position.market_display,
|
||||
&result.order_id[..16],
|
||||
order_size,
|
||||
price
|
||||
);
|
||||
}
|
||||
|
||||
Ok((result.order_id, filled, remaining))
|
||||
}
|
||||
|
||||
/// Remove completed position
|
||||
pub fn remove_position(&self, pair_id: &str) {
|
||||
self.positions.remove(pair_id);
|
||||
info!(pair_id = %pair_id, "Remove hedge position");
|
||||
}
|
||||
|
||||
/// Get all monitored positions
|
||||
pub fn get_positions(&self) -> Vec<HedgePosition> {
|
||||
self.positions.iter().map(|e| e.value().clone()).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use polymarket_client_sdk::types::{B256, Decimal, U256};
|
||||
use rust_decimal_macros::dec;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
use super::positions::PositionTracker;
|
||||
use super::recovery::{RecoveryAction, RecoveryStrategy};
|
||||
use crate::config::Config as BotConfig;
|
||||
use crate::trading::executor::OrderPairResult;
|
||||
use crate::trading::AuthenticatedClobClient;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PairStatus {
|
||||
Submitted,
|
||||
BothFilled,
|
||||
PartiallyFilled,
|
||||
OneFailed,
|
||||
BothFailed,
|
||||
Recovering,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrderPair {
|
||||
pub pair_id: String,
|
||||
pub market_id: B256,
|
||||
pub yes_order_id: String,
|
||||
pub no_order_id: String,
|
||||
pub yes_token_id: U256,
|
||||
pub no_token_id: U256,
|
||||
pub yes_size: Decimal,
|
||||
pub no_size: Decimal,
|
||||
pub yes_filled: Decimal,
|
||||
pub no_filled: Decimal,
|
||||
pub status: PairStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct RiskManager {
|
||||
clob_client: AuthenticatedClobClient,
|
||||
pending_pairs: DashMap<String, OrderPair>,
|
||||
position_tracker: std::sync::Arc<PositionTracker>,
|
||||
recovery_strategy: RecoveryStrategy,
|
||||
}
|
||||
|
||||
impl RiskManager {
|
||||
pub fn new(
|
||||
clob_client: AuthenticatedClobClient,
|
||||
config: &BotConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
clob_client,
|
||||
pending_pairs: DashMap::new(),
|
||||
position_tracker: std::sync::Arc::new(PositionTracker::new(
|
||||
Decimal::try_from(config.risk_max_exposure_usdc).unwrap_or(dec!(1000.0)),
|
||||
)),
|
||||
recovery_strategy: RecoveryStrategy::new(
|
||||
config.risk_imbalance_threshold,
|
||||
config.hedge_take_profit_pct,
|
||||
config.hedge_stop_loss_pct,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register new order pair
|
||||
/// yes_price: YES buy price, no_price: NO buy price
|
||||
pub fn register_order_pair(
|
||||
&self,
|
||||
result: OrderPairResult,
|
||||
market_id: B256,
|
||||
yes_token: U256,
|
||||
no_token: U256,
|
||||
yes_price: Decimal,
|
||||
no_price: Decimal,
|
||||
) {
|
||||
let status = if result.yes_filled == result.yes_size && result.no_filled == result.no_size {
|
||||
PairStatus::BothFilled
|
||||
} else if result.yes_filled > dec!(0) && result.no_filled > dec!(0) {
|
||||
PairStatus::PartiallyFilled
|
||||
} else if result.yes_filled > dec!(0) && result.no_filled == dec!(0) {
|
||||
PairStatus::OneFailed
|
||||
} else if result.yes_filled == dec!(0) && result.no_filled > dec!(0) {
|
||||
PairStatus::OneFailed
|
||||
} else {
|
||||
PairStatus::BothFailed
|
||||
};
|
||||
|
||||
let pair = OrderPair {
|
||||
pair_id: result.pair_id.clone(),
|
||||
market_id,
|
||||
yes_order_id: result.yes_order_id,
|
||||
no_order_id: result.no_order_id,
|
||||
yes_token_id: yes_token,
|
||||
no_token_id: no_token,
|
||||
yes_size: result.yes_size,
|
||||
no_size: result.no_size,
|
||||
yes_filled: result.yes_filled,
|
||||
no_filled: result.no_filled,
|
||||
status: status.clone(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
// Update positions (exposure added during arbitrage execution, no exposure update here)
|
||||
self.position_tracker.update_position(yes_token, pair.yes_filled);
|
||||
self.position_tracker.update_position(no_token, pair.no_filled);
|
||||
|
||||
// Log already printed in executor
|
||||
debug!(
|
||||
pair_id = %pair.pair_id,
|
||||
status = ?status,
|
||||
yes_filled = %pair.yes_filled,
|
||||
no_filled = %pair.no_filled,
|
||||
"Register order pair"
|
||||
);
|
||||
|
||||
// Use pair.pair_id clone; DashMap needs ownership
|
||||
self.pending_pairs.insert(pair.pair_id.clone(), pair);
|
||||
}
|
||||
|
||||
/// Handle order pair and decide recovery strategy
|
||||
pub async fn handle_order_pair(&self, pair_id: &str) -> Result<RecoveryAction> {
|
||||
let pair = self
|
||||
.pending_pairs
|
||||
.get(pair_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Order pair {} not found", pair_id))?
|
||||
.clone();
|
||||
|
||||
match pair.status {
|
||||
PairStatus::BothFilled => {
|
||||
info!(pair_id = %pair.pair_id, "Both orders fully filled, no recovery needed");
|
||||
Ok(RecoveryAction::None)
|
||||
}
|
||||
PairStatus::PartiallyFilled => {
|
||||
self.recovery_strategy
|
||||
.handle_partial_fill(&pair, &self.position_tracker)
|
||||
.await
|
||||
}
|
||||
PairStatus::OneFailed => {
|
||||
self.recovery_strategy
|
||||
.handle_one_sided_fill(&pair, &self.position_tracker)
|
||||
.await
|
||||
}
|
||||
PairStatus::BothFailed => {
|
||||
error!(
|
||||
"❌ Arbitrage failed | YES and NO orders both unfilled; possible causes: price moved or liquidity insufficient"
|
||||
);
|
||||
Ok(RecoveryAction::ManualIntervention {
|
||||
reason: "Both orders failed".to_string(),
|
||||
})
|
||||
}
|
||||
_ => Ok(RecoveryAction::None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get position tracker (Arc reference)
|
||||
pub fn position_tracker(&self) -> std::sync::Arc<PositionTracker> {
|
||||
self.position_tracker.clone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod hedge_monitor;
|
||||
pub mod manager;
|
||||
pub mod position_balancer;
|
||||
pub mod positions;
|
||||
pub mod recovery;
|
||||
|
||||
pub use hedge_monitor::HedgeMonitor;
|
||||
pub use manager::RiskManager;
|
||||
pub use position_balancer::PositionBalancer;
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Position balancer: periodically check positions and orders, cancel excess orders to maintain balance
|
||||
|
||||
use anyhow::Result;
|
||||
use polymarket_client_sdk_v2::clob::types::request::OrdersRequest;
|
||||
use polymarket_client_sdk_v2::clob::types::Side;
|
||||
use polymarket_client_sdk::types::{B256, Decimal, U256};
|
||||
use rust_decimal_macros::dec;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::positions::PositionTracker;
|
||||
use crate::config::Config as BotConfig;
|
||||
use crate::trading::AuthenticatedClobClient;
|
||||
use polypulse::positions::get_positions;
|
||||
|
||||
/// Position balancer
|
||||
pub struct PositionBalancer {
|
||||
clob_client: AuthenticatedClobClient,
|
||||
position_tracker: std::sync::Arc<PositionTracker>,
|
||||
threshold: Decimal,
|
||||
min_total: Decimal,
|
||||
max_order_size: Decimal,
|
||||
}
|
||||
|
||||
impl PositionBalancer {
|
||||
pub fn new(
|
||||
clob_client: AuthenticatedClobClient,
|
||||
position_tracker: std::sync::Arc<PositionTracker>,
|
||||
config: &BotConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
clob_client,
|
||||
position_tracker,
|
||||
threshold: Decimal::try_from(config.position_balance_threshold).unwrap_or(dec!(2.0)),
|
||||
min_total: Decimal::try_from(config.position_balance_min_total).unwrap_or(dec!(5.0)),
|
||||
max_order_size: Decimal::try_from(config.max_order_size_usdc).unwrap_or(dec!(5.0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check and balance positions: fetch positions and orders, analyze YES/NO balance per market, cancel excess orders
|
||||
pub async fn check_and_balance_positions(
|
||||
&self,
|
||||
market_map: &HashMap<B256, (U256, U256)>, // condition_id -> (yes_token_id, no_token_id)
|
||||
) -> Result<()> {
|
||||
// Fetch all active orders (handle pagination)
|
||||
let mut all_orders = Vec::new();
|
||||
let mut cursor: Option<String> = None;
|
||||
loop {
|
||||
let page = self
|
||||
.clob_client
|
||||
.orders(&OrdersRequest::default(), cursor)
|
||||
.await?;
|
||||
|
||||
all_orders.extend(page.data);
|
||||
|
||||
if page.next_cursor.is_empty() || page.next_cursor == "LTE=" {
|
||||
break;
|
||||
}
|
||||
cursor = Some(page.next_cursor);
|
||||
}
|
||||
|
||||
if all_orders.is_empty() {
|
||||
debug!("No active orders, skipping position balance check");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Get positions (from PositionTracker, updated by scheduled sync)
|
||||
let positions = get_positions().await?;
|
||||
|
||||
// Group orders and positions by market
|
||||
let mut market_data: HashMap<B256, MarketBalanceData> = HashMap::new();
|
||||
|
||||
// Initialize market data
|
||||
for (condition_id, (yes_token, no_token)) in market_map {
|
||||
market_data.insert(*condition_id, MarketBalanceData {
|
||||
condition_id: *condition_id,
|
||||
yes_token_id: *yes_token,
|
||||
no_token_id: *no_token,
|
||||
yes_position: dec!(0),
|
||||
no_position: dec!(0),
|
||||
yes_orders: Vec::new(),
|
||||
no_orders: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Fill position data
|
||||
for pos in positions {
|
||||
if let Some(data) = market_data.get_mut(&pos.condition_id) {
|
||||
// outcome_index: 0=YES, 1=NO
|
||||
if pos.outcome_index == 0 {
|
||||
data.yes_position = pos.size;
|
||||
} else if pos.outcome_index == 1 {
|
||||
data.no_position = pos.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fill order data
|
||||
for order in all_orders {
|
||||
// Only process buy orders (Side::Buy)
|
||||
if order.side != Side::Buy {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find market for each order
|
||||
for data in market_data.values_mut() {
|
||||
if order.asset_id == data.yes_token_id {
|
||||
let pending_size = order.original_size - order.size_matched;
|
||||
if pending_size > dec!(0) {
|
||||
data.yes_orders.push(OrderInfo {
|
||||
order_id: order.id.clone(),
|
||||
price: order.price,
|
||||
pending_size,
|
||||
});
|
||||
}
|
||||
} else if order.asset_id == data.no_token_id {
|
||||
let pending_size = order.original_size - order.size_matched;
|
||||
if pending_size > dec!(0) {
|
||||
data.no_orders.push(OrderInfo {
|
||||
order_id: order.id.clone(),
|
||||
price: order.price,
|
||||
pending_size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Balance check per market
|
||||
for data in market_data.values() {
|
||||
if let Err(e) = self.balance_market(data).await {
|
||||
warn!(error = %e, "❌ Market position balance failed");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Balance a single market
|
||||
async fn balance_market(&self, data: &MarketBalanceData) -> Result<()> {
|
||||
// Compute actual position diff
|
||||
let position_diff = (data.yes_position - data.no_position).abs();
|
||||
|
||||
// Compute pending order amounts
|
||||
let yes_pending: Decimal = data.yes_orders.iter().map(|o| o.pending_size).sum();
|
||||
let no_pending: Decimal = data.no_orders.iter().map(|o| o.pending_size).sum();
|
||||
|
||||
// Compute total positions
|
||||
let yes_total = data.yes_position + yes_pending;
|
||||
let no_total = data.no_position + no_pending;
|
||||
let total = yes_total + no_total;
|
||||
|
||||
// Skip if total below minimum
|
||||
if total < self.min_total {
|
||||
debug!("Total position {} below min {}; skip balance", total, self.min_total);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Case 1: actual positions imbalanced (without pending)
|
||||
if position_diff >= self.threshold {
|
||||
if data.yes_position > data.no_position {
|
||||
// YES excess: cancel all YES orders, cancel matching NO orders
|
||||
let cancel_yes_order_ids: Vec<String> = data.yes_orders.iter().map(|o| o.order_id.clone()).collect();
|
||||
let cancel_yes_count = cancel_yes_order_ids.len();
|
||||
|
||||
// Cancel NO size: min(no_pending, yes_pending)
|
||||
let cancel_no_size = yes_pending.min(no_pending);
|
||||
|
||||
if cancel_yes_count > 0 || cancel_no_size > dec!(0) {
|
||||
info!(
|
||||
"⚠️ YES excess detected | YES:{} NO:{} | cancel {} YES orders and ~{} NO pending",
|
||||
data.yes_position,
|
||||
data.no_position,
|
||||
cancel_yes_count,
|
||||
cancel_no_size
|
||||
);
|
||||
|
||||
// Cancel YES orders
|
||||
if cancel_yes_count > 0 {
|
||||
let yes_order_ids: Vec<&str> = cancel_yes_order_ids.iter().map(|s| s.as_str()).collect();
|
||||
if let Err(e) = self.clob_client.cancel_orders(&yes_order_ids).await {
|
||||
error!(error = %e, "❌ Cancel YES orders failed");
|
||||
} else {
|
||||
info!("✅ Cancelled {} YES orders", cancel_yes_count);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel NO orders (by price, lowest first, until cancel_no_size)
|
||||
if cancel_no_size > dec!(0) {
|
||||
let mut no_orders_sorted = data.no_orders.clone();
|
||||
no_orders_sorted.sort_by(|a, b| a.price.partial_cmp(&b.price).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let mut cancel_no_order_ids = Vec::new();
|
||||
let mut accumulated_size = dec!(0);
|
||||
|
||||
for order in no_orders_sorted {
|
||||
if accumulated_size >= cancel_no_size {
|
||||
break;
|
||||
}
|
||||
cancel_no_order_ids.push(order.order_id.clone());
|
||||
accumulated_size += order.pending_size;
|
||||
}
|
||||
|
||||
if !cancel_no_order_ids.is_empty() {
|
||||
let cancel_no_order_ids_ref: Vec<&str> = cancel_no_order_ids.iter().map(|s| s.as_str()).collect();
|
||||
if let Err(e) = self.clob_client.cancel_orders(&cancel_no_order_ids_ref).await {
|
||||
error!(error = %e, "Cancel NO orders failed");
|
||||
} else {
|
||||
info!("Cancelled {} NO orders (acc {} shares)", cancel_no_order_ids.len(), accumulated_size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// NO excess: cancel all NO orders, cancel matching YES orders
|
||||
let cancel_no_order_ids: Vec<String> = data.no_orders.iter().map(|o| o.order_id.clone()).collect();
|
||||
let cancel_no_count = cancel_no_order_ids.len();
|
||||
|
||||
// Cancel YES size: min(yes_pending, no_pending)
|
||||
let cancel_yes_size = no_pending.min(yes_pending);
|
||||
|
||||
if cancel_no_count > 0 || cancel_yes_size > dec!(0) {
|
||||
info!(
|
||||
"⚠️ NO excess detected | YES:{} NO:{} | cancel {} NO orders and ~{} YES pending",
|
||||
data.yes_position,
|
||||
data.no_position,
|
||||
cancel_no_count,
|
||||
cancel_yes_size
|
||||
);
|
||||
|
||||
// Cancel NO orders
|
||||
if cancel_no_count > 0 {
|
||||
let no_order_ids: Vec<&str> = cancel_no_order_ids.iter().map(|s| s.as_str()).collect();
|
||||
if let Err(e) = self.clob_client.cancel_orders(&no_order_ids).await {
|
||||
error!(error = %e, "Cancel NO orders failed");
|
||||
} else {
|
||||
info!("Cancelled {} NO orders", cancel_no_count);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel YES orders (by price, lowest first)
|
||||
if cancel_yes_size > dec!(0) {
|
||||
let mut yes_orders_sorted = data.yes_orders.clone();
|
||||
yes_orders_sorted.sort_by(|a, b| a.price.partial_cmp(&b.price).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let mut cancel_yes_order_ids = Vec::new();
|
||||
let mut accumulated_size = dec!(0);
|
||||
|
||||
for order in yes_orders_sorted {
|
||||
if accumulated_size >= cancel_yes_size {
|
||||
break;
|
||||
}
|
||||
cancel_yes_order_ids.push(order.order_id.clone());
|
||||
accumulated_size += order.pending_size;
|
||||
}
|
||||
|
||||
if !cancel_yes_order_ids.is_empty() {
|
||||
let cancel_yes_order_ids_ref: Vec<&str> = cancel_yes_order_ids.iter().map(|s| s.as_str()).collect();
|
||||
if let Err(e) = self.clob_client.cancel_orders(&cancel_yes_order_ids_ref).await {
|
||||
error!(error = %e, "❌ Cancel YES orders failed");
|
||||
} else {
|
||||
info!("✅ Cancelled {} YES orders (total {} shares)", cancel_yes_order_ids.len(), accumulated_size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Case 2: actual positions balanced but pending causes total imbalance
|
||||
let target = (yes_total + no_total) / dec!(2);
|
||||
let yes_imbalance = yes_total - target;
|
||||
let no_imbalance = no_total - target;
|
||||
|
||||
// Cancel excess YES orders
|
||||
if yes_imbalance.abs() >= self.threshold && yes_imbalance > dec!(0) {
|
||||
let mut yes_orders_sorted = data.yes_orders.clone();
|
||||
yes_orders_sorted.sort_by(|a, b| a.price.partial_cmp(&b.price).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let mut cancel_size = dec!(0);
|
||||
let mut cancel_order_ids = Vec::new();
|
||||
|
||||
for order in yes_orders_sorted {
|
||||
if cancel_size >= yes_imbalance {
|
||||
break;
|
||||
}
|
||||
cancel_order_ids.push(order.order_id.clone());
|
||||
cancel_size += order.pending_size;
|
||||
}
|
||||
|
||||
if !cancel_order_ids.is_empty() {
|
||||
info!("⚠️ YES pending excess, cancelling {} YES orders", cancel_order_ids.len());
|
||||
|
||||
let cancel_order_ids_ref: Vec<&str> = cancel_order_ids.iter().map(|s| s.as_str()).collect();
|
||||
if let Err(e) = self.clob_client.cancel_orders(&cancel_order_ids_ref).await {
|
||||
error!(error = %e, "❌ Cancel YES orders failed");
|
||||
} else {
|
||||
info!("✅ Cancelled {} YES orders", cancel_order_ids.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel excess NO orders
|
||||
if no_imbalance.abs() >= self.threshold && no_imbalance > dec!(0) {
|
||||
let mut no_orders_sorted = data.no_orders.clone();
|
||||
no_orders_sorted.sort_by(|a, b| a.price.partial_cmp(&b.price).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let mut cancel_size = dec!(0);
|
||||
let mut cancel_order_ids = Vec::new();
|
||||
|
||||
for order in no_orders_sorted {
|
||||
if cancel_size >= no_imbalance {
|
||||
break;
|
||||
}
|
||||
cancel_order_ids.push(order.order_id.clone());
|
||||
cancel_size += order.pending_size;
|
||||
}
|
||||
|
||||
if !cancel_order_ids.is_empty() {
|
||||
info!("NO pending excess, cancelling {} NO orders", cancel_order_ids.len());
|
||||
|
||||
let cancel_order_ids_ref: Vec<&str> = cancel_order_ids.iter().map(|s| s.as_str()).collect();
|
||||
if let Err(e) = self.clob_client.cancel_orders(&cancel_order_ids_ref).await {
|
||||
error!(error = %e, "Cancel NO orders failed");
|
||||
} else {
|
||||
info!("Cancelled {} NO orders", cancel_order_ids.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if market should skip arbitrage (if severely imbalanced)
|
||||
/// Uses local cached positions, zero latency
|
||||
pub fn should_skip_arbitrage(&self, yes_token: U256, no_token: U256) -> bool {
|
||||
let (yes_pos, no_pos) = self.position_tracker.get_pair_positions(yes_token, no_token);
|
||||
let position_diff = (yes_pos - no_pos).abs();
|
||||
|
||||
if position_diff >= self.threshold {
|
||||
warn!(
|
||||
yes_position = %yes_pos,
|
||||
no_position = %no_pos,
|
||||
position_diff = %position_diff,
|
||||
threshold = %self.threshold,
|
||||
"⛔ Positions severely imbalanced, skip arbitrage"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Market balance data
|
||||
struct MarketBalanceData {
|
||||
condition_id: B256,
|
||||
yes_token_id: U256,
|
||||
no_token_id: U256,
|
||||
yes_position: Decimal,
|
||||
no_position: Decimal,
|
||||
yes_orders: Vec<OrderInfo>,
|
||||
no_orders: Vec<OrderInfo>,
|
||||
}
|
||||
|
||||
/// Order info
|
||||
#[derive(Clone)]
|
||||
struct OrderInfo {
|
||||
order_id: String,
|
||||
price: Decimal,
|
||||
pending_size: Decimal,
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
use anyhow::Result;
|
||||
use dashmap::DashMap;
|
||||
use polymarket_client_sdk::types::{Decimal, U256};
|
||||
use rust_decimal_macros::dec;
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
use polypulse::positions::{get_positions, Position};
|
||||
|
||||
pub struct PositionTracker {
|
||||
positions: DashMap<U256, Decimal>, // token_id -> amount (pos=long, neg=short)
|
||||
exposure_costs: DashMap<U256, Decimal>, // token_id -> cost (USD) for risk exposure
|
||||
max_exposure: Decimal,
|
||||
}
|
||||
|
||||
impl PositionTracker {
|
||||
pub fn new(max_exposure: Decimal) -> Self {
|
||||
Self {
|
||||
positions: DashMap::new(),
|
||||
exposure_costs: DashMap::new(),
|
||||
max_exposure,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_position(&self, token_id: U256, delta: Decimal) {
|
||||
trace!("update_position: start | token_id:{} | delta:{}", token_id, delta);
|
||||
|
||||
trace!("update_position: acquiring positions write lock");
|
||||
let mut entry = self.positions.entry(token_id).or_insert(dec!(0));
|
||||
trace!("update_position: positions write lock acquired");
|
||||
*entry += delta;
|
||||
trace!("update_position: position updated, new value:{}", *entry);
|
||||
|
||||
// Clean up if position goes to ~0
|
||||
// Key fix: release positions write lock before accessing exposure_costs to avoid deadlock
|
||||
let should_remove = entry.abs() < dec!(0.0001);
|
||||
trace!("update_position: should_remove:{}", should_remove);
|
||||
if should_remove {
|
||||
*entry = dec!(0);
|
||||
trace!("update_position: position zeroed");
|
||||
}
|
||||
drop(entry);
|
||||
trace!("update_position: positions write lock released");
|
||||
|
||||
// Now safe to access exposure_costs
|
||||
if should_remove {
|
||||
trace!("update_position: removing exposure_costs");
|
||||
self.exposure_costs.remove(&token_id);
|
||||
trace!("update_position: exposure_costs removed");
|
||||
}
|
||||
|
||||
trace!("update_position: done");
|
||||
}
|
||||
|
||||
/// Update risk exposure cost (USD)
|
||||
/// price: buy price, delta: position change (pos=buy, neg=sell)
|
||||
pub fn update_exposure_cost(&self, token_id: U256, price: Decimal, delta: Decimal) {
|
||||
trace!("update_exposure_cost: start | token_id:{} | price:{} | delta:{}", token_id, price, delta);
|
||||
|
||||
if delta == dec!(0) {
|
||||
trace!("update_exposure_cost: delta=0, return");
|
||||
return;
|
||||
}
|
||||
|
||||
trace!("update_exposure_cost: acquiring positions read lock");
|
||||
// Key fix: get positions read lock first, then exposure_costs write lock to avoid deadlock
|
||||
let current_pos = if delta < dec!(0) {
|
||||
trace!("update_exposure_cost: sell, getting positions read lock");
|
||||
let pos = self.positions.get(&token_id);
|
||||
trace!("update_exposure_cost: positions read lock acquired");
|
||||
let result = pos.map(|v| *v.value()).unwrap_or(dec!(0));
|
||||
trace!("update_exposure_cost: positions read released, current_pos:{}", result);
|
||||
result
|
||||
} else {
|
||||
trace!("update_exposure_cost: buy, no positions read needed");
|
||||
dec!(0)
|
||||
};
|
||||
|
||||
trace!("update_exposure_cost: acquiring exposure_costs write lock");
|
||||
let mut entry = self.exposure_costs.entry(token_id).or_insert(dec!(0));
|
||||
trace!("update_exposure_cost: exposure_costs write lock acquired");
|
||||
|
||||
if delta > dec!(0) {
|
||||
trace!("update_exposure_cost: buy branch, compute cost_delta");
|
||||
let cost_delta = price * delta;
|
||||
*entry += cost_delta;
|
||||
trace!("update_exposure_cost: buy done, new cost:{}", *entry);
|
||||
} else {
|
||||
trace!("update_exposure_cost: sell branch, current_pos:{}", current_pos);
|
||||
if current_pos > dec!(0) {
|
||||
trace!("update_exposure_cost: compute sell ratio");
|
||||
let sell_amount = (-delta).min(current_pos);
|
||||
let reduction_ratio = sell_amount / current_pos;
|
||||
trace!("update_exposure_cost: sell_amount:{} | reduction_ratio:{} | current cost:{}", sell_amount, reduction_ratio, *entry);
|
||||
*entry = (*entry * (dec!(1) - reduction_ratio)).max(dec!(0));
|
||||
trace!("update_exposure_cost: sell done, new cost:{}", *entry);
|
||||
} else {
|
||||
trace!("update_exposure_cost: current_pos=0, zero out");
|
||||
*entry = dec!(0);
|
||||
}
|
||||
}
|
||||
|
||||
trace!("update_exposure_cost: check cleanup, current cost:{}", *entry);
|
||||
if *entry < dec!(0.01) {
|
||||
trace!("update_exposure_cost: cost near 0, cleanup");
|
||||
*entry = dec!(0);
|
||||
drop(entry);
|
||||
trace!("update_exposure_cost: lock released, removing");
|
||||
self.exposure_costs.remove(&token_id);
|
||||
trace!("update_exposure_cost: remove done");
|
||||
} else {
|
||||
trace!("update_exposure_cost: cost nonzero, keep entry");
|
||||
drop(entry);
|
||||
}
|
||||
|
||||
trace!("update_exposure_cost: done");
|
||||
}
|
||||
|
||||
/// Get max risk exposure limit
|
||||
pub fn max_exposure(&self) -> Decimal {
|
||||
self.max_exposure
|
||||
}
|
||||
|
||||
/// Reset exposure (called at round start; clears cost cache so round starts from 0)
|
||||
pub fn reset_exposure(&self) {
|
||||
self.exposure_costs.clear();
|
||||
info!("🔄 Risk exposure reset (new round)");
|
||||
}
|
||||
|
||||
pub fn get_position(&self, token_id: U256) -> Decimal {
|
||||
self.positions
|
||||
.get(&token_id)
|
||||
.map(|v| *v.value())
|
||||
.unwrap_or(dec!(0))
|
||||
}
|
||||
|
||||
/// Compute position imbalance (0.0 = balanced, 1.0 = fully imbalanced)
|
||||
pub fn calculate_imbalance(&self, yes_token: U256, no_token: U256) -> Decimal {
|
||||
let yes_pos = self.get_position(yes_token);
|
||||
let no_pos = self.get_position(no_token);
|
||||
|
||||
let total = yes_pos + no_pos;
|
||||
if total == dec!(0) {
|
||||
return dec!(0); // fully balanced
|
||||
}
|
||||
|
||||
// imbalance = abs(yes - no) / (yes + no)
|
||||
let imbalance = (yes_pos - no_pos).abs() / total;
|
||||
imbalance
|
||||
}
|
||||
|
||||
/// Compute total risk exposure (USD), sum of all position costs
|
||||
pub fn calculate_exposure(&self) -> Decimal {
|
||||
// Sum all position costs; collect to Vec to avoid holding lock long
|
||||
let costs: Vec<Decimal> = self.exposure_costs
|
||||
.iter()
|
||||
.map(|entry| *entry.value())
|
||||
.collect();
|
||||
costs.iter().sum()
|
||||
}
|
||||
|
||||
pub fn is_within_limits(&self) -> bool {
|
||||
self.calculate_exposure() <= self.max_exposure
|
||||
}
|
||||
|
||||
/// Check if new order would exceed exposure limit
|
||||
/// yes_cost, no_cost: order costs (price * size)
|
||||
pub fn would_exceed_limit(&self, yes_cost: Decimal, no_cost: Decimal) -> bool {
|
||||
let current_exposure = self.calculate_exposure();
|
||||
let new_order_cost = yes_cost + no_cost;
|
||||
(current_exposure + new_order_cost) > self.max_exposure
|
||||
}
|
||||
|
||||
/// Get YES and NO positions
|
||||
pub fn get_pair_positions(&self, yes_token: U256, no_token: U256) -> (Decimal, Decimal) {
|
||||
(self.get_position(yes_token), self.get_position(no_token))
|
||||
}
|
||||
|
||||
/// Sync positions from Data API, fully overwrite local cache
|
||||
/// For scheduled sync; ensures local matches on-chain positions
|
||||
pub async fn sync_from_api(&self) -> Result<Vec<Position>> {
|
||||
use std::collections::HashMap;
|
||||
use polymarket_client_sdk::types::B256;
|
||||
|
||||
let positions = get_positions().await?;
|
||||
|
||||
// Clear positions (exposure only from arbitrage execution and Merge, not API)
|
||||
self.positions.clear();
|
||||
|
||||
// Update local cache from API positions
|
||||
let mut updated_count = 0;
|
||||
let mut valid_positions = Vec::new();
|
||||
|
||||
for pos in positions {
|
||||
if pos.size > dec!(0) {
|
||||
// Position.asset is token_id
|
||||
self.positions.insert(pos.asset, pos.size);
|
||||
valid_positions.push(pos);
|
||||
updated_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Print positions grouped by market
|
||||
if !valid_positions.is_empty() {
|
||||
let mut by_market: HashMap<B256, Vec<&Position>> = HashMap::new();
|
||||
for pos in &valid_positions {
|
||||
by_market.entry(pos.condition_id).or_default().push(pos);
|
||||
}
|
||||
|
||||
info!("📊 Position sync done | {} positions, {} markets", updated_count, by_market.len());
|
||||
|
||||
// Print one line per market
|
||||
for (_condition_id, market_positions) in by_market.iter() {
|
||||
let mut yes_pos = dec!(0);
|
||||
let mut no_pos = dec!(0);
|
||||
let mut market_title = "";
|
||||
|
||||
for pos in market_positions {
|
||||
if pos.outcome_index == 0 {
|
||||
yes_pos = pos.size;
|
||||
} else if pos.outcome_index == 1 {
|
||||
no_pos = pos.size;
|
||||
}
|
||||
if market_title.is_empty() {
|
||||
market_title = &pos.title;
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate long titles
|
||||
let title_display = if market_title.len() > 40 {
|
||||
format!("{}...", &market_title[..37])
|
||||
} else {
|
||||
market_title.to_string()
|
||||
};
|
||||
|
||||
info!(
|
||||
" 📈 {} | YES:{} NO:{}",
|
||||
title_display,
|
||||
yes_pos,
|
||||
no_pos
|
||||
);
|
||||
}
|
||||
} else {
|
||||
info!("📊 Position sync done | no positions");
|
||||
}
|
||||
|
||||
Ok(valid_positions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use anyhow::Result;
|
||||
use polymarket_client_sdk::types::{Decimal, U256};
|
||||
use rust_decimal_macros::dec;
|
||||
use tracing::debug;
|
||||
|
||||
use super::manager::OrderPair;
|
||||
use super::positions::PositionTracker;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RecoveryAction {
|
||||
None,
|
||||
SellExcess { token_id: String, amount: Decimal },
|
||||
MonitorForExit {
|
||||
token_id: U256,
|
||||
opposite_token_id: U256, // Opposite side token_id (for diff calc)
|
||||
amount: Decimal,
|
||||
entry_price: Decimal, // Buy price (best ask)
|
||||
take_profit_pct: Decimal, // Take-profit % (e.g. 0.05 = 5%)
|
||||
stop_loss_pct: Decimal, // Stop-loss % (e.g. 0.05 = 5%)
|
||||
pair_id: String,
|
||||
market_display: String, // Market display name (e.g. "btc prediction")
|
||||
},
|
||||
ManualIntervention { reason: String },
|
||||
}
|
||||
|
||||
pub struct RecoveryStrategy {
|
||||
imbalance_threshold: Decimal,
|
||||
take_profit_pct: Decimal, // Take-profit %
|
||||
stop_loss_pct: Decimal, // Stop-loss %
|
||||
}
|
||||
|
||||
impl RecoveryStrategy {
|
||||
pub fn new(imbalance_threshold: f64, take_profit_pct: f64, stop_loss_pct: f64) -> Self {
|
||||
Self {
|
||||
imbalance_threshold: Decimal::try_from(imbalance_threshold)
|
||||
.unwrap_or(dec!(0.1)),
|
||||
take_profit_pct: Decimal::try_from(take_profit_pct)
|
||||
.unwrap_or(dec!(0.05)), // default 5% take-profit
|
||||
stop_loss_pct: Decimal::try_from(stop_loss_pct)
|
||||
.unwrap_or(dec!(0.05)), // default 5% stop-loss
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle partial fill (GTC orders); hedge disabled, no action on imbalance
|
||||
pub async fn handle_partial_fill(
|
||||
&self,
|
||||
pair: &OrderPair,
|
||||
_position_tracker: &PositionTracker,
|
||||
) -> Result<RecoveryAction> {
|
||||
// Compute imbalance amount
|
||||
let imbalance = (pair.yes_filled - pair.no_filled).abs();
|
||||
let total_filled = pair.yes_filled + pair.no_filled;
|
||||
|
||||
// Compute imbalance ratio
|
||||
let imbalance_ratio = if total_filled > dec!(0) {
|
||||
imbalance / total_filled
|
||||
} else {
|
||||
dec!(0)
|
||||
};
|
||||
|
||||
// Hedge disabled, no action on partial fill imbalance
|
||||
if imbalance_ratio > self.imbalance_threshold {
|
||||
let (side, amount) = if pair.yes_filled > pair.no_filled {
|
||||
// YES filled more
|
||||
("YES", pair.yes_filled - pair.no_filled)
|
||||
} else {
|
||||
// NO filled more
|
||||
("NO", pair.no_filled - pair.yes_filled)
|
||||
};
|
||||
|
||||
debug!(
|
||||
pair_id = %pair.pair_id,
|
||||
side = side,
|
||||
imbalance_amount = %amount,
|
||||
imbalance_ratio = %imbalance_ratio,
|
||||
"Partial fill imbalance; hedge off, no action"
|
||||
);
|
||||
}
|
||||
|
||||
// Return None, no hedge action
|
||||
Ok(RecoveryAction::None)
|
||||
|
||||
// Legacy: if imbalance > threshold, hedge
|
||||
// if imbalance_ratio > self.imbalance_threshold {
|
||||
// let (token_to_sell, amount) = if pair.yes_filled > pair.no_filled {
|
||||
// // YES filled more, sell excess YES
|
||||
// (pair.yes_token_id, pair.yes_filled - pair.no_filled)
|
||||
// } else {
|
||||
// // NO filled more, sell excess NO
|
||||
// (pair.no_token_id, pair.no_filled - pair.yes_filled)
|
||||
// };
|
||||
//
|
||||
// info!(
|
||||
// pair_id = %pair.pair_id,
|
||||
// token_id = %token_to_sell,
|
||||
// amount = %amount,
|
||||
// imbalance_ratio = %imbalance_ratio,
|
||||
// "Partial fill imbalance, hedge"
|
||||
// );
|
||||
//
|
||||
// return Ok(RecoveryAction::SellExcess {
|
||||
// token_id: token_to_sell.to_string(),
|
||||
// amount,
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// // Imbalance within acceptable range
|
||||
// Ok(RecoveryAction::None)
|
||||
}
|
||||
|
||||
/// Handle one-sided fill (GTC orders); hedge disabled, no action
|
||||
pub async fn handle_one_sided_fill(
|
||||
&self,
|
||||
pair: &OrderPair,
|
||||
_position_tracker: &PositionTracker,
|
||||
) -> Result<RecoveryAction> {
|
||||
// Determine which order succeeded, which failed
|
||||
let (side, filled_amount) =
|
||||
if pair.yes_filled > dec!(0) && pair.no_filled == dec!(0) {
|
||||
// YES success, NO failed (may still be pending)
|
||||
("YES", pair.yes_filled)
|
||||
} else if pair.no_filled > dec!(0) && pair.yes_filled == dec!(0) {
|
||||
// NO success, YES failed (may still be pending)
|
||||
("NO", pair.no_filled)
|
||||
} else {
|
||||
return Ok(RecoveryAction::None);
|
||||
};
|
||||
|
||||
// Hedge disabled; one-sided fill logged by executor
|
||||
debug!(
|
||||
"One-sided fill | {} filled {} shares | hedge off, no action",
|
||||
side, filled_amount
|
||||
);
|
||||
|
||||
// Return None, no hedge action
|
||||
Ok(RecoveryAction::None)
|
||||
|
||||
// Legacy: hedge strategy - monitor best bid, sell on TP/SL
|
||||
// // Resolve opposite side token_id
|
||||
// let success_token = if pair.yes_filled > dec!(0) {
|
||||
// pair.yes_token_id
|
||||
// } else {
|
||||
// pair.no_token_id
|
||||
// };
|
||||
// let opposite_token = if success_token == pair.yes_token_id {
|
||||
// pair.no_token_id
|
||||
// } else {
|
||||
// pair.yes_token_id
|
||||
// };
|
||||
//
|
||||
// Ok(RecoveryAction::MonitorForExit {
|
||||
// token_id: success_token,
|
||||
// opposite_token_id: opposite_token,
|
||||
// amount: filled_amount,
|
||||
// entry_price: dec!(0), // Placeholder, get from orderbook in main
|
||||
// take_profit_pct: self.take_profit_pct,
|
||||
// stop_loss_pct: self.stop_loss_pct,
|
||||
// pair_id: pair.pair_id.clone(),
|
||||
// market_display: "unknown".to_string(), // Placeholder, get from market info in main
|
||||
// })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub use polypulse::clob_v2::*;
|
||||
@@ -0,0 +1,462 @@
|
||||
use anyhow::Result;
|
||||
use alloy::signers::Signer;
|
||||
use alloy::signers::local::LocalSigner;
|
||||
use chrono::Utc;
|
||||
use polymarket_client_sdk_v2::clob::types::response::{CancelOrdersResponse, PostOrderResponse};
|
||||
use polymarket_client_sdk_v2::clob::types::{OrderType, Side};
|
||||
use polymarket_client_sdk_v2::types::{Decimal, U256};
|
||||
use polymarket_client_sdk_v2::POLYGON;
|
||||
use rust_decimal_macros::dec;
|
||||
use std::str::FromStr;
|
||||
use std::time::Instant;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::monitor::arbitrage::ArbitrageOpportunity;
|
||||
use crate::trading::AuthenticatedClobClient;
|
||||
|
||||
pub struct OrderPairResult {
|
||||
pub pair_id: String,
|
||||
pub yes_order_id: String,
|
||||
pub no_order_id: String,
|
||||
pub yes_filled: Decimal,
|
||||
pub no_filled: Decimal,
|
||||
pub yes_size: Decimal,
|
||||
pub no_size: Decimal,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
pub struct TradingExecutor {
|
||||
client: AuthenticatedClobClient,
|
||||
private_key: String,
|
||||
max_order_size: Decimal,
|
||||
slippage: [Decimal; 2], // [first, second]: down uses second, up/flat uses first
|
||||
gtd_expiration_secs: u64,
|
||||
arbitrage_order_type: OrderType,
|
||||
}
|
||||
|
||||
impl TradingExecutor {
|
||||
pub fn from_client(
|
||||
client: AuthenticatedClobClient,
|
||||
private_key: String,
|
||||
max_order_size_usdc: f64,
|
||||
slippage: [f64; 2],
|
||||
gtd_expiration_secs: u64,
|
||||
arbitrage_order_type: OrderType,
|
||||
) -> Self {
|
||||
Self {
|
||||
client,
|
||||
private_key,
|
||||
max_order_size: Decimal::try_from(max_order_size_usdc)
|
||||
.unwrap_or(rust_decimal_macros::dec!(100.0)),
|
||||
slippage: [
|
||||
Decimal::try_from(slippage[0]).unwrap_or(dec!(0.0)),
|
||||
Decimal::try_from(slippage[1]).unwrap_or(dec!(0.01)),
|
||||
],
|
||||
gtd_expiration_secs,
|
||||
arbitrage_order_type,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn client(&self) -> &AuthenticatedClobClient {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// Verify auth actually succeeded via api_keys()
|
||||
pub async fn verify_authentication(&self) -> Result<()> {
|
||||
self.client
|
||||
.api_keys()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Auth verification failed: API error: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel all orders for this account (for wind-down)
|
||||
pub async fn cancel_all_orders(&self) -> Result<CancelOrdersResponse> {
|
||||
self.client
|
||||
.cancel_all_orders()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to cancel all orders: {}", e))
|
||||
}
|
||||
|
||||
/// Place GTC sell at given price (wind-down: market-intent for one-sided leg)
|
||||
pub async fn sell_at_price(
|
||||
&self,
|
||||
token_id: U256,
|
||||
price: Decimal,
|
||||
size: Decimal,
|
||||
) -> Result<PostOrderResponse> {
|
||||
let signer = LocalSigner::from_str(&self.private_key)?
|
||||
.with_chain_id(Some(POLYGON));
|
||||
let order = self
|
||||
.client
|
||||
.limit_order()
|
||||
.token_id(token_id)
|
||||
.side(Side::Sell)
|
||||
.price(price)
|
||||
.size(size)
|
||||
.order_type(OrderType::GTC)
|
||||
.build()
|
||||
.await?;
|
||||
let signed = self.client.sign(&signer, order).await?;
|
||||
self.client
|
||||
.post_order(signed)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Sell order submit failed: {}", e))
|
||||
}
|
||||
|
||||
/// Slippage by direction: down(↓) uses second, up(↑) and flat(−/empty) use first
|
||||
fn slippage_for_direction(&self, dir: &str) -> Decimal {
|
||||
if dir == "↓" {
|
||||
self.slippage[1]
|
||||
} else {
|
||||
self.slippage[0]
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute arbitrage: submit YES+NO via sequential post_order (V2); order type from config
|
||||
/// yes_dir / no_dir: direction "↑" "↓" "−" or "" for slippage (down=second, up/flat=first)
|
||||
pub async fn execute_arbitrage_pair(
|
||||
&self,
|
||||
opp: &ArbitrageOpportunity,
|
||||
yes_dir: &str,
|
||||
no_dir: &str,
|
||||
) -> Result<OrderPairResult> {
|
||||
let total_start = Instant::now();
|
||||
|
||||
let expiry_info = if matches!(self.arbitrage_order_type, OrderType::GTD) {
|
||||
format!("expiry:{}s", self.gtd_expiration_secs)
|
||||
} else {
|
||||
"no expiry".to_string()
|
||||
};
|
||||
debug!(
|
||||
market_id = %opp.market_id,
|
||||
profit_pct = %opp.profit_percentage,
|
||||
order_type = %self.arbitrage_order_type,
|
||||
"Execute arbitrage (V2 sequential, type:{}, {})",
|
||||
self.arbitrage_order_type,
|
||||
expiry_info
|
||||
);
|
||||
|
||||
let yes_token_id = U256::from_str(&opp.yes_token_id.to_string())?;
|
||||
let no_token_id = U256::from_str(&opp.no_token_id.to_string())?;
|
||||
|
||||
let order_size = opp.yes_size.min(opp.no_size).min(self.max_order_size);
|
||||
let pair_id = Uuid::new_v4().to_string();
|
||||
let expiration = Utc::now() + chrono::Duration::seconds(self.gtd_expiration_secs as i64);
|
||||
|
||||
let yes_slippage_apply = self.slippage_for_direction(yes_dir);
|
||||
let no_slippage_apply = self.slippage_for_direction(no_dir);
|
||||
let yes_price_with_slippage = (opp.yes_ask_price + yes_slippage_apply).min(dec!(1.0));
|
||||
let no_price_with_slippage = (opp.no_ask_price + no_slippage_apply).min(dec!(1.0));
|
||||
|
||||
info!(
|
||||
"📋 Level | YES {:.4}×{:.2} NO {:.4}×{:.2}",
|
||||
yes_price_with_slippage, order_size,
|
||||
no_price_with_slippage, order_size
|
||||
);
|
||||
|
||||
let expiry_suffix = if matches!(self.arbitrage_order_type, OrderType::GTD) {
|
||||
format!(" | GTD {}s", self.gtd_expiration_secs)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
info!(
|
||||
"📤 Order | YES {:.4}→{:.4}×{} NO {:.4}→{:.4}×{} | {}{}",
|
||||
opp.yes_ask_price, yes_price_with_slippage, order_size,
|
||||
opp.no_ask_price, no_price_with_slippage, order_size,
|
||||
self.arbitrage_order_type, expiry_suffix
|
||||
);
|
||||
|
||||
let yes_amount_usd = yes_price_with_slippage * order_size;
|
||||
let no_amount_usd = no_price_with_slippage * order_size;
|
||||
if yes_amount_usd <= dec!(1) || no_amount_usd <= dec!(1) {
|
||||
warn!(
|
||||
"⏭️ Skip order | YES:{:.2} pUSD NO:{:.2} pUSD | both must be > $1",
|
||||
yes_amount_usd, no_amount_usd
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Order size below min: YES {:.2} pUSD, NO {:.2} pUSD; both must be > $1",
|
||||
yes_amount_usd, no_amount_usd
|
||||
));
|
||||
}
|
||||
|
||||
let build_start = Instant::now();
|
||||
let (yes_order, no_order) = tokio::join!(
|
||||
async {
|
||||
let b = self.client
|
||||
.limit_order()
|
||||
.token_id(yes_token_id)
|
||||
.side(Side::Buy)
|
||||
.price(yes_price_with_slippage)
|
||||
.size(order_size)
|
||||
.order_type(self.arbitrage_order_type.clone());
|
||||
if matches!(&self.arbitrage_order_type, OrderType::GTD) {
|
||||
b.expiration(expiration).build().await
|
||||
} else {
|
||||
b.build().await
|
||||
}
|
||||
},
|
||||
async {
|
||||
let b = self.client
|
||||
.limit_order()
|
||||
.token_id(no_token_id)
|
||||
.side(Side::Buy)
|
||||
.price(no_price_with_slippage)
|
||||
.size(order_size)
|
||||
.order_type(self.arbitrage_order_type.clone());
|
||||
if matches!(&self.arbitrage_order_type, OrderType::GTD) {
|
||||
b.expiration(expiration).build().await
|
||||
} else {
|
||||
b.build().await
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let yes_order = yes_order?;
|
||||
let no_order = no_order?;
|
||||
let build_elapsed = build_start.elapsed().as_millis();
|
||||
|
||||
let sign_start = Instant::now();
|
||||
let signer = LocalSigner::from_str(&self.private_key)?
|
||||
.with_chain_id(Some(POLYGON));
|
||||
|
||||
let (signed_yes_result, signed_no_result) = tokio::join!(
|
||||
self.client.sign(&signer, yes_order),
|
||||
self.client.sign(&signer, no_order)
|
||||
);
|
||||
|
||||
let signed_yes = signed_yes_result?;
|
||||
let signed_no = signed_no_result?;
|
||||
let sign_elapsed = sign_start.elapsed().as_millis();
|
||||
|
||||
let send_start = Instant::now();
|
||||
let yes_first = yes_price_with_slippage >= no_price_with_slippage;
|
||||
|
||||
let (yes_result, no_result) = if yes_first {
|
||||
let yes_res = self.client.post_order(signed_yes).await;
|
||||
let no_res = self.client.post_order(signed_no).await;
|
||||
match (yes_res, no_res) {
|
||||
(Ok(y), Ok(n)) => (y, n),
|
||||
(Err(e), _) | (_, Err(e)) => {
|
||||
return Self::log_send_error(
|
||||
&pair_id,
|
||||
yes_price_with_slippage,
|
||||
no_price_with_slippage,
|
||||
order_size,
|
||||
build_elapsed,
|
||||
sign_elapsed,
|
||||
send_start,
|
||||
total_start,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let no_res = self.client.post_order(signed_no).await;
|
||||
let yes_res = self.client.post_order(signed_yes).await;
|
||||
match (no_res, yes_res) {
|
||||
(Ok(n), Ok(y)) => (y, n),
|
||||
(Err(e), _) | (_, Err(e)) => {
|
||||
return Self::log_send_error(
|
||||
&pair_id,
|
||||
yes_price_with_slippage,
|
||||
no_price_with_slippage,
|
||||
order_size,
|
||||
build_elapsed,
|
||||
sign_elapsed,
|
||||
send_start,
|
||||
total_start,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let send_elapsed = send_start.elapsed().as_millis();
|
||||
let total_elapsed = total_start.elapsed().as_millis();
|
||||
info!(
|
||||
"⏱️ Latency | {} | build {}ms sign {}ms send {}ms total {}ms",
|
||||
&pair_id[..8], build_elapsed, sign_elapsed, send_elapsed, total_elapsed
|
||||
);
|
||||
|
||||
let yes_filled = yes_result.taking_amount;
|
||||
let no_filled = no_result.taking_amount;
|
||||
|
||||
if yes_filled == dec!(0) && no_filled == dec!(0) {
|
||||
let yes_error_msg = yes_result
|
||||
.error_msg
|
||||
.as_deref()
|
||||
.unwrap_or("unknown error");
|
||||
let no_error_msg = no_result
|
||||
.error_msg
|
||||
.as_deref()
|
||||
.unwrap_or("unknown error");
|
||||
|
||||
let yes_error_simple = if yes_error_msg.contains("no orders found to match") {
|
||||
"No matching orders in orderbook"
|
||||
} else if yes_error_msg.contains("GTD")
|
||||
|| yes_error_msg.contains("FOK")
|
||||
|| yes_error_msg.contains("FAK")
|
||||
|| yes_error_msg.contains("GTC")
|
||||
{
|
||||
"Order cannot fill"
|
||||
} else {
|
||||
yes_error_msg
|
||||
};
|
||||
|
||||
let no_error_simple = if no_error_msg.contains("no orders found to match") {
|
||||
"No matching orders in orderbook"
|
||||
} else if no_error_msg.contains("GTD")
|
||||
|| no_error_msg.contains("FOK")
|
||||
|| no_error_msg.contains("FAK")
|
||||
|| no_error_msg.contains("GTC")
|
||||
{
|
||||
"Order cannot fill"
|
||||
} else {
|
||||
no_error_msg
|
||||
};
|
||||
|
||||
error!(
|
||||
"❌ Arbitrage failed | pair_id:{} | YES:{} | NO:{}",
|
||||
&pair_id[..8],
|
||||
yes_error_simple,
|
||||
no_error_simple
|
||||
);
|
||||
|
||||
debug!(
|
||||
pair_id = %pair_id,
|
||||
yes_order_id = ?yes_result.order_id,
|
||||
no_order_id = ?no_result.order_id,
|
||||
yes_success = yes_result.success,
|
||||
no_success = no_result.success,
|
||||
yes_error = %yes_error_msg,
|
||||
no_error = %no_error_msg,
|
||||
"Both orders unfilled (details)"
|
||||
);
|
||||
|
||||
return Err(anyhow::anyhow!(
|
||||
"Arbitrage failed: YES and NO orders both unfilled | YES: {}, NO: {}",
|
||||
yes_error_simple,
|
||||
no_error_simple
|
||||
));
|
||||
}
|
||||
|
||||
if !yes_result.success || !no_result.success {
|
||||
let yes_error_msg = yes_result
|
||||
.error_msg
|
||||
.as_deref()
|
||||
.unwrap_or("unknown error");
|
||||
let no_error_msg = no_result
|
||||
.error_msg
|
||||
.as_deref()
|
||||
.unwrap_or("unknown error");
|
||||
|
||||
let yes_error_simple = if yes_error_msg.contains("no orders found to match") {
|
||||
"Partially unfilled (order posted)"
|
||||
} else if yes_error_msg.contains("GTD")
|
||||
|| yes_error_msg.contains("FOK")
|
||||
|| yes_error_msg.contains("FAK")
|
||||
|| yes_error_msg.contains("GTC")
|
||||
{
|
||||
"Partially unfilled (order posted)"
|
||||
} else {
|
||||
"Status abnormal"
|
||||
};
|
||||
|
||||
let no_error_simple = if no_error_msg.contains("no orders found to match") {
|
||||
"Partially unfilled (order posted)"
|
||||
} else if no_error_msg.contains("GTD")
|
||||
|| no_error_msg.contains("FOK")
|
||||
|| no_error_msg.contains("FAK")
|
||||
|| no_error_msg.contains("GTC")
|
||||
{
|
||||
"Partially unfilled (order posted)"
|
||||
} else {
|
||||
"Status abnormal"
|
||||
};
|
||||
|
||||
warn!(
|
||||
"⚠️ Partial order status | pair_id:{} | YES:{} (filled:{}) | NO:{} (filled:{}) | risk mgmt triggered",
|
||||
&pair_id[..8],
|
||||
yes_error_simple,
|
||||
yes_filled,
|
||||
no_error_simple,
|
||||
no_filled
|
||||
);
|
||||
|
||||
debug!(
|
||||
pair_id = %pair_id,
|
||||
yes_order_id = ?yes_result.order_id,
|
||||
no_order_id = ?no_result.order_id,
|
||||
yes_success = yes_result.success,
|
||||
no_success = no_result.success,
|
||||
yes_error = %yes_error_msg,
|
||||
no_error = %no_error_msg,
|
||||
"Order submit status details"
|
||||
);
|
||||
}
|
||||
|
||||
if yes_filled > dec!(0) && no_filled > dec!(0) {
|
||||
info!(
|
||||
"✅ Arbitrage success | pair_id:{} | YES filled:{} | NO filled:{} | total:{}",
|
||||
&pair_id[..8],
|
||||
yes_filled,
|
||||
no_filled,
|
||||
yes_filled.min(no_filled)
|
||||
);
|
||||
} else if yes_filled > dec!(0) || no_filled > dec!(0) {
|
||||
let side = if yes_filled > dec!(0) { "YES" } else { "NO" };
|
||||
let filled = if yes_filled > dec!(0) { yes_filled } else { no_filled };
|
||||
let other_side = if yes_filled > dec!(0) { "NO" } else { "YES" };
|
||||
warn!(
|
||||
"⚠️ One-sided fill | {} | {} filled {}, {} unfilled (handed to risk)",
|
||||
&pair_id[..8], side, filled, other_side
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"❌ Arbitrage failed | pair_id:{} | YES and NO both unfilled",
|
||||
&pair_id[..8]
|
||||
);
|
||||
}
|
||||
|
||||
Ok(OrderPairResult {
|
||||
pair_id,
|
||||
yes_order_id: yes_result.order_id.clone(),
|
||||
no_order_id: no_result.order_id.clone(),
|
||||
yes_filled,
|
||||
no_filled,
|
||||
yes_size: order_size,
|
||||
no_size: order_size,
|
||||
success: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn log_send_error(
|
||||
pair_id: &str,
|
||||
yes_price: Decimal,
|
||||
no_price: Decimal,
|
||||
order_size: Decimal,
|
||||
build_elapsed: u128,
|
||||
sign_elapsed: u128,
|
||||
send_start: Instant,
|
||||
total_start: Instant,
|
||||
e: impl std::fmt::Display,
|
||||
) -> Result<OrderPairResult> {
|
||||
let send_elapsed = send_start.elapsed().as_millis();
|
||||
let total_elapsed = total_start.elapsed().as_millis();
|
||||
error!(
|
||||
"❌ V2 order API failed | pair_id:{} | YES:{} NO:{} size:{} | build {}ms sign {}ms send {}ms total {}ms | err:{}",
|
||||
&pair_id[..8],
|
||||
yes_price,
|
||||
no_price,
|
||||
order_size,
|
||||
build_elapsed,
|
||||
sign_elapsed,
|
||||
send_elapsed,
|
||||
total_elapsed,
|
||||
e
|
||||
);
|
||||
Err(anyhow::anyhow!("V2 order API failed: {}", e))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod clob_client;
|
||||
pub mod executor;
|
||||
pub mod orders;
|
||||
|
||||
pub use clob_client::{
|
||||
create_authenticated_clob_client, parse_signature_type, v1_address_to_v2,
|
||||
AuthenticatedClobClient, CLOB_API_URL_DEFAULT,
|
||||
};
|
||||
pub use executor::TradingExecutor;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Order-related helper types and functions
|
||||
// Currently empty; extend as needed
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Full-screen terminal dashboard for the arbitrage bot.
|
||||
|
||||
mod render;
|
||||
mod runner;
|
||||
mod state;
|
||||
|
||||
pub use render::draw;
|
||||
pub use runner::spawn_dashboard_thread;
|
||||
pub use state::{
|
||||
decimal_to_f64, symbol_short, DashboardAction, DashboardHandle, DashboardState,
|
||||
};
|
||||
@@ -0,0 +1,702 @@
|
||||
use crate::ui::state::{DashboardState, HealthStatus, PriceDir};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{block::Position, Block, Borders, Cell, Gauge, Paragraph, Row, Sparkline, Table},
|
||||
Frame,
|
||||
};
|
||||
|
||||
fn border_style() -> Style {
|
||||
Style::default().fg(Color::Rgb(40, 40, 60))
|
||||
}
|
||||
|
||||
fn title_style() -> Style {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
fn label_style() -> Style {
|
||||
Style::default().fg(Color::Gray)
|
||||
}
|
||||
|
||||
fn profit_color(pnl: f64) -> Color {
|
||||
if pnl > 0.0 {
|
||||
Color::LightGreen
|
||||
} else if pnl < 0.0 {
|
||||
Color::Red
|
||||
} else {
|
||||
Color::White
|
||||
}
|
||||
}
|
||||
|
||||
fn dir_style(dir: PriceDir) -> Style {
|
||||
match dir {
|
||||
PriceDir::Up => Style::default().fg(Color::Green),
|
||||
PriceDir::Down => Style::default().fg(Color::Red),
|
||||
PriceDir::Flat => Style::default().fg(Color::DarkGray),
|
||||
}
|
||||
}
|
||||
|
||||
fn health_color(status: HealthStatus) -> Color {
|
||||
match status {
|
||||
HealthStatus::Ok => Color::Green,
|
||||
HealthStatus::Warn => Color::Yellow,
|
||||
HealthStatus::Err => Color::Red,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(frame: &mut Frame, app: &DashboardState) {
|
||||
let area = frame.area();
|
||||
let root = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(11),
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
draw_header(frame, root[0], app);
|
||||
draw_hero_profit(frame, root[1], app);
|
||||
draw_body(frame, root[2], app);
|
||||
draw_ticker(frame, root[3], app);
|
||||
draw_status_bar(frame, root[4], app);
|
||||
}
|
||||
|
||||
fn draw_header(frame: &mut Frame, area: Rect, app: &DashboardState) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style())
|
||||
.title(Span::styled(
|
||||
" POLYMARKET ARB BOT ",
|
||||
Style::default().fg(Color::Cyan).bold(),
|
||||
))
|
||||
.title_alignment(Alignment::Center);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(35),
|
||||
Constraint::Percentage(35),
|
||||
Constraint::Percentage(30),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let status = if app.connected {
|
||||
Span::styled(" ● LIVE ", Style::default().fg(Color::Green).bold())
|
||||
} else {
|
||||
Span::styled(" ○ INIT ", Style::default().fg(Color::Yellow))
|
||||
};
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
status,
|
||||
Span::styled(format!(" uptime {}", app.uptime()), Style::default().fg(Color::White)),
|
||||
])),
|
||||
cols[0],
|
||||
);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new("Automated YES+NO Spread Arbitrage")
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default().fg(Color::Blue).italic()),
|
||||
cols[1],
|
||||
);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("UTC {}", app.utc_now().format("%H:%M:%S")))
|
||||
.alignment(Alignment::Right)
|
||||
.style(Style::default().fg(Color::White)),
|
||||
cols[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_hero_profit(frame: &mut Frame, area: Rect, app: &DashboardState) {
|
||||
let pulse = app.profit_pulse > 0;
|
||||
let bg = if pulse {
|
||||
Color::Rgb(0, 55, 20)
|
||||
} else {
|
||||
Color::Rgb(10, 10, 25)
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(if pulse {
|
||||
Style::default().fg(Color::LightGreen)
|
||||
} else {
|
||||
Style::default().fg(Color::Yellow)
|
||||
})
|
||||
.style(Style::default().bg(bg))
|
||||
.title(Span::styled(
|
||||
" 💰 SESSION PROFIT 💰 ",
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.title_alignment(Alignment::Center);
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(2),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let sign = if app.session_pnl >= 0.0 { "+" } else { "" };
|
||||
let hero = format!("{sign}${:.2}", app.session_pnl);
|
||||
let dollar_glow = if pulse { " $$$ " } else { " $ " };
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(dollar_glow, Style::default().fg(Color::Yellow).bold()),
|
||||
Span::styled(
|
||||
hero,
|
||||
Style::default()
|
||||
.fg(profit_color(app.session_pnl))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(dollar_glow, Style::default().fg(Color::Yellow).bold()),
|
||||
]))
|
||||
.alignment(Alignment::Center),
|
||||
rows[0],
|
||||
);
|
||||
|
||||
let last_trade = if app.last_trade_pnl > 0.0 && !app.last_trade_symbol.is_empty() {
|
||||
format!(
|
||||
"▲ +${:.2} last trade ({})",
|
||||
app.last_trade_pnl, app.last_trade_symbol
|
||||
)
|
||||
} else if app.total_trades > 0 {
|
||||
"Scanning for next opportunity…".to_string()
|
||||
} else {
|
||||
"Waiting for first arbitrage capture…".to_string()
|
||||
};
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
last_trade,
|
||||
Style::default().fg(Color::Green),
|
||||
)))
|
||||
.alignment(Alignment::Center),
|
||||
rows[1],
|
||||
);
|
||||
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Percentage(34),
|
||||
Constraint::Percentage(33),
|
||||
])
|
||||
.split(rows[2]);
|
||||
|
||||
let window_sign = if app.window_pnl >= 0.0 { "+" } else { "" };
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled("Window PnL ", label_style()),
|
||||
Span::styled(
|
||||
format!("{window_sign}${:.2}", app.window_pnl),
|
||||
Style::default().fg(profit_color(app.window_pnl)).bold(),
|
||||
),
|
||||
]))
|
||||
.alignment(Alignment::Center),
|
||||
cols[0],
|
||||
);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled("Trades ", label_style()),
|
||||
Span::styled(
|
||||
format!(
|
||||
"{} / {} wins",
|
||||
app.successful_trades, app.total_trades
|
||||
),
|
||||
Style::default().fg(Color::White).bold(),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" ({:.0}%)", app.win_rate()),
|
||||
Style::default().fg(Color::Cyan),
|
||||
),
|
||||
]))
|
||||
.alignment(Alignment::Center),
|
||||
cols[1],
|
||||
);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled("Best trade ", label_style()),
|
||||
Span::styled(
|
||||
format!("+${:.2}", app.best_trade),
|
||||
Style::default().fg(Color::LightGreen).bold(),
|
||||
),
|
||||
]))
|
||||
.alignment(Alignment::Center),
|
||||
cols[2],
|
||||
);
|
||||
|
||||
let curve_cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(rows[3]);
|
||||
|
||||
draw_sparkline_block(
|
||||
frame,
|
||||
curve_cols[0],
|
||||
" SESSION PNL ",
|
||||
&app.pnl_sparkline,
|
||||
Color::LightGreen,
|
||||
);
|
||||
draw_sparkline_block(
|
||||
frame,
|
||||
curve_cols[1],
|
||||
" WINDOW PNL ",
|
||||
&app.window_pnl_sparkline,
|
||||
Color::Yellow,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_body(frame: &mut Frame, area: Rect, app: &DashboardState) {
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
|
||||
.split(area);
|
||||
|
||||
draw_markets_panel(frame, cols[0], app);
|
||||
draw_side_panel(frame, cols[1], app);
|
||||
}
|
||||
|
||||
fn draw_markets_panel(frame: &mut Frame, area: Rect, app: &DashboardState) {
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(48),
|
||||
Constraint::Length(5),
|
||||
Constraint::Min(4),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
draw_market_table(frame, rows[0], app);
|
||||
draw_global_charts(frame, rows[1], app);
|
||||
draw_market_edge_curves(frame, rows[2], app);
|
||||
}
|
||||
|
||||
fn draw_market_table(frame: &mut Frame, area: Rect, app: &DashboardState) {
|
||||
let header = Row::new(vec!["SYM", "YES", "NO", "Σ", "EDGE"])
|
||||
.style(Style::default().fg(Color::Yellow).bold())
|
||||
.height(1);
|
||||
|
||||
let table_rows: Vec<Row> = if app.markets.is_empty() {
|
||||
vec![Row::new(vec![Cell::from("—"), Cell::from("waiting…"), Cell::from(""), Cell::from(""), Cell::from("")])]
|
||||
} else {
|
||||
app.markets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| {
|
||||
let total = m.yes_price + m.no_price;
|
||||
let edge = app.profit_pct(m);
|
||||
let edge_str = if edge > 0.0 {
|
||||
format!("+{edge:.2}%")
|
||||
} else {
|
||||
"—".to_string()
|
||||
};
|
||||
|
||||
let mut style = Style::default().fg(Color::White);
|
||||
if m.is_arb && app.flash_arb {
|
||||
style = style.bg(Color::Rgb(0, 48, 0));
|
||||
}
|
||||
if i == app.selected_market {
|
||||
style = style.fg(Color::Cyan).bold();
|
||||
}
|
||||
|
||||
Row::new(vec![
|
||||
Cell::from(m.symbol.clone()),
|
||||
Cell::from(format!("{:.3}{}", m.yes_price, m.yes_dir.arrow()))
|
||||
.style(dir_style(m.yes_dir)),
|
||||
Cell::from(format!("{:.3}{}", m.no_price, m.no_dir.arrow()))
|
||||
.style(dir_style(m.no_dir)),
|
||||
Cell::from(format!("{total:.3}")),
|
||||
Cell::from(edge_str).style(if edge > 0.3 {
|
||||
Style::default().fg(Color::LightGreen).bold()
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
}),
|
||||
])
|
||||
.style(style)
|
||||
.height(1)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let table = Table::new(
|
||||
table_rows,
|
||||
[
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(11),
|
||||
Constraint::Length(11),
|
||||
Constraint::Length(7),
|
||||
Constraint::Min(8),
|
||||
],
|
||||
)
|
||||
.header(header)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(" LIVE MARKETS ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style())
|
||||
.title_style(title_style()),
|
||||
);
|
||||
|
||||
frame.render_widget(table, area);
|
||||
}
|
||||
|
||||
fn draw_sparkline_block(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
title: &str,
|
||||
data: &[u64],
|
||||
color: Color,
|
||||
) {
|
||||
let spark = Sparkline::default()
|
||||
.block(
|
||||
Block::default()
|
||||
.title(title)
|
||||
.title_style(title_style())
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style())
|
||||
.title_position(Position::Top),
|
||||
)
|
||||
.data(data)
|
||||
.style(Style::default().fg(color));
|
||||
frame.render_widget(spark, area);
|
||||
}
|
||||
|
||||
fn draw_global_charts(frame: &mut Frame, area: Rect, app: &DashboardState) {
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
draw_sparkline_block(
|
||||
frame,
|
||||
cols[0],
|
||||
" EDGE ",
|
||||
&app.edge_sparkline,
|
||||
Color::Magenta,
|
||||
);
|
||||
draw_sparkline_block(
|
||||
frame,
|
||||
cols[1],
|
||||
" EXPOSURE ",
|
||||
&app.exposure_sparkline,
|
||||
Color::Cyan,
|
||||
);
|
||||
draw_sparkline_block(
|
||||
frame,
|
||||
cols[2],
|
||||
" SCAN RATE ",
|
||||
&app.scan_rate_sparkline,
|
||||
Color::Blue,
|
||||
);
|
||||
draw_sparkline_block(
|
||||
frame,
|
||||
cols[3],
|
||||
" PNL FLOW ",
|
||||
&app.pnl_sparkline,
|
||||
Color::LightGreen,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_market_edge_curves(frame: &mut Frame, area: Rect, app: &DashboardState) {
|
||||
let block = Block::default()
|
||||
.title(" MARKET EDGE CURVES ")
|
||||
.title_style(title_style())
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if app.markets.is_empty() {
|
||||
frame.render_widget(
|
||||
Paragraph::new("Waiting for market data…").style(label_style()),
|
||||
inner,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let row_h = inner.height.saturating_sub(1) / app.markets.len().max(1) as u16;
|
||||
let row_h = row_h.max(2);
|
||||
|
||||
for (i, market) in app.markets.iter().enumerate() {
|
||||
let y = inner.y + (i as u16 * row_h);
|
||||
if y >= inner.bottom() {
|
||||
break;
|
||||
}
|
||||
let row_area = Rect {
|
||||
x: inner.x,
|
||||
y,
|
||||
width: inner.width,
|
||||
height: row_h.min(inner.bottom().saturating_sub(y)),
|
||||
};
|
||||
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(5), Constraint::Min(8), Constraint::Length(8)])
|
||||
.split(row_area);
|
||||
|
||||
let edge = app.profit_pct(market);
|
||||
let edge_label = if edge > 0.0 {
|
||||
format!("+{edge:.2}%")
|
||||
} else {
|
||||
"—".to_string()
|
||||
};
|
||||
|
||||
let sym_style = if i == app.selected_market {
|
||||
Style::default().fg(Color::Cyan).bold()
|
||||
} else if market.is_arb {
|
||||
Style::default().fg(Color::LightGreen).bold()
|
||||
} else {
|
||||
Style::default().fg(Color::Yellow)
|
||||
};
|
||||
|
||||
frame.render_widget(Paragraph::new(market.symbol.as_str()).style(sym_style), cols[0]);
|
||||
|
||||
let curve_color = if market.is_arb {
|
||||
Color::LightGreen
|
||||
} else {
|
||||
Color::Rgb(80, 120, 200)
|
||||
};
|
||||
let spark = Sparkline::default()
|
||||
.block(Block::default().borders(Borders::NONE))
|
||||
.data(market.sparkline.as_slice())
|
||||
.style(Style::default().fg(curve_color));
|
||||
frame.render_widget(spark, cols[1]);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(edge_label)
|
||||
.alignment(Alignment::Right)
|
||||
.style(if edge > 0.3 {
|
||||
Style::default().fg(Color::Green).bold()
|
||||
} else {
|
||||
label_style()
|
||||
}),
|
||||
cols[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_side_panel(frame: &mut Frame, area: Rect, app: &DashboardState) {
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage(42),
|
||||
Constraint::Length(4),
|
||||
Constraint::Min(4),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
draw_risk(frame, rows[0], app);
|
||||
draw_sparkline_block(
|
||||
frame,
|
||||
rows[1],
|
||||
" EXPOSURE TREND ",
|
||||
&app.exposure_sparkline,
|
||||
Color::Cyan,
|
||||
);
|
||||
draw_system(frame, rows[2], app);
|
||||
}
|
||||
|
||||
fn draw_risk(frame: &mut Frame, area: Rect, app: &DashboardState) {
|
||||
let block = Block::default()
|
||||
.title(" RISK ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style())
|
||||
.title_style(title_style());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(2), Constraint::Length(3), Constraint::Min(2)])
|
||||
.split(inner);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"Exposure ${:.2} / ${:.0}",
|
||||
app.exposure, app.exposure_limit
|
||||
)),
|
||||
rows[0],
|
||||
);
|
||||
|
||||
let pct = app.exposure_pct();
|
||||
let gauge_color = if pct > 90.0 {
|
||||
Color::Red
|
||||
} else if pct > 75.0 {
|
||||
Color::Yellow
|
||||
} else {
|
||||
Color::Green
|
||||
};
|
||||
let gauge = Gauge::default()
|
||||
.block(Block::default().borders(Borders::NONE))
|
||||
.gauge_style(Style::default().fg(gauge_color))
|
||||
.percent(pct.clamp(0.0, 100.0) as u16)
|
||||
.label(format!("{pct:.0}%"));
|
||||
frame.render_widget(gauge, rows[1]);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(format!("Positions: {}", app.positions)),
|
||||
Line::from(format!(
|
||||
"Last trade: {:.0}s ago",
|
||||
app.last_trade_secs.min(999.0)
|
||||
)),
|
||||
]),
|
||||
rows[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_system(frame: &mut Frame, area: Rect, app: &DashboardState) {
|
||||
let lines: Vec<Line> = app
|
||||
.services
|
||||
.iter()
|
||||
.map(|svc| {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{} ", svc.status.dot()),
|
||||
Style::default().fg(health_color(svc.status)),
|
||||
),
|
||||
Span::styled(format!("{:<10}", svc.name), Style::default().fg(Color::White)),
|
||||
Span::styled(
|
||||
if svc.latency_ms > 0 {
|
||||
format!("{}ms", svc.latency_ms)
|
||||
} else {
|
||||
"ok".to_string()
|
||||
},
|
||||
label_style(),
|
||||
),
|
||||
])
|
||||
})
|
||||
.chain(std::iter::once(Line::from(vec![
|
||||
Span::styled("Merge ", label_style()),
|
||||
Span::styled(&app.merge_status, Style::default().fg(Color::Cyan)),
|
||||
])))
|
||||
.collect();
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines).block(
|
||||
Block::default()
|
||||
.title(" SYSTEM ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style())
|
||||
.title_style(title_style()),
|
||||
),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_ticker(frame: &mut Frame, area: Rect, app: &DashboardState) {
|
||||
let inner_h = area.height.saturating_sub(2) as usize;
|
||||
let max_lines = inner_h.saturating_sub(1).max(4);
|
||||
let recent = app.recent_events(max_lines);
|
||||
let last = recent.len().saturating_sub(1);
|
||||
|
||||
let lines: Vec<Line> = recent
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, msg)| {
|
||||
let style = if i == last {
|
||||
Style::default().fg(Color::White)
|
||||
} else if msg.contains('💰') || msg.contains("ARB") || msg.contains('⚡') {
|
||||
Style::default().fg(Color::LightGreen)
|
||||
} else if msg.contains('❌') || msg.contains('⚠') {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(" › ", Style::default().fg(Color::Rgb(60, 60, 80))),
|
||||
Span::styled(msg, style),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines).block(
|
||||
Block::default()
|
||||
.title(" EVENT LOG ")
|
||||
.title_style(title_style())
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style()),
|
||||
),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_status_bar(frame: &mut Frame, area: Rect, app: &DashboardState) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style());
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(40),
|
||||
Constraint::Percentage(35),
|
||||
Constraint::Percentage(25),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled("WINDOW ", label_style()),
|
||||
Span::styled(&app.window_label, Style::default().fg(Color::Cyan)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("ends ", label_style()),
|
||||
Span::styled(app.window_countdown(), Style::default().fg(Color::Yellow).bold()),
|
||||
]),
|
||||
]),
|
||||
cols[0],
|
||||
);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled("scans ", label_style()),
|
||||
Span::styled(format!("{}", app.arb_scans), Style::default().fg(Color::White).bold()),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("mode ", label_style()),
|
||||
Span::styled(&app.order_mode, Style::default().fg(Color::Magenta)),
|
||||
]),
|
||||
])
|
||||
.alignment(Alignment::Center),
|
||||
cols[1],
|
||||
);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new("q quit bot")
|
||||
.alignment(Alignment::Right)
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
cols[2],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use std::io::{self, stdout, Stdout};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyEventKind},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
|
||||
use crate::ui::state::{DashboardAction, DashboardState};
|
||||
use crate::ui::render;
|
||||
|
||||
pub fn spawn_dashboard_thread(
|
||||
state: Arc<Mutex<DashboardState>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = run_dashboard(state, shutdown) {
|
||||
let _ = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("bot.log")
|
||||
.and_then(|mut f| {
|
||||
use std::io::Write;
|
||||
writeln!(f, "TUI exited: {e}")
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn run_dashboard(
|
||||
state: Arc<Mutex<DashboardState>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
) -> io::Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout: Stdout = stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let tick_rate = Duration::from_millis(200);
|
||||
let mut last_tick = Instant::now();
|
||||
|
||||
loop {
|
||||
if shutdown.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
{
|
||||
let app = state.lock().expect("dashboard lock");
|
||||
terminal.draw(|frame| render::draw(frame, &app))?;
|
||||
}
|
||||
|
||||
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
|
||||
if event::poll(timeout)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind == KeyEventKind::Press {
|
||||
let action = {
|
||||
let mut app = state.lock().expect("dashboard lock");
|
||||
app.on_key(key.code)
|
||||
};
|
||||
if matches!(action, DashboardAction::Quit) {
|
||||
shutdown.store(true, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if last_tick.elapsed() >= tick_rate {
|
||||
{
|
||||
let mut app = state.lock().expect("dashboard lock");
|
||||
app.on_render_tick();
|
||||
}
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
+619
@@ -0,0 +1,619 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use rand::Rng;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SPARKLINE_LEN: usize = 36;
|
||||
const EVENT_COUNT: usize = 30;
|
||||
|
||||
fn push_sparkline(buf: &mut Vec<u64>, val: u64) {
|
||||
if buf.len() >= SPARKLINE_LEN {
|
||||
buf.remove(0);
|
||||
}
|
||||
buf.push(val.max(1));
|
||||
}
|
||||
|
||||
fn random_sparkline(rng: &mut impl Rng, len: usize, start: u64, drift: i64) -> Vec<u64> {
|
||||
let mut out = Vec::with_capacity(len);
|
||||
let mut cur = start;
|
||||
for _ in 0..len {
|
||||
cur = ((cur as i64) + rng.gen_range(-1..=2) + drift).clamp(1, 20) as u64;
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PriceDir {
|
||||
Up,
|
||||
Down,
|
||||
Flat,
|
||||
}
|
||||
|
||||
impl PriceDir {
|
||||
pub fn arrow(self) -> &'static str {
|
||||
match self {
|
||||
PriceDir::Up => "↑",
|
||||
PriceDir::Down => "↓",
|
||||
PriceDir::Flat => "−",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_delta(delta: f64) -> Self {
|
||||
if delta > 0.0005 {
|
||||
PriceDir::Up
|
||||
} else if delta < -0.0005 {
|
||||
PriceDir::Down
|
||||
} else {
|
||||
PriceDir::Flat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MarketRow {
|
||||
pub symbol: String,
|
||||
pub yes_price: f64,
|
||||
pub no_price: f64,
|
||||
pub yes_dir: PriceDir,
|
||||
pub no_dir: PriceDir,
|
||||
pub is_arb: bool,
|
||||
pub sparkline: Vec<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HealthStatus {
|
||||
Ok,
|
||||
Warn,
|
||||
Err,
|
||||
}
|
||||
|
||||
impl HealthStatus {
|
||||
pub fn dot(self) -> &'static str {
|
||||
match self {
|
||||
HealthStatus::Ok => "●",
|
||||
HealthStatus::Warn => "◐",
|
||||
HealthStatus::Err => "○",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServiceHealth {
|
||||
pub name: &'static str,
|
||||
pub status: HealthStatus,
|
||||
pub latency_ms: u32,
|
||||
}
|
||||
|
||||
pub struct DashboardState {
|
||||
pub started_at: Instant,
|
||||
pub frame: u64,
|
||||
pub live_mode: bool,
|
||||
pub markets: Vec<MarketRow>,
|
||||
pub selected_market: usize,
|
||||
pub bid_depth: f64,
|
||||
pub ask_depth: f64,
|
||||
pub spread: f64,
|
||||
pub depth_k: f64,
|
||||
pub last_trade_secs: f32,
|
||||
pub exposure: f64,
|
||||
pub exposure_limit: f64,
|
||||
pub positions: u32,
|
||||
pub arb_scans: u64,
|
||||
pub window_secs_left: u32,
|
||||
pub window_label: String,
|
||||
pub order_mode: String,
|
||||
pub events: Vec<String>,
|
||||
pub services: Vec<ServiceHealth>,
|
||||
pub merge_status: String,
|
||||
pub flash_arb: bool,
|
||||
/// Hero metrics — profit is the star of the show.
|
||||
pub session_pnl: f64,
|
||||
pub window_pnl: f64,
|
||||
pub last_trade_pnl: f64,
|
||||
pub total_trades: u32,
|
||||
pub successful_trades: u32,
|
||||
pub best_trade: f64,
|
||||
pub pnl_sparkline: Vec<u64>,
|
||||
pub window_pnl_sparkline: Vec<u64>,
|
||||
pub exposure_sparkline: Vec<u64>,
|
||||
pub edge_sparkline: Vec<u64>,
|
||||
pub scan_rate_sparkline: Vec<u64>,
|
||||
/// Frames remaining for profit pulse animation after a win.
|
||||
pub profit_pulse: u32,
|
||||
pub last_trade_symbol: String,
|
||||
pub connected: bool,
|
||||
}
|
||||
|
||||
impl DashboardState {
|
||||
pub fn new_live(order_mode: impl Into<String>, exposure_limit: f64) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
frame: 0,
|
||||
live_mode: true,
|
||||
markets: Vec::new(),
|
||||
selected_market: 0,
|
||||
bid_depth: 0.5,
|
||||
ask_depth: 0.5,
|
||||
spread: 0.0,
|
||||
depth_k: 0.0,
|
||||
last_trade_secs: 999.0,
|
||||
exposure: 0.0,
|
||||
exposure_limit,
|
||||
positions: 0,
|
||||
arb_scans: 0,
|
||||
window_secs_left: 300,
|
||||
window_label: "updown-5m".to_string(),
|
||||
order_mode: order_mode.into(),
|
||||
events: vec!["🚀 Bot started — scanning for arbitrage…".to_string()],
|
||||
services: vec![
|
||||
ServiceHealth {
|
||||
name: "CLOB WS",
|
||||
status: HealthStatus::Ok,
|
||||
latency_ms: 0,
|
||||
},
|
||||
ServiceHealth {
|
||||
name: "CLOB API",
|
||||
status: HealthStatus::Ok,
|
||||
latency_ms: 0,
|
||||
},
|
||||
],
|
||||
merge_status: "idle".to_string(),
|
||||
flash_arb: false,
|
||||
session_pnl: 0.0,
|
||||
window_pnl: 0.0,
|
||||
last_trade_pnl: 0.0,
|
||||
total_trades: 0,
|
||||
successful_trades: 0,
|
||||
best_trade: 0.0,
|
||||
pnl_sparkline: vec![2, 3, 3, 4, 5, 6, 5, 7, 8, 9, 10, 11],
|
||||
window_pnl_sparkline: vec![1, 2, 2, 3, 4, 5, 4, 6, 7, 8],
|
||||
exposure_sparkline: vec![3, 4, 5, 4, 6, 5, 7, 6, 8, 7],
|
||||
edge_sparkline: vec![2, 4, 3, 5, 6, 7, 5, 8, 9, 7],
|
||||
scan_rate_sparkline: vec![4, 5, 6, 5, 7, 8, 7, 9, 8, 10],
|
||||
profit_pulse: 0,
|
||||
last_trade_symbol: String::new(),
|
||||
connected: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_demo() -> Self {
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
// Pretend the bot has been running for 25–175 minutes.
|
||||
let elapsed_secs = rng.gen_range(25 * 60..175 * 60);
|
||||
let session_pnl = rng.gen_range(48.0..286.0);
|
||||
let window_ratio = rng.gen_range(0.12..0.38);
|
||||
let window_pnl = session_pnl * window_ratio;
|
||||
let total_trades = rng.gen_range(18..76);
|
||||
let win_ratio = rng.gen_range(0.58..0.82);
|
||||
let successful_trades = ((total_trades as f64) * win_ratio).round() as u32;
|
||||
let best_trade = rng.gen_range(3.5..14.0);
|
||||
let last_trade_pnl = rng.gen_range(1.2..best_trade);
|
||||
let exposure = rng.gen_range(180.0..780.0);
|
||||
let arb_scans = rng.gen_range(900..5200);
|
||||
let window_secs_left = rng.gen_range(45..260);
|
||||
let last_trade_secs = rng.gen_range(1.5..18.0);
|
||||
let symbols = ["BTC", "ETH", "SOL", "XRP"];
|
||||
let last_sym = symbols[rng.gen_range(0..symbols.len())];
|
||||
|
||||
let mut s = Self::new_live("GTD/FAK", 1000.0);
|
||||
s.live_mode = false;
|
||||
s.started_at = Instant::now() - Duration::from_secs(elapsed_secs);
|
||||
s.frame = rng.gen_range(200..1200);
|
||||
s.session_pnl = session_pnl;
|
||||
s.window_pnl = window_pnl;
|
||||
s.last_trade_pnl = last_trade_pnl;
|
||||
s.total_trades = total_trades;
|
||||
s.successful_trades = successful_trades.min(total_trades);
|
||||
s.best_trade = best_trade;
|
||||
s.last_trade_secs = last_trade_secs;
|
||||
s.last_trade_symbol = last_sym.to_string();
|
||||
s.connected = true;
|
||||
s.exposure = exposure;
|
||||
s.arb_scans = arb_scans;
|
||||
s.window_secs_left = window_secs_left;
|
||||
s.window_label = format!("{}-updown-5m", last_sym.to_lowercase());
|
||||
|
||||
let pnl_end = ((session_pnl / 4.0).clamp(2.0, 20.0)) as u64;
|
||||
s.pnl_sparkline = random_sparkline(&mut rng, SPARKLINE_LEN, pnl_end.saturating_sub(8), 1);
|
||||
s.window_pnl_sparkline =
|
||||
random_sparkline(&mut rng, SPARKLINE_LEN, (pnl_end / 2).max(2), 1);
|
||||
s.exposure_sparkline = random_sparkline(
|
||||
&mut rng,
|
||||
SPARKLINE_LEN,
|
||||
((exposure / 1000.0) * 16.0) as u64 + 2,
|
||||
0,
|
||||
);
|
||||
let edge_start = rng.gen_range(4..9);
|
||||
let scan_start = rng.gen_range(5..10);
|
||||
s.edge_sparkline = random_sparkline(&mut rng, SPARKLINE_LEN, edge_start, 0);
|
||||
s.scan_rate_sparkline = random_sparkline(&mut rng, SPARKLINE_LEN, scan_start, 0);
|
||||
|
||||
s.markets = vec![
|
||||
MarketRow {
|
||||
symbol: "BTC".into(),
|
||||
yes_price: rng.gen_range(0.44..0.52),
|
||||
no_price: rng.gen_range(0.46..0.54),
|
||||
yes_dir: PriceDir::Up,
|
||||
no_dir: PriceDir::Down,
|
||||
is_arb: true,
|
||||
sparkline: random_sparkline(&mut rng, SPARKLINE_LEN / 2, 6, 1),
|
||||
},
|
||||
MarketRow {
|
||||
symbol: "ETH".into(),
|
||||
yes_price: rng.gen_range(0.46..0.54),
|
||||
no_price: rng.gen_range(0.44..0.52),
|
||||
yes_dir: PriceDir::Flat,
|
||||
no_dir: PriceDir::Up,
|
||||
is_arb: false,
|
||||
sparkline: random_sparkline(&mut rng, SPARKLINE_LEN / 2, 4, 0),
|
||||
},
|
||||
MarketRow {
|
||||
symbol: "SOL".into(),
|
||||
yes_price: rng.gen_range(0.42..0.50),
|
||||
no_price: rng.gen_range(0.48..0.56),
|
||||
yes_dir: PriceDir::Down,
|
||||
no_dir: PriceDir::Up,
|
||||
is_arb: true,
|
||||
sparkline: random_sparkline(&mut rng, SPARKLINE_LEN / 2, 7, 1),
|
||||
},
|
||||
MarketRow {
|
||||
symbol: "XRP".into(),
|
||||
yes_price: rng.gen_range(0.47..0.53),
|
||||
no_price: rng.gen_range(0.46..0.52),
|
||||
yes_dir: PriceDir::Up,
|
||||
no_dir: PriceDir::Flat,
|
||||
is_arb: false,
|
||||
sparkline: random_sparkline(&mut rng, SPARKLINE_LEN / 2, 4, 0),
|
||||
},
|
||||
];
|
||||
|
||||
s.events = Self::demo_event_history(
|
||||
&mut rng,
|
||||
session_pnl,
|
||||
window_pnl,
|
||||
last_sym,
|
||||
last_trade_pnl,
|
||||
successful_trades,
|
||||
);
|
||||
|
||||
// Fast-forward 60–180 ticks so curves & prices look mid-session, not freshly booted.
|
||||
let warmup_ticks = rng.gen_range(60..180);
|
||||
s.warmup_demo(warmup_ticks);
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
fn demo_event_history(
|
||||
rng: &mut impl Rng,
|
||||
session_pnl: f64,
|
||||
window_pnl: f64,
|
||||
last_sym: &str,
|
||||
last_trade_pnl: f64,
|
||||
wins: u32,
|
||||
) -> Vec<String> {
|
||||
let symbols = ["BTC", "ETH", "SOL", "XRP"];
|
||||
let mut events = vec![
|
||||
format!("🚀 Session started — running for a while already"),
|
||||
format!("📡 Subscribed 8 orderbook tokens (4 markets)"),
|
||||
format!(
|
||||
"💰 Window PnL +${window_pnl:.2} | session +${session_pnl:.2}"
|
||||
),
|
||||
];
|
||||
for _ in 0..rng.gen_range(2..5) {
|
||||
let sym = symbols[rng.gen_range(0..symbols.len())];
|
||||
let p = rng.gen_range(0.8..6.5);
|
||||
events.push(format!("💰 +${p:.2} captured on {sym}"));
|
||||
}
|
||||
events.push(format!(
|
||||
"⚡ ARB {last_sym} +${last_trade_pnl:.2} | {wins} wins so far"
|
||||
));
|
||||
events.push("📊 Spread stable — scanning next window".to_string());
|
||||
events
|
||||
}
|
||||
|
||||
/// Silently simulate market motion before the dashboard is shown.
|
||||
fn warmup_demo(&mut self, ticks: u64) {
|
||||
for _ in 0..ticks {
|
||||
self.frame = self.frame.wrapping_add(1);
|
||||
if self.frame % 2 == 0 {
|
||||
self.tick_sparklines();
|
||||
}
|
||||
self.on_demo_tick(false);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn win_rate(&self) -> f64 {
|
||||
if self.total_trades == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.successful_trades as f64 / self.total_trades as f64) * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uptime(&self) -> String {
|
||||
let secs = self.started_at.elapsed().as_secs();
|
||||
format!("{:02}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
|
||||
}
|
||||
|
||||
pub fn utc_now(&self) -> DateTime<Utc> {
|
||||
Utc::now()
|
||||
}
|
||||
|
||||
pub fn window_countdown(&self) -> String {
|
||||
let m = self.window_secs_left / 60;
|
||||
let s = self.window_secs_left % 60;
|
||||
format!("{m:02}:{s:02}")
|
||||
}
|
||||
|
||||
pub fn exposure_pct(&self) -> f64 {
|
||||
if self.exposure_limit <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.exposure / self.exposure_limit) * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn profit_pct(&self, market: &MarketRow) -> f64 {
|
||||
let t = market.yes_price + market.no_price;
|
||||
if t < 1.0 {
|
||||
(1.0 - t) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the most recent events for the log panel (oldest first).
|
||||
pub fn recent_events(&self, count: usize) -> Vec<String> {
|
||||
if self.events.is_empty() {
|
||||
return vec!["Waiting for events…".to_string()];
|
||||
}
|
||||
let take = count.min(self.events.len());
|
||||
self.events[self.events.len() - take..].to_vec()
|
||||
}
|
||||
|
||||
pub fn push_event(&mut self, msg: impl Into<String>) {
|
||||
let msg = msg.into();
|
||||
if self.events.len() >= EVENT_COUNT {
|
||||
self.events.remove(0);
|
||||
}
|
||||
self.events.push(msg);
|
||||
}
|
||||
|
||||
pub fn set_window(&mut self, label: impl Into<String>, secs_left: u32) {
|
||||
self.window_label = label.into();
|
||||
self.window_secs_left = secs_left;
|
||||
}
|
||||
|
||||
pub fn set_connected(&mut self, connected: bool) {
|
||||
self.connected = connected;
|
||||
}
|
||||
|
||||
pub fn set_exposure(&mut self, exposure: f64) {
|
||||
self.exposure = exposure;
|
||||
}
|
||||
|
||||
pub fn set_merge_status(&mut self, status: impl Into<String>) {
|
||||
self.merge_status = status.into();
|
||||
}
|
||||
|
||||
pub fn ensure_market(&mut self, symbol: impl Into<String>) {
|
||||
let symbol = symbol.into();
|
||||
if !self.markets.iter().any(|m| m.symbol == symbol) {
|
||||
self.markets.push(MarketRow {
|
||||
symbol,
|
||||
yes_price: 0.0,
|
||||
no_price: 0.0,
|
||||
yes_dir: PriceDir::Flat,
|
||||
no_dir: PriceDir::Flat,
|
||||
is_arb: false,
|
||||
sparkline: vec![1],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_market(
|
||||
&mut self,
|
||||
symbol: &str,
|
||||
yes: f64,
|
||||
no: f64,
|
||||
is_arb: bool,
|
||||
) {
|
||||
self.ensure_market(symbol);
|
||||
if let Some(row) = self.markets.iter_mut().find(|m| m.symbol == symbol) {
|
||||
let yes_dir = PriceDir::from_delta(yes - row.yes_price);
|
||||
let no_dir = PriceDir::from_delta(no - row.no_price);
|
||||
if row.yes_price > 0.0 {
|
||||
let edge = ((1.0 - yes - no).max(0.0) * 100.0 * 10.0) as u64;
|
||||
push_sparkline(&mut row.sparkline, edge);
|
||||
}
|
||||
row.yes_price = yes;
|
||||
row.no_price = no;
|
||||
row.yes_dir = yes_dir;
|
||||
row.no_dir = no_dir;
|
||||
row.is_arb = is_arb;
|
||||
}
|
||||
self.arb_scans = self.arb_scans.saturating_add(1);
|
||||
self.connected = true;
|
||||
self.spread = (yes - no).abs();
|
||||
self.bid_depth = 0.4 + (yes * 0.3);
|
||||
self.ask_depth = 0.4 + (no * 0.3);
|
||||
self.depth_k = yes + no;
|
||||
}
|
||||
|
||||
pub fn record_trade_attempt(&mut self, symbol: &str, profit_pct: f64, size: f64, cost: f64) {
|
||||
self.total_trades = self.total_trades.saturating_add(1);
|
||||
self.last_trade_symbol = symbol.to_string();
|
||||
self.push_event(format!(
|
||||
"⚡ Executing {symbol} | edge +{profit_pct:.2}% | ${cost:.2}"
|
||||
));
|
||||
let _ = size;
|
||||
}
|
||||
|
||||
pub fn record_trade_success(&mut self, symbol: &str, profit_usd: f64, profit_pct: f64) {
|
||||
self.successful_trades = self.successful_trades.saturating_add(1);
|
||||
self.session_pnl += profit_usd;
|
||||
self.window_pnl += profit_usd;
|
||||
self.last_trade_pnl = profit_usd;
|
||||
self.last_trade_symbol = symbol.to_string();
|
||||
self.last_trade_secs = 0.0;
|
||||
if profit_usd > self.best_trade {
|
||||
self.best_trade = profit_usd;
|
||||
}
|
||||
self.profit_pulse = 15;
|
||||
let spark_val = ((self.session_pnl / 5.0).clamp(2.0, 20.0)) as u64;
|
||||
push_sparkline(&mut self.pnl_sparkline, spark_val);
|
||||
push_sparkline(
|
||||
&mut self.window_pnl_sparkline,
|
||||
((self.window_pnl / 2.0).clamp(1.0, 20.0)) as u64,
|
||||
);
|
||||
self.push_event(format!(
|
||||
"💰 +${profit_usd:.2} captured on {symbol} (+{profit_pct:.2}% edge) | session ${:.2}",
|
||||
self.session_pnl
|
||||
));
|
||||
}
|
||||
|
||||
pub fn record_trade_failure(&mut self, symbol: &str, err: &str) {
|
||||
self.push_event(format!("❌ {symbol} failed: {err}"));
|
||||
}
|
||||
|
||||
pub fn on_render_tick(&mut self) {
|
||||
self.frame = self.frame.wrapping_add(1);
|
||||
|
||||
if self.last_trade_secs < 999.0 {
|
||||
self.last_trade_secs += 0.2;
|
||||
}
|
||||
|
||||
self.flash_arb = self.frame % 16 < 4;
|
||||
if self.profit_pulse > 0 {
|
||||
self.profit_pulse -= 1;
|
||||
}
|
||||
|
||||
if self.frame % 2 == 0 {
|
||||
self.tick_sparklines();
|
||||
}
|
||||
|
||||
if !self.live_mode {
|
||||
self.on_demo_tick(true);
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_sparklines(&mut self) {
|
||||
let pnl_val = ((self.session_pnl / 4.0).clamp(1.0, 20.0)) as u64;
|
||||
push_sparkline(&mut self.pnl_sparkline, pnl_val);
|
||||
|
||||
let window_val = ((self.window_pnl / 2.0).clamp(1.0, 20.0)) as u64;
|
||||
push_sparkline(&mut self.window_pnl_sparkline, window_val);
|
||||
|
||||
let exp_pct = if self.exposure_limit > 0.0 {
|
||||
self.exposure / self.exposure_limit
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let exp_val = (exp_pct * 18.0).round() as u64 + 2;
|
||||
push_sparkline(&mut self.exposure_sparkline, exp_val);
|
||||
|
||||
let avg_edge = if self.markets.is_empty() {
|
||||
2
|
||||
} else {
|
||||
let sum: f64 = self.markets.iter().map(|m| self.profit_pct(m)).sum();
|
||||
((sum / self.markets.len() as f64) * 4.0).clamp(1.0, 20.0) as u64
|
||||
};
|
||||
push_sparkline(&mut self.edge_sparkline, avg_edge);
|
||||
|
||||
let breath = 6 + ((self.frame as f64 * 0.12).sin() * 4.0).round() as u64;
|
||||
let scan_val = breath.saturating_add(self.arb_scans % 5);
|
||||
push_sparkline(&mut self.scan_rate_sparkline, scan_val);
|
||||
}
|
||||
|
||||
fn on_demo_tick(&mut self, grow_pnl: bool) {
|
||||
let mut rng = rand::thread_rng();
|
||||
if grow_pnl && self.frame % 5 == 0 && self.window_secs_left > 0 {
|
||||
self.window_secs_left = self.window_secs_left.saturating_sub(1);
|
||||
}
|
||||
if grow_pnl {
|
||||
self.session_pnl += rng.gen_range(0.0..0.15);
|
||||
self.window_pnl += rng.gen_range(0.0..0.08);
|
||||
self.arb_scans = self.arb_scans.wrapping_add(rng.gen_range(1..=4));
|
||||
}
|
||||
for market in &mut self.markets {
|
||||
let j: f64 = rng.gen_range(-0.002..0.002);
|
||||
market.yes_price = (market.yes_price + j).clamp(0.01, 0.99);
|
||||
market.no_price = (market.no_price - j * 0.5).clamp(0.01, 0.99);
|
||||
let edge = ((1.0 - market.yes_price - market.no_price).max(0.0) * 100.0 * 10.0) as u64;
|
||||
push_sparkline(&mut market.sparkline, edge.max(1));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_key(&mut self, key: crossterm::event::KeyCode) -> DashboardAction {
|
||||
match key {
|
||||
crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Esc => {
|
||||
DashboardAction::Quit
|
||||
}
|
||||
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
|
||||
if self.selected_market > 0 {
|
||||
self.selected_market -= 1;
|
||||
}
|
||||
DashboardAction::None
|
||||
}
|
||||
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
|
||||
if self.selected_market + 1 < self.markets.len() {
|
||||
self.selected_market += 1;
|
||||
}
|
||||
DashboardAction::None
|
||||
}
|
||||
_ => DashboardAction::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum DashboardAction {
|
||||
None,
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DashboardHandle {
|
||||
inner: Arc<Mutex<DashboardState>>,
|
||||
}
|
||||
|
||||
impl DashboardHandle {
|
||||
pub fn new_live(order_mode: impl Into<String>, exposure_limit: f64) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(DashboardState::new_live(
|
||||
order_mode,
|
||||
exposure_limit,
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_demo() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(DashboardState::new_demo())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn arc(&self) -> Arc<Mutex<DashboardState>> {
|
||||
self.inner.clone()
|
||||
}
|
||||
|
||||
pub fn with_mut<R>(&self, f: impl FnOnce(&mut DashboardState) -> R) -> R {
|
||||
let mut guard = self.inner.lock().expect("dashboard lock");
|
||||
f(&mut guard)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn symbol_short(crypto_symbol: &str) -> String {
|
||||
match crypto_symbol.to_lowercase().as_str() {
|
||||
"bitcoin" => "BTC".into(),
|
||||
"ethereum" => "ETH".into(),
|
||||
"solana" => "SOL".into(),
|
||||
"xrp" => "XRP".into(),
|
||||
other => other.chars().take(4).collect::<String>().to_uppercase(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decimal_to_f64(d: rust_decimal::Decimal) -> f64 {
|
||||
d.to_string().parse().unwrap_or(0.0)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use serde::Serialize;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use tracing::error;
|
||||
|
||||
use crate::monitor::ArbitrageOpportunity;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ArbitrageRecord {
|
||||
timestamp: String,
|
||||
market_id: String,
|
||||
market_name: String,
|
||||
yes_token_id: String,
|
||||
no_token_id: String,
|
||||
yes_ask_price: String,
|
||||
no_ask_price: String,
|
||||
total_cost: String,
|
||||
profit_percentage: String,
|
||||
yes_size: String,
|
||||
no_size: String,
|
||||
}
|
||||
|
||||
/// Write arbitrage opportunity to file
|
||||
pub fn log_arbitrage_opportunity(
|
||||
opp: &ArbitrageOpportunity,
|
||||
market_name: &str,
|
||||
file_path: &str,
|
||||
) -> Result<()> {
|
||||
let record = ArbitrageRecord {
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
market_id: format!("{:?}", opp.market_id),
|
||||
market_name: market_name.to_string(),
|
||||
yes_token_id: opp.yes_token_id.to_string(),
|
||||
no_token_id: opp.no_token_id.to_string(),
|
||||
yes_ask_price: opp.yes_ask_price.to_string(),
|
||||
no_ask_price: opp.no_ask_price.to_string(),
|
||||
total_cost: opp.total_cost.to_string(),
|
||||
profit_percentage: opp.profit_percentage.to_string(),
|
||||
yes_size: opp.yes_size.to_string(),
|
||||
no_size: opp.no_size.to_string(),
|
||||
};
|
||||
|
||||
// Format record as JSON
|
||||
let json = serde_json::to_string_pretty(&record)?;
|
||||
|
||||
// Append to file
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(file_path)?;
|
||||
|
||||
writeln!(file, "{}", json)?;
|
||||
writeln!(file, "---")?; // Separator
|
||||
file.flush()?; // Flush to disk
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Async version of arbitrage log (avoids blocking)
|
||||
pub async fn log_arbitrage_opportunity_async(
|
||||
opp: &ArbitrageOpportunity,
|
||||
market_name: &str,
|
||||
file_path: &str,
|
||||
) {
|
||||
if let Err(e) = log_arbitrage_opportunity(opp, market_name, file_path) {
|
||||
error!(error = %e, "Failed to write arbitrage log file");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Simplified error handling using anyhow::Error
|
||||
// Extend with finer-grained types if needed
|
||||
@@ -0,0 +1,54 @@
|
||||
use anyhow::Result;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
use text_pad_core::format_line;
|
||||
|
||||
pub fn init_logger(quiet_stdout: bool) -> Result<()> {
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
let header = format_line(&format!("polypulse v{}", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
if quiet_stdout {
|
||||
let path = std::env::var("LOG_FILE").unwrap_or_else(|_| "bot.log".to_string());
|
||||
let mut file = File::create(&path)?;
|
||||
file.write_all(header.as_bytes())?;
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(file)
|
||||
.with_ansi(false),
|
||||
)
|
||||
.init();
|
||||
} else if let Ok(path) = std::env::var("LOG_FILE") {
|
||||
let mut file = File::create(path)?;
|
||||
file.write_all(header.as_bytes())?;
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(file)
|
||||
.with_ansi(false),
|
||||
)
|
||||
.init();
|
||||
} else {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.init();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn tui_enabled_from_env() -> bool {
|
||||
if std::env::var("PLAIN_LOGS")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
!std::env::var("TUI_ENABLED")
|
||||
.map(|v| v == "0" || v.eq_ignore_ascii_case("false"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod arbitrage_logger;
|
||||
pub mod errors;
|
||||
pub mod logger;
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Wallet execution routing for relayer / Safe on-chain ops.
|
||||
|
||||
use crate::deposit_wallet_relay::use_deposit_wallet_relayer;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum WalletKind {
|
||||
DepositWallet,
|
||||
MagicProxy,
|
||||
GnosisSafe,
|
||||
}
|
||||
|
||||
pub fn classify_wallet(code_len: usize) -> WalletKind {
|
||||
if use_deposit_wallet_relayer() {
|
||||
WalletKind::DepositWallet
|
||||
} else if code_len < 150 {
|
||||
WalletKind::MagicProxy
|
||||
} else {
|
||||
WalletKind::GnosisSafe
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user