From 1a4688a931432ce4e34350f63f9d86c3339312a4 Mon Sep 17 00:00:00 2001 From: smith1ee Date: Mon, 27 Jul 2026 10:13:14 +0800 Subject: [PATCH] Initial commit Co-authored-by: Cursor --- .cargo/config.toml | 7 + .env.example | 218 +++++++ .github/workflows/release.yml | 130 +++++ .gitignore | 6 + Cargo.toml | 39 ++ README.md | 104 ++++ README.zh-CN.md | 120 ++++ image/account.png | Bin 0 -> 103351 bytes image/main.png | Bin 0 -> 94753 bytes src/adapter_auth.rs | 135 +++++ src/clob_v2.rs | 71 +++ src/config.rs | 182 ++++++ src/deposit_wallet_relay.rs | 217 +++++++ src/lib.rs | 23 + src/main.rs | 1022 +++++++++++++++++++++++++++++++++ src/market/discoverer.rs | 138 +++++ src/market/mod.rs | 5 + src/market/scheduler.rs | 117 ++++ src/merge.rs | 634 ++++++++++++++++++++ src/monitor/arbitrage.rs | 146 +++++ src/monitor/mod.rs | 5 + src/monitor/orderbook.rs | 163 ++++++ src/positions.rs | 45 ++ src/proxy_relay.rs | 386 +++++++++++++ src/pusd_wrap.rs | 278 +++++++++ src/redeem.rs | 406 +++++++++++++ src/risk/hedge_monitor.rs | 593 +++++++++++++++++++ src/risk/manager.rs | 160 ++++++ src/risk/mod.rs | 9 + src/risk/position_balancer.rs | 373 ++++++++++++ src/risk/positions.rs | 248 ++++++++ src/risk/recovery.rs | 162 ++++++ src/trading/clob_client.rs | 1 + src/trading/executor.rs | 462 +++++++++++++++ src/trading/mod.rs | 9 + src/trading/orders.rs | 2 + src/ui/mod.rs | 11 + src/ui/render.rs | 702 ++++++++++++++++++++++ src/ui/runner.rs | 86 +++ src/ui/state.rs | 619 ++++++++++++++++++++ src/utils/arbitrage_logger.rs | 70 +++ src/utils/errors.rs | 2 + src/utils/logger.rs | 54 ++ src/utils/mod.rs | 3 + src/wallet_kind.rs | 20 + 45 files changed, 8183 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .env.example create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 README.zh-CN.md create mode 100644 image/account.png create mode 100644 image/main.png create mode 100644 src/adapter_auth.rs create mode 100644 src/clob_v2.rs create mode 100644 src/config.rs create mode 100644 src/deposit_wallet_relay.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/market/discoverer.rs create mode 100644 src/market/mod.rs create mode 100644 src/market/scheduler.rs create mode 100644 src/merge.rs create mode 100644 src/monitor/arbitrage.rs create mode 100644 src/monitor/mod.rs create mode 100644 src/monitor/orderbook.rs create mode 100644 src/positions.rs create mode 100644 src/proxy_relay.rs create mode 100644 src/pusd_wrap.rs create mode 100644 src/redeem.rs create mode 100644 src/risk/hedge_monitor.rs create mode 100644 src/risk/manager.rs create mode 100644 src/risk/mod.rs create mode 100644 src/risk/position_balancer.rs create mode 100644 src/risk/positions.rs create mode 100644 src/risk/recovery.rs create mode 100644 src/trading/clob_client.rs create mode 100644 src/trading/executor.rs create mode 100644 src/trading/mod.rs create mode 100644 src/trading/orders.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/render.rs create mode 100644 src/ui/runner.rs create mode 100644 src/ui/state.rs create mode 100644 src/utils/arbitrage_logger.rs create mode 100644 src/utils/errors.rs create mode 100644 src/utils/logger.rs create mode 100644 src/utils/mod.rs create mode 100644 src/wallet_kind.rs 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 0000000000000000000000000000000000000000..d5d2ce44f0b48d1a05b0b55fbb2977cf841875f9 GIT binary patch literal 103351 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%0D6#jq_?4bw3{|CdwyzP~x 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 K=d#Wzp$PzG@8r<{ literal 0 HcmV?d00001 diff --git a/image/main.png b/image/main.png new file mode 100644 index 0000000000000000000000000000000000000000..fd02e910c550eed2b02d6efbc1713bbfafb22338 GIT binary patch literal 94753 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
  • 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 >f5 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 literal 0 HcmV?d00001 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 + } +}