commit 1a4688a931432ce4e34350f63f9d86c3339312a4 Author: smith1ee Date: Mon Jul 27 10:13:14 2026 +0800 Initial commit Co-authored-by: Cursor diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..653b09e --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +[target.x86_64-unknown-linux-musl] +linker = "musl-gcc" + +[profile.release] +strip = true +lto = true +codegen-units = 1 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e9db6c7 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ce55f45 --- /dev/null +++ b/.github/workflows/release.yml @@ -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/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..972f911 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +.env +bot.log +.idea +_vendor +Cargo.lock \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6735a73 --- /dev/null +++ b/Cargo.toml @@ -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" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed03064 --- /dev/null +++ b/README.md @@ -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 Polymarket’s terms and applicable laws. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..491416d --- /dev/null +++ b/README.zh-CN.md @@ -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 服务条款及当地法律法规。 \ No newline at end of file diff --git a/image/account.png b/image/account.png new file mode 100644 index 0000000..d5d2ce4 Binary files /dev/null and b/image/account.png differ diff --git a/image/main.png b/image/main.png new file mode 100644 index 0000000..fd02e91 Binary files /dev/null and b/image/main.png differ diff --git a/src/adapter_auth.rs b/src/adapter_auth.rs new file mode 100644 index 0000000..0becd15 --- /dev/null +++ b/src/adapter_auth.rs @@ -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 { + 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( + provider: &P, + ctf: Address, + owner: Address, + adapter: Address, +) -> Result { + 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(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( + _provider: &P, + wallet: Address, + adapter: Address, + signer: &impl alloy::signers::Signer, + builder_key: &str, + builder_secret: &str, + builder_passphrase: &str, + relayer_url: &str, +) -> Result { + 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( + 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(()) +} diff --git a/src/clob_v2.rs b/src/clob_v2.rs new file mode 100644 index 0000000..9343cd5 --- /dev/null +++ b/src/clob_v2.rs @@ -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
, + signature_type: SignatureType, +) -> Result { + 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") +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..04e287f --- /dev/null +++ b/src/config.rs @@ -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 = 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
, // 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, + 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 { + dotenvy::dotenv().ok(); + + // Parse proxy_address (optional) + let proxy_address: Option
= 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 + }) + } +} diff --git a/src/deposit_wallet_relay.rs b/src/deposit_wallet_relay.rs new file mode 100644 index 0000000..f044a2a --- /dev/null +++ b/src/deposit_wallet_relay.rs @@ -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 { + 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)>, +) -> Result { + let domain = eip712_domain! { + name: "DepositWallet", + version: "1", + chain_id: chain_id, + verifying_contract: deposit_wallet, + }; + let batch_calls: Vec = 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)], + deposit_wallet: Address, + signer: &impl alloy::signers::Signer, + builder_key: &str, + builder_secret: &str, + builder_passphrase: &str, + relayer_url: &str, + metadata: &str, +) -> Result { + 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)> = 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 = 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 { + relayer_execute_deposit_wallet_calls( + &[(target, calldata.to_vec())], + deposit_wallet, + signer, + builder_key, + builder_secret, + builder_passphrase, + relayer_url, + metadata, + ) + .await +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ff39565 --- /dev/null +++ b/src/lib.rs @@ -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; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9ebb17c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1022 @@ +mod config; +mod market; +mod monitor; +mod risk; +mod trading; +mod utils; + +use polypulse::merge; +use polypulse::positions::{get_positions, Position}; +use polypulse::ui::{decimal_to_f64, spawn_dashboard_thread, symbol_short, DashboardHandle}; + +use anyhow::Result; +use dashmap::DashMap; +use futures::StreamExt; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; +use std::collections::{HashMap, HashSet}; +use std::io::{stdout, IsTerminal}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::str::FromStr as _; +use std::time::{Duration, Instant}; +use tokio::time::sleep; +use tracing::{debug, error, info, warn}; +use polymarket_client_sdk::types::{Address, B256, U256}; + +use crate::config::Config; +use crate::market::{MarketDiscoverer, MarketInfo, MarketScheduler}; +use crate::monitor::{ArbitrageDetector, OrderBookMonitor}; +use crate::risk::positions::PositionTracker; +use crate::risk::{HedgeMonitor, PositionBalancer, RiskManager}; +use crate::trading::{ + create_authenticated_clob_client, parse_signature_type, v1_address_to_v2, TradingExecutor, +}; + +/// Filter condition_ids from positions where **both YES and NO** are held; only these markets can merge. One-sided positions are skipped. +/// Data API may return outcome_index 0/1 (0=Yes, 1=No) or 1/2 (CTF index_set convention); both are supported. +fn condition_ids_with_both_sides(positions: &[Position]) -> Vec { + let mut by_condition: HashMap> = HashMap::new(); + for p in positions { + if p.size <= dec!(0) || !p.mergeable { + continue; + } + by_condition + .entry(p.condition_id) + .or_default() + .insert(p.outcome_index); + } + by_condition + .into_iter() + .filter(|(_, indices)| { + (indices.contains(&0) && indices.contains(&1)) || (indices.contains(&1) && indices.contains(&2)) + }) + .map(|(c, _)| c) + .collect() +} + +/// Build condition_id -> (yes_token_id, no_token_id, merge_amount) from positions for exposure deduction after successful merge. +/// Supports outcome_index 0/1 (0=Yes, 1=No) and 1/2 (CTF convention). +fn merge_info_with_both_sides(positions: &[Position]) -> HashMap { + // outcome_index -> (asset, size) grouped by condition + let mut by_condition: HashMap> = HashMap::new(); + for p in positions { + if p.size <= dec!(0) || !p.mergeable { + continue; + } + by_condition + .entry(p.condition_id) + .or_default() + .insert(p.outcome_index, (p.asset, p.size)); + } + by_condition + .into_iter() + .filter_map(|(c, map)| { + // Prefer CTF convention 1=Yes, 2=No; otherwise use 0=Yes, 1=No + if let (Some((yes_token, yes_size)), Some((no_token, no_size))) = + (map.get(&1).copied(), map.get(&2).copied()) + { + return Some((c, (yes_token, no_token, yes_size.min(no_size)))); + } + if let (Some((yes_token, yes_size)), Some((no_token, no_size))) = + (map.get(&0).copied(), map.get(&1).copied()) + { + return Some((c, (yes_token, no_token, yes_size.min(no_size)))); + } + None + }) + .collect() +} + +/// Convert on-chain share amount (6 decimals) to Decimal for position tracker updates. +fn raw_shares_to_decimal(raw: U256) -> Decimal { + Decimal::from_str(&raw.to_string()).unwrap_or(dec!(0)) / dec!(1_000_000) +} + +/// Scheduled Merge task: every interval_minutes minutes fetch **positions**, run merge_max **serially** only for markets with both YES+NO positions, +/// skip one-sided positions; delay between each merge, retry once on RPC rate limit. After merge success, deduct position_tracker holdings and exposure. +/// Brief initial delay before first run to avoid blocking the orderbook stream by competing for runtime at startup. +async fn run_merge_task( + interval_minutes: u64, + proxy: Address, + private_key: String, + position_tracker: Arc, + wind_down_in_progress: Arc, +) { + let interval = Duration::from_secs(interval_minutes * 60); + /// Delay between each merge to reduce RPC bursts + const DELAY_BETWEEN_MERGES: Duration = Duration::from_secs(30); + /// Duration to wait before retry on rate limit (slightly longer than "retry in 10s") + const RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(12); + /// Initial delay so main loop can finish orderbook subscription and enter select! before first merge + const INITIAL_DELAY: Duration = Duration::from_secs(10); + + // Let main loop complete get_markets, create stream and start orderbook listening before first merge + sleep(INITIAL_DELAY).await; + + loop { + if wind_down_in_progress.load(Ordering::Relaxed) { + info!("Wind-down in progress, skipping merge for this round"); + sleep(interval).await; + continue; + } + let (condition_ids, merge_info) = match get_positions().await { + Ok(positions) => ( + condition_ids_with_both_sides(&positions), + merge_info_with_both_sides(&positions), + ), + Err(e) => { + warn!(error = %e, "❌ Failed to fetch positions, skipping merge for this round"); + sleep(interval).await; + continue; + } + }; + + if condition_ids.is_empty() { + debug!("🔄 Merge round: no markets with both YES+NO positions"); + } else { + info!( + count = condition_ids.len(), + "🔄 Merge round: {} markets have both YES+NO positions", + condition_ids.len() + ); + } + + for (i, &condition_id) in condition_ids.iter().enumerate() { + // For 2nd and later markets: wait 30s before merge to avoid overlap with previous on-chain tx + if i > 0 { + info!("Merge round: waiting 30s before merging next market ({}/{})", i + 1, condition_ids.len()); + sleep(DELAY_BETWEEN_MERGES).await; + } + let asset_hint = merge_info + .get(&condition_id) + .map(|(yes_token, no_token, _)| (*yes_token, *no_token)); + let mut result = + merge::merge_max(condition_id, proxy, &private_key, None, asset_hint).await; + if result.is_err() { + let msg = result.as_ref().unwrap_err().to_string(); + if msg.contains("rate limit") || msg.contains("retry in") { + warn!(condition_id = %condition_id, "⏳ RPC rate limit, waiting {}s before retry", RATE_LIMIT_BACKOFF.as_secs()); + sleep(RATE_LIMIT_BACKOFF).await; + result = merge::merge_max(condition_id, proxy, &private_key, None, asset_hint).await; + } + } + match result { + Ok(merge_result) => { + info!("✅ Merge complete | condition_id={:#x}", condition_id); + info!(" 📝 tx={}", merge_result.tx_hash); + let chain_amt = raw_shares_to_decimal(merge_result.merged_amount); + if let Some((yes_token, no_token, merge_amt)) = merge_info.get(&condition_id) { + let deduct = chain_amt.min(*merge_amt); + position_tracker.update_exposure_cost(*yes_token, dec!(0), -deduct); + position_tracker.update_exposure_cost(*no_token, dec!(0), -deduct); + position_tracker.update_position(*yes_token, -deduct); + position_tracker.update_position(*no_token, -deduct); + info!( + "💰 Merge deducted exposure | condition_id={:#x} | amount:{} (chain:{})", + condition_id, deduct, chain_amt + ); + } + } + Err(e) => { + let msg = e.to_string(); + if msg.contains("no mergeable shares") { + debug!(condition_id = %condition_id, "⏭️ Skip merge: no mergeable shares"); + } else { + warn!(condition_id = %condition_id, error = %e, "❌ Merge failed"); + } + } + } + tokio::task::yield_now().await; + } + + sleep(interval).await; + } +} + +#[tokio::main] +async fn main() -> Result<()> { + // Must set rustls default crypto provider first, otherwise reqwest/alloy etc. will panic when using TLS + + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + let use_tui = utils::logger::tui_enabled_from_env() && stdout().is_terminal(); + utils::logger::init_logger(use_tui)?; + + let shutdown = Arc::new(AtomicBool::new(false)); + + if use_tui { + tracing::info!("Polymarket 5-minute arbitrage bot starting (dashboard mode, logs → bot.log)"); + } else { + tracing::info!("Polymarket 5-minute arbitrage bot starting"); + } + + // Load config + let config = Config::from_env()?; + tracing::info!("Config loaded"); + + let order_mode = format!("{:?}", config.arbitrage_order_type); + let dashboard = DashboardHandle::new_live(order_mode, config.risk_max_exposure_usdc); + if use_tui { + spawn_dashboard_thread(dashboard.arc(), shutdown.clone()); + dashboard.with_mut(|d| { + d.push_event("🚀 Dashboard ready — hunting arbitrage opportunities"); + }); + } + + // Initialize components (currently unused, main loop disabled) + let _discoverer = MarketDiscoverer::new(config.crypto_symbols.clone()); + let _scheduler = MarketScheduler::new(_discoverer, config.market_refresh_advance_secs); + let _detector = ArbitrageDetector::new(config.min_profit_threshold); + + // Validate private key format + info!("Verifying private key format..."); + use alloy::signers::local::LocalSigner; + use std::str::FromStr; + + let _signer_test = LocalSigner::from_str(&config.private_key) + .map_err(|e| anyhow::anyhow!("Invalid private key format: {}", e))?; + info!("Private key format validated"); + + // Initialize trading executor (requires API auth) + info!("Initializing trading executor (requires API auth)..."); + if let Some(ref proxy) = config.proxy_address { + info!( + proxy_address = %proxy, + signature_type = %config.signature_type, + "Using funder address for V2 CLOB (default SIGNATURE_TYPE=Poly1271 for deposit wallet; use Proxy only for legacy Magic proxy)" + ); + } else { + info!("Using EOA signature type (direct trading)"); + } + info!("Note: A 'Could not create api key' warning is normal. The SDK tries to create a new API key first; if it fails, it will use the derived method and auth will still succeed."); + info!(clob_url = %config.clob_api_url, signature_type = %config.signature_type, "CLOB V2 client config"); + + let v2_proxy = config.proxy_address.map(v1_address_to_v2); + let sig_type = parse_signature_type(&config.signature_type); + + let clob_client = match create_authenticated_clob_client( + &config.private_key, + &config.clob_api_url, + v2_proxy, + sig_type, + ) + .await + { + Ok(client) => { + info!("CLOB V2 client authenticated"); + client + } + Err(e) => { + error!(error = %e, "CLOB V2 authentication failed! Cannot continue."); + error!("Please check:"); + error!(" 1. POLYMARKET_PRIVATE_KEY is correctly set"); + error!(" 2. Private key format (64-char hex, no 0x prefix)"); + error!(" 3. Network connectivity"); + error!(" 4. Polymarket API availability"); + error!(" 5. CLOB_API_URL (use https://clob.polymarket.com, not clob-v2 host)"); + return Err(anyhow::anyhow!("Authentication failed, exiting: {}", e)); + } + }; + + let executor = Arc::new(TradingExecutor::from_client( + clob_client.clone(), + config.private_key.clone(), + config.max_order_size_usdc, + config.slippage, + config.gtd_expiration_secs, + config.arbitrage_order_type.clone(), + )); + + let _risk_manager = Arc::new(RiskManager::new(clob_client.clone(), &config)); + + // Create hedge monitor (pass PositionTracker Arc for exposure updates) + // Hedge strategy is currently disabled but hedge_monitor kept for future use + let position_tracker = _risk_manager.position_tracker(); + let _hedge_monitor = HedgeMonitor::new( + clob_client.clone(), + config.private_key.clone(), + config.proxy_address.clone(), + position_tracker, + ); + + // Verify auth actually succeeded - try a simple API call + info!("Verifying authentication (via API call test)..."); + match executor.verify_authentication().await { + Ok(_) => { + info!("✅ Auth verified, API calls OK"); + dashboard.with_mut(|d| d.push_event("✅ CLOB authenticated — ready to trade")); + } + Err(e) => { + error!(error = %e, "❌ Auth verification failed! authenticate() did not error but API calls fail."); + error!("This indicates auth did not actually succeed. Possible causes:"); + error!(" 1. API key creation failed ('Could not create api key' warning)"); + error!(" 2. Account may not be registered on Polymarket"); + error!(" 3. Account may be restricted or suspended"); + error!(" 4. Network issues"); + error!("Exiting. Please fix authentication before running again."); + return Err(anyhow::anyhow!("Auth verification failed: {}", e)); + } + } + + info!("✅ All components initialized, auth verified"); + + // Create position balancer + let position_balancer = Arc::new(PositionBalancer::new( + clob_client.clone(), + _risk_manager.position_tracker(), + &config, + )); + + // Scheduled position sync: every N seconds fetch latest positions from API, overwrite local cache + let position_sync_interval = config.position_sync_interval_secs; + if position_sync_interval > 0 { + let position_tracker_sync = _risk_manager.position_tracker(); + tokio::spawn(async move { + let interval = Duration::from_secs(position_sync_interval); + loop { + match position_tracker_sync.sync_from_api().await { + Ok(_) => { + // Positions printed in sync_from_api + } + Err(e) => { + warn!(error = %e, "Position sync failed, will retry next loop"); + } + } + sleep(interval).await; + } + }); + info!( + interval_secs = position_sync_interval, + "Started position sync task, every {}s fetch latest positions from API", + position_sync_interval + ); + } else { + warn!("POSITION_SYNC_INTERVAL_SECS=0, position sync disabled"); + } + + // Scheduled position balance: every N seconds check positions and orders, cancel excess orders + // Note: balance task runs in main loop as it needs market mapping + let balance_interval = config.position_balance_interval_secs; + if balance_interval > 0 { + info!( + interval_secs = balance_interval, + "Position balance task runs in main loop every {}s", + balance_interval + ); + } else { + info!("Position balance not enabled (POSITION_BALANCE_INTERVAL_SECS=0)"); + } + + // Wind-down in progress flag: scheduled merge checks and skips to avoid race with wind-down merge + let wind_down_in_progress = Arc::new(AtomicBool::new(false)); + + // Minimum interval between two arbitrage trades + const MIN_TRADE_INTERVAL: Duration = Duration::from_secs(3); + let last_trade_time: Arc>> = Arc::new(tokio::sync::Mutex::new(None)); + + // Scheduled Merge: every N minutes run merge by positions, only for markets with both YES+NO + let merge_interval = config.merge_interval_minutes; + if merge_interval > 0 { + if let Some(proxy) = config.proxy_address { + let private_key = config.private_key.clone(); + let position_tracker = _risk_manager.position_tracker().clone(); + let wind_down_flag = wind_down_in_progress.clone(); + tokio::spawn(async move { + run_merge_task(merge_interval, proxy, private_key, position_tracker, wind_down_flag).await; + }); + info!( + interval_minutes = merge_interval, + "Started scheduled Merge task, every {} minutes (YES+NO both positions only)", + merge_interval + ); + } else { + warn!("MERGE_INTERVAL_MINUTES={} but POLYMARKET_PROXY_ADDRESS not set, scheduled Merge disabled", merge_interval); + } + } else { + info!("Scheduled Merge not enabled (MERGE_INTERVAL_MINUTES=0). To enable, set MERGE_INTERVAL_MINUTES in .env (e.g. 5 or 15)"); + } + + // Main loop enabled, start monitoring and trading + #[allow(unreachable_code)] + loop { + if shutdown.load(Ordering::Relaxed) { + tracing::info!("Shutdown requested from dashboard"); + return Ok(()); + } + + // Fetch markets for current window immediately, or wait for next window on failure + let markets = match _scheduler.get_markets_immediately_or_wait().await { + Ok(markets) => markets, + Err(e) => { + error!(error = %e, "Failed to fetch markets"); + sleep(Duration::from_secs(60)).await; + continue; + } + }; + + if markets.is_empty() { + warn!("No markets found, skipping current window"); + continue; + } + + // New round: reset risk exposure so this round accumulates from 0 + _risk_manager.position_tracker().reset_exposure(); + + dashboard.with_mut(|d| { + d.window_pnl = 0.0; + d.markets.clear(); + }); + + // Initialize orderbook monitor + let mut monitor = OrderBookMonitor::new(); + + // Subscribe to all markets + for market in &markets { + dashboard.with_mut(|d| d.ensure_market(symbol_short(&market.crypto_symbol))); + if let Err(e) = monitor.subscribe_market(market) { + error!(error = %e, market_id = %market.market_id, "Failed to subscribe to market"); + } + } + + // Create orderbook stream + let mut stream = match monitor.create_orderbook_stream() { + Ok(stream) => stream, + Err(e) => { + error!(error = %e, "Failed to create orderbook stream"); + continue; + } + }; + + info!(market_count = markets.len(), "Monitoring orderbook"); + + let window_label = markets + .first() + .map(|m| format!("{}-updown-5m", symbol_short(&m.crypto_symbol).to_lowercase())) + .unwrap_or_else(|| "updown-5m".to_string()); + + dashboard.with_mut(|d| { + d.set_window(&window_label, 300); + d.set_connected(true); + d.push_event(format!("📡 Live — {} markets in {}", markets.len(), window_label)); + }); + + // Record current window timestamp for cycle switch and wind-down detection + use chrono::Utc; + use crate::market::discoverer::FIVE_MIN_SECS; + let current_window_timestamp = MarketDiscoverer::calculate_current_window_timestamp(Utc::now()); + let window_end = chrono::DateTime::from_timestamp(current_window_timestamp + FIVE_MIN_SECS, 0) + .unwrap_or_else(|| Utc::now()); + let mut wind_down_done = false; + + // Create market_id -> market info mapping + let market_map: HashMap = markets.iter() + .map(|m| (m.market_id, m)) + .collect(); + + // Create market mapping (condition_id -> (yes_token_id, no_token_id)) for position balance + let market_token_map: HashMap = markets.iter() + .map(|m| (m.market_id, (m.yes_token_id, m.no_token_id))) + .collect(); + + // Create position balance timer + let balance_interval = config.position_balance_interval_secs; + let mut balance_timer = if balance_interval > 0 { + let mut timer = tokio::time::interval(Duration::from_secs(balance_interval)); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + timer.tick().await; // Fire first tick immediately + Some(timer) + } else { + None + }; + + // Record last best-ask per market for direction (↑↓) calc (single HashMap read/write, no perf impact) + let last_prices: DashMap = DashMap::new(); + + // Monitor orderbook updates + loop { + // Wind-down check: run once when <= N minutes to window end (stay in loop until natural switch by new-window detection) + // Use second precision; num_minutes() truncation may miss in 5min windows + if config.wind_down_before_window_end_minutes > 0 && !wind_down_done { + let now = Utc::now(); + let seconds_until_end = (window_end - now).num_seconds(); + let threshold_seconds = config.wind_down_before_window_end_minutes as i64 * 60; + if seconds_until_end <= threshold_seconds { + info!("🛑 Wind-down triggered | {}s until window end", seconds_until_end); + wind_down_done = true; + wind_down_in_progress.store(true, Ordering::Relaxed); + + // Wind-down runs in separate task to avoid blocking orderbook; 30s between merges per market + let executor_wd = executor.clone(); + let config_wd = config.clone(); + let risk_manager_wd = _risk_manager.clone(); + let wind_down_flag = wind_down_in_progress.clone(); + tokio::spawn(async move { + const MERGE_INTERVAL: Duration = Duration::from_secs(30); + + // 1. Cancel all orders + if let Err(e) = executor_wd.cancel_all_orders().await { + warn!(error = %e, "Wind-down: failed to cancel all orders, continuing with Merge and sell"); + } else { + info!("✅ Wind-down: all orders cancelled"); + } + + // Wait 10s after cancel before Merge so recent fills can settle on-chain + const DELAY_AFTER_CANCEL: Duration = Duration::from_secs(10); + sleep(DELAY_AFTER_CANCEL).await; + + // 2. Merge both sides (30s between markets) and update exposure + let position_tracker = risk_manager_wd.position_tracker(); + let mut did_any_merge = false; + if let Some(proxy) = config_wd.proxy_address { + match get_positions().await { + Ok(positions) => { + let condition_ids = condition_ids_with_both_sides(&positions); + let merge_info = merge_info_with_both_sides(&positions); + let n = condition_ids.len(); + for (i, condition_id) in condition_ids.iter().enumerate() { + let asset_hint = merge_info + .get(condition_id) + .map(|(yes_token, no_token, _)| (*yes_token, *no_token)); + match merge::merge_max( + *condition_id, + proxy, + &config_wd.private_key, + None, + asset_hint, + ) + .await { + Ok(merge_result) => { + did_any_merge = true; + info!( + "✅ Wind-down: Merge done | condition_id={:#x} | tx={}", + condition_id, merge_result.tx_hash + ); + let chain_amt = raw_shares_to_decimal(merge_result.merged_amount); + if let Some((yes_token, no_token, merge_amt)) = merge_info.get(condition_id) { + let deduct = chain_amt.min(*merge_amt); + position_tracker.update_exposure_cost(*yes_token, dec!(0), -deduct); + position_tracker.update_exposure_cost(*no_token, dec!(0), -deduct); + position_tracker.update_position(*yes_token, -deduct); + position_tracker.update_position(*no_token, -deduct); + info!( + "💰 Wind-down: Merge deducted exposure | condition_id={:#x} | amount:{} (chain:{})", + condition_id, deduct, chain_amt + ); + } + } + Err(e) => { + warn!(condition_id = %condition_id, error = %e, "Wind-down: Merge failed"); + } + } + // Wait 30s after each market merge before next, for on-chain settlement + if i + 1 < n { + info!("Wind-down: waiting 30s before next merge"); + sleep(MERGE_INTERVAL).await; + } + } + } + Err(e) => { warn!(error = %e, "Wind-down: failed to get positions, skipping Merge"); } + } + } else { + warn!("Wind-down: POLYMARKET_PROXY_ADDRESS not set, skipping Merge"); + } + + // If merge ran, wait 30s before selling one-sided legs for on-chain; else no wait + if did_any_merge { + sleep(MERGE_INTERVAL).await; + } + + // 3. Market-sell remaining one-sided positions + let wind_down_sell_price = Decimal::try_from(config_wd.wind_down_sell_price).unwrap_or(dec!(0.01)); + match get_positions().await { + Ok(positions) => { + for pos in positions.iter().filter(|p| p.size > dec!(0)) { + let size_floor = (pos.size * dec!(100)).floor() / dec!(100); + if size_floor < dec!(0.01) { + debug!(token_id = %pos.asset, size = %pos.size, "Wind-down: position too small, skip sell"); + continue; + } + if let Err(e) = executor_wd.sell_at_price(pos.asset, wind_down_sell_price, size_floor).await { + warn!(token_id = %pos.asset, size = %pos.size, error = %e, "Wind-down: failed to sell one-sided leg"); + } else { + info!("✅ Wind-down: sell order placed | token_id={:#x} | amount:{} | price:{:.4}", pos.asset, size_floor, wind_down_sell_price); + } + } + } + Err(e) => { warn!(error = %e, "Wind-down: failed to get positions, skipping sell"); } + } + + info!("🛑 Wind-down complete, continuing to monitor until window end"); + wind_down_flag.store(false, Ordering::Relaxed); + }); + } + } + + tokio::select! { + // Handle orderbook updates + book_result = stream.next() => { + match book_result { + Some(Ok(book)) => { + // Process orderbook update (book will be moved) + if let Some(pair) = monitor.handle_book_update(book) { + // Note: asks last element is best ask + let yes_best_ask = pair.yes_book.asks.last().map(|a| (a.price, a.size)); + let no_best_ask = pair.no_book.asks.last().map(|a| (a.price, a.size)); + let total_ask_price = yes_best_ask.and_then(|(p, _)| no_best_ask.map(|(np, _)| p + np)); + + let market_id = pair.market_id; + // Compare with prev tick for direction (↑ up ↓ down − flat), no arrow on first + let (yes_dir, no_dir) = match (yes_best_ask, no_best_ask) { + (Some((yp, _)), Some((np, _))) => { + let prev = last_prices.get(&market_id).map(|r| (r.0, r.1)); + let (y_dir, n_dir) = prev + .map(|(ly, ln)| ( + if yp > ly { "↑" } else if yp < ly { "↓" } else { "−" }, + if np > ln { "↑" } else if np < ln { "↓" } else { "−" }, + )) + .unwrap_or(("", "")); + last_prices.insert(market_id, (yp, np)); + (y_dir, n_dir) + } + _ => ("", ""), + }; + + let market_info = market_map.get(&pair.market_id); + let market_title = market_info.map(|m| m.title.as_str()).unwrap_or("unknown market"); + let market_symbol = market_info.map(|m| m.crypto_symbol.as_str()).unwrap_or(""); + let market_display = if !market_symbol.is_empty() { + format!("{} prediction market", market_symbol) + } else { + market_title.to_string() + }; + + let (prefix, spread_info) = total_ask_price + .map(|t| { + if t < dec!(1.0) { + let profit_pct = (dec!(1.0) - t) * dec!(100.0); + ("🚨 Arbitrage", format!("total:{:.4} profit:{:.2}%", t, profit_pct)) + } else { + ("📊", format!("total:{:.4} (no arb)", t)) + } + }) + .unwrap_or_else(|| ("📊", "no data".to_string())); + + // Direction arrows shown only for arbitrage + let is_arbitrage = prefix == "🚨 Arbitrage"; + let yes_info = yes_best_ask + .map(|(p, s)| { + if is_arbitrage && !yes_dir.is_empty() { + format!("Yes:{:.4} size:{} {}", p, s, yes_dir) + } else { + format!("Yes:{:.4} size:{}", p, s) + } + }) + .unwrap_or_else(|| "Yes:none".to_string()); + let no_info = no_best_ask + .map(|(p, s)| { + if is_arbitrage && !no_dir.is_empty() { + format!("No:{:.4} size:{} {}", p, s, no_dir) + } else { + format!("No:{:.4} size:{}", p, s) + } + }) + .unwrap_or_else(|| "No:none".to_string()); + + if use_tui { + if let (Some((yp, _)), Some((np, _))) = (yes_best_ask, no_best_ask) { + let sym = symbol_short(market_symbol); + let is_arb = total_ask_price + .map(|t| t < dec!(1.0)) + .unwrap_or(false); + dashboard.with_mut(|d| { + d.update_market( + &sym, + decimal_to_f64(yp), + decimal_to_f64(np), + is_arb, + ); + }); + } + } else { + info!( + "{} {} | {} | {} | {}", + prefix, + market_display, + yes_info, + no_info, + spread_info + ); + } + + // Keep structured debug log (optional) + debug!( + market_id = %pair.market_id, + yes_token = %pair.yes_book.asset_id, + no_token = %pair.no_book.asset_id, + "Orderbook pair details" + ); + + // Detect arbitrage (monitoring: execute only when total <= 1 - execution spread) + use rust_decimal::Decimal; + let execution_threshold = dec!(1.0) - Decimal::try_from(config.arbitrage_execution_spread) + .unwrap_or(dec!(0.01)); + if let Some(total_price) = total_ask_price { + if total_price <= execution_threshold { + if let Some(opp) = _detector.check_arbitrage( + &pair.yes_book, + &pair.no_book, + &pair.market_id, + ) { + // Check YES price threshold + if config.min_yes_price_threshold > 0.0 { + use rust_decimal::Decimal; + let min_yes_price_decimal = Decimal::try_from(config.min_yes_price_threshold) + .unwrap_or(dec!(0.0)); + if opp.yes_ask_price < min_yes_price_decimal { + debug!( + "⏸️ YES price below threshold, skip arbitrage | market:{} | YES:{:.4} | threshold:{:.4}", + market_display, + opp.yes_ask_price, + config.min_yes_price_threshold + ); + continue; // Skip this arbitrage + } + } + + // Check NO price threshold + if config.min_no_price_threshold > 0.0 { + use rust_decimal::Decimal; + let min_no_price_decimal = Decimal::try_from(config.min_no_price_threshold) + .unwrap_or(dec!(0.0)); + if opp.no_ask_price < min_no_price_decimal { + debug!( + "⏸️ NO price below threshold, skip arbitrage | market:{} | NO:{:.4} | threshold:{:.4}", + market_display, + opp.no_ask_price, + config.min_no_price_threshold + ); + continue; // Skip this arbitrage + } + } + + // Check if near market end (if stop time configured) + // Second precision; num_minutes() may miss in 5min markets + if config.stop_arbitrage_before_end_minutes > 0 { + if let Some(market_info) = market_map.get(&pair.market_id) { + use chrono::Utc; + let now = Utc::now(); + let time_until_end = market_info.end_date.signed_duration_since(now); + let seconds_until_end = time_until_end.num_seconds(); + let threshold_seconds = config.stop_arbitrage_before_end_minutes as i64 * 60; + + if seconds_until_end <= threshold_seconds { + debug!( + "⏰ Near market end, skip arbitrage | market:{} | seconds to end:{} | stop threshold:{}min", + market_display, + seconds_until_end, + config.stop_arbitrage_before_end_minutes + ); + continue; // Skip this arbitrage + } + } + } + + // Calculate order cost (USD) + // Use actual available size from arb, but cap at max order size + use rust_decimal::Decimal; + let max_order_size = Decimal::try_from(config.max_order_size_usdc).unwrap_or(dec!(100.0)); + let order_size = opp.yes_size.min(opp.no_size).min(max_order_size); + let yes_cost = opp.yes_ask_price * order_size; + let no_cost = opp.no_ask_price * order_size; + let total_cost = yes_cost + no_cost; + + // Check risk exposure limit + let position_tracker = _risk_manager.position_tracker(); + let current_exposure = position_tracker.calculate_exposure(); + + if position_tracker.would_exceed_limit(yes_cost, no_cost) { + warn!( + "�� ️ Risk exposure limit exceeded, skip arbitrage | market:{} | exposure:{:.2} USD | order cost:{:.2} USD | limit:{:.2} USD", + market_display, + current_exposure, + total_cost, + position_tracker.max_exposure() + ); + continue; // Skip this arbitrage + } + + // Check position balance (local cache, zero latency) + if position_balancer.should_skip_arbitrage(opp.yes_token_id, opp.no_token_id) { + warn!( + "�� ️ Positions severely imbalanced, skip arbitrage | market:{}", + market_display + ); + continue; // Skip this arbitrage + } + + // Check trade interval: min 3s between trades + { + let mut guard = last_trade_time.lock().await; + let now = Instant::now(); + if let Some(last) = *guard { + if now.saturating_duration_since(last) < MIN_TRADE_INTERVAL { + let elapsed = now.saturating_duration_since(last).as_secs_f32(); + debug!( + "⏱️ Trade interval < 3s, skip | market:{} | since last:{}s", + market_display, + elapsed + ); + continue; // Skip this arb + } + } + *guard = Some(now); + } + + let trade_sym = symbol_short(market_symbol); + let profit_usd = decimal_to_f64( + (dec!(1.0) - opp.yes_ask_price - opp.no_ask_price) + * order_size, + ); + if use_tui { + dashboard.with_mut(|d| { + d.set_exposure(decimal_to_f64(current_exposure)); + d.record_trade_attempt( + &trade_sym, + decimal_to_f64(opp.profit_percentage), + decimal_to_f64(order_size), + decimal_to_f64(total_cost), + ); + }); + } else { + info!( + "⚡ Execute arbitrage | market:{} | profit:{:.2}% | size:{} | cost:{:.2} USD | exposure:{:.2} USD", + market_display, + opp.profit_percentage, + order_size, + total_cost, + current_exposure + ); + } + // Simplified exposure: add on arb execution regardless of fill + let _pt = _risk_manager.position_tracker(); + _pt.update_exposure_cost(opp.yes_token_id, opp.yes_ask_price, order_size); + _pt.update_exposure_cost(opp.no_token_id, opp.no_ask_price, order_size); + + // Arb execution: run whenever total <= threshold, direction only for slippage (down=second, up/flat=first) + // Clone vars for spawned task (direction used for slippage allocation) + let executor_clone = executor.clone(); + let risk_manager_clone = _risk_manager.clone(); + let opp_clone = opp.clone(); + let yes_dir_s = yes_dir.to_string(); + let no_dir_s = no_dir.to_string(); + let dashboard_trade = dashboard.clone(); + let trade_sym_spawn = trade_sym.clone(); + + // Spawn async to avoid blocking orderbook updates + tokio::spawn(async move { + // Execute arbitrage (slippage: down=second, up/flat=first) + match executor_clone.execute_arbitrage_pair(&opp_clone, &yes_dir_s, &no_dir_s).await { + Ok(result) => { + if result.success { + dashboard_trade.with_mut(|d| { + d.record_trade_success( + &trade_sym_spawn, + profit_usd, + decimal_to_f64(opp_clone.profit_percentage), + ); + }); + } else { + dashboard_trade.with_mut(|d| { + d.record_trade_failure( + &trade_sym_spawn, + "orders not filled", + ); + }); + } + + // Save pair_id first, result will be moved + let pair_id = result.pair_id.clone(); + + // Register with risk manager (with prices for exposure calc) + risk_manager_clone.register_order_pair( + result, + opp_clone.market_id, + opp_clone.yes_token_id, + opp_clone.no_token_id, + opp_clone.yes_ask_price, + opp_clone.no_ask_price, + ); + + // Handle risk recovery + // Hedge strategy disabled; no action on one-sided fills + match risk_manager_clone.handle_order_pair(&pair_id).await { + Ok(action) => { + // Hedge strategy off; no MonitorForExit/SellExcess + match action { + crate::risk::recovery::RecoveryAction::None => { + // Normal case, no action + } + crate::risk::recovery::RecoveryAction::MonitorForExit { .. } => { + info!("One-sided fill, hedge strategy disabled, no action"); + } + crate::risk::recovery::RecoveryAction::SellExcess { .. } => { + info!("Partial fill imbalance, hedge strategy disabled, no action"); + } + crate::risk::recovery::RecoveryAction::ManualIntervention { reason } => { + warn!("Manual intervention needed: {}", reason); + } + } + } + Err(e) => { + error!("Risk handling failed: {}", e); + } + } + } + Err(e) => { + // Error details in executor; brief summary here + let error_msg = e.to_string(); + dashboard_trade.with_mut(|d| { + d.record_trade_failure(&trade_sym_spawn, &error_msg); + }); + // Extract simplified error + if error_msg.contains("Arbitrage failed") { + // Error already formatted + error!("{}", error_msg); + } else { + error!("Arbitrage execution failed: {}", error_msg); + } + } + } + }); + } + } + } + } + } + Some(Err(e)) => { + error!(error = %e, "Orderbook update error"); + // Stream error, recreate stream + break; + } + None => { + warn!("Orderbook stream ended, recreating"); + break; + } + } + } + + // Position balance task + _ = async { + if let Some(ref mut timer) = balance_timer { + timer.tick().await; + if let Err(e) = position_balancer.check_and_balance_positions(&market_token_map).await { + warn!(error = %e, "Position balance check failed"); + } + } else { + futures::future::pending::<()>().await; + } + } => { + // Balance task done + } + + // Periodic check: 1) new 5min window 2) wind-down trigger + _ = sleep(Duration::from_secs(1)) => { + if shutdown.load(Ordering::Relaxed) { + return Ok(()); + } + + let now = Utc::now(); + let seconds_until_end = + (window_end - now).num_seconds().max(0) as u32; + let exposure = _risk_manager.position_tracker().calculate_exposure(); + dashboard.with_mut(|d| { + d.set_window(&window_label, seconds_until_end); + d.set_exposure(decimal_to_f64(exposure)); + }); + + let new_window_timestamp = MarketDiscoverer::calculate_current_window_timestamp(now); + + // If window timestamp changed, we're in a new window + if new_window_timestamp != current_window_timestamp { + info!( + old_window = current_window_timestamp, + new_window = new_window_timestamp, + "New 5min window detected, cancelling old subscriptions and switching" + ); + // Drop stream to release monitor borrow, then clear old subs + drop(stream); + monitor.clear(); + break; + } + } + } + } + + // monitor is dropped at loop end, no manual cleanup + info!("Current window monitoring ended, refreshing markets for next round"); + } +} + diff --git a/src/market/discoverer.rs b/src/market/discoverer.rs new file mode 100644 index 0000000..cacbea2 --- /dev/null +++ b/src/market/discoverer.rs @@ -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, + pub crypto_symbol: String, +} + +pub struct MarketDiscoverer { + gamma_client: Client, + crypto_symbols: Vec, +} + +impl MarketDiscoverer { + pub fn new(crypto_symbols: Vec) -> 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) -> 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) -> 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 { + 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> { + // 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 = 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 { + // 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, + }) + } +} diff --git a/src/market/mod.rs b/src/market/mod.rs new file mode 100644 index 0000000..473589f --- /dev/null +++ b/src/market/mod.rs @@ -0,0 +1,5 @@ +pub mod discoverer; +pub mod scheduler; + +pub use discoverer::*; +pub use scheduler::*; diff --git a/src/market/scheduler.rs b/src/market/scheduler.rs new file mode 100644 index 0000000..1913729 --- /dev/null +++ b/src/market/scheduler.rs @@ -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) -> 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> { + // 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> { + 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; + } + } + } + } +} diff --git a/src/merge.rs b/src/merge.rs new file mode 100644 index 0000000..a9ddafe --- /dev/null +++ b/src/merge.rs @@ -0,0 +1,634 @@ +//! CTF Merge module: merge equal YES/NO tokens back to pUSD (V2) or USDC.e (legacy). +//! +//! Supports **Gnosis Safe** (execTransaction), **Magic/Email** (PROXY relayer), +//! and **V2 deposit wallet** (WALLET batch relayer when `SIGNATURE_TYPE=Poly1271`). +//! V2 默认经 CollateralAdapter merge 为 pUSD;USDC.e 抵押持仓 merge 后可自动 wrap 为 pUSD(`MERGE_WRAP_TO_PUSD`,默认开启)。 +//! 设 `MERGE_OUTPUT_TOKEN=USDC.e` 可走 legacy 路径。 +//! Merge amount is automatically `min(YES_balance, NO_balance)`. + +use std::env; + +use alloy::primitives::{keccak256, Address, B256, U256}; +use alloy::providers::{Provider, ProviderBuilder}; +use alloy::signers::local::LocalSigner; +use alloy::signers::Signer as _; +use anyhow::Result; +use polymarket_client_sdk::ctf::types::{CollectionIdRequest, MergePositionsRequest, PositionIdRequest}; +use polymarket_client_sdk::ctf::Client; +use polymarket_client_sdk::{contract_config, POLYGON}; +use std::str::FromStr as _; +use tracing::{info, warn}; + +use crate::adapter_auth::{ensure_adapter_approved, encode_set_approval_for_all, wait_relayer_tx}; +use crate::deposit_wallet_relay::relayer_execute_deposit_wallet_calldata; +use crate::pusd_wrap::{merge_wrap_to_pusd, wrap_usdce_to_pusd}; +use crate::wallet_kind::{classify_wallet, WalletKind}; +use crate::proxy_relay::{ + self, derive_proxy_wallet, relayer_execute_proxy_calldata, IGnosisSafe, CTF_COLLATERAL_ADAPTER, + CTF_POLYGON, PROXY_FACTORY, PROXY_MERGE_PUSD_GAS, PUSD_POLYGON, RELAYER_URL_DEFAULT, + RPC_URL_DEFAULT, USDC_POLYGON, +}; + +use alloy::sol; +sol! { + #[sol(rpc)] + interface IERC1155Balance { + function balanceOf(address account, uint256 id) external view returns (uint256); + } +} + +fn merge_to_pusd() -> bool { + match env::var("MERGE_OUTPUT_TOKEN") { + Ok(v) => { + let s = v.trim().to_lowercase(); + s != "usdc.e" && s != "usdc" + } + Err(_) => true, + } +} + +fn merge_collateral() -> Address { + if merge_to_pusd() { + PUSD_POLYGON + } else { + USDC_POLYGON + } +} + +fn merge_target_for_collateral(ctf: Address, collateral: Address) -> Address { + if collateral == PUSD_POLYGON && merge_to_pusd() { + CTF_COLLATERAL_ADAPTER + } else { + ctf + } +} + +fn merge_gas_for_collateral(collateral: Address) -> Option { + 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, + 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, + prov_read: &impl Provider, + wallet: Address, + condition_id: B256, + asset_hint: Option<(U256, U256)>, +) -> Result { + let collaterals = if merge_to_pusd() { + [PUSD_POLYGON, USDC_POLYGON] + } else { + [USDC_POLYGON, PUSD_POLYGON] + }; + + if let Some((yes_id, no_id)) = asset_hint { + let b_yes = erc1155_balance(prov_read, wallet, yes_id).await?; + let b_no = erc1155_balance(prov_read, wallet, no_id).await?; + if b_yes > 0 && b_no > 0 { + for &collateral in &collaterals { + let (py, pn) = binary_position_ids(client, condition_id, collateral).await?; + if py == yes_id && pn == no_id { + return Ok(ResolvedMerge { + collateral, + yes_id, + no_id, + b_yes, + b_no, + }); + } + } + warn!( + "API asset IDs have balance but don't match computed position IDs; using preferred collateral {:?}", + merge_collateral() + ); + return Ok(ResolvedMerge { + collateral: merge_collateral(), + yes_id, + no_id, + b_yes, + b_no, + }); + } + } + + for &collateral in &collaterals { + let (yes_id, no_id) = binary_position_ids(client, condition_id, collateral).await?; + let b_yes = erc1155_balance(prov_read, wallet, yes_id).await?; + let b_no = erc1155_balance(prov_read, wallet, no_id).await?; + if b_yes > 0 && b_no > 0 { + if collateral == USDC_POLYGON && merge_to_pusd() && merge_wrap_to_pusd() { + info!("链上持仓为 USDC.e 抵押,merge 后将自动 wrap 为 pUSD"); + } else if collateral == USDC_POLYGON && merge_to_pusd() { + warn!("链上持仓为 USDC.e 抵押 outcome token,merge 产出 USDC.e(MERGE_WRAP_TO_PUSD=0)"); + } + return Ok(ResolvedMerge { + collateral, + yes_id, + no_id, + b_yes, + b_no, + }); + } + } + + let (pref_yes, pref_no) = binary_position_ids(client, condition_id, merge_collateral()).await?; + let pref_by = erc1155_balance(prov_read, wallet, pref_yes).await.unwrap_or(U256::ZERO); + let pref_bn = erc1155_balance(prov_read, wallet, pref_no).await.unwrap_or(U256::ZERO); + if let Some((yes_id, no_id)) = asset_hint { + let hint_yes = erc1155_balance(prov_read, wallet, yes_id) + .await + .unwrap_or(U256::ZERO); + let hint_no = erc1155_balance(prov_read, wallet, no_id) + .await + .unwrap_or(U256::ZERO); + anyhow::bail!( + "No mergeable shares: computed YES={} NO={} | API assets yes={} no={} (balances {}/{}) | wallet={:?}", + pref_by, + pref_bn, + yes_id, + no_id, + hint_yes, + hint_no, + wallet + ); + } + anyhow::bail!( + "No mergeable shares: YES={} NO={} (token ids {} / {}), wallet={:?}", + pref_by, + pref_bn, + pref_yes, + pref_no, + wallet + ); +} + +fn encode_merge_calldata(req: &MergePositionsRequest) -> Vec { + 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( + 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( + provider: &P, + account: Address, + token_id: U256, +) -> Result { + 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, +) -> Result { + 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( + safe: &IGnosisSafe::IGnosisSafeInstance

, + signer: &impl alloy::signers::Signer, + to: Address, + calldata: Vec, +) -> Result { + 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( + provider: &P, + prov_read: &impl Provider, + wallet: Address, + collateral: Address, + merged_amount: U256, + wallet_kind: WalletKind, + safe: Option<&IGnosisSafe::IGnosisSafeInstance

>, + 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 { + 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, + }) +} diff --git a/src/monitor/arbitrage.rs b/src/monitor/arbitrage.rs new file mode 100644 index 0000000..9098dd9 --- /dev/null +++ b/src/monitor/arbitrage.rs @@ -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 = 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 = 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 { + // 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, + }) + } +} diff --git a/src/monitor/mod.rs b/src/monitor/mod.rs new file mode 100644 index 0000000..af4f16b --- /dev/null +++ b/src/monitor/mod.rs @@ -0,0 +1,5 @@ +pub mod arbitrage; +pub mod orderbook; + +pub use arbitrage::*; +pub use orderbook::*; diff --git a/src/monitor/orderbook.rs b/src/monitor/orderbook.rs new file mode 100644 index 0000000..f850ba8 --- /dev/null +++ b/src/monitor/orderbook.rs @@ -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, + market_map: HashMap, // 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> + Send + '_>>> { + // Collect all token_ids to subscribe + let token_ids: Vec = 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 { + + // Print top 5 bid/ask (debug) + if !book.bids.is_empty() { + let top_bids: Vec = 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 = 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 { + self.books.get(&token_id).map(|b| b.clone()) + } + + /// Clear all subscriptions + pub fn clear(&mut self) { + self.books.clear(); + self.market_map.clear(); + } +} diff --git a/src/positions.rs b/src/positions.rs new file mode 100644 index 0000000..93da6f9 --- /dev/null +++ b/src/positions.rs @@ -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> { + 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") +} diff --git a/src/proxy_relay.rs b/src/proxy_relay.rs new file mode 100644 index 0000000..3718a27 --- /dev/null +++ b/src/proxy_relay.rs @@ -0,0 +1,386 @@ +//! Proxy wallet + Relayer/Safe shared infrastructure for merge, redeem, withdraw. +//! +//! Relayer requests (/relay-payload, /submit), proxy call encoding, Gnosis Safe interface and signing. + +use std::env; + +use alloy::primitives::{keccak256, Address, B256, Bytes, U256}; +use alloy::sol_types::SolCall; +use anyhow::Result; +use tracing::info; + +use polymarket_client_sdk::types::address; + +use alloy::sol; +sol! { + #[sol(rpc)] + interface IGnosisSafe { + function nonce() external view returns (uint256); + function encodeTransactionData( + address to, + uint256 value, + bytes memory data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 _nonce + ) external view returns (bytes memory); + function execTransaction( + address to, + uint256 value, + bytes memory data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + bytes memory signatures + ) external payable returns (bool success); + } +} + +sol! { + struct ProxyCallTuple { + uint8 typeCode; + address to; + uint256 value; + bytes data; + } + function proxy(ProxyCallTuple[] calls) external payable returns (bytes[] returnValues); +} + +pub const RPC_URL_DEFAULT: &str = "https://polygon-bor-rpc.publicnode.com"; +pub const RELAYER_URL_DEFAULT: &str = "https://relayer-v2.polymarket.com"; +/// USDC.e(bridged),V1 及旧持仓抵押品 +pub const USDC_POLYGON: Address = address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"); +/// pUSD(Polymarket USD),V2 抵押品 +pub const PUSD_POLYGON: Address = address!("0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"); +/// CTF(Conditional Tokens Framework) +pub const CTF_POLYGON: Address = address!("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"); +/// NegRisk 适配器(V1 赎回 USDC.e) +pub const NEG_RISK_ADAPTER: Address = address!("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"); +/// V2 标准市场抵押品适配器(赎回/merge 为 pUSD) +pub const CTF_COLLATERAL_ADAPTER: Address = address!("0xAdA100Db00Ca00073811820692005400218FcE1f"); +/// V2 Collateral Onramp:USDC.e → pUSD wrap +pub const COLLATERAL_ONRAMP: Address = address!("0x93070a847efEf7F70739046A929D47a521F5B8ee"); +/// V2 NegRisk 抵押品适配器(赎回为 pUSD) +pub const NEG_RISK_COLLATERAL_ADAPTER: Address = address!("0xadA2005600Dec949baf300f4C6120000bDB6eAab"); + +const RELAYER_GET_RELAY_PAYLOAD: &str = "/relay-payload"; +const RELAYER_SUBMIT: &str = "/submit"; + +pub const PROXY_FACTORY: Address = address!("0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"); +const RELAY_HUB: Address = address!("0xD216153c06E857cD7f72665E0aF1d7D82172F494"); +const PROXY_INIT_CODE_HASH: [u8; 32] = [ + 0xd2, 0x1d, 0xf8, 0xdc, 0x65, 0x88, 0x0a, 0x86, 0x06, 0xf0, 0x9f, 0xe0, 0xce, 0x3d, 0xf9, 0xb8, + 0x86, 0x92, 0x87, 0xab, 0x0b, 0x05, 0x8b, 0xe0, 0x5a, 0xa9, 0xe8, 0xaf, 0x63, 0x30, 0xa0, 0x0b, +]; +pub const PROXY_DEFAULT_GAS: u64 = 160_000; +/// pUSD CollateralAdapter redeem 实测约需 370k+ gas +pub const PROXY_REDEEM_PUSD_GAS: u64 = 450_000; +/// legacy CTF redeem 实测约需 166k gas +pub const PROXY_REDEEM_LEGACY_GAS: u64 = 220_000; +/// pUSD CollateralAdapter merge 实测约需 370k+ gas +pub const PROXY_MERGE_PUSD_GAS: u64 = 450_000; + +/// Shorten long 0x-prefixed hex to `0x` + first 8 + `..` + last 6 for logs. +pub fn short_hex(s: &str) -> String { + let hex = s.strip_prefix("0x").unwrap_or(s); + if hex.len() > 14 { + let lo = hex.len().saturating_sub(6); + format!("0x{}..{}", &hex[..8.min(hex.len())], &hex[lo..]) + } else { + format!("0x{}", hex) + } +} + +use base64::Engine; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +type HmacSha256 = Hmac; + +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::

().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 { + encode_proxy_calls(&[(target, data)]) +} + +pub(crate) fn encode_proxy_calls(calls: &[(Address, &[u8])]) -> Vec { + let tuples: Vec = 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, +) -> Result { + 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)], + 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, +) -> Result { + 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 { + 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 { + 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 diff --git a/src/pusd_wrap.rs b/src/pusd_wrap.rs new file mode 100644 index 0000000..e8da738 --- /dev/null +++ b/src/pusd_wrap.rs @@ -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 { + 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 { + 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(provider: &P, owner: Address, token: Address) -> Result { + IERC20::new(token, provider) + .balanceOf(owner) + .call() + .await + .map_err(|e| anyhow::anyhow!("ERC20 balanceOf failed: {}", e)) +} + +async fn erc20_allowance( + provider: &P, + owner: Address, + token: Address, + spender: Address, +) -> Result { + IERC20::new(token, provider) + .allowance(owner, spender) + .call() + .await + .map_err(|e| anyhow::anyhow!("ERC20 allowance failed: {}", e)) +} + +async fn safe_exec_call( + safe: &IGnosisSafe::IGnosisSafeInstance

, + signer: &impl alloy::signers::Signer, + to: Address, + calldata: Vec, +) -> Result { + 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)>, + signer: &impl alloy::signers::Signer, + builder_key: &str, + builder_secret: &str, + builder_passphrase: &str, + relayer_url: &str, +) -> Result { + 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( + provider: &P, + prov_read: &impl Provider, + wallet: Address, + amount: U256, + wallet_kind: WalletKind, + safe: Option<&IGnosisSafe::IGnosisSafeInstance

>, + 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)> = 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(()) +} diff --git a/src/redeem.rs b/src/redeem.rs new file mode 100644 index 0000000..19b8fc1 --- /dev/null +++ b/src/redeem.rs @@ -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 { + 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 { + 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, + size_raw: Option, +) -> Result<(Address, Vec)> { + 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(provider: &P, proxy: Address, asset: U256) -> Result { + 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( + 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, +) -> Result { + 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( + safe: &IGnosisSafe::IGnosisSafeInstance

, + signer: &impl alloy::signers::Signer, + to: Address, + calldata: Vec, +) -> Result { + 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( + provider: &P, + proxy: Address, + neg_risk: bool, + redeem_target: Address, + redeem_calldata: Vec, +) -> Result)>> { + 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, + size_raw: Option, + verify_assets: &[U256], +) -> Result { + 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)) +} diff --git a/src/risk/hedge_monitor.rs b/src/risk/hedge_monitor.rs new file mode 100644 index 0000000..50fe379 --- /dev/null +++ b/src/risk/hedge_monitor.rs @@ -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, // GTC order ID if placed + pub pending_sell_amount: Decimal, // Pending sell amount +} + +pub struct HedgeMonitor { + client: AuthenticatedClobClient, + private_key: String, + proxy_address: Option

, + positions: DashMap, // pair_id -> position + position_tracker: Arc, // For exposure updates +} + +impl HedgeMonitor { + pub fn new( + client: AuthenticatedClobClient, + private_key: String, + proxy_address: Option
, + position_tracker: Arc, + ) -> 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, + 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, + ) -> 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 { + self.positions.iter().map(|e| e.value().clone()).collect() + } +} diff --git a/src/risk/manager.rs b/src/risk/manager.rs new file mode 100644 index 0000000..479e70d --- /dev/null +++ b/src/risk/manager.rs @@ -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, +} + +pub struct RiskManager { + clob_client: AuthenticatedClobClient, + pending_pairs: DashMap, + position_tracker: std::sync::Arc, + 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 { + 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 { + self.position_tracker.clone() + } +} diff --git a/src/risk/mod.rs b/src/risk/mod.rs new file mode 100644 index 0000000..ca91f5d --- /dev/null +++ b/src/risk/mod.rs @@ -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; \ No newline at end of file diff --git a/src/risk/position_balancer.rs b/src/risk/position_balancer.rs new file mode 100644 index 0000000..ebe5fa6 --- /dev/null +++ b/src/risk/position_balancer.rs @@ -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, + threshold: Decimal, + min_total: Decimal, + max_order_size: Decimal, +} + +impl PositionBalancer { + pub fn new( + clob_client: AuthenticatedClobClient, + position_tracker: std::sync::Arc, + 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, // 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 = 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 = 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 = 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 = 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, + no_orders: Vec, +} + +/// Order info +#[derive(Clone)] +struct OrderInfo { + order_id: String, + price: Decimal, + pending_size: Decimal, +} diff --git a/src/risk/positions.rs b/src/risk/positions.rs new file mode 100644 index 0000000..f1824ca --- /dev/null +++ b/src/risk/positions.rs @@ -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, // token_id -> amount (pos=long, neg=short) + exposure_costs: DashMap, // 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 = 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> { + 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> = 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) + } +} diff --git a/src/risk/recovery.rs b/src/risk/recovery.rs new file mode 100644 index 0000000..bebe20c --- /dev/null +++ b/src/risk/recovery.rs @@ -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 { + // 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 { + // 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 + // }) + } +} diff --git a/src/trading/clob_client.rs b/src/trading/clob_client.rs new file mode 100644 index 0000000..f82900e --- /dev/null +++ b/src/trading/clob_client.rs @@ -0,0 +1 @@ +pub use polypulse::clob_v2::*; diff --git a/src/trading/executor.rs b/src/trading/executor.rs new file mode 100644 index 0000000..fef62a1 --- /dev/null +++ b/src/trading/executor.rs @@ -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 { + 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 { + 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 { + 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 { + 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)) + } +} diff --git a/src/trading/mod.rs b/src/trading/mod.rs new file mode 100644 index 0000000..b047db7 --- /dev/null +++ b/src/trading/mod.rs @@ -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; diff --git a/src/trading/orders.rs b/src/trading/orders.rs new file mode 100644 index 0000000..b722646 --- /dev/null +++ b/src/trading/orders.rs @@ -0,0 +1,2 @@ +// Order-related helper types and functions +// Currently empty; extend as needed diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..b881d58 --- /dev/null +++ b/src/ui/mod.rs @@ -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, +}; diff --git a/src/ui/render.rs b/src/ui/render.rs new file mode 100644 index 0000000..af3d9e4 --- /dev/null +++ b/src/ui/render.rs @@ -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 = 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 = 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 = 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], + ); +} diff --git a/src/ui/runner.rs b/src/ui/runner.rs new file mode 100644 index 0000000..2537d28 --- /dev/null +++ b/src/ui/runner.rs @@ -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>, + shutdown: Arc, +) { + 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>, + shutdown: Arc, +) -> 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(()) +} diff --git a/src/ui/state.rs b/src/ui/state.rs new file mode 100644 index 0000000..7300ac2 --- /dev/null +++ b/src/ui/state.rs @@ -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, 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 { + 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, +} + +#[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, + 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, + pub services: Vec, + 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, + pub window_pnl_sparkline: Vec, + pub exposure_sparkline: Vec, + pub edge_sparkline: Vec, + pub scan_rate_sparkline: Vec, + /// 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, exposure_limit: f64) -> Self { + Self { + started_at: Instant::now(), + frame: 0, + live_mode: true, + markets: Vec::new(), + selected_market: 0, + bid_depth: 0.5, + ask_depth: 0.5, + spread: 0.0, + depth_k: 0.0, + last_trade_secs: 999.0, + exposure: 0.0, + exposure_limit, + positions: 0, + arb_scans: 0, + window_secs_left: 300, + window_label: "updown-5m".to_string(), + order_mode: order_mode.into(), + events: vec!["🚀 Bot started — scanning for arbitrage…".to_string()], + services: vec![ + ServiceHealth { + name: "CLOB WS", + status: HealthStatus::Ok, + latency_ms: 0, + }, + ServiceHealth { + name: "CLOB API", + status: HealthStatus::Ok, + latency_ms: 0, + }, + ], + merge_status: "idle".to_string(), + flash_arb: false, + session_pnl: 0.0, + window_pnl: 0.0, + last_trade_pnl: 0.0, + total_trades: 0, + successful_trades: 0, + best_trade: 0.0, + pnl_sparkline: vec![2, 3, 3, 4, 5, 6, 5, 7, 8, 9, 10, 11], + window_pnl_sparkline: vec![1, 2, 2, 3, 4, 5, 4, 6, 7, 8], + exposure_sparkline: vec![3, 4, 5, 4, 6, 5, 7, 6, 8, 7], + edge_sparkline: vec![2, 4, 3, 5, 6, 7, 5, 8, 9, 7], + scan_rate_sparkline: vec![4, 5, 6, 5, 7, 8, 7, 9, 8, 10], + profit_pulse: 0, + last_trade_symbol: String::new(), + connected: false, + } + } + + pub fn new_demo() -> Self { + let mut rng = rand::thread_rng(); + + // Pretend the bot has been running for 25–175 minutes. + let elapsed_secs = rng.gen_range(25 * 60..175 * 60); + let session_pnl = rng.gen_range(48.0..286.0); + let window_ratio = rng.gen_range(0.12..0.38); + let window_pnl = session_pnl * window_ratio; + let total_trades = rng.gen_range(18..76); + let win_ratio = rng.gen_range(0.58..0.82); + let successful_trades = ((total_trades as f64) * win_ratio).round() as u32; + let best_trade = rng.gen_range(3.5..14.0); + let last_trade_pnl = rng.gen_range(1.2..best_trade); + let exposure = rng.gen_range(180.0..780.0); + let arb_scans = rng.gen_range(900..5200); + let window_secs_left = rng.gen_range(45..260); + let last_trade_secs = rng.gen_range(1.5..18.0); + let symbols = ["BTC", "ETH", "SOL", "XRP"]; + let last_sym = symbols[rng.gen_range(0..symbols.len())]; + + let mut s = Self::new_live("GTD/FAK", 1000.0); + s.live_mode = false; + s.started_at = Instant::now() - Duration::from_secs(elapsed_secs); + s.frame = rng.gen_range(200..1200); + s.session_pnl = session_pnl; + s.window_pnl = window_pnl; + s.last_trade_pnl = last_trade_pnl; + s.total_trades = total_trades; + s.successful_trades = successful_trades.min(total_trades); + s.best_trade = best_trade; + s.last_trade_secs = last_trade_secs; + s.last_trade_symbol = last_sym.to_string(); + s.connected = true; + s.exposure = exposure; + s.arb_scans = arb_scans; + s.window_secs_left = window_secs_left; + s.window_label = format!("{}-updown-5m", last_sym.to_lowercase()); + + let pnl_end = ((session_pnl / 4.0).clamp(2.0, 20.0)) as u64; + s.pnl_sparkline = random_sparkline(&mut rng, SPARKLINE_LEN, pnl_end.saturating_sub(8), 1); + s.window_pnl_sparkline = + random_sparkline(&mut rng, SPARKLINE_LEN, (pnl_end / 2).max(2), 1); + s.exposure_sparkline = random_sparkline( + &mut rng, + SPARKLINE_LEN, + ((exposure / 1000.0) * 16.0) as u64 + 2, + 0, + ); + let edge_start = rng.gen_range(4..9); + let scan_start = rng.gen_range(5..10); + s.edge_sparkline = random_sparkline(&mut rng, SPARKLINE_LEN, edge_start, 0); + s.scan_rate_sparkline = random_sparkline(&mut rng, SPARKLINE_LEN, scan_start, 0); + + s.markets = vec![ + MarketRow { + symbol: "BTC".into(), + yes_price: rng.gen_range(0.44..0.52), + no_price: rng.gen_range(0.46..0.54), + yes_dir: PriceDir::Up, + no_dir: PriceDir::Down, + is_arb: true, + sparkline: random_sparkline(&mut rng, SPARKLINE_LEN / 2, 6, 1), + }, + MarketRow { + symbol: "ETH".into(), + yes_price: rng.gen_range(0.46..0.54), + no_price: rng.gen_range(0.44..0.52), + yes_dir: PriceDir::Flat, + no_dir: PriceDir::Up, + is_arb: false, + sparkline: random_sparkline(&mut rng, SPARKLINE_LEN / 2, 4, 0), + }, + MarketRow { + symbol: "SOL".into(), + yes_price: rng.gen_range(0.42..0.50), + no_price: rng.gen_range(0.48..0.56), + yes_dir: PriceDir::Down, + no_dir: PriceDir::Up, + is_arb: true, + sparkline: random_sparkline(&mut rng, SPARKLINE_LEN / 2, 7, 1), + }, + MarketRow { + symbol: "XRP".into(), + yes_price: rng.gen_range(0.47..0.53), + no_price: rng.gen_range(0.46..0.52), + yes_dir: PriceDir::Up, + no_dir: PriceDir::Flat, + is_arb: false, + sparkline: random_sparkline(&mut rng, SPARKLINE_LEN / 2, 4, 0), + }, + ]; + + s.events = Self::demo_event_history( + &mut rng, + session_pnl, + window_pnl, + last_sym, + last_trade_pnl, + successful_trades, + ); + + // Fast-forward 60–180 ticks so curves & prices look mid-session, not freshly booted. + let warmup_ticks = rng.gen_range(60..180); + s.warmup_demo(warmup_ticks); + + s + } + + fn demo_event_history( + rng: &mut impl Rng, + session_pnl: f64, + window_pnl: f64, + last_sym: &str, + last_trade_pnl: f64, + wins: u32, + ) -> Vec { + 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::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 { + 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) { + 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, 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) { + self.merge_status = status.into(); + } + + pub fn ensure_market(&mut self, symbol: impl Into) { + 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>, +} + +impl DashboardHandle { + pub fn new_live(order_mode: impl Into, 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> { + self.inner.clone() + } + + pub fn with_mut(&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::().to_uppercase(), + } +} + +pub fn decimal_to_f64(d: rust_decimal::Decimal) -> f64 { + d.to_string().parse().unwrap_or(0.0) +} diff --git a/src/utils/arbitrage_logger.rs b/src/utils/arbitrage_logger.rs new file mode 100644 index 0000000..c9d5fde --- /dev/null +++ b/src/utils/arbitrage_logger.rs @@ -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"); + } +} diff --git a/src/utils/errors.rs b/src/utils/errors.rs new file mode 100644 index 0000000..3657235 --- /dev/null +++ b/src/utils/errors.rs @@ -0,0 +1,2 @@ +// Simplified error handling using anyhow::Error +// Extend with finer-grained types if needed diff --git a/src/utils/logger.rs b/src/utils/logger.rs new file mode 100644 index 0000000..0cad3f8 --- /dev/null +++ b/src/utils/logger.rs @@ -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) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..0c79534 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod arbitrage_logger; +pub mod errors; +pub mod logger; diff --git a/src/wallet_kind.rs b/src/wallet_kind.rs new file mode 100644 index 0000000..777c3d0 --- /dev/null +++ b/src/wallet_kind.rs @@ -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 + } +}