Initial commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
smith1ee
2026-07-27 10:13:14 +08:00
co-authored by Cursor
commit 1a4688a931
45 changed files with 8183 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
[target.x86_64-unknown-linux-musl]
linker = "musl-gcc"
[profile.release]
strip = true
lto = true
codegen-units = 1
+218
View File
@@ -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
+130
View File
@@ -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/*
+6
View File
@@ -0,0 +1,6 @@
/target
.env
bot.log
.idea
_vendor
Cargo.lock
+39
View File
@@ -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"
+104
View File
@@ -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.
![account](image/account.png)
![Dashboard](image/main.png)
## 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 Polymarkets terms and applicable laws.
+120
View File
@@ -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 服务条款及当地法律法规。
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+135
View File
@@ -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(())
}
+71
View File
@@ -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
View File
@@ -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
})
}
}
+217
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+138
View File
@@ -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,
})
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod discoverer;
pub mod scheduler;
pub use discoverer::*;
pub use scheduler::*;
+117
View File
@@ -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
View File
@@ -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 为 pUSDUSDC.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 tokenmerge 产出 USDC.eMERGE_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,
})
}
+146
View File
@@ -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,
})
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod arbitrage;
pub mod orderbook;
pub use arbitrage::*;
pub use orderbook::*;
+163
View File
@@ -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();
}
}
+45
View File
@@ -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")
}
+386
View File
@@ -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.ebridged),V1 及旧持仓抵押品
pub const USDC_POLYGON: Address = address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174");
/// pUSDPolymarket USD),V2 抵押品
pub const PUSD_POLYGON: Address = address!("0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB");
/// CTFConditional 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 OnrampUSDC.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
+278
View File
@@ -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
View File
@@ -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))
}
+593
View File
@@ -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()
}
}
+160
View File
@@ -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()
}
}
+9
View File
@@ -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;
+373
View File
@@ -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,
}
+248
View File
@@ -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)
}
}
+162
View File
@@ -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
// })
}
}
+1
View File
@@ -0,0 +1 @@
pub use polypulse::clob_v2::*;
+462
View File
@@ -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))
}
}
+9
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
// Order-related helper types and functions
// Currently empty; extend as needed
+11
View File
@@ -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,
};
+702
View File
@@ -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],
);
}
+86
View File
@@ -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
View File
@@ -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 25175 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 60180 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)
}
+70
View File
@@ -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");
}
}
+2
View File
@@ -0,0 +1,2 @@
// Simplified error handling using anyhow::Error
// Extend with finer-grained types if needed
+54
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
pub mod arbitrage_logger;
pub mod errors;
pub mod logger;
+20
View File
@@ -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
}
}