@clawhub-synththoughts-947d425369
跨交易所资金费率套利策略。在费率低的交易所做多永续、费率高的交易所做空永续,Delta-neutral 赚取 funding spread。支持 Hyperliquid + Binance,自动扫描机会、稳定性验证、原子开仓、健康检查、自动切仓。适用于资金费率套利、Delta 中性、跨所套利场景。
---
name: cross-funding-arb
description: "跨交易所资金费率套利策略。在费率低的交易所做多永续、费率高的交易所做空永续,Delta-neutral 赚取 funding spread。支持 Hyperliquid + Binance,自动扫描机会、稳定性验证、原子开仓、健康检查、自动切仓。适用于资金费率套利、Delta 中性、跨所套利场景。"
license: Apache-2.0
metadata:
author: SynthThoughts
version: "2.8.0"
pattern: "pipeline"
steps: "5"
openclaw:
requires:
env:
- HL_PRIVATE_KEY
- BINANCE_API_KEY
- BINANCE_SECRET_KEY
optional_env:
- HL_VAULT_ADDRESS
- HL_TESTNET
- BINANCE_TESTNET
- DISCORD_CHANNEL_ID
- DISCORD_BOT_TOKEN
- TELEGRAM_BOT_TOKEN
- TELEGRAM_CHAT_ID
- STATE_DIR
bins:
- python3
primaryEnv: HL_PRIVATE_KEY
entrypoint: references/cross_funding.py
os:
- darwin
- linux
---
# Cross-Exchange Funding Rate Arbitrage v2
Cron 驱动的跨交易所资金费率套利机器人。核心思路:**在费率低的交易所做多永续合约,在费率高的交易所做空永续合约**,两腿等量 delta-neutral,赚取 funding spread。
数据源:VarFunding API(预计算的跨所套利机会)。执行层:Hyperliquid SDK(EIP-712 私钥签名)+ Binance Futures API(HMAC 签名)。
每个 tick:扫描机会 → 稳定性验证 → 深度验证 → 开仓/维护 → 报告。
## Architecture
```
VarFunding API ──→ Scanner ──→ 稳定性验证 ──→ 深度验证
│
┌─────────────────────────┘
↓
CrossFundingEngine
/ \
HLClient BinanceClient
(EIP-712) (HMAC-SHA256)
| |
Hyperliquid Binance Futures
(永续合约) (USDT-M 永续)
```
## Pipeline: Execution Steps
### Step 0: Prerequisites
- Python 3.10+
- `hyperliquid-python-sdk >= 0.21.0`
- `eth-account >= 0.13.7`
- `python-dotenv >= 1.0.0`
- `requests >= 2.31.0`
- Hyperliquid 账户 + 私钥(主账户或 Agent Wallet)
- Binance Futures 账户 + API Key/Secret(需 USDT-M 交易权限)
### Step 1: Price & Rate Scanning
**Goal**: 从 VarFunding API 获取跨所套利机会
**Actions**:
1. 调用 `VarFundingScanner.fetch_opportunities()` 获取 HL × Binance 的套利机会
2. 过滤:估计 APR ≥ `min_apr_pct`,置信度 ≥ `min_confidence`
3. 按 APR 降序排列,取 Top 5
**Gate**:
- [ ] 至少有一个机会满足 APR 和置信度门槛
- [ ] 无机会 → 输出 `action: idle`,等待下一 tick
### Step 2: Stability Verification
**Goal**: 防止瞬时费率波动导致错误开仓
**Actions**:
1. 将 Top 5 机会记录为费率快照(state 中保留最近 20 条)
2. 按币种分组,检查同一币种的快照数 ≥ `stability_snapshots`(默认 3)
3. 计算 spread 的标准差占比 `std/avg`,须 < `stability_max_std_ratio`(默认 0.3)
**Gate**:
- [ ] 存在至少一个币种通过稳定性检查
- [ ] 未通过 → 输出 `action: accumulating`,继续积累快照
### Step 3: Deep Verification
**Goal**: 独立验证两所实际费率 + 价格差 + 往返成本
**Actions**:
1. 分别从 HL 和 Binance 获取实时费率和中间价
2. 计算实际 spread = short_rate − long_rate
3. 计算价格差异 `price_basis_pct = |hl_price − bn_price| / avg_price`
4. 计算净 APR = gross_annual − round_trip_cost_pct
**Gate** (ALL must pass):
- [ ] 实际 spread > 0
- [ ] 价格差异 < `max_price_basis_pct`(默认 0.5%)
- [ ] 净 APR ≥ `min_apr_pct`(默认 10%)
- [ ] 任一失败 → 输出 `action: rejected` + 原因
### Step 4: Atomic Execution
**Goal**: 原子开仓,先 HL 后 Binance,失败自动回滚
**Actions**:
1. 计算 size:`min(hl_budget, bn_budget) × 0.5 × 0.95 × leverage / price`
2. 两所分别 round_size,取较小值
3. 两所设杠杆
4. **先下 HL 单**(分级保证金限制更严,失败代价低)
- 使用 LIMIT IOC 模拟市价,滑点 0.1%
- Insufficient margin → 自动减半 size 重试(最多 3 次)
5. **再下 Binance 单**
- Binance 失败 → 自动回滚 HL 腿(close_position)
6. 保存完整开仓状态 + 两所初始余额
**Gate**:
- [ ] 两腿均成功开仓
- [ ] HL 失败 → 不开 Binance,直接终止
- [ ] Binance 失败 → 回滚 HL 腿,发出 rollback 通知
### Step 5: Health Monitoring & Auto-Switch
**Goal**: 持仓期间持续监控,spread 不利或缺腿时自动处理
**每 tick 检查**:
1. **Delta 检查**:两腿 size 偏差 < 20%
2. **Spread 检查**:当前 spread > `close_spread_threshold`(默认 0.005%)
3. **双腿检查**:两所都有持仓
**自动处理**:
- Spread 不利(< threshold)→ 平仓(先平 short,后平 long)
- 缺腿 → 平仓 + risk_alert 通知
- 健康 → 缓存快照,每小时推送 hourly_pulse
**切仓逻辑**:
- 持仓中发现更好机会:当前 APR vs 新机会 APR 差距 > `switch_threshold_apr` → 平仓 → 下个 tick 自动开新仓
## Tunable Parameters
### Cross Funding Configuration
| Parameter | Default | Description |
|---|---|---|
| `hl_budget_usd` | `0` | Hyperliquid 单边预算 (USDC),0 = 自动读取账户余额 |
| `bn_budget_usd` | `0` | Binance 单边预算 (USDT),0 = 自动读取账户余额 |
| `min_apr_pct` | `10.0` | 最低年化收益率门槛 (%) |
| `min_confidence` | `"medium"` | VarFunding 最低置信度 (low/medium/high) |
| `leverage` | `1` | 杠杆倍数(默认无杠杆) |
| `stability_snapshots` | `3` | 稳定性验证所需快照数 |
| `stability_max_std_ratio` | `0.3` | Spread 标准差/均值上限 |
| `close_spread_threshold` | `0.0001` | 平仓 spread 下限(8h 费率差,≈10.95% APR) |
| `switch_threshold_apr` | `5.0` | 切仓 APR 差距门槛 (%) |
| `max_price_basis_pct` | `0.5` | 两所价格差异上限 (%) |
| `round_trip_cost_pct` | `0.12` | 往返成本 (%, 含手续费+滑点) |
### Shared Configuration
| Parameter | Default | Description |
|---|---|---|
| `max_consecutive_errors` | `5` | 连续错误触发熔断 |
| `cooldown_after_errors` | `3600` | 熔断冷却时间 (秒) |
| `min_order_usd` | `10` | 最小下单金额 (USD) |
### Environment Variables
| Variable | Required | Description |
|---|---|---|
| `HL_PRIVATE_KEY` | ✅ | Hyperliquid 私钥(主账户或 Agent Wallet) |
| `HL_VAULT_ADDRESS` | ❌ | Agent Wallet 的 master 地址 |
| `HL_TESTNET` | ❌ | `true` 使用测试网 |
| `BINANCE_API_KEY` | ✅ | Binance Futures API Key |
| `BINANCE_SECRET_KEY` | ✅ | Binance Futures API Secret |
| `BINANCE_TESTNET` | ❌ | `true` 使用测试网 |
| `DISCORD_BOT_TOKEN` | ❌ | Discord 通知 bot token |
| `DISCORD_CHANNEL_ID` | ❌ | Discord 目标频道 ID |
| `TELEGRAM_BOT_TOKEN` | ❌ | Telegram 通知 bot token |
| `TELEGRAM_CHAT_ID` | ❌ | Telegram 目标 chat ID |
## Operational Interface
| Command | Description | Typical Use |
|---|---|---|
| `tick` | 主循环:扫描 → 验证 → 开仓/维护 | Cron 每 5 分钟 |
| `report` | 生成日报(含 PnL、余额、费率) | Cron 每天 00:00 UTC |
| `status` | 当前状态(优先读缓存) | 手动查询 |
```bash
# 单次 tick
cd ~/scripts/cross-funding && set -a && . ./.env && set +a && python3 cross_funding.py tick
# 日报
python3 cross_funding.py report
# 状态查询
python3 cross_funding.py status
```
## Notification Tiers
| Tier | When | Color | Key Fields |
|---|---|---|---|
| `trade_alert` | 开仓/平仓 | 🟢 Green / 🟠 Orange | Long/Short 交易所, Size, Spread, APR |
| `risk_alert` | Spread 不利/缺腿/熔断 | 🔴 Red | 原因, 当前 APR, Delta 偏差 |
| `hourly_pulse` | 每小时(持仓中) | ⚪ Grey | 两所余额, 费率, Spread, PnL |
| `daily_report` | 每日报告 | 🔵 Blue | 持仓详情, 总资产, ROI, 年化 |
通知同时推送 Discord embed + Telegram markdown。凭证解析优先级:环境变量 > OpenClaw `openclaw.json` > ZeroClaw `config.toml`。未配置时静默跳过。
## State Schema
```json
{
"current_coin": "FET",
"direction": {
"long_exchange": "hyperliquid",
"short_exchange": "binance"
},
"entry_time": "2026-03-25T10:00:00+00:00",
"entry_spread": 0.0006,
"entry_hl_rate": 0.0000125,
"entry_bn_rate": 0.0006194,
"size": 150.0,
"entry_price": 0.85,
"budget_hl": 300,
"budget_bn": 450,
"entry_hl_balance": 295.5,
"entry_bn_balance": 445.2,
"entry_total_balance": 740.7,
"total_funding_earned": 0.0,
"rate_snapshots": [],
"cached_snapshot": {},
"cached_snapshot_ts": "...",
"last_tick": "...",
"last_pulse_ts": "..."
}
```
## Core Algorithm
```
1. 获取进程锁(flock,防止并发 tick)
2. 检查熔断器状态(连续 5 次错误 → 冷却 1h)
3. 加载状态
4. IF 无持仓:
a. VarFunding API 扫描套利机会
b. 记录费率快照
c. 检查稳定性(需 3+ 快照,std_ratio < 0.3)
d. 深度验证(实时费率 + 价格差 + 净 APR)
e. 原子开仓(先 HL 后 BN,HL 失败自动减半重试)
5. IF 有持仓:
a. 健康检查(delta + spread + 双腿)
b. 不健康 → 自动平仓
c. 健康 → 缓存快照,每小时推送 pulse
6. 记录成功/错误
7. 释放锁
```
## Risk Control
| Layer | Check | Action |
|---|---|---|
| 稳定性验证 | 费率快照 std_ratio < 0.3 | 阻止开仓,继续积累 |
| 深度验证 | 实际 spread > 0, 价格差 < 0.5%, APR ≥ 门槛 | 拒绝不合格机会 |
| 保守预算 | 只用 min(两所预算) × 50% | 预留安全余量 |
| 滑点控制 | 市价单滑点 0.1%(非常规 5%) | 降低套利成本 |
| HL Margin 重试 | Insufficient margin → size 减半,最多 3 次 | 适应分级保证金 |
| 原子回滚 | BN 失败 → 自动平 HL | 防止单腿裸露 |
| Delta 监控 | 两腿偏差 > 20% → 告警 | 发现脱钩 |
| Spread 监控 | spread < threshold → 自动平仓 | 防止费率反转亏损 |
| 熔断器 | 连续 5 错 → 冷却 1h | 防止连环失败 |
## Failure & Rollback
```
IF tick fails:
1. 记录错误到熔断器
2. 连续 5 次 → 进入冷却(1h)
3. 开仓失败:
- HL 失败 → 不开 Binance,直接终止
- Binance 失败 → 回滚 HL 腿(close_position)
- 发出 risk_alert 通知
4. 健康检查发现问题:
- 缺腿 → 平仓
- Spread 不利 → 平仓
5. 冷却结束后自动恢复
```
## Anti-Patterns
| Pattern | Problem |
|---|---|
| 不做稳定性验证直接开仓 | 瞬时费率波动导致开仓即亏 |
| 滑点设太大(>1%) | 套利利润被滑点吞噬 |
| 两所预算差太大 | 大腿 size 受限于小腿,资金利用率低 |
| 忽略价格差异 | 两所标记价差异大时 delta 不中性 |
| 不检查双腿一致性 | 单腿被清算另一腿裸露 |
| 杠杆过高 | 小币波动大,保证金不足被强平 |
| 只看 VarFunding 不独立验证 | API 数据可能滞后于实际费率 |
| 熔断后手动重启不排查 | 掩盖系统性问题 |
## Data Source: VarFunding API
```
GET https://varfunding.xyz/api/funding?exchanges=hyperliquid,binance
Response: {
"markets": [{
"baseAsset": "FET",
"variational": { "exchange": "hyperliquid", "rate": 0.0000125 },
"comparisons": [{ "exchange": "binance", "rate": 0.0006194 }],
"arbitrageOpportunity": {
"longExchange": "hyperliquid",
"shortExchange": "binance",
"spread": 0.0006,
"estimatedApr": 66.5,
"confidence": "medium"
}
}]
}
```
## Deployment
安装后脚本位于标准 skill 目录,无需手动拷贝。
```
# OpenClaw 安装路径
~/.openclaw/skills/cross-funding-arb/references/cross_funding.py
# ZeroClaw 安装路径
~/.zeroclaw/skills/cross-funding-arb/references/cross_funding.py
```
**安装后配置**:将 `.env` 放到 `references/` 目录(与 `cross_funding.py` 同级):
```bash
# OpenClaw
cp .env.example ~/.openclaw/skills/cross-funding-arb/references/.env
# 编辑填入 HL_PRIVATE_KEY, BINANCE_API_KEY, BINANCE_SECRET_KEY
# ZeroClaw
cp .env.example ~/.zeroclaw/skills/cross-funding-arb/references/.env
```
### OpenClaw Cron
```bash
SKILL_DIR=~/.openclaw/skills/cross-funding-arb/references
# tick: 每 5 分钟(主会话执行 shell 命令)
openclaw cron add \
--name "cross-funding-tick" \
--cron "*/5 * * * *" \
--session main \
--system-event "cd $SKILL_DIR && set -a && . ./.env && set +a && python3 cross_funding.py tick"
# 日报: 每天 00:00 UTC(隔离会话,结果投递到 Discord)
openclaw cron add \
--name "cross-funding-report" \
--cron "0 0 * * *" \
--tz "UTC" \
--session isolated \
--message "执行跨交易所资金费率套利日报: cd $SKILL_DIR && set -a && . ./.env && set +a && python3 cross_funding.py report。将完整输出结果总结后回复我。" \
--announce \
--channel discord
```
### ZeroClaw Cron
```bash
SKILL_DIR=~/.zeroclaw/skills/cross-funding-arb/references
# tick: 每 5 分钟
zeroclaw cron add --expr "*/5 * * * *" --shell \
"cd $SKILL_DIR && set -a && . ./.env && set +a && python3 cross_funding.py tick"
# 日报: 每天 00:00 UTC
zeroclaw cron add --expr "0 0 * * *" --agent \
"执行跨交易所资金费率套利日报: cd $SKILL_DIR && set -a && . ./.env && set +a && python3 cross_funding.py report。将完整输出结果总结后回复我。"
```
### Manual
```bash
cd ~/.openclaw/skills/cross-funding-arb/references
set -a && . ./.env && set +a
python3 cross_funding.py tick # 单次 tick
python3 cross_funding.py status # 状态查询
python3 cross_funding.py report # 日报
```
## Install
```bash
npx clawhub install cross-funding-arb --force
```
## Security Notice
> **安装时可能出现安全扫描告警(需 `--force`),这是误报。**
本技能是交易策略,代码中涉及的"可疑模式"均为正常业务需求:
| 被标记的模式 | 实际用途 | 安全性 |
|---|---|---|
| `private_key` / `secret_key` / `api_key` | 交易所 API 凭证变量名 | 全部通过 `.env` 环境变量注入,**代码中无硬编码密钥** |
| `hmac` + `hashlib.sha256` | Binance Futures API 请求签名 | 标准 HMAC-SHA256 签名,[Binance 官方要求](https://developers.binance.com/docs/binance-spot-api-docs/rest-api/public-api-definitions#signed-trade-and-user_data-endpoint-security) |
| 外部 HTTP 请求 | 调用 Binance / Hyperliquid / VarFunding / Discord / Telegram API | 仅与已知交易所和通知服务通信 |
| `bot_token` / `chat_id` | Discord / Telegram 通知推送凭证 | 可选功能,未配置时静默跳过 |
**代码中不包含**:`eval` / `exec` / `subprocess` / `os.system` / 文件系统扫描 / 动态代码加载 / 数据外泄逻辑。
如需审查,完整源码位于 `references/cross_funding.py`(单文件,约 2400 行)。
FILE:README.md
# Cross-Exchange Funding Rate Arbitrage
跨交易所资金费率套利策略。在费率低的交易所做多永续、费率高的做空,Delta-neutral 赚取 funding spread。
## Features
- **自动扫描**:VarFunding API 实时发现 HL × Binance 套利机会
- **稳定性验证**:多次快照确认费率稳定后才开仓,防止瞬时波动
- **原子开仓**:先 HL 后 Binance,失败自动回滚,无单腿裸露风险
- **健康监控**:Delta 偏差 + Spread 监控 + 双腿一致性检查
- **自动切仓**:Spread 不利时平仓,下一 tick 自动寻找新机会
- **多渠道通知**:Discord embed + Telegram markdown,按 tier 分级推送
## Quick Start
### 1. Install
```bash
npx clawhub install cross-funding-arb --force
pip install -r references/requirements.txt
```
### 2. Configure
```bash
# OpenClaw
cp .env.example ~/.openclaw/skills/cross-funding-arb/references/.env
# 编辑 .env,填入 HL_PRIVATE_KEY, BINANCE_API_KEY, BINANCE_SECRET_KEY
# 可选:调整 references/config.json 中的风控参数
```
### 3. Test
```bash
cd ~/.openclaw/skills/cross-funding-arb/references
set -a && . ./.env && set +a
python3 cross_funding.py status # 查看当前状态
python3 cross_funding.py tick # 单次 tick
```
### 4. Deploy
```bash
SKILL_DIR=~/.openclaw/skills/cross-funding-arb/references
# OpenClaw cron
openclaw cron add \
--name "cross-funding-tick" \
--cron "*/5 * * * *" \
--session main \
--system-event "cd $SKILL_DIR && set -a && . ./.env && set +a && python3 cross_funding.py tick"
# 或系统 crontab
*/5 * * * * cd $SKILL_DIR && set -a && . ./.env && set +a && python3 cross_funding.py tick >> /tmp/cross_funding.log 2>&1
```
## Commands
| Command | Description |
|---|---|
| `tick` | 主循环:扫描 → 验证 → 开仓/维护 |
| `report` | 日报:持仓、PnL、余额、费率 |
| `status` | 当前状态(优先读缓存) |
## Risk Controls
| Control | Description |
|---|---|
| 稳定性验证 | 3+ 快照 + std_ratio < 0.3 |
| 深度验证 | 实时费率 + 价格差 < 0.5% + 净 APR ≥ 10% |
| 保守预算 | min(两所) × 50%,budget=0 自动读取账户余额 |
| 滑点 0.1% | 远低于常规 5%,保护套利利润 |
| HL Margin 重试 | size 减半最多 3 次 |
| 原子回滚 | BN 失败 → 自动平 HL |
| 熔断器 | 连续 5 错 → 冷却 1h |
## Prerequisites
- Python 3.10+
- Hyperliquid 账户 + 私钥
- Binance Futures 账户 + API Key(USDT-M 交易权限)
- 两所均需存入保证金(策略自动检测账户余额)
## License
Apache-2.0
FILE:references/config.json
{
"shared": {
"max_consecutive_errors": 5,
"cooldown_after_errors": 3600,
"min_order_usd": 10
},
"cross_funding": {
"enabled": true,
"hl_budget_usd": 0,
"bn_budget_usd": 0,
"min_apr_pct": 30.0,
"min_hold_apr_pct": 15.0,
"min_confidence": "medium",
"leverage": 1,
"stability_snapshots": 12,
"stability_max_std_ratio": 0.2,
"close_spread_threshold": 0.0003,
"switch_threshold_apr": 15.0,
"max_price_basis_pct": 0.5,
"round_trip_cost_pct": 0.25,
"max_breakeven_days": 0.5,
"min_hold_ticks": 96,
"pnl_stop_loss_pct": -1.0,
"delta_exit": {
"enabled": true,
"neutral_threshold_pct": 2.0,
"max_defer_ticks": 12,
"force_close_delta_pct": 15.0
}
}
}
FILE:references/cross_funding.py
#!/usr/bin/env python3
"""Cross-exchange funding rate arbitrage (HL + Binance).
Monolithic single-file strategy for distribution as a skill.
Merges: config, state, emit, circuit_breaker, hl_client, bn_client,
varfunding_scanner, cross_funding_engine, hl_cross_funding.
Usage:
python cross_funding.py tick
python cross_funding.py report
python cross_funding.py status
"""
from __future__ import annotations
import argparse
import fcntl
import hashlib
import hmac
import json
import math
import os
import statistics
import tempfile
import time
import traceback
from datetime import datetime, timedelta, timezone
from decimal import ROUND_DOWN, Decimal
from pathlib import Path
from urllib.parse import urlencode
import eth_account
import requests
from hyperliquid.exchange import Exchange
from hyperliquid.info import Info
from hyperliquid.utils import constants
# ═══════════════════════════════════════════════════════════════════════════════
# Section 1: Config
# ═══════════════════════════════════════════════════════════════════════════════
SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_PATH = SCRIPT_DIR / "config.json"
def load_config() -> dict:
"""Load config.json and return the full config dict."""
with open(CONFIG_PATH) as f:
return json.load(f)
def _env(key: str, default: str = "") -> str:
return os.environ.get(key, default)
def _env_bool(key: str, default: bool = False) -> bool:
v = os.environ.get(key, "")
if not v:
return default
return v.lower() in ("true", "1", "yes")
def hl_private_key() -> str:
k = _env("HL_PRIVATE_KEY")
if not k:
raise RuntimeError("HL_PRIVATE_KEY not set")
return k
def hl_testnet() -> bool:
return _env_bool("HL_TESTNET", default=False)
def hl_vault_address() -> str:
return _env("HL_VAULT_ADDRESS")
def state_dir() -> Path:
d = _env("STATE_DIR")
return Path(d) if d else SCRIPT_DIR
def binance_api_key() -> str:
k = _env("BINANCE_API_KEY")
if not k:
raise RuntimeError("BINANCE_API_KEY not set")
return k
def binance_secret_key() -> str:
k = _env("BINANCE_SECRET_KEY")
if not k:
raise RuntimeError("BINANCE_SECRET_KEY not set")
return k
def bn_testnet() -> bool:
return _env_bool("BINANCE_TESTNET", default=False)
# ═══════════════════════════════════════════════════════════════════════════════
# Section 2: State Management
# ═══════════════════════════════════════════════════════════════════════════════
def state_path(name: str) -> Path:
return state_dir() / f"{name}_state.json"
def load_state(name: str) -> dict:
p = state_path(name)
if not p.exists():
return {}
with open(p) as f:
return json.load(f)
def save_state(name: str, data: dict) -> None:
"""Atomic state file write."""
p = state_path(name)
p.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=p.parent, suffix=".json.tmp")
try:
with os.fdopen(fd, "w") as f:
f.write(json.dumps(data, indent=2, ensure_ascii=False))
f.flush()
os.fsync(f.fileno())
os.replace(tmp, p)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
# --- Process lock ---
_lock_fd = None
def acquire_lock(name: str) -> bool:
"""Acquire exclusive lock to prevent concurrent instances."""
global _lock_fd
lock_path = state_dir() / f".{name}.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
_lock_fd = open(lock_path, "w")
try:
fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
_lock_fd.write(str(os.getpid()))
_lock_fd.flush()
return True
except (OSError, IOError):
_lock_fd.close()
_lock_fd = None
return False
def release_lock() -> None:
global _lock_fd
if _lock_fd is not None:
try:
fcntl.flock(_lock_fd, fcntl.LOCK_UN)
_lock_fd.close()
except Exception:
pass
_lock_fd = None
# ═══════════════════════════════════════════════════════════════════════════════
# Section 3: Emit (structured JSON output + notifications)
# ═══════════════════════════════════════════════════════════════════════════════
# ── Notification credentials ────────────────────────────────────────────────
def _parse_toml_section(text: str, section: str) -> dict[str, str]:
"""Extract key=value pairs from a TOML section. Simple parser, no deps."""
result: dict[str, str] = {}
in_section = False
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("["):
in_section = stripped.rstrip("]").lstrip("[").strip() == section
continue
if in_section and "=" in stripped and not stripped.startswith("#"):
k, v = stripped.split("=", 1)
result[k.strip()] = v.strip().strip('"').strip("'")
return result
def _read_daemon_configs() -> list[dict]:
"""Read all available daemon configs for bot token resolution.
Returns a list of config sources (OpenClaw JSON + ZeroClaw TOML instances).
Callers try each source in order until a token is found.
"""
sources: list[dict] = []
# OpenClaw: JSON format
oc_path = Path.home() / ".openclaw" / "openclaw.json"
if oc_path.exists():
try:
data = json.loads(oc_path.read_text())
channels = data.get("channels", {})
sources.append({"_type": "openclaw", "_data": channels})
except Exception:
pass
# ZeroClaw: TOML format (try all instances)
for instance in ["zeroclaw-strategy", "zeroclaw", "zeroclaw-data", "zeroclaw-ops"]:
cfg_path = Path.home() / f".{instance}" / "config.toml"
if cfg_path.exists():
try:
sources.append({"_type": "zeroclaw", "_text": cfg_path.read_text()})
except Exception:
pass
return sources
_DAEMON_CONFIGS = _read_daemon_configs()
def _get_discord_token() -> str:
"""Discord bot token: env > first available daemon config."""
env_token = os.environ.get("DISCORD_BOT_TOKEN", "")
if env_token:
return env_token
for cfg in _DAEMON_CONFIGS:
token = ""
if cfg["_type"] == "openclaw":
token = cfg.get("_data", {}).get("discord", {}).get("token", "")
elif cfg["_type"] == "zeroclaw":
section = _parse_toml_section(
cfg.get("_text", ""), "channels_config.discord"
)
token = section.get("bot_token", "")
if token:
return token
return ""
def _get_discord_channel_id() -> str:
"""Discord channel ID: only from env (each strategy configures its own)."""
return os.environ.get("DISCORD_CHANNEL_ID", "")
def _get_telegram_config() -> tuple[str, str]:
"""Telegram creds: env > first available daemon config.
bot_token falls back to daemon config, chat_id from env only.
"""
token = os.environ.get("TELEGRAM_BOT_TOKEN", "")
chat_id = os.environ.get("TELEGRAM_CHAT_ID", "")
if not token:
for cfg in _DAEMON_CONFIGS:
if cfg["_type"] == "openclaw":
# OpenClaw uses camelCase "botToken"
token = cfg.get("_data", {}).get("telegram", {}).get("botToken", "")
elif cfg["_type"] == "zeroclaw":
section = _parse_toml_section(
cfg.get("_text", ""), "channels_config.telegram"
)
token = section.get("bot_token", "")
if token:
break
return token, chat_id
DISCORD_CHANNEL_ID = _get_discord_channel_id()
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID = _get_telegram_config()
# ── Notification card builder ────────────────────────────────────────────────
STRATEGY_LABEL = "Cross-Funding"
_EX_SHORT = {"hyperliquid": "HL", "binance": "BN"}
def _next_settlement_countdown() -> dict[str, str]:
"""Calculate countdown to next funding rate settlement for both exchanges.
HL: settles every hour (xx:00)
Binance: settles every 8 hours (00:00 / 08:00 / 16:00 UTC)
"""
now = datetime.now(timezone.utc)
# HL: next whole hour
hl_next = now.replace(minute=0, second=0, microsecond=0)
if hl_next <= now:
hl_next += timedelta(hours=1)
hl_secs = (hl_next - now).total_seconds()
hl_m = int(hl_secs // 60)
hl_str = f"{hl_m}m"
# Binance: next 0/8/16 hour mark
bn_hours = [0, 8, 16, 24] # 24 = next day 0:00
for h in bn_hours:
bn_next = now.replace(hour=h % 24, minute=0, second=0, microsecond=0)
if h == 24:
bn_next += timedelta(days=1)
bn_next = bn_next.replace(hour=0)
if bn_next > now:
break
bn_secs = (bn_next - now).total_seconds()
bn_h = int(bn_secs // 3600)
bn_m = int((bn_secs % 3600) // 60)
bn_str = f"{bn_h}h{bn_m}m" if bn_h else f"{bn_m}m"
return {"hl": hl_str, "bn": bn_str}
def _build_notification(tier: str, data: dict) -> dict | None:
"""Build dual-format notification (discord embed + text markdown).
Returns {"tier": str, "discord": {...}, "text": str} or None if silent.
"""
# ── Trade Alert (open / close) ───────────────────────────────────────
if tier == "trade_alert":
event = data.get("type", "")
coin = data.get("coin", "?")
long_ex = data.get("long_exchange", "?")
short_ex = data.get("short_exchange", "?")
if event == "position_opened":
size = data.get("size", 0)
price = data.get("entry_price", 0)
notional = round(size * price, 2)
leverage = data.get("leverage", 1)
hl_rate = data.get("hl_rate", 0)
bn_rate = data.get("bn_rate", 0)
spread = abs(bn_rate - hl_rate)
apr = spread * 3 * 365 * 100
fields = [
{
"name": "Long",
"value": _EX_SHORT.get(long_ex, long_ex),
"inline": True,
},
{
"name": "Short",
"value": _EX_SHORT.get(short_ex, short_ex),
"inline": True,
},
{"name": "杠杆", "value": f"{leverage}x", "inline": True},
{"name": "Size", "value": f"{size:g} {coin}", "inline": True},
{"name": "名义价值", "value": f",.2f", "inline": True},
{"name": "Spread", "value": f"{spread:.4%}", "inline": True},
{
"name": "预估 APR",
"value": f"{apr:.1f}%",
"inline": True,
},
]
text_lines = [
f"🔄 **开仓 · {coin} · {STRATEGY_LABEL}**",
f"📍 Long `{long_ex}` / Short `{short_ex}` | `{leverage}x`",
f"📦 Size: `{size:g} {coin}` (`,.2f`)",
f"📈 Spread: `{spread:.4%}` → `{apr:.1f}%` APR",
]
return {
"tier": "trade_alert",
"discord": {
"title": f"🔄 开仓 · {coin} · {STRATEGY_LABEL}",
"color": 0x00CC66,
"fields": fields,
},
"text": "\n".join(text_lines),
}
if event == "position_closed":
funding = data.get("funding_earned", 0)
text_lines = [
f"📤 **平仓 · {coin} · {STRATEGY_LABEL}**",
f"💵 Funding Earned: `,.2f`",
]
return {
"tier": "trade_alert",
"discord": {
"title": f"📤 平仓 · {coin} · {STRATEGY_LABEL}",
"color": 0xFF6600,
"fields": [
{
"name": "Funding Earned",
"value": f",.2f",
"inline": True,
},
],
},
"text": "\n".join(text_lines),
}
if event == "switch_start":
from_coin = data.get("from", "?")
to_coin = data.get("to", "?")
from_apr = data.get("from_apr", 0)
to_apr = data.get("to_apr", 0)
apr_gain = data.get("apr_gain", 0)
trading_cost = data.get("trading_cost_pct", 0)
bn_unreal = data.get("bn_unrealized_pct", 0)
hl_unreal = data.get("hl_unrealized_pct", 0)
sunk_cost = data.get("sunk_cost_pct", 0)
total_cost = data.get("total_switch_cost", 0)
bn_elapsed = data.get("bn_elapsed_h", 0)
hl_elapsed = data.get("hl_elapsed_m", 0)
# Show sunk sign: positive = forfeit earnings, negative = avoid losses
bn_sign = "+" if bn_unreal >= 0 else ""
hl_sign = "+" if hl_unreal >= 0 else ""
sunk_sign = "+" if sunk_cost >= 0 else ""
fields = [
{
"name": "From",
"value": f"{from_coin} ({from_apr:.1f}%)",
"inline": True,
},
{"name": "To", "value": f"{to_coin} ({to_apr:.1f}%)", "inline": True},
{"name": "净收益", "value": f"+{apr_gain:.1f}% APR", "inline": True},
{
"name": "交易成本",
"value": f"{trading_cost:.2f}%",
"inline": True,
},
{
"name": "BN 未实现",
"value": f"{bn_sign}{bn_unreal:.4f}% ({bn_elapsed:.1f}h/8h)",
"inline": True,
},
{
"name": "HL 未实现",
"value": f"{hl_sign}{hl_unreal:.4f}% ({hl_elapsed:.0f}m/60m)",
"inline": True,
},
{
"name": "总成本",
"value": f"{total_cost:.2f}% (交易{trading_cost:.2f} + 沉没{sunk_sign}{sunk_cost:.4f})",
"inline": False,
},
]
text_lines = [
f"🔄 **换仓 · {STRATEGY_LABEL}**",
f"📉 `{from_coin}` ({from_apr:.1f}%) → 📈 `{to_coin}` ({to_apr:.1f}%)",
f"💰 净收益: `+{apr_gain:.1f}%` APR",
f"💸 交易: `{trading_cost:.2f}%` · BN未实现: `{bn_sign}{bn_unreal:.4f}%` ({bn_elapsed:.1f}h/8h) · HL未实现: `{hl_sign}{hl_unreal:.4f}%` ({hl_elapsed:.0f}m/60m)",
f"📊 总成本: `{total_cost:.2f}%`",
]
return {
"tier": "trade_alert",
"discord": {
"title": f"🔄 换仓 · {from_coin} → {to_coin} · {STRATEGY_LABEL}",
"color": 0x9B59B6,
"fields": fields,
},
"text": "\n".join(text_lines),
}
return None
# ── Risk Alert ───────────────────────────────────────────────────────
if tier == "risk_alert":
coin = data.get("coin", "?")
reason = data.get("reason", data.get("context", "unknown"))
current_apr = data.get("current_apr", 0)
delta_pct = data.get("delta_pct", 0)
fields = [
{"name": "原因", "value": str(reason), "inline": False},
{"name": "当前 APR", "value": f"{current_apr:.1f}%", "inline": True},
{"name": "Delta 偏差", "value": f"{delta_pct:.1f}%", "inline": True},
]
text_lines = [
f"🛑 **风险告警 · {coin} · {STRATEGY_LABEL}**",
f"⚠️ 原因: `{reason}`",
f"📊 APR: `{current_apr:.1f}%` | Delta: `{delta_pct:.1f}%`",
]
return {
"tier": "risk_alert",
"discord": {
"title": f"🛑 风险告警 · {coin} · {STRATEGY_LABEL}",
"color": 0xFF0000,
"fields": fields,
},
"text": "\n".join(text_lines),
}
# ── Opportunity Alert (APR ≥ 20%) ───────────────────────────────────
if tier == "opportunity_alert":
opps = data.get("opportunities", [])
count = data.get("count", 0)
# Build per-line list sorted by APR descending (already sorted)
desc_lines = []
text_lines = [f"🔍 **{count} 个套利机会 · APR ≥ 20%**"]
for o in opps:
coin = o["coin"]
apr = o["apr"]
long_ex = _EX_SHORT.get(o["long"], o["long"])
short_ex = _EX_SHORT.get(o["short"], o["short"])
desc_lines.append(
f"`{coin}` — **{apr:.1f}%** APR · L:{long_ex} S:{short_ex}"
)
text_lines.append(
f"• `{coin}` — `{apr:.1f}%` APR · Long `{long_ex}` / Short `{short_ex}`"
)
return {
"tier": "opportunity_alert",
"discord": {
"title": f"🔍 {count} 个套利机会 · APR ≥ 20%",
"color": 0x3498DB,
"description": "\n".join(desc_lines),
},
"text": "\n".join(text_lines),
}
# ── Hourly Pulse ─────────────────────────────────────────────────────
if tier == "hourly_pulse":
healthy = data.get("healthy", True)
position_count = data.get("position_count", 0)
report_positions = data.get("positions", [])
position_health = data.get("position_health", [])
hl_bal = data.get("hl_balance", 0)
bn_bal = data.get("bn_balance", 0)
total = round(hl_bal + bn_bal, 2)
pnl = data.get("pnl", 0)
roi_pct = data.get("roi_pct", 0)
health_icon = "✅" if healthy else "⚠️"
countdown = _next_settlement_countdown()
fields = [
# Row 1: Assets
{"name": "HL", "value": f",.2f", "inline": True},
{"name": "BN", "value": f",.2f", "inline": True},
{"name": "Total", "value": f",.2f", "inline": True},
]
# Add each position as a field
for rp in report_positions:
rp_coin = rp.get("coin", "?")
rp_dir = rp.get("direction", {})
rp_long_ex = rp_dir.get("long_exchange", "?")
rp_short_ex = rp_dir.get("short_exchange", "?")
rp_long_label = _EX_SHORT.get(rp_long_ex, rp_long_ex)
rp_short_label = _EX_SHORT.get(rp_short_ex, rp_short_ex)
rp_size = rp.get("size", 0)
rp_price = rp.get("entry_price", 0)
rp_notional = round(rp_size * rp_price, 2) if rp_size and rp_price else 0
rp_apr = rp.get("current_apr", 0)
rp_spread = rp.get("current_spread", 0)
rp_healthy = rp.get("healthy", True)
rp_health_icon = "✅" if rp_healthy else "⚠️"
fields.append({
"name": f"{rp_health_icon} {rp_coin}",
"value": (
f"L:{rp_long_label}/S:{rp_short_label} · "
f"`{rp_size:g}` (,.0f) · "
f"{rp_spread:.4%}/8h → {rp_apr:.1f}% APR"
),
"inline": False,
})
fields.append({
"name": "结算倒计时",
"value": f"HL `{countdown['hl']}` / BN `{countdown['bn']}`",
"inline": False,
})
footer = f"PnL +,.2f ({roi_pct:+.2f}%) · {health_icon} {position_count}仓"
coins_str = "/".join(rp.get("coin", "?") for rp in report_positions) or "空仓"
text_lines = [
f"📊 **{coins_str} · {STRATEGY_LABEL} · 运行中**",
f"💰 HL `,.2f` + BN `,.2f` = **`,.2f`**",
]
for rp in report_positions:
rp_coin = rp.get("coin", "?")
rp_dir = rp.get("direction", {})
rp_long_label = _EX_SHORT.get(rp_dir.get("long_exchange", "?"), "?")
rp_short_label = _EX_SHORT.get(rp_dir.get("short_exchange", "?"), "?")
rp_size = rp.get("size", 0)
rp_price = rp.get("entry_price", 0)
rp_notional = round(rp_size * rp_price, 2) if rp_size and rp_price else 0
rp_apr = rp.get("current_apr", 0)
rp_spread = rp.get("current_spread", 0)
text_lines.append(
f"📍 `{rp_coin}` L:`{rp_long_label}` S:`{rp_short_label}` | "
f"`{rp_size:g}` (,.0f) | "
f"{rp_spread:.4%}/8h ({rp_apr:.1f}% APR)"
)
text_lines += [
f"⏱ 结算: HL `{countdown['hl']}` / BN `{countdown['bn']}`",
f"_{footer}_",
]
return {
"tier": "hourly_pulse",
"discord": {
"title": f"📊 {coins_str} · {STRATEGY_LABEL} · 运行中",
"color": 0x808080,
"fields": fields,
"footer": {"text": footer},
},
"text": "\n".join(text_lines),
}
# ── Daily Report ─────────────────────────────────────────────────────
if tier == "daily_report":
coin = data.get("coin", "—")
direction = data.get("direction", {})
long_ex = direction.get("long_exchange", "?")
short_ex = direction.get("short_exchange", "?")
hl_bal = data.get("hl_balance", 0)
bn_bal = data.get("bn_balance", 0)
total = data.get("current_total_balance", round(hl_bal + bn_bal, 2))
entry_total = data.get("entry_total_balance", 0)
pnl = data.get("pnl", 0)
roi_pct = data.get("roi_pct", 0)
rate_map = {
"hyperliquid": data.get("hl_rate", 0),
"binance": data.get("bn_rate", 0),
}
long_rate = rate_map.get(long_ex, 0)
short_rate = rate_map.get(short_ex, 0)
current_apr = data.get("current_apr", 0)
current_spread = data.get("current_spread", 0)
has_positions = data.get("has_positions", False)
position_count = data.get("position_count", 0)
report_positions = data.get("positions", [])
today = datetime.now(timezone.utc).date().isoformat()
if has_positions and report_positions:
# Build fields for each position
fields = []
for rp in report_positions:
rp_coin = rp.get("coin", "?")
rp_dir = rp.get("direction", {})
rp_long_ex = rp_dir.get("long_exchange", "?")
rp_short_ex = rp_dir.get("short_exchange", "?")
rp_long_label = _EX_SHORT.get(rp_long_ex, rp_long_ex)
rp_short_label = _EX_SHORT.get(rp_short_ex, rp_short_ex)
rp_apr = rp.get("current_apr", 0)
rp_funding = rp.get("total_funding_earned", 0)
rp_entry_time = rp.get("entry_time", "")
rp_hours = 0.0
if rp_entry_time:
try:
rp_hours = (
datetime.now(timezone.utc) - datetime.fromisoformat(rp_entry_time)
).total_seconds() / 3600
except (ValueError, TypeError):
pass
fields.append({
"name": f"{rp_coin}",
"value": (
f"L:{rp_long_label}/S:{rp_short_label} · "
f"{rp_apr:.1f}% APR · {rp_hours:.1f}h · "
f"funding +.2f"
),
"inline": False,
})
# Assets row
fields += [
{"name": "HL", "value": f",.2f", "inline": True},
{"name": "BN", "value": f",.2f", "inline": True},
{"name": "Total", "value": f",.2f", "inline": True},
{
"name": "💵 PnL",
"value": f"+,.2f ({roi_pct:+.2f}%)",
"inline": True,
},
]
footer = f"本金 ,.0f · {position_count} 个持仓"
else:
fields = [
{"name": "状态", "value": "空仓观望中", "inline": True},
{"name": "HL", "value": f",.2f", "inline": True},
{
"name": "BN",
"value": f",.2f",
"inline": True,
},
{
"name": "💰 总资产",
"value": f",.2f",
"inline": True,
},
]
footer = "无持仓"
text_lines = [
f"📈 **日报 · {STRATEGY_LABEL} · {today}**",
"",
]
if has_positions and report_positions:
text_lines.append(f"**持仓 ({position_count})**")
for rp in report_positions:
rp_coin = rp.get("coin", "?")
rp_dir = rp.get("direction", {})
rp_long_label = _EX_SHORT.get(rp_dir.get("long_exchange", "?"), "?")
rp_short_label = _EX_SHORT.get(rp_dir.get("short_exchange", "?"), "?")
rp_apr = rp.get("current_apr", 0)
rp_spread = rp.get("current_spread", 0)
text_lines.append(
f" `{rp_coin}` | L:`{rp_long_label}` S:`{rp_short_label}` | "
f"Spread `{rp_spread:.4%}/8h` ({rp_apr:.1f}% APR)"
)
text_lines += [
"",
"**资产**",
f" HL: `,.2f` | BN: `,.2f` | Total: `,.2f`",
f" PnL: `+,.2f` (`{roi_pct:+.2f}%`)",
]
else:
text_lines += [
"**状态**: 空仓观望中",
f"**资产**: HL `,.2f` | BN `,.2f` | Total `,.2f`",
]
text_lines.append(f"\n_{footer}_")
return {
"tier": "daily_report",
"discord": {
"title": f"📈 日报 · {STRATEGY_LABEL} · {today}",
"color": 0x3399FF,
"fields": fields,
"footer": {"text": footer},
},
"text": "\n".join(text_lines),
}
return None
# ── Notification sending ─────────────────────────────────────────────────────
def _send_telegram(text: str) -> bool:
import urllib.error
import urllib.request
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
return False
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
payload = {
"chat_id": TELEGRAM_CHAT_ID,
"text": text,
"parse_mode": "Markdown",
"disable_web_page_preview": True,
}
req = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
try:
urllib.request.urlopen(req, timeout=10)
return True
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError):
return False
def _send_notification(notif: dict) -> None:
"""Send notification to Discord (embed) and Telegram (text)."""
import urllib.error
import urllib.request
discord_ok = False
token = _get_discord_token()
embed = notif.get("discord", {})
if token and DISCORD_CHANNEL_ID and embed:
url = f"https://discord.com/api/v10/channels/{DISCORD_CHANNEL_ID}/messages"
payload = {"embeds": [embed]}
req = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bot {token}",
"Content-Type": "application/json",
"User-Agent": "DiscordBot (https://openclaw.ai, 1.0)",
},
)
try:
urllib.request.urlopen(req, timeout=10)
discord_ok = True
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError):
pass
tg_ok = False
text = notif.get("text", "")
if text:
tg_ok = _send_telegram(text)
if not discord_ok and not tg_ok and (token or TELEGRAM_BOT_TOKEN):
pass
# ── Public emit API ──────────────────────────────────────────────────────────
def emit(event_type: str, data: dict, *, notify: bool = False, tier: str = "") -> None:
"""Output one JSON event line to stdout, optionally push notification by tier.
Args:
event_type: Event type (tick, report, position_opened, etc.)
data: Event data dict
notify: Whether to mark as needing notification
tier: Notification level (trade_alert, risk_alert, hourly_pulse, daily_report)
Empty string means no push
"""
payload = {
"type": event_type,
"ts": datetime.now(timezone.utc).isoformat(),
"notify": notify or bool(tier),
**data,
}
if tier:
notif = _build_notification(tier, {**data, "type": event_type})
if notif:
payload["notification"] = notif
_send_notification(notif)
print(json.dumps(payload, ensure_ascii=False), flush=True)
def emit_error(context: str, error: Exception, *, notify: bool = False) -> None:
data = {
"context": context,
"error": str(error),
"traceback": traceback.format_exc(),
}
tier = "risk_alert" if notify else ""
emit("error", data, notify=notify, tier=tier)
# ═══════════════════════════════════════════════════════════════════════════════
# Section 4: Circuit Breaker
# ═══════════════════════════════════════════════════════════════════════════════
class CircuitBreaker:
def __init__(self) -> None:
cfg = load_config()["shared"]
self.max_errors: int = cfg["max_consecutive_errors"]
self.cooldown: int = cfg["cooldown_after_errors"]
self.consecutive_errors = 0
self.cooldown_until = 0.0
def is_open(self) -> bool:
if time.time() < self.cooldown_until:
return True
if self.cooldown_until > 0 and time.time() >= self.cooldown_until:
self.consecutive_errors = 0
self.cooldown_until = 0.0
return False
def record_success(self) -> None:
self.consecutive_errors = 0
self.cooldown_until = 0.0
def record_error(self, context: str = "") -> bool:
"""Record error, returns True if circuit breaker tripped."""
self.consecutive_errors += 1
if self.consecutive_errors >= self.max_errors:
self.cooldown_until = time.time() + self.cooldown
emit(
"circuit_breaker",
{
"status": "open",
"errors": self.consecutive_errors,
"cooldown_s": self.cooldown,
"context": context,
},
notify=True,
)
return True
return False
# ═══════════════════════════════════════════════════════════════════════════════
# Section 5: HLClient (Hyperliquid SDK wrapper)
# ═══════════════════════════════════════════════════════════════════════════════
# ---- Spot name mapping ----
_SPOT_TOKEN_MAP = {"BTC": "UBTC", "ETH": "UETH"}
_SPOT_TOKEN_REVERSE = {v: k for k, v in _SPOT_TOKEN_MAP.items()}
def _perp_to_spot_token(coin: str) -> str:
return _SPOT_TOKEN_MAP.get(coin, coin)
def _perp_to_spot_pair(coin: str) -> str:
return f"{_perp_to_spot_token(coin)}/USDC"
def _spot_token_to_perp(token: str) -> str:
return _SPOT_TOKEN_REVERSE.get(token, token)
def _interval_to_ms(interval: str) -> int:
unit = interval[-1]
val = int(interval[:-1])
multipliers = {"m": 60_000, "h": 3_600_000, "d": 86_400_000}
return val * multipliers.get(unit, 3_600_000)
class HLClient:
"""Unified Hyperliquid read + write API wrapper."""
def __init__(
self, private_key: str, testnet: bool = False, vault_address: str = ""
) -> None:
self.account = eth_account.Account.from_key(private_key)
self.wallet_address = self.account.address
base_url = constants.TESTNET_API_URL if testnet else constants.MAINNET_API_URL
self.base_url = base_url
self.testnet = testnet
info_kwargs: dict = {"skip_ws": True}
try:
self.info = Info(base_url, **info_kwargs)
except (IndexError, KeyError):
self.info = Info(
base_url, skip_ws=True, spot_meta={"tokens": [], "universe": []}
)
exchange_kwargs: dict = {}
if vault_address:
exchange_kwargs["account_address"] = vault_address
try:
self.exchange = Exchange(self.account, base_url, **exchange_kwargs)
except (IndexError, KeyError):
self.exchange = Exchange(
self.account,
base_url,
spot_meta={"tokens": [], "universe": []},
**exchange_kwargs,
)
if vault_address:
self.address = vault_address
self.balance_address = vault_address
else:
master = self._resolve_master_address()
if master:
self.address = master
self.balance_address = master
else:
self.address = self.wallet_address
self.balance_address = self.wallet_address
self._meta: dict[str, object] | None = None
self._spot_meta: dict | None = None
self._sz_decimals: dict[str, int] = {}
def _resolve_master_address(self) -> str | None:
"""Detect if current key is an agent wallet, return master address."""
try:
resp = requests.post(
f"{self.base_url}/info",
json={
"type": "userNonFundingLedgerUpdates",
"user": self.wallet_address,
"startTime": 0,
},
timeout=5,
)
updates = resp.json()
for u in updates:
delta = u.get("delta", {})
if delta.get("type") == "send":
source = delta.get("user", "")
if source and source.lower() != self.wallet_address.lower():
emit(
"agent_wallet_detected",
{
"agent": self.wallet_address,
"master": source,
},
)
return source
except Exception:
pass
return None
# ---- Metadata ----
def _ensure_meta(self) -> None:
if self._meta is None:
self._meta = self.info.meta()
for asset in self._meta["universe"]:
self._sz_decimals[asset["name"]] = asset["szDecimals"]
def _ensure_spot_meta(self) -> None:
if self._spot_meta is None:
self._spot_meta = self.info.spot_meta()
def sz_decimals(self, coin: str) -> int:
self._ensure_meta()
return self._sz_decimals.get(coin, 2)
# ---- Market data ----
def get_mid_price(self, coin: str) -> float:
mids = self.info.all_mids()
return float(mids[coin])
def get_candles(
self, coin: str, interval: str = "1h", count: int = 24
) -> list[dict]:
now_ms = int(time.time() * 1000)
interval_ms = _interval_to_ms(interval)
start_ms = now_ms - interval_ms * count
raw = self.info.candles_snapshot(coin, interval, start_ms, now_ms)
result = []
for c in raw:
result.append(
{
"t": c["t"],
"o": float(c["o"]),
"h": float(c["h"]),
"l": float(c["l"]),
"c": float(c["c"]),
"v": float(c["v"]),
}
)
return result[-count:]
def get_funding_rate(self, coin: str) -> float:
"""Get current funding rate for a coin (per 8h)."""
ctx = self.info.meta_and_asset_ctxs()
for asset_meta, asset_ctx in zip(ctx[0]["universe"], ctx[1]):
if asset_meta["name"] == coin:
return float(asset_ctx["funding"])
return 0.0
def get_all_funding_rates(self) -> dict[str, float]:
ctx = self.info.meta_and_asset_ctxs()
rates = {}
for asset_meta, asset_ctx in zip(ctx[0]["universe"], ctx[1]):
rates[asset_meta["name"]] = float(asset_ctx["funding"])
return rates
# ---- Account ----
def get_usdc_balance(self) -> float:
"""Get total equity: spot USDC total (includes perp margin + unrealized PnL)."""
try:
spot_state = self.info.spot_user_state(self.balance_address)
for bal in spot_state.get("balances", []):
if bal["coin"] == "USDC":
return float(bal["total"])
except Exception:
pass
# Fallback: perp accountValue only (spot API unavailable)
state = self.info.user_state(self.balance_address)
return float(state["marginSummary"]["accountValue"])
def get_withdrawable(self) -> float:
state = self.info.user_state(self.balance_address)
return float(state["withdrawable"])
def get_position(self, coin: str) -> dict | None:
state = self.info.user_state(self.address)
for pos in state.get("assetPositions", []):
item = pos["position"]
if item["coin"] == coin:
return {
"coin": item["coin"],
"size": float(item["szi"]),
"entry_px": float(item["entryPx"]) if item.get("entryPx") else 0.0,
"unrealized_pnl": float(item["unrealizedPnl"]),
"cum_funding": float(item.get("cumFunding", {}).get("sinceOpen", 0.0)),
"leverage_type": item["leverage"]["type"],
"leverage_value": int(item["leverage"]["value"]),
"liquidation_px": float(item["liquidationPx"])
if item.get("liquidationPx")
else 0.0,
"mark_px": float(item.get("positionValue", 0)) / abs(float(item["szi"]))
if float(item["szi"]) != 0
else 0.0,
}
return None
def get_all_positions(self) -> list[dict]:
state = self.info.user_state(self.address)
positions = []
for pos in state.get("assetPositions", []):
item = pos["position"]
sz = float(item["szi"])
if sz != 0:
positions.append(
{
"coin": item["coin"],
"size": sz,
"entry_px": float(item["entryPx"])
if item.get("entryPx")
else 0.0,
"unrealized_pnl": float(item["unrealizedPnl"]),
}
)
return positions
def get_spot_balance(self, coin: str) -> float:
spot_coin = _perp_to_spot_token(coin)
balances = self.info.spot_user_state(self.balance_address)
for bal in balances.get("balances", []):
if bal["coin"] == spot_coin:
return float(bal["total"]) - float(bal["hold"])
return 0.0
def get_spot_usdc(self) -> float:
balances = self.info.spot_user_state(self.balance_address)
for bal in balances.get("balances", []):
if bal["coin"] == "USDC":
return float(bal["total"]) - float(bal["hold"])
return 0.0
def get_coins_with_spot_and_perp(self) -> set[str]:
self._ensure_meta()
self._ensure_spot_meta()
perp_names = {a["name"] for a in self._meta["universe"]} # type: ignore[index]
spot_tokens = set()
for token in self._spot_meta.get("tokens", []): # type: ignore[union-attr]
name = token["name"]
if name == "USDC":
continue
perp_name = (
name[1:] if name.startswith("U") and name[1:] in perp_names else name
)
if perp_name in perp_names:
spot_tokens.add(perp_name)
return spot_tokens
def transfer_to_spot(self, usd_amount: float) -> dict:
return self.exchange.usd_class_transfer(usd_amount, to_perp=False)
def transfer_to_perp(self, usd_amount: float) -> dict:
return self.exchange.usd_class_transfer(usd_amount, to_perp=True)
def get_open_orders(self, coin: str | None = None) -> list[dict]:
orders = self.info.open_orders(self.address)
result = []
for o in orders:
if coin and o["coin"] != coin:
continue
result.append(
{
"oid": o["oid"],
"coin": o["coin"],
"side": "buy" if o["side"] == "B" else "sell",
"size": float(o["sz"]),
"price": float(o["limitPx"]),
"order_type": o.get("orderType", "limit"),
}
)
return result
# ---- Trading ----
def set_leverage(self, coin: str, leverage: int, cross: bool = False) -> None:
self.exchange.update_leverage(leverage, coin, is_cross=cross)
def limit_order(
self,
coin: str,
is_buy: bool,
size: float,
price: float,
*,
reduce_only: bool = False,
) -> dict:
size = self.round_size(coin, size)
price = self._round_price(price)
if size <= 0:
return {"status": "error", "msg": "size too small"}
result = self.exchange.order(
coin,
is_buy,
size,
price,
{"limit": {"tif": "Gtc"}},
reduce_only=reduce_only,
)
self._log_order("limit", coin, is_buy, size, price, result)
return result
def market_order(
self,
coin: str,
is_buy: bool,
size: float,
*,
slippage: float = 0.05,
) -> dict:
size = self.round_size(coin, size)
if size <= 0:
return {"status": "error", "msg": "size too small"}
mid = self.get_mid_price(coin)
px = mid * (1 + slippage) if is_buy else mid * (1 - slippage)
px = self._round_price(px)
result = self.exchange.order(
coin,
is_buy,
size,
px,
{"limit": {"tif": "Ioc"}},
)
self._log_order("market", coin, is_buy, size, px, result)
return result
def place_tp(self, coin: str, is_buy: bool, size: float, trigger_px: float) -> dict:
size = self.round_size(coin, size)
trigger_px = self._round_price(trigger_px)
result = self.exchange.order(
coin,
is_buy,
size,
trigger_px,
{"trigger": {"isMarket": True, "triggerPx": str(trigger_px), "tpsl": "tp"}},
reduce_only=True,
)
self._log_order("tp", coin, is_buy, size, trigger_px, result)
return result
def place_sl(self, coin: str, is_buy: bool, size: float, trigger_px: float) -> dict:
size = self.round_size(coin, size)
trigger_px = self._round_price(trigger_px)
result = self.exchange.order(
coin,
is_buy,
size,
trigger_px,
{"trigger": {"isMarket": True, "triggerPx": str(trigger_px), "tpsl": "sl"}},
reduce_only=True,
)
self._log_order("sl", coin, is_buy, size, trigger_px, result)
return result
def cancel_all(self, coin: str) -> None:
orders = self.info.open_orders(self.address)
for o in orders:
if o["coin"] == coin:
try:
self.exchange.cancel(coin, o["oid"])
except Exception:
pass
def spot_market_buy(
self, coin: str, size: float, *, slippage: float = 0.05
) -> dict:
spot_pair = _perp_to_spot_pair(coin)
size = self.round_size(coin, size)
if size <= 0:
return {"status": "error", "msg": "size too small"}
mid = self.get_mid_price(spot_pair)
px = self._round_price(mid * (1 + slippage))
result = self.exchange.order(
spot_pair, True, size, px, {"limit": {"tif": "Ioc"}}
)
self._log_order("spot_buy", coin, True, size, px, result)
return result
def spot_market_sell(
self, coin: str, size: float, *, slippage: float = 0.05
) -> dict:
spot_pair = _perp_to_spot_pair(coin)
size = self.round_size(coin, size)
if size <= 0:
return {"status": "error", "msg": "size too small"}
mid = self.get_mid_price(spot_pair)
px = self._round_price(mid * (1 - slippage))
result = self.exchange.order(
spot_pair, False, size, px, {"limit": {"tif": "Ioc"}}
)
self._log_order("spot_sell", coin, False, size, px, result)
return result
def close_position(self, coin: str, *, slippage: float = 0.001) -> dict | None:
pos = self.get_position(coin)
if not pos or pos["size"] == 0:
return None
is_buy = pos["size"] < 0
size = abs(pos["size"])
return self.market_order(coin, is_buy, size, slippage=slippage)
# ---- Utils ----
def round_size(self, coin: str, size: float) -> float:
decimals = self.sz_decimals(coin)
factor = 10**decimals
return math.floor(size * factor) / factor
def _round_price(self, price: float) -> float:
if price <= 0:
return 0.0
magnitude = 10 ** math.floor(math.log10(price))
return round(price / magnitude, 4) * magnitude
def _log_order(
self,
kind: str,
coin: str,
is_buy: bool,
size: float,
price: float,
result: dict,
) -> None:
status = "ok"
error = ""
oid = None
if result.get("status") == "err":
status = "error"
error = result.get("response", "")
elif "response" in result and "data" in result["response"]:
data = result["response"]["data"]
if "statuses" in data and data["statuses"]:
s = data["statuses"][0]
if "error" in s:
status = "error"
error = s["error"]
elif "resting" in s:
oid = s["resting"]["oid"]
elif "filled" in s:
oid = s["filled"]["oid"]
emit(
"order",
{
"kind": kind,
"coin": coin,
"side": "buy" if is_buy else "sell",
"size": size,
"price": price,
"status": status,
"oid": oid,
"error": error,
},
)
# ═══════════════════════════════════════════════════════════════════════════════
# Section 6: BinanceClient (Binance USDS-M Futures REST)
# ═══════════════════════════════════════════════════════════════════════════════
class BinanceClient:
"""Binance USDS-M Futures REST API client."""
MAINNET_URL = "https://fapi.binance.com"
TESTNET_URL = "https://demo-fapi.binance.com"
RECV_WINDOW = 10_000
TIME_SYNC_TTL = 30
def __init__(self, api_key: str, secret_key: str, testnet: bool = False) -> None:
self.api_key = api_key
self.secret_key = secret_key
self.base_url = self.TESTNET_URL if testnet else self.MAINNET_URL
self.session = requests.Session()
self.session.headers.update(
{
"X-MBX-APIKEY": self.api_key,
"Content-Type": "application/x-www-form-urlencoded",
}
)
self._exchange_info_cache: dict | None = None
self._exchange_info_ts: float = 0.0
self._cache_ttl: float = 3600.0
self._ts_offset_ms: int = 0
self._ts_synced_at: float = 0.0
# ---- Internal helpers ----
def _get_timestamp(self) -> int:
return int(time.time() * 1000) + self._ts_offset_ms
def _sync_server_time(self, force: bool = False) -> None:
now = time.time()
if (
not force
and self._ts_synced_at
and (now - self._ts_synced_at) < self.TIME_SYNC_TTL
):
return
url = f"{self.base_url}/fapi/v1/time"
t0 = int(time.time() * 1000)
resp = self.session.get(url, timeout=5)
t1 = int(time.time() * 1000)
resp.raise_for_status()
server_time = int(resp.json()["serverTime"])
midpoint = (t0 + t1) // 2
self._ts_offset_ms = server_time - midpoint
self._ts_synced_at = time.time()
def _sign(self, params: dict) -> str:
query_string = urlencode(params)
return hmac.new(
self.secret_key.encode(),
query_string.encode(),
hashlib.sha256,
).hexdigest()
def _request(
self,
method: str,
endpoint: str,
params: dict | None = None,
signed: bool = False,
) -> dict:
url = f"{self.base_url}{endpoint}"
base_params = dict(params or {})
max_attempts = 2 if signed else 1
for attempt in range(max_attempts):
req_params = dict(base_params)
if signed:
self._sync_server_time(force=(attempt > 0))
req_params["timestamp"] = self._get_timestamp()
req_params["recvWindow"] = self.RECV_WINDOW
req_params["signature"] = self._sign(req_params)
if method == "GET":
resp = self.session.get(url, params=req_params, timeout=10)
else:
resp = self.session.post(url, data=req_params, timeout=10)
if resp.status_code != 200:
try:
err = resp.json()
except ValueError:
err = {}
code = err.get("code", resp.status_code)
msg = err.get("msg", resp.text)
if (
signed
and attempt == 0
and (str(code) == "-1021" or "recvWindow" in str(msg))
):
self._ts_synced_at = 0.0
continue
raise RuntimeError(f"Binance API Error {code}: {msg}")
return resp.json()
raise RuntimeError(f"Binance request failed after retries: {method} {endpoint}")
def _to_symbol(self, coin: str) -> str:
return f"{coin}USDT"
def _get_exchange_info(self) -> dict:
now = time.time()
if (
self._exchange_info_cache
and (now - self._exchange_info_ts) < self._cache_ttl
):
return self._exchange_info_cache
self._exchange_info_cache = self._request("GET", "/fapi/v1/exchangeInfo")
self._exchange_info_ts = now
return self._exchange_info_cache
def _get_precision(self, symbol: str) -> dict:
info = self._get_exchange_info()
for s in info.get("symbols", []):
if s["symbol"] == symbol:
result: dict = {
"tick_size": "0.01",
"step_size": "0.001",
"min_qty": "0.001",
}
for f in s.get("filters", []):
if f["filterType"] == "PRICE_FILTER":
result["tick_size"] = f["tickSize"]
elif f["filterType"] == "LOT_SIZE":
result["step_size"] = f["stepSize"]
result["min_qty"] = f["minQty"]
return result
raise RuntimeError(f"Symbol {symbol} not found in exchange info")
# ---- Market Data ----
def get_mid_price(self, coin: str) -> float:
symbol = self._to_symbol(coin)
data = self._request("GET", "/fapi/v1/premiumIndex", {"symbol": symbol})
return float(data["markPrice"])
def get_funding_rate(self, coin: str) -> float:
symbol = self._to_symbol(coin)
data = self._request("GET", "/fapi/v1/premiumIndex", {"symbol": symbol})
return float(data["lastFundingRate"])
def get_all_funding_rates(self) -> dict[str, float]:
data = self._request("GET", "/fapi/v1/premiumIndex")
rates: dict[str, float] = {}
for item in data:
symbol: str = item["symbol"]
if symbol.endswith("USDT"):
coin = symbol[: -len("USDT")]
rates[coin] = float(item["lastFundingRate"])
return rates
# ---- Account ----
def get_usdt_balance(self) -> float:
"""获取 USDT 账户权益(含未实现盈亏),与 HL accountValue 口径一致。"""
data = self._request("GET", "/fapi/v2/balance", signed=True)
for asset in data:
if asset["asset"] == "USDT":
balance = float(asset["balance"])
unrealized_pnl = float(asset.get("crossUnPnl", 0))
return balance + unrealized_pnl
return 0.0
def get_position(self, coin: str) -> dict | None:
symbol = self._to_symbol(coin)
data = self._request(
"GET", "/fapi/v3/positionRisk", {"symbol": symbol}, signed=True
)
for pos in data:
if pos["symbol"] == symbol:
size = float(pos["positionAmt"])
if size == 0:
return None
return {
"coin": coin,
"size": size,
"entry_px": float(pos["entryPrice"]),
"unrealized_pnl": float(pos["unRealizedProfit"]),
"mark_px": float(pos.get("markPrice", 0)),
}
return None
def get_all_positions(self) -> list[dict]:
data = self._request("GET", "/fapi/v3/positionRisk", signed=True)
positions = []
for pos in data:
size = float(pos["positionAmt"])
if size != 0:
symbol: str = pos["symbol"]
coin = symbol[: -len("USDT")] if symbol.endswith("USDT") else symbol
positions.append(
{
"coin": coin,
"size": size,
"entry_px": float(pos["entryPrice"]),
"unrealized_pnl": float(pos["unRealizedProfit"]),
}
)
return positions
def get_funding_income(self, coin: str, start_time_ms: int) -> float:
"""Get total realized funding income since start_time_ms."""
symbol = self._to_symbol(coin)
total = 0.0
current_start = start_time_ms
while True:
data = self._request(
"GET",
"/fapi/v1/income",
{
"symbol": symbol,
"incomeType": "FUNDING_FEE",
"startTime": current_start,
"limit": 1000,
},
signed=True,
)
if not data:
break
for item in data:
total += float(item["income"])
if len(data) < 1000:
break
current_start = int(data[-1]["time"]) + 1
return round(total, 4)
# ---- Trading ----
def set_leverage(self, coin: str, leverage: int) -> None:
symbol = self._to_symbol(coin)
self._request(
"POST",
"/fapi/v1/leverage",
{"symbol": symbol, "leverage": leverage},
signed=True,
)
def market_order(
self, coin: str, is_buy: bool, size: float, *, slippage: float = 0.0
) -> dict:
symbol = self._to_symbol(coin)
size = self.round_size(coin, size)
if slippage > 0:
mid = self.get_mid_price(coin)
px = mid * (1 + slippage) if is_buy else mid * (1 - slippage)
px = self._round_price(coin, px)
params = {
"symbol": symbol,
"side": "BUY" if is_buy else "SELL",
"type": "LIMIT",
"quantity": size,
"price": px,
"timeInForce": "IOC",
}
else:
params = {
"symbol": symbol,
"side": "BUY" if is_buy else "SELL",
"type": "MARKET",
"quantity": size,
}
return self._request("POST", "/fapi/v1/order", params, signed=True)
def close_position(self, coin: str, *, slippage: float = 0.001) -> dict | None:
pos = self.get_position(coin)
if not pos or pos["size"] == 0:
return None
is_buy = pos["size"] < 0
size = abs(pos["size"])
return self.market_order(coin, is_buy, size, slippage=slippage)
# ---- Utils ----
def round_size(self, coin: str, size: float) -> float:
symbol = self._to_symbol(coin)
precision = self._get_precision(symbol)
step = Decimal(precision["step_size"])
d_size = Decimal(str(size))
rounded = (d_size / step).to_integral_value(rounding=ROUND_DOWN) * step
return float(rounded)
def _round_price(self, coin: str, price: float) -> float:
symbol = self._to_symbol(coin)
precision = self._get_precision(symbol)
tick = Decimal(precision["tick_size"])
d_price = Decimal(str(price))
rounded = (d_price / tick).to_integral_value(rounding=ROUND_DOWN) * tick
return float(rounded)
# ═══════════════════════════════════════════════════════════════════════════════
# Section 7: VarFunding Scanner
# ═══════════════════════════════════════════════════════════════════════════════
CONFIDENCE_LEVELS = {"high": 3, "medium": 2, "low": 1}
TARGET_EXCHANGES = {"hyperliquid", "binance"}
class VarFundingScanner:
API_URL = "https://varfunding.xyz/api/funding"
TIMEOUT = 15
def __init__(
self,
min_apr: float = 10.0,
min_confidence: str = "medium",
stability_threshold: float = 0.3,
):
self.min_apr = min_apr
self.min_confidence_level = CONFIDENCE_LEVELS.get(min_confidence, 2)
self.stability_threshold = stability_threshold
def fetch_opportunities(
self,
exchanges: tuple[str, str] = ("hyperliquid", "binance"),
) -> list[dict]:
"""Fetch and filter arbitrage opportunities from VarFunding API."""
resp = requests.get(
self.API_URL,
params={"exchanges": ",".join(exchanges)},
timeout=self.TIMEOUT,
)
resp.raise_for_status()
data = resp.json()
exchange_set = set(exchanges)
results: list[dict] = []
for market in data.get("markets", []):
arb = market.get("arbitrageOpportunity")
if not arb:
continue
long_ex = arb.get("longExchange", "")
short_ex = arb.get("shortExchange", "")
if long_ex not in exchange_set or short_ex not in exchange_set:
continue
confidence = arb.get("confidence", "low")
if CONFIDENCE_LEVELS.get(confidence, 0) < self.min_confidence_level:
continue
estimated_apr = arb.get("estimatedApr", 0.0)
if estimated_apr < self.min_apr:
continue
rate_map: dict[str, float] = {}
var = market.get("variational")
if var and var.get("exchange") in exchange_set:
rate_map[var["exchange"]] = var.get("rate", 0.0)
for comp in market.get("comparisons", []):
if comp.get("exchange") in exchange_set:
rate_map[comp["exchange"]] = comp.get("rate", 0.0)
results.append(
{
"coin": market.get("baseAsset", ""),
"long_exchange": long_ex,
"short_exchange": short_ex,
"spread": arb.get("spread", 0.0),
"estimated_apr": estimated_apr,
"confidence": confidence,
"hl_rate": rate_map.get("hyperliquid", 0.0),
"bn_rate": rate_map.get("binance", 0.0),
}
)
results.sort(key=lambda x: x["estimated_apr"], reverse=True)
# Filter high-APR opportunities for notification
hot = [r for r in results if r["estimated_apr"] >= 20.0]
emit(
"varfunding_scan",
{
"count": len(results),
"top_5": [
{"coin": r["coin"], "apr": round(r["estimated_apr"], 1)}
for r in results[:5]
],
},
)
if hot:
emit(
"opportunity_alert",
{
"count": len(hot),
"opportunities": [
{
"coin": r["coin"],
"apr": round(r["estimated_apr"], 1),
"spread": round(r["spread"], 6),
"long": r["long_exchange"],
"short": r["short_exchange"],
}
for r in hot[:5]
],
},
tier="opportunity_alert",
)
return results
def check_stability(self, snapshots: list[dict]) -> dict:
"""Analyze multiple snapshots for rate stability."""
count = len(snapshots)
if count < 3:
return {
"stable": False,
"count": count,
"avg_spread": 0.0,
"std_spread": 0.0,
"std_ratio": 0.0,
}
spreads = [s["spread"] for s in snapshots]
avg_spread = statistics.mean(spreads)
std_spread = statistics.stdev(spreads)
if avg_spread == 0:
std_ratio = float("inf")
else:
std_ratio = std_spread / abs(avg_spread)
return {
"stable": std_ratio < self.stability_threshold,
"count": count,
"avg_spread": avg_spread,
"std_spread": std_spread,
"std_ratio": std_ratio,
}
# ═══════════════════════════════════════════════════════════════════════════════
# Section 8: CrossFundingEngine
# ═══════════════════════════════════════════════════════════════════════════════
STATE_NAME = "cross_funding"
def _is_order_error(result: dict) -> bool:
"""Check if order result is an error. Compatible with HL and Binance formats."""
if result.get("status") in ("error", "err"):
return True
try:
s = result["response"]["data"]["statuses"][0]
return "error" in s
except (KeyError, IndexError, TypeError):
pass
if "code" in result and int(result.get("code", 0)) < 0:
return True
return False
class CrossFundingEngine:
def __init__(
self,
hl_client: HLClient,
bn_client: BinanceClient,
scanner: VarFundingScanner,
cfg: dict,
) -> None:
self.hl = hl_client
self.bn = bn_client
self.scanner = scanner
self.cfg = cfg
self.hl_budget_cfg: float = cfg.get("hl_budget_usd", 0)
self.bn_budget_cfg: float = cfg.get("bn_budget_usd", 0)
self.leverage: int = cfg.get("leverage", 1)
self.min_apr: float = cfg["min_apr_pct"]
self.min_hold_apr: float = cfg.get("min_hold_apr_pct", self.min_apr)
self.stability_snapshots: int = cfg.get("stability_snapshots", 3)
self.close_spread_threshold: float = cfg.get("close_spread_threshold", 0.0001)
self.switch_threshold_apr: float = cfg.get("switch_threshold_apr", 5.0)
self.max_breakeven_days: float = cfg.get("max_breakeven_days", 3.0)
self.max_price_basis_pct: float = cfg.get("max_price_basis_pct", 0.3)
self.max_entry_breakeven_days: float = cfg.get("max_entry_breakeven_days", 5.0)
self.round_trip_cost_pct: float = cfg.get("round_trip_cost_pct", 0.12)
self.max_positions: int = cfg.get("max_positions", 3)
self.min_position_usd: float = cfg.get("min_position_usd", 50)
# PnL stop-loss and minimum hold time
self.pnl_stop_loss_pct: float = cfg.get("pnl_stop_loss_pct", -1.0)
self.min_hold_ticks: int = cfg.get("min_hold_ticks", 96)
# Delta-aware exit parameters
delta_exit_cfg = cfg.get("delta_exit", {})
self.delta_exit_enabled: bool = delta_exit_cfg.get("enabled", True)
# Delta % below which we consider the position "neutral" (safe to close)
self.delta_neutral_threshold_pct: float = delta_exit_cfg.get("neutral_threshold_pct", 2.0)
# Max ticks to wait for favorable delta before force-closing (12 × 5min = 1h)
self.delta_max_defer_ticks: int = delta_exit_cfg.get("max_defer_ticks", 12)
# Force close if delta exceeds this regardless of PnL
self.delta_force_close_pct: float = delta_exit_cfg.get("force_close_delta_pct", 15.0)
# ---- State management ----
def _load(self) -> dict:
state = load_state(STATE_NAME)
# Migrate v1 → v2
if "current_coin" in state and "positions" not in state:
coin = state.pop("current_coin", None)
positions: list[dict] = []
if coin:
pos = {
"coin": coin,
"direction": state.pop("direction", {}),
"size": state.pop("size", 0),
"entry_price": state.pop("entry_price", 0),
"entry_time": state.pop("entry_time", ""),
"entry_spread": state.pop("entry_spread", 0),
"total_funding_earned": state.pop("total_funding_earned", 0),
}
positions.append(pos)
# Clean up other v1 fields that are now per-position
for k in ["entry_hl_rate", "entry_bn_rate", "budget_hl", "budget_bn",
"entry_hl_balance", "entry_bn_balance"]:
state.pop(k, None)
state["positions"] = positions
state["version"] = 2
self._save(state)
return state
def _save(self, state: dict) -> None:
state["last_tick"] = datetime.now(timezone.utc).isoformat()
save_state(STATE_NAME, state)
def _get_budgets(self) -> tuple[float, float]:
"""Return (hl_budget, bn_budget). If config is 0, read actual balance."""
hl = (
self.hl_budget_cfg if self.hl_budget_cfg > 0 else self.hl.get_usdc_balance()
)
bn = (
self.bn_budget_cfg if self.bn_budget_cfg > 0 else self.bn.get_usdt_balance()
)
return hl, bn
def _get_positions(self) -> list[dict]:
return self._load().get("positions", [])
def _get_position_by_coin(self, coin: str) -> dict | None:
for p in self._get_positions():
if p["coin"] == coin:
return p
return None
def _occupied_coins(self) -> set[str]:
return {p["coin"] for p in self._get_positions()}
def _get_available_budgets(self) -> tuple[float, float]:
"""Return available (not margin-locked) balance per exchange."""
hl_total = self.hl.get_usdc_balance()
bn_total = self.bn.get_usdt_balance()
positions = self._get_positions()
# Calculate used margin per exchange
hl_used = 0.0
bn_used = 0.0
for pos in positions:
direction = pos.get("direction", {})
notional = pos.get("size", 0) * pos.get("entry_price", 0)
margin = notional / self.leverage if self.leverage > 0 else notional
if direction.get("long_exchange") == "hyperliquid":
hl_used += margin
bn_used += margin
else:
bn_used += margin
hl_used += margin
hl_available = max(hl_total - hl_used * 1.1, 0) # 10% safety buffer
bn_available = max(bn_total - bn_used * 1.1, 0)
return hl_available, bn_available
# ---- Client routing ----
def _get_client(self, exchange: str) -> HLClient | BinanceClient:
if exchange == "hyperliquid":
return self.hl
return self.bn
def _get_mid_price(self, exchange: str, coin: str) -> float:
return self._get_client(exchange).get_mid_price(coin)
def _get_funding_rate(self, exchange: str, coin: str) -> float:
return self._get_client(exchange).get_funding_rate(coin)
# ---- Scanning ----
def scan_opportunities(self) -> list[dict]:
return self.scanner.fetch_opportunities()
def record_snapshot(self, opportunities: list[dict]) -> None:
state = self._load()
snapshots = state.get("rate_snapshots", [])
now = datetime.now(timezone.utc).isoformat()
for opp in opportunities[:5]:
snapshots.append(
{
"ts": now,
"coin": opp["coin"],
"spread": opp["spread"],
"estimated_apr": opp["estimated_apr"],
"long_exchange": opp["long_exchange"],
"short_exchange": opp["short_exchange"],
}
)
state["rate_snapshots"] = snapshots[-20:]
self._save(state)
def get_stable_opportunity(self) -> dict | None:
state = self._load()
snapshots = state.get("rate_snapshots", [])
if not snapshots:
return None
by_coin: dict[str, list[dict]] = {}
for s in snapshots:
coin = s["coin"]
by_coin.setdefault(coin, []).append(s)
best: dict | None = None
best_apr = 0.0
for coin, coin_snaps in by_coin.items():
stability = self.scanner.check_stability(coin_snaps)
if not stability["stable"]:
continue
latest = coin_snaps[-1]
apr = latest.get("estimated_apr", 0.0)
if apr > best_apr:
best_apr = apr
best = latest
return best
# ---- Deep verification ----
def verify_opportunity(self, coin: str, direction: dict) -> dict:
long_ex = direction["long_exchange"]
short_ex = direction["short_exchange"]
hl_rate = self.hl.get_funding_rate(coin)
bn_rate = self.bn.get_funding_rate(coin)
hl_price = self.hl.get_mid_price(coin)
bn_price = self.bn.get_mid_price(coin)
rate_map = {"hyperliquid": hl_rate, "binance": bn_rate}
actual_spread = rate_map[short_ex] - rate_map[long_ex]
avg_price = (hl_price + bn_price) / 2
price_basis_pct = abs(hl_price - bn_price) / avg_price * 100 if avg_price else 0
gross_annual = actual_spread * 3 * 365 * 100
# Entry cost = trading fees + price basis (cross-exchange slippage)
total_entry_cost_pct = self.round_trip_cost_pct + price_basis_pct
# Daily funding income as % of notional
daily_funding_pct = actual_spread * 3 * 100
# Days needed for funding to recover all entry costs
breakeven_days = total_entry_cost_pct / daily_funding_pct if daily_funding_pct > 0 else float("inf")
# Net APR after amortizing entry cost over expected 30-day hold
net_apr = gross_annual - total_entry_cost_pct / 30 * 365
max_breakeven = getattr(self, "max_entry_breakeven_days", 5.0)
reject_reason = None
if actual_spread <= 0:
reject_reason = f"spread non-positive: {actual_spread:.6f}"
elif price_basis_pct > self.max_price_basis_pct:
reject_reason = f"price basis too large: {price_basis_pct:.2f}%"
elif breakeven_days > max_breakeven:
reject_reason = (
f"breakeven {breakeven_days:.1f}d > {max_breakeven}d "
f"(basis={price_basis_pct:.2f}% + fees={self.round_trip_cost_pct:.2f}% "
f"= {total_entry_cost_pct:.2f}%, daily={daily_funding_pct:.3f}%)"
)
elif net_apr < self.min_apr:
reject_reason = f"net APR too low: {net_apr:.1f}%"
result = {
"valid": reject_reason is None,
"hl_rate": hl_rate,
"bn_rate": bn_rate,
"actual_spread": actual_spread,
"hl_price": hl_price,
"bn_price": bn_price,
"price_basis_pct": round(price_basis_pct, 4),
"total_entry_cost_pct": round(total_entry_cost_pct, 4),
"breakeven_days": round(breakeven_days, 1) if breakeven_days < 999 else None,
"round_trip_cost_pct": self.round_trip_cost_pct,
"net_apr_after_costs": round(net_apr, 2),
"reject_reason": reject_reason,
}
emit("verify_opportunity", {"coin": coin, **result})
return result
# ---- Open position ----
def _calculate_size(self, budget_per_exchange: float, price: float) -> float:
if price <= 0:
return 0.0
effective = budget_per_exchange * 0.95
return effective * self.leverage / price
def open_position(self, coin: str, direction: dict) -> bool:
"""Atomic open: HL leg first (stricter limits), then Binance. Rollback on failure.
HL tiered margin is strict for small coins; auto-halves size on retry (max 3x).
Multi-position: appends to positions array. Checks duplicates and max slots.
"""
state = self._load()
positions = state.get("positions", [])
# Guard: duplicate coin
occupied = {p["coin"] for p in positions}
if coin in occupied:
emit(
"warn",
{"msg": f"already has position in {coin}, skip open"},
)
return False
# Guard: max positions
if len(positions) >= self.max_positions:
emit(
"warn",
{"msg": f"max positions ({self.max_positions}) reached, skip open"},
)
return False
long_ex = direction["long_exchange"]
short_ex = direction["short_exchange"]
hl_is_long = long_ex == "hyperliquid"
hl_side = "long" if hl_is_long else "short"
bn_side = "short" if hl_is_long else "long"
price = self.hl.get_mid_price(coin)
if price <= 0:
emit_error("price", RuntimeError(f"invalid price for {coin}: {price}"))
return False
# Use available budgets (account for existing positions' margin)
hl_available, bn_available = self._get_available_budgets()
budget = min(hl_available, bn_available)
# Guard: minimum budget
if budget < self.min_position_usd:
emit(
"warn",
{"msg": f"available budget .0f < min .0f, skip open"},
)
return False
conservative = budget * 0.8
raw_size = self._calculate_size(conservative, price)
hl_rounded = self.hl.round_size(coin, raw_size)
bn_rounded = self.bn.round_size(coin, raw_size)
size = min(hl_rounded, bn_rounded)
if size <= 0:
emit_error("calc_size", RuntimeError(f"size=0 for {coin}"))
return False
emit(
"open_sizing",
{
"coin": coin,
"budget": budget,
"conservative_budget": conservative,
"price": price,
"raw_size": raw_size,
"final_size": size,
"notional": round(size * price, 2),
"position_slot": len(positions) + 1,
"max_positions": self.max_positions,
},
)
# 1) Set leverage on both exchanges
try:
self.hl.set_leverage(coin, self.leverage, cross=True)
except Exception as e:
emit_error("set_leverage_hl", e)
try:
self.bn.set_leverage(coin, self.leverage)
except Exception as e:
emit_error("set_leverage_bn", e)
# 2) HL leg first (stricter, lower failure cost)
arb_slippage = 0.001
hl_is_buy = hl_is_long
hl_result = None
for attempt in range(4):
try:
hl_result = self.hl.market_order(
coin, is_buy=hl_is_buy, size=size, slippage=arb_slippage
)
if not _is_order_error(hl_result):
break
err_msg = str(hl_result)
if "Insufficient margin" in err_msg and attempt < 3:
size = self.hl.round_size(coin, size * 0.5)
size = min(size, self.bn.round_size(coin, size))
if size <= 0 or size * price < 10:
emit(
"size_retry",
{
"coin": coin,
"abort": True,
"reason": "size too small after halving",
},
)
break
emit(
"size_retry",
{
"coin": coin,
"attempt": attempt + 1,
"new_size": size,
"new_notional": round(size * price, 2),
},
)
continue
break
except Exception as e:
emit_error(f"hl_{hl_side}_order", e)
return False
if hl_result is None or _is_order_error(hl_result):
emit_error(
f"hl_{hl_side}_order",
RuntimeError(f"HL order failed after retries: {hl_result}"),
)
return False
time.sleep(1)
# 3) Binance leg (using HL's actual filled size)
bn_is_buy = not hl_is_long
bn_size = self.bn.round_size(coin, size)
try:
bn_result = self.bn.market_order(
coin, is_buy=bn_is_buy, size=bn_size, slippage=arb_slippage
)
if _is_order_error(bn_result):
emit_error(
f"bn_{bn_side}_order",
RuntimeError(f"BN order failed: {bn_result}"),
)
emit("rollback", {"reason": "BN leg failed", "coin": coin})
try:
self.hl.close_position(coin)
except Exception as re:
emit_error("rollback_hl", re, notify=True)
return False
except Exception as e:
emit_error(f"bn_{bn_side}_order", e)
emit("rollback", {"reason": "BN leg failed", "coin": coin})
try:
self.hl.close_position(coin)
except Exception as re:
emit_error("rollback_hl", re, notify=True)
return False
time.sleep(2)
# Verify both legs are filled before saving state
hl_pos = self.hl.get_position(coin)
bn_pos = self.bn.get_position(coin)
hl_filled = abs(hl_pos["size"]) if hl_pos else 0
bn_filled = abs(bn_pos["size"]) if bn_pos else 0
if hl_filled == 0 or bn_filled == 0:
missing = "HL" if hl_filled == 0 else "BN"
emit_error(
"position_verify",
RuntimeError(
f"leg verification failed: {missing} has no position "
f"(HL={hl_filled}, BN={bn_filled})"
),
notify=True,
)
# Rollback: close whichever leg exists
if hl_filled > 0:
try:
self.hl.close_position(coin)
except Exception as re:
emit_error("rollback_hl", re, notify=True)
if bn_filled > 0:
try:
self.bn.close_position(coin)
except Exception as re:
emit_error("rollback_bn", re, notify=True)
return False
# Check delta — tolerate up to 10% size mismatch
avg_size = (hl_filled + bn_filled) / 2
delta_pct = abs(hl_filled - bn_filled) / avg_size * 100 if avg_size else 0
if delta_pct > 10:
emit(
"position_verify_warn",
{
"coin": coin,
"hl_size": hl_filled,
"bn_size": bn_filled,
"delta_pct": round(delta_pct, 2),
"msg": "size mismatch >10%, closing both legs",
},
notify=True,
tier="risk_alert",
)
try:
self.hl.close_position(coin)
except Exception as re:
emit_error("rollback_hl", re, notify=True)
try:
self.bn.close_position(coin)
except Exception as re:
emit_error("rollback_bn", re, notify=True)
return False
# Use verified size (smaller of the two, in case of rounding diffs)
size = min(hl_filled, bn_filled)
# Save state — append to positions array
hl_rate = self.hl.get_funding_rate(coin)
bn_rate = self.bn.get_funding_rate(coin)
now_iso = datetime.now(timezone.utc).isoformat()
new_pos = {
"coin": coin,
"direction": direction,
"entry_time": now_iso,
"entry_spread": abs(hl_rate - bn_rate),
"entry_hl_rate": hl_rate,
"entry_bn_rate": bn_rate,
"size": size,
"entry_price": price,
"total_funding_earned": 0.0,
}
# Re-load state to avoid race; append position
state = self._load()
positions = state.get("positions", [])
positions.append(new_pos)
state["positions"] = positions
state["version"] = 2
# Strategy-level fields (set once on first open)
if not state.get("strategy_start_time"):
state["strategy_start_time"] = now_iso
if not state.get("entry_total_balance"):
entry_hl_balance = self.hl.get_usdc_balance()
entry_bn_balance = self.bn.get_usdt_balance()
state["entry_total_balance"] = round(entry_hl_balance + entry_bn_balance, 2)
state["rate_snapshots"] = state.get("rate_snapshots", [])
self._save(state)
emit(
"position_opened",
{
"coin": coin,
"size": size,
"long_exchange": long_ex,
"short_exchange": short_ex,
"leverage": self.leverage,
"entry_price": price,
"hl_rate": hl_rate,
"bn_rate": bn_rate,
"position_count": len(positions),
},
notify=True,
tier="trade_alert",
)
return True
# ---- Close position ----
def _verify_leg_closed(self, client, coin: str) -> bool:
"""Check that a position on the given exchange is actually closed."""
try:
pos = client.get_position(coin)
if pos and abs(pos.get("size", 0)) > 0:
return False
return True
except Exception as e:
emit_error("verify_leg_closed", e)
return False
def close_position(self, coin: str) -> bool:
state = self._load()
positions = state.get("positions", [])
# Find the position for this coin
pos_data = None
pos_idx = -1
for i, p in enumerate(positions):
if p["coin"] == coin:
pos_data = p
pos_idx = i
break
if pos_data is None:
emit("warn", {"msg": f"no position found for {coin}, skip close"})
return False
direction = pos_data.get("direction", {})
long_ex = direction.get("long_exchange", "hyperliquid")
short_ex = direction.get("short_exchange", "binance")
long_client = self._get_client(long_ex)
short_client = self._get_client(short_ex)
# Capture pre-close balances for per-position PnL estimate
pre_hl = self.hl.get_usdc_balance()
pre_bn = self.bn.get_usdt_balance()
short_ok = False
long_ok = False
try:
short_client.close_position(coin)
except Exception as e:
emit_error("close_short", e)
time.sleep(1)
short_ok = self._verify_leg_closed(short_client, coin)
try:
long_client.close_position(coin)
except Exception as e:
emit_error("close_long", e)
time.sleep(1)
long_ok = self._verify_leg_closed(long_client, coin)
if not short_ok or not long_ok:
failed_legs = []
if not short_ok:
failed_legs.append(f"short({short_ex})")
if not long_ok:
failed_legs.append(f"long({long_ex})")
emit(
"close_incomplete",
{
"coin": coin,
"failed_legs": failed_legs,
"short_closed": short_ok,
"long_closed": long_ok,
},
notify=True,
tier="risk_alert",
)
# Do NOT remove from state — position needs manual intervention
# or will be retried on next tick
return False
funding_earned = pos_data.get("total_funding_earned", 0.0)
current_price = self.hl.get_mid_price(coin)
# Per-position PnL: balance change from closing this specific position
post_hl = self.hl.get_usdc_balance()
post_bn = self.bn.get_usdt_balance()
pnl = round((post_hl + post_bn) - (pre_hl + pre_bn), 2)
emit(
"position_closed",
{
"coin": coin,
"long_exchange": long_ex,
"short_exchange": short_ex,
"funding_earned": funding_earned,
"pnl": pnl,
"remaining_positions": len(positions) - 1,
},
notify=True,
tier="trade_alert",
)
log_trade(
"close", coin, direction,
size=pos_data.get("size", 0),
entry_price=pos_data.get("entry_price", 0),
exit_price=current_price,
pnl=pnl,
funding_pnl=round(funding_earned, 2),
reason=f"funding earned: {funding_earned:.2f}",
)
# Remove position from array (do NOT clear entire state)
positions.pop(pos_idx)
state["positions"] = positions
self._save(state)
return True
# ---- Reconciliation ----
def reconcile_positions(self) -> list[dict]:
"""Compare state positions vs actual exchange positions.
Returns a list of orphan positions (on-exchange but not in state).
Emits risk_alert for each orphan found.
"""
state_coins = {p["coin"] for p in self._get_positions()}
orphans: list[dict] = []
for exchange_name, client in [("hyperliquid", self.hl), ("binance", self.bn)]:
try:
actual = client.get_all_positions()
except Exception as e:
emit_error(f"reconcile_{exchange_name}", e)
continue
for pos in actual:
if pos["coin"] not in state_coins:
orphan = {
"coin": pos["coin"],
"exchange": exchange_name,
"size": pos["size"],
"unrealized_pnl": pos.get("unrealized_pnl", 0),
}
orphans.append(orphan)
emit(
"orphan_position",
orphan,
notify=True,
tier="risk_alert",
)
return orphans
# ---- Health check ----
def _check_position_health(self, pos_data: dict) -> dict:
"""Check health of a single position. Returns per-position health dict."""
coin = pos_data["coin"]
direction = pos_data.get("direction", {})
long_ex = direction.get("long_exchange", "hyperliquid")
short_ex = direction.get("short_exchange", "binance")
long_client = self._get_client(long_ex)
short_client = self._get_client(short_ex)
long_pos = long_client.get_position(coin)
short_pos = short_client.get_position(coin)
long_size = abs(long_pos["size"]) if long_pos else 0.0
short_size = abs(short_pos["size"]) if short_pos else 0.0
# Notional-based delta: use each exchange's mark price to get USD exposure
long_mark_px = long_pos.get("mark_px", 0.0) if long_pos else 0.0
short_mark_px = short_pos.get("mark_px", 0.0) if short_pos else 0.0
long_notional = long_size * long_mark_px if long_mark_px > 0 else 0.0
short_notional = short_size * short_mark_px if short_mark_px > 0 else 0.0
avg_notional = (long_notional + short_notional) / 2 if (long_notional + short_notional) > 0 else 1
delta_notional = abs(long_notional - short_notional)
delta_pct = delta_notional / avg_notional * 100
hl_rate = self.hl.get_funding_rate(coin)
bn_rate = self.bn.get_funding_rate(coin)
rate_map = {"hyperliquid": hl_rate, "binance": bn_rate}
current_spread = rate_map[short_ex] - rate_map[long_ex]
current_apr = current_spread * 3 * 365 * 100
spread_favorable = current_spread > self.close_spread_threshold
has_both_legs = long_size > 0 and short_size > 0
# PnL stop-loss: compute unrealized PnL as % of notional
long_pnl = long_pos.get("unrealized_pnl", 0.0) if long_pos else 0.0
short_pnl = short_pos.get("unrealized_pnl", 0.0) if short_pos else 0.0
funding_earned = pos_data.get("total_funding_earned", 0.0)
total_pnl_usd = long_pnl + short_pnl + funding_earned
pnl_pct = total_pnl_usd / avg_notional * 100 if avg_notional > 1 else 0.0
pnl_stop_triggered = pnl_pct < self.pnl_stop_loss_pct
# Min hold time: count ticks since entry
entry_time_str = pos_data.get("entry_time", "")
hold_ticks = 0
if entry_time_str:
try:
entry_dt = datetime.fromisoformat(entry_time_str)
elapsed_s = (datetime.now(timezone.utc) - entry_dt).total_seconds()
hold_ticks = int(elapsed_s / 300) # 5 min per tick
except (ValueError, TypeError):
pass
within_min_hold = hold_ticks < self.min_hold_ticks
healthy = has_both_legs and spread_favorable and delta_pct < 20 and not pnl_stop_triggered
result = {
"coin": coin,
"healthy": healthy,
"long_exchange": long_ex,
"short_exchange": short_ex,
"long_size": long_size,
"short_size": short_size,
"long_mark_px": long_mark_px,
"short_mark_px": short_mark_px,
"delta_pct": round(delta_pct, 2),
"delta_notional_usd": round(delta_notional, 4),
"current_spread": current_spread,
"current_apr": round(current_apr, 2),
"spread_favorable": spread_favorable,
"has_both_legs": has_both_legs,
"hl_rate": hl_rate,
"bn_rate": bn_rate,
"pnl_pct": round(pnl_pct, 4),
"pnl_stop_triggered": pnl_stop_triggered,
"total_pnl_usd": round(total_pnl_usd, 4),
"hold_ticks": hold_ticks,
"within_min_hold": within_min_hold,
}
if not healthy:
emit("health_warning", result, notify=True, tier="risk_alert")
return result
def _assess_delta_for_exit(self, pos_data: dict, health: dict) -> dict:
"""Assess whether delta conditions are favorable for closing a position.
Weighs three cost components:
1. Trading cost (fixed, incurred on close regardless of timing)
2. Delta PnL (variable, depends on when we close)
3. Funding bleed (ongoing cost of holding an unfavorable-spread position)
If the adverse delta PnL is smaller than the funding we'd lose by
waiting one more tick, deferring is not worth it — close now.
Returns a dict with:
- favorable: bool — True if delta state is good for exit
- reason: str — human-readable reason
- delta_pnl_usd: float — signed PnL from delta drift
- trading_cost_usd: float — one-way trading cost to close
- funding_bleed_per_tick: float — funding loss per tick if spread unfavorable
- net_exit_cost_usd: float — trading_cost - delta_pnl (lower is better)
- signed_delta_pct: float — positive = net long exposure
- should_force: bool — True if delta is dangerously high
- defer_count: int — how many times we've already deferred
"""
coin = pos_data["coin"]
entry_price = pos_data.get("entry_price", 0.0)
long_size = health.get("long_size", 0.0)
short_size = health.get("short_size", 0.0)
# Use each exchange's mark price for notional-based delta
long_mark_px = health.get("long_mark_px", 0.0)
short_mark_px = health.get("short_mark_px", 0.0)
current_price = self.hl.get_mid_price(coin)
# Fallback to mid price if mark prices unavailable
if long_mark_px <= 0:
long_mark_px = current_price
if short_mark_px <= 0:
short_mark_px = current_price
long_notional = long_size * long_mark_px
short_notional = short_size * short_mark_px
avg_notional = (long_notional + short_notional) / 2 if (long_notional + short_notional) > 0 else 1
# Signed delta in notional USD: positive = net long exposure
signed_delta_notional = long_notional - short_notional
signed_delta_pct = signed_delta_notional / avg_notional * 100 if avg_notional else 0
abs_delta_pct = abs(signed_delta_pct)
# Delta PnL: actual unrealized PnL difference between the two legs
# This captures the real USD impact of the delta drift
long_pnl = (long_mark_px - entry_price) * long_size if long_size > 0 else 0
short_pnl = (entry_price - short_mark_px) * short_size if short_size > 0 else 0
delta_pnl_usd = long_pnl + short_pnl
# Trading cost: notional × round_trip_cost_pct / 100 (one-way close)
notional = avg_notional
trading_cost_usd = notional * (self.round_trip_cost_pct / 100) / 2 # half of round-trip
# Funding bleed per tick: how much we lose per 5-min tick when spread
# is unfavorable. Spread is per 8h settlement, tick is 5 min.
# bleed = |spread| × notional × (5min / 480min)
current_spread = health.get("current_spread", 0.0)
tick_minutes = 5.0
bleed_per_tick = abs(current_spread) * notional * (tick_minutes / 480.0)
# Pending funding: unrealized funding that would be forfeited if we
# close before the next settlement. Binance settles every 8h
# (00:00/08:00/16:00 UTC), Hyperliquid settles every 1h.
direction = pos_data.get("direction", {})
long_ex = direction.get("long_exchange", "hyperliquid")
hl_rate = health.get("hl_rate", 0.0)
bn_rate = health.get("bn_rate", 0.0)
now = datetime.now(timezone.utc)
bn_elapsed_h = (now.hour % 8) + now.minute / 60
bn_fraction = bn_elapsed_h / 8.0
hl_elapsed_m = now.minute + now.second / 60
hl_fraction = hl_elapsed_m / 60.0
# Pending funding for each leg (positive = we earn, negative = we pay)
# For the short leg, we earn when rate > 0 (longs pay shorts)
# For the long leg, we earn when rate < 0 (shorts pay longs)
if long_ex == "binance":
bn_pending = -bn_rate * bn_fraction * notional # long BN: earn when rate < 0
hl_pending = hl_rate * hl_fraction * notional # short HL: earn when rate > 0
else:
hl_pending = -hl_rate * hl_fraction * notional # long HL: earn when rate < 0
bn_pending = bn_rate * bn_fraction * notional # short BN: earn when rate > 0
pending_funding_usd = bn_pending + hl_pending
# Net exit cost = trading_cost - delta_pnl + forfeited_funding
# - trading_cost: always paid on close
# - delta_pnl: positive reduces cost, negative increases cost
# - pending_funding: if positive, we forfeit earnings by closing now;
# if negative, we avoid paying by closing now
forfeited_funding = max(pending_funding_usd, 0.0)
avoided_payment = max(-pending_funding_usd, 0.0)
net_exit_cost_usd = trading_cost_usd - delta_pnl_usd + forfeited_funding - avoided_payment
# Track how many times this position has been deferred
defer_count = pos_data.get("delta_exit_defer_count", 0)
# Cumulative bleed already lost from prior deferrals
cumulative_bleed = bleed_per_tick * defer_count
# Time until next settlement for each exchange
bn_remaining_h = 8.0 - bn_elapsed_h
bn_remaining_ticks = bn_remaining_h * 60 / tick_minutes
hl_remaining_m = 60.0 - hl_elapsed_m
hl_remaining_ticks = hl_remaining_m / tick_minutes
# Use the nearest settlement that has positive pending
nearest_remaining_ticks = min(
bn_remaining_ticks if bn_pending > 0 else float("inf"),
hl_remaining_ticks if hl_pending > 0 else float("inf"),
)
nearest_remaining_h = min(
bn_remaining_h if bn_pending > 0 else float("inf"),
hl_remaining_m / 60 if hl_pending > 0 else float("inf"),
)
# Decision logic — ordered by priority
should_force = abs_delta_pct >= self.delta_force_close_pct
exceeded_max_defer = defer_count >= self.delta_max_defer_ticks
if should_force:
favorable = True
reason = (
f"delta {signed_delta_pct:+.2f}% exceeds force-close "
f"threshold {self.delta_force_close_pct}%"
)
elif exceeded_max_defer:
favorable = True
reason = (
f"max defer ticks reached ({defer_count}/{self.delta_max_defer_ticks}), "
f"delta PnL +.2f, "
f"cumulative bleed .2f, "
f"forfeited funding .2f"
)
elif forfeited_funding > 0 and nearest_remaining_ticks <= 3:
# Near settlement with positive pending funding — wait to collect it
# 3 ticks = 15 min, close enough to just hold on
favorable = False
reason = (
f"settlement in {nearest_remaining_h:.2f}h "
f"({nearest_remaining_ticks:.0f} ticks), "
f"pending funding +.2f "
f"(BN +.4f, HL +.4f) "
f"would be forfeited, deferring to collect "
f"(defer {defer_count+1}/{self.delta_max_defer_ticks})"
)
elif abs_delta_pct <= self.delta_neutral_threshold_pct:
# Delta is near zero — ideal exit window
favorable = True
reason = (
f"delta neutral ({signed_delta_pct:+.2f}% within "
f"±{self.delta_neutral_threshold_pct}%), "
f"net exit cost +.2f"
)
elif delta_pnl_usd >= trading_cost_usd + forfeited_funding:
# Delta PnL covers trading cost + forfeited funding — close and profit
net_gain = delta_pnl_usd - trading_cost_usd - forfeited_funding
favorable = True
reason = (
f"delta PnL +.2f covers trading cost "
f".2f + forfeited funding "
f".2f, net +.2f"
)
elif delta_pnl_usd >= 0:
# Delta PnL is positive but doesn't fully cover all costs — still OK
favorable = True
reason = (
f"delta PnL +.2f partially offsets costs "
f"(trade .2f + forfeit .2f), "
f"net exit cost .2f"
)
elif bleed_per_tick > 0 and abs(delta_pnl_usd) <= bleed_per_tick:
# Adverse delta PnL is smaller than one tick of funding bleed —
# deferring costs more than the delta loss, close now
favorable = True
reason = (
f"adverse delta PnL +.2f < bleed/tick "
f".4f, deferring not worth it"
)
else:
# Adverse delta PnL exceeds bleed/tick — worth waiting for recovery
favorable = False
remaining_ticks = self.delta_max_defer_ticks - defer_count
max_total_bleed = bleed_per_tick * remaining_ticks
reason = (
f"delta PnL adverse +.2f > bleed/tick "
f".4f, deferring "
f"(delta {signed_delta_pct:+.2f}%, "
f"defer {defer_count+1}/{self.delta_max_defer_ticks}, "
f"max bleed if waiting .2f, "
f"pending funding +.2f)"
)
return {
"favorable": favorable,
"reason": reason,
"delta_pnl_usd": round(delta_pnl_usd, 4),
"trading_cost_usd": round(trading_cost_usd, 4),
"pending_funding_usd": round(pending_funding_usd, 4),
"forfeited_funding_usd": round(forfeited_funding, 4),
"funding_bleed_per_tick": round(bleed_per_tick, 6),
"net_exit_cost_usd": round(net_exit_cost_usd, 4),
"cumulative_bleed_usd": round(cumulative_bleed, 4),
"signed_delta_pct": round(signed_delta_pct, 2),
"abs_delta_pct": round(abs_delta_pct, 2),
"should_force": should_force,
"defer_count": defer_count,
"current_price": current_price,
"entry_price": entry_price,
"bn_remaining_h": round(bn_remaining_h, 2),
"hl_remaining_m": round(hl_remaining_m, 2),
"nearest_settlement_ticks": round(nearest_remaining_ticks, 1),
}
def _increment_defer_count(self, coin: str) -> None:
"""Increment the delta exit defer counter for a position in state."""
state = self._load()
for pos in state.get("positions", []):
if pos["coin"] == coin:
pos["delta_exit_defer_count"] = pos.get("delta_exit_defer_count", 0) + 1
break
self._save(state)
def _reset_defer_count(self, coin: str) -> None:
"""Reset the delta exit defer counter (e.g. when spread becomes favorable again)."""
state = self._load()
for pos in state.get("positions", []):
if pos["coin"] == coin:
pos.pop("delta_exit_defer_count", None)
break
self._save(state)
def check_health(self) -> dict:
"""Check health for ALL positions. Returns aggregate + per-position health."""
positions = self._get_positions()
if not positions:
return {
"healthy": True,
"has_positions": False,
"position_count": 0,
"position_health": [],
}
position_health = []
all_healthy = True
for pos in positions:
ph = self._check_position_health(pos)
position_health.append(ph)
if not ph["healthy"]:
all_healthy = False
return {
"healthy": all_healthy,
"has_positions": True,
"position_count": len(positions),
"position_health": position_health,
}
# ---- Position management ----
def _evaluate_switch_candidate(
self, coin: str, current_apr: float, health: dict,
opportunities: list[dict],
) -> dict | None:
"""Evaluate if a better opportunity justifies switching out of `coin`.
Returns the best verified candidate dict or None if no switch warranted.
Uses time-cost breakeven model with settlement deferral.
"""
hl_rate = health.get("hl_rate", 0.0)
bn_rate = health.get("bn_rate", 0.0)
long_ex = health.get("long_exchange", "binance")
now = datetime.now(timezone.utc)
# --- Cost component 1: Trading fees ---
trading_cost_pct = self.round_trip_cost_pct * 2 # close + open
# --- Cost component 2: Sunk funding (unrealized since last settlement) ---
bn_elapsed_h = (now.hour % 8) + now.minute / 60
bn_fraction = bn_elapsed_h / 8.0
bn_remaining_h = 8.0 - bn_elapsed_h
hl_elapsed_m = now.minute + now.second / 60
hl_fraction = hl_elapsed_m / 60.0
hl_remaining_m = 60.0 - hl_elapsed_m
if long_ex == "binance":
bn_unrealized = -bn_rate * bn_fraction * 100
hl_unrealized = hl_rate * hl_fraction * 100
else:
hl_unrealized = -hl_rate * hl_fraction * 100
bn_unrealized = bn_rate * bn_fraction * 100
sunk_cost_pct = bn_unrealized + hl_unrealized
# --- Cost component 3: Realized price PnL ---
long_client = self._get_client(long_ex)
short_ex_name = "binance" if long_ex == "hyperliquid" else "hyperliquid"
short_client = self._get_client(short_ex_name)
long_pos = long_client.get_position(coin)
short_pos = short_client.get_position(coin)
long_pnl = long_pos.get("unrealized_pnl", 0.0) if long_pos else 0.0
short_pnl = short_pos.get("unrealized_pnl", 0.0) if short_pos else 0.0
price_pnl = long_pnl + short_pnl
long_notional = abs(long_pos["size"]) * long_pos.get("mark_px", 0) if long_pos else 0
short_notional = abs(short_pos["size"]) * short_pos.get("mark_px", 0) if short_pos else 0
avg_notional = (long_notional + short_notional) / 2 if (long_notional + short_notional) > 0 else 1
price_pnl_pct = price_pnl / avg_notional * 100
total_cost_pct = trading_cost_pct + sunk_cost_pct - price_pnl_pct
max_breakeven_days = self.max_breakeven_days
occupied = self._occupied_coins()
best = None
best_breakeven = float("inf")
for opp in opportunities:
if opp["coin"] == coin or opp["coin"] in occupied:
continue
apr_gain = opp["estimated_apr"] - current_apr
if apr_gain <= 0:
continue
daily_gain_pct = apr_gain / 365.0
if daily_gain_pct > 0:
quick_breakeven = total_cost_pct / daily_gain_pct
if quick_breakeven <= max_breakeven_days and quick_breakeven < best_breakeven:
best = opp
best_breakeven = quick_breakeven
if not best:
return None
# Deep-verify the candidate
direction = {
"long_exchange": best["long_exchange"],
"short_exchange": best["short_exchange"],
}
verification = self.verify_opportunity(best["coin"], direction)
if not verification["valid"]:
emit(
"switch_rejected",
{"from": coin, "to": best["coin"], "reason": verification["reject_reason"]},
)
return None
verified_apr = verification["net_apr_after_costs"]
apr_gain = verified_apr - current_apr
if apr_gain <= 0:
emit(
"switch_rejected",
{"from": coin, "to": best["coin"], "reason": f"verified APR gain <= 0: {apr_gain:.1f}%"},
)
return None
daily_gain_pct = apr_gain / 365.0
breakeven_days = total_cost_pct / daily_gain_pct if daily_gain_pct > 0 else float("inf")
if breakeven_days > max_breakeven_days:
emit(
"switch_rejected",
{
"from": coin, "to": best["coin"],
"reason": f"breakeven {breakeven_days:.1f}d > max {max_breakeven_days}d "
f"(cost={total_cost_pct:.3f}%, gain={apr_gain:.1f}%/yr, "
f"trading={trading_cost_pct:.2f}%, sunk={sunk_cost_pct:.3f}%, "
f"price_pnl={price_pnl_pct:.3f}%)",
},
)
return None
# --- Settlement deferral check ---
if long_ex == "binance":
bn_pending = -bn_rate * (1 - bn_fraction) * 100
hl_pending = hl_rate * (1 - hl_fraction) * 100
else:
hl_pending = -hl_rate * (1 - hl_fraction) * 100
bn_pending = bn_rate * (1 - bn_fraction) * 100
bn_wait_hours = bn_remaining_h
bn_funding_gain = bn_pending
bn_opportunity_cost = apr_gain / 365.0 / 24.0 * bn_wait_hours
defer_for_bn = bn_funding_gain > 0 and bn_funding_gain > bn_opportunity_cost
if defer_for_bn:
emit(
"switch_deferred",
{
"from": coin, "to": best["coin"],
"reason": f"BN settlement in {bn_remaining_h:.1f}h, "
f"pending funding {bn_funding_gain:.4f}% > "
f"opportunity cost {bn_opportunity_cost:.4f}%",
"breakeven_days": round(breakeven_days, 1),
},
)
return None
return {
"candidate": best,
"direction": direction,
"verified_apr": verified_apr,
"apr_gain": apr_gain,
"breakeven_days": breakeven_days,
"trading_cost_pct": trading_cost_pct,
"sunk_cost_pct": sunk_cost_pct,
"price_pnl_pct": price_pnl_pct,
"total_cost_pct": total_cost_pct,
"bn_elapsed_h": bn_elapsed_h,
"hl_elapsed_m": hl_elapsed_m,
}
def check_and_manage(self, opportunities: list[dict] | None = None) -> bool:
"""Manage all positions: close unhealthy, open new with idle funds, switch worst.
Returns True if any action was taken.
"""
acted = False
positions = self._get_positions()
if not positions and not opportunities:
return False
# 1) Close unhealthy positions
for pos in list(positions):
coin = pos["coin"]
ph = self._check_position_health(pos)
# Reset defer counter when spread recovers to favorable
if ph["spread_favorable"] and pos.get("delta_exit_defer_count", 0) > 0:
self._reset_defer_count(coin)
# PnL stop-loss: force close regardless of spread or delta
if ph["pnl_stop_triggered"]:
emit(
"manage_close",
{
"coin": coin,
"reason": "pnl_stop_loss",
"pnl_pct": ph["pnl_pct"],
"total_pnl_usd": ph["total_pnl_usd"],
"threshold": self.pnl_stop_loss_pct,
"hold_ticks": ph["hold_ticks"],
},
notify=True,
tier="risk_alert",
)
self._reset_defer_count(coin)
self.close_position(coin)
acted = True
continue
if not ph["spread_favorable"]:
# Delta-aware exit: defer close if delta PnL is adverse
if self.delta_exit_enabled:
delta_assessment = self._assess_delta_for_exit(pos, ph)
if delta_assessment["favorable"]:
emit(
"manage_close",
{
"coin": coin,
"reason": "spread unfavorable, delta favorable for exit",
"current_spread": ph["current_spread"],
"current_apr": ph["current_apr"],
"delta_reason": delta_assessment["reason"],
"delta_pnl_usd": delta_assessment["delta_pnl_usd"],
"signed_delta_pct": delta_assessment["signed_delta_pct"],
},
notify=True,
tier="risk_alert",
)
self._reset_defer_count(coin)
self.close_position(coin)
acted = True
else:
# Defer: delta PnL is adverse, wait for better window
self._increment_defer_count(coin)
emit(
"exit_deferred_delta",
{
"coin": coin,
"reason": delta_assessment["reason"],
"delta_pnl_usd": delta_assessment["delta_pnl_usd"],
"signed_delta_pct": delta_assessment["signed_delta_pct"],
"defer_count": delta_assessment["defer_count"] + 1,
"max_defer": self.delta_max_defer_ticks,
"current_spread": ph["current_spread"],
"current_apr": ph["current_apr"],
},
notify=True,
tier="info",
)
else:
# Delta exit disabled — close immediately as before
emit(
"manage_close",
{
"coin": coin,
"reason": "spread unfavorable",
"current_spread": ph["current_spread"],
"current_apr": ph["current_apr"],
},
notify=True,
tier="risk_alert",
)
self.close_position(coin)
acted = True
elif not ph["has_both_legs"]:
emit(
"manage_close",
{
"coin": coin,
"reason": "missing leg",
"long_size": ph["long_size"],
"short_size": ph["short_size"],
},
notify=True,
tier="risk_alert",
)
self.close_position(coin)
acted = True
elif ph["current_apr"] < self.min_hold_apr:
# APR decayed below minimum hold threshold — exit
if self.delta_exit_enabled:
delta_assessment = self._assess_delta_for_exit(pos, ph)
if not delta_assessment["favorable"]:
self._increment_defer_count(coin)
emit(
"exit_deferred_delta",
{
"coin": coin,
"reason": f"APR decayed ({ph['current_apr']:.1f}% < "
f"{self.min_hold_apr:.1f}%), "
+ delta_assessment["reason"],
"current_apr": ph["current_apr"],
"min_hold_apr": self.min_hold_apr,
"defer_count": delta_assessment["defer_count"] + 1,
"max_defer": self.delta_max_defer_ticks,
},
notify=True,
tier="info",
)
continue
emit(
"manage_close",
{
"coin": coin,
"reason": "APR decayed below minimum",
"current_apr": ph["current_apr"],
"min_hold_apr": self.min_hold_apr,
},
notify=True,
tier="risk_alert",
)
self.close_position(coin)
acted = True
if not opportunities:
return acted
# Refresh positions after potential closes
positions = self._get_positions()
occupied = {p["coin"] for p in positions}
# 2) Open new positions with idle funds if below max
if len(positions) < self.max_positions:
hl_avail, bn_avail = self._get_available_budgets()
available_budget = min(hl_avail, bn_avail)
if available_budget >= self.min_position_usd:
# Find best opportunity not already held
for opp in opportunities:
if opp["coin"] in occupied:
continue
if len(self._get_positions()) >= self.max_positions:
break
direction = {
"long_exchange": opp["long_exchange"],
"short_exchange": opp["short_exchange"],
}
verification = self.verify_opportunity(opp["coin"], direction)
if not verification["valid"]:
continue
emit(
"manage_open",
{
"coin": opp["coin"],
"apr": verification["net_apr_after_costs"],
"direction": direction,
"slot": len(self._get_positions()) + 1,
},
)
success = self.open_position(opp["coin"], direction)
if success:
acted = True
occupied.add(opp["coin"])
log_trade(
"open", opp["coin"], direction,
size=self._get_position_by_coin(opp["coin"]).get("size", 0),
entry_price=self._get_position_by_coin(opp["coin"]).get("entry_price", 0),
reason=f"APR {verification['net_apr_after_costs']:.1f}%",
)
# Re-check budget for next slot
hl_avail, bn_avail = self._get_available_budgets()
available_budget = min(hl_avail, bn_avail)
if available_budget < self.min_position_usd:
break
else:
break # Don't keep trying if open failed
return acted
# 3) At max positions — try to switch the worst one
positions = self._get_positions()
if not positions:
return acted
# Find worst position by current APR
worst_coin = None
worst_apr = float("inf")
worst_health = None
for pos in positions:
ph = self._check_position_health(pos)
if ph["current_apr"] < worst_apr:
worst_apr = ph["current_apr"]
worst_coin = pos["coin"]
worst_health = ph
if worst_coin and worst_health:
# Min hold time: skip switch if position is too young
if worst_health.get("within_min_hold", False):
emit(
"switch_skipped_min_hold",
{
"coin": worst_coin,
"hold_ticks": worst_health["hold_ticks"],
"min_hold_ticks": self.min_hold_ticks,
},
)
return acted
switch = self._evaluate_switch_candidate(
worst_coin, worst_apr, worst_health, opportunities,
)
if switch:
# Delta-aware exit check before switching
worst_pos = self._get_position_by_coin(worst_coin)
if self.delta_exit_enabled and worst_pos:
delta_assessment = self._assess_delta_for_exit(worst_pos, worst_health)
if not delta_assessment["favorable"]:
self._increment_defer_count(worst_coin)
emit(
"switch_deferred_delta",
{
"from": worst_coin,
"to": switch["candidate"]["coin"],
"reason": delta_assessment["reason"],
"delta_pnl_usd": delta_assessment["delta_pnl_usd"],
"signed_delta_pct": delta_assessment["signed_delta_pct"],
"defer_count": delta_assessment["defer_count"] + 1,
"max_defer": self.delta_max_defer_ticks,
},
notify=True,
tier="info",
)
return acted
candidate = switch["candidate"]
direction = switch["direction"]
emit(
"switch_start",
{
"from": worst_coin,
"from_apr": worst_apr,
"to": candidate["coin"],
"to_apr": switch["verified_apr"],
"apr_gain": round(switch["apr_gain"], 1),
"breakeven_days": round(switch["breakeven_days"], 1),
"trading_cost_pct": round(switch["trading_cost_pct"], 2),
"sunk_cost_pct": round(switch["sunk_cost_pct"], 4),
"price_pnl_pct": round(switch["price_pnl_pct"], 4),
"total_cost_pct": round(switch["total_cost_pct"], 3),
"bn_elapsed_h": round(switch["bn_elapsed_h"], 1),
"hl_elapsed_m": round(switch["hl_elapsed_m"], 1),
},
notify=True,
tier="trade_alert",
)
self._reset_defer_count(worst_coin)
self.close_position(worst_coin)
time.sleep(2)
success = self.open_position(candidate["coin"], direction)
if success:
log_trade(
"open", candidate["coin"], direction,
size=self._get_position_by_coin(candidate["coin"]).get("size", 0),
entry_price=self._get_position_by_coin(candidate["coin"]).get("entry_price", 0),
reason=f"switch from {worst_coin}, APR {switch['verified_apr']:.1f}%",
)
else:
emit(
"switch_failed",
{"from": worst_coin, "to": candidate["coin"], "reason": "open_position failed"},
notify=True,
tier="risk_alert",
)
acted = True
return acted
# ---- Report ----
def get_status(self) -> dict:
state = self._load()
positions = state.get("positions", [])
health = self.check_health()
hl_balance = self.hl.get_usdc_balance()
bn_balance = self.bn.get_usdt_balance()
if not positions:
return {
"has_positions": False,
"position_count": 0,
"positions": [],
"hl_balance": hl_balance,
"bn_balance": bn_balance,
}
position_statuses = []
health_by_coin = {
ph["coin"]: ph for ph in health.get("position_health", [])
}
for pos in positions:
coin = pos["coin"]
ph = health_by_coin.get(coin, {})
position_statuses.append({
"coin": coin,
"direction": pos.get("direction"),
"entry_time": pos.get("entry_time"),
"size": pos.get("size"),
"entry_price": pos.get("entry_price"),
"entry_spread": pos.get("entry_spread"),
"current_spread": ph.get("current_spread"),
"current_apr": ph.get("current_apr"),
"hl_rate": ph.get("hl_rate"),
"bn_rate": ph.get("bn_rate"),
"long_size": ph.get("long_size"),
"short_size": ph.get("short_size"),
"delta_pct": ph.get("delta_pct"),
"healthy": ph.get("healthy"),
"total_funding_earned": pos.get("total_funding_earned", 0.0),
})
return {
"has_positions": True,
"position_count": len(positions),
"positions": position_statuses,
"healthy": health.get("healthy"),
"hl_balance": hl_balance,
"bn_balance": bn_balance,
}
def get_report(self) -> dict:
"""Generate full report with real balance-based PnL (aggregate across all positions)."""
status = self.get_status()
state = self._load()
entry_total = state.get("entry_total_balance", 0.0)
current_hl = status.get("hl_balance", 0.0)
current_bn = status.get("bn_balance", 0.0)
current_total = round(current_hl + current_bn, 2)
pnl = round(current_total - entry_total, 2) if entry_total else 0.0
roi_pct = round(pnl / entry_total * 100, 4) if entry_total else 0.0
# Aggregate funding earned across all positions
total_funding = sum(
p.get("total_funding_earned", 0.0)
for p in state.get("positions", [])
)
annualized_roi_pct = 0.0
strategy_start = state.get("strategy_start_time")
if strategy_start and entry_total:
try:
hours_running = (
datetime.now(timezone.utc) - datetime.fromisoformat(strategy_start)
).total_seconds() / 3600
effective_hours = max(hours_running, 24.0)
if effective_hours > 0:
annualized_roi_pct = round(roi_pct / effective_hours * 24 * 365, 2)
except (ValueError, TypeError):
pass
return {
**status,
"entry_total_balance": entry_total,
"current_total_balance": current_total,
"total_funding_earned": round(total_funding, 2),
"pnl": pnl,
"roi_pct": roi_pct,
"annualized_roi_pct": annualized_roi_pct,
}
# ═══════════════════════════════════════════════════════════════════════════════
# Section 9: Entry point (tick / report / status CLI)
# ═══════════════════════════════════════════════════════════════════════════════
LOCK_NAME = "cross_funding"
PULSE_INTERVAL_SECONDS = 3600 # 1h
_cb: CircuitBreaker | None = None
def _get_cb() -> CircuitBreaker:
"""Lazy-init circuit breaker (config must be loaded after dotenv)."""
global _cb
if _cb is None:
_cb = CircuitBreaker()
return _cb
def _build_engine() -> CrossFundingEngine:
cfg = load_config()
cross_cfg = cfg["cross_funding"]
hl_client = HLClient(
hl_private_key(), testnet=hl_testnet(), vault_address=hl_vault_address()
)
bn_client = BinanceClient(
binance_api_key(), binance_secret_key(), testnet=bn_testnet()
)
scanner = VarFundingScanner(
min_apr=cross_cfg["min_apr_pct"],
min_confidence=cross_cfg.get("min_confidence", "medium"),
stability_threshold=cross_cfg.get("stability_max_std_ratio", 0.3),
)
return CrossFundingEngine(hl_client, bn_client, scanner, cross_cfg)
def _should_pulse(state: dict) -> bool:
"""Check if enough time has passed since last hourly pulse."""
last_pulse = state.get("last_pulse_ts")
if not last_pulse:
return True
try:
last_dt = datetime.fromisoformat(last_pulse)
elapsed = (datetime.now(timezone.utc) - last_dt).total_seconds()
return elapsed >= PULSE_INTERVAL_SECONDS
except (ValueError, TypeError):
return True
TRADE_HISTORY_PATH = SCRIPT_DIR / "trade_history.json"
DASHBOARD_PATH = SCRIPT_DIR / "dashboard_data.json"
def _load_trade_history() -> list[dict]:
if TRADE_HISTORY_PATH.exists():
try:
return json.loads(TRADE_HISTORY_PATH.read_text())
except (json.JSONDecodeError, OSError):
pass
return []
def _save_trade_history(trades: list[dict]) -> None:
# Keep last 200 trades
trades = trades[-200:]
TRADE_HISTORY_PATH.write_text(json.dumps(trades, indent=2, ensure_ascii=False))
def log_trade(
trade_type: str, coin: str, direction: dict, size: float, entry_price: float,
exit_price: float | None = None, pnl: float | None = None,
funding_pnl: float | None = None, reason: str = "",
) -> None:
"""Append a trade record to trade_history.json."""
trades = _load_trade_history()
record: dict = {
"time": datetime.now(timezone.utc).isoformat(),
"type": trade_type,
"coin": coin,
"direction": direction,
"size": size,
"entry_price": entry_price,
"exit_price": exit_price,
"pnl": pnl,
"reason": reason,
}
if funding_pnl is not None:
record["funding_pnl"] = funding_pnl
trades.append(record)
_save_trade_history(trades)
def _build_position_dashboard(
engine: CrossFundingEngine, pos_data: dict, roi_pct: float,
) -> dict:
"""Build dashboard data for a single position."""
coin = pos_data["coin"]
direction = pos_data.get("direction", {})
long_ex = direction.get("long_exchange", "hyperliquid")
short_ex = direction.get("short_exchange", "binance")
long_client = engine._get_client(long_ex)
short_client = engine._get_client(short_ex)
long_pos = long_client.get_position(coin)
short_pos = short_client.get_position(coin)
long_size = abs(long_pos["size"]) if long_pos else 0.0
short_size = abs(short_pos["size"]) if short_pos else 0.0
hl_rate = engine.hl.get_funding_rate(coin)
bn_rate = engine.bn.get_funding_rate(coin)
current_price = engine.hl.get_mid_price(coin)
entry_price = pos_data.get("entry_price", 0.0)
rate_map = {"hyperliquid": hl_rate, "binance": bn_rate}
current_spread = rate_map[short_ex] - rate_map[long_ex]
# Settlement countdown
now = datetime.now(timezone.utc)
hl_next_min = 60 - now.minute
bn_next_secs = 0
for h in [0, 8, 16, 24]:
bn_next = now.replace(hour=h % 24, minute=0, second=0, microsecond=0)
if h == 24:
bn_next += timedelta(days=1)
bn_next = bn_next.replace(hour=0)
if bn_next > now:
bn_next_secs = (bn_next - now).total_seconds()
break
bn_next_min = int(bn_next_secs / 60)
# Funding payments count
entry_time = pos_data.get("entry_time", "")
hours_held = 0.0
if entry_time:
try:
hours_held = (
now - datetime.fromisoformat(entry_time)
).total_seconds() / 3600
except (ValueError, TypeError):
pass
hl_payments = int(hours_held)
bn_payments = int(hours_held / 8)
long_entry_px = long_pos.get("entry_px", entry_price) if long_pos else entry_price
short_entry_px = short_pos.get("entry_px", entry_price) if short_pos else entry_price
long_pnl = long_pos.get("unrealized_pnl", 0.0) if long_pos else 0.0
short_pnl = short_pos.get("unrealized_pnl", 0.0) if short_pos else 0.0
total_price_pnl = round(long_pnl + short_pnl, 2)
# Real funding PnL from exchange APIs
hl_cum_funding = 0.0
bn_cum_funding = 0.0
if long_ex == "hyperliquid":
hl_cum_funding = -(long_pos.get("cum_funding", 0.0)) if long_pos else 0.0
else:
hl_cum_funding = -(short_pos.get("cum_funding", 0.0)) if short_pos else 0.0
entry_time_val = pos_data.get("entry_time", "")
if entry_time_val:
try:
entry_ms = int(datetime.fromisoformat(entry_time_val).timestamp() * 1000)
bn_cum_funding = engine.bn.get_funding_income(coin, entry_ms)
except (ValueError, TypeError):
pass
if long_ex == "hyperliquid":
hl_funding_share = round(hl_cum_funding, 4)
bn_funding_share = round(bn_cum_funding, 4)
else:
hl_funding_share = round(hl_cum_funding, 4)
bn_funding_share = round(bn_cum_funding, 4)
total_funding = round(hl_funding_share + bn_funding_share, 2)
# Build leg data
hl_leg_data = {
"exchange": "hyperliquid",
"leverage": engine.leverage,
"funding_rate": hl_rate,
"settlement_cycle_h": 1,
"next_settlement_min": hl_next_min,
}
bn_leg_data = {
"exchange": "binance",
"leverage": engine.leverage,
"funding_rate": bn_rate,
"settlement_cycle_h": 8,
"next_settlement_min": bn_next_min,
}
if long_ex == "hyperliquid":
long_leg_extra = hl_leg_data
short_leg_extra = bn_leg_data
long_funding = hl_funding_share
short_funding = bn_funding_share
long_payments = hl_payments
short_payments = bn_payments
else:
long_leg_extra = bn_leg_data
short_leg_extra = hl_leg_data
long_funding = bn_funding_share
short_funding = hl_funding_share
long_payments = bn_payments
short_payments = hl_payments
long_mark_px = long_pos.get("mark_px", current_price) if long_pos else current_price
short_mark_px = short_pos.get("mark_px", current_price) if short_pos else current_price
long_notional = round(long_size * long_mark_px, 2)
short_notional = round(short_size * short_mark_px, 2)
# Pending (unrealized) funding: rate * notional * elapsed fraction
# Sign: positive rate means longs pay shorts
def _pending_funding(rate: float, notional: float, side: str, cycle_h: int, next_min: int) -> float:
elapsed_min = cycle_h * 60 - next_min
fraction = elapsed_min / (cycle_h * 60) if cycle_h > 0 else 0
# Funding payment = rate * notional; longs pay when rate > 0
raw = rate * notional * fraction
return round(-raw if side == "long" else raw, 4)
long_pending = _pending_funding(
long_leg_extra["funding_rate"], long_notional, "long",
long_leg_extra["settlement_cycle_h"], long_leg_extra["next_settlement_min"],
)
short_pending = _pending_funding(
short_leg_extra["funding_rate"], short_notional, "short",
short_leg_extra["settlement_cycle_h"], short_leg_extra["next_settlement_min"],
)
avg_notional = (long_notional + short_notional) / 2 if (long_notional + short_notional) > 0 else 1
delta_exposure = abs(long_notional - short_notional)
delta_pct = round(delta_exposure / avg_notional * 100, 2)
# APR & daily projection based on actual funding PnL
total_funding_with_pending = total_funding + long_pending + short_pending
if hours_held > 0 and avg_notional > 0:
actual_rate_per_hour = total_funding_with_pending / avg_notional / hours_held
current_apr = round(actual_rate_per_hour * 8760 * 100, 2)
projected_daily = round(actual_rate_per_hour * 24 * avg_notional, 2)
else:
current_apr = 0.0
projected_daily = 0.0
return {
"has_position": True,
"coin": coin,
"direction": direction,
"entry_time": entry_time,
"entry_spread": pos_data.get("entry_spread", 0.0),
"current_spread": current_spread,
"long_leg": {
**long_leg_extra,
"side": "long",
"size": long_size,
"entry_price": long_entry_px,
"current_price": current_price,
"notional": long_notional,
"unrealized_pnl": round(long_pnl, 2),
"accumulated_funding": long_funding,
"pending_funding": long_pending,
"funding_payments": long_payments,
},
"short_leg": {
**short_leg_extra,
"side": "short",
"size": short_size,
"entry_price": short_entry_px,
"current_price": current_price,
"notional": short_notional,
"unrealized_pnl": round(short_pnl, 2),
"accumulated_funding": short_funding,
"pending_funding": short_pending,
"funding_payments": short_payments,
},
"delta_neutral": delta_pct < 5,
"delta_exposure": round(delta_exposure, 6),
"delta_exposure_pct": delta_pct,
"total_funding_pnl": total_funding,
"total_pending_funding": round(long_pending + short_pending, 4),
"total_price_pnl": total_price_pnl,
"total_pnl": round(total_funding + total_price_pnl, 2),
"roi_pct": roi_pct,
"current_apr": current_apr,
"projected_daily_usd": projected_daily,
}, total_funding
def export_dashboard(
engine: CrossFundingEngine,
opportunities: list[dict] | None = None,
) -> None:
"""Write dashboard_data.json for the frontend."""
try:
state = engine._load()
positions = state.get("positions", [])
# Balances
hl_bal = engine.hl.get_usdc_balance()
bn_bal = engine.bn.get_usdt_balance()
entry_total = state.get("entry_total_balance", 0.0)
current_total = round(hl_bal + bn_bal, 2)
# Build positions array first to get live funding PnL
dashboard_positions: list[dict] = []
live_funding_total = 0.0
for pos_data in positions:
pos_dashboard, pos_funding = _build_position_dashboard(
engine, pos_data, 0.0,
)
dashboard_positions.append(pos_dashboard)
live_funding_total += pos_funding
live_funding_total += pos_dashboard.get("total_pending_funding", 0.0)
# Update per-position funding in state
for sp in state.get("positions", []):
if sp["coin"] == pos_data["coin"]:
sp["total_funding_earned"] = pos_funding
break
if positions:
engine._save(state)
# PnL & ROI: closed trades + live positions funding
trades = _load_trade_history()
closed_pnl = sum(
t.get("funding_pnl", 0) for t in trades
if t.get("funding_pnl") is not None
)
total_pnl = round(closed_pnl + live_funding_total, 2)
roi_pct = round(total_pnl / entry_total * 100, 4) if entry_total else 0.0
annualized_roi_pct = 0.0
strategy_start = state.get("strategy_start_time", "")
if strategy_start and entry_total:
try:
hours_running = (
datetime.now(timezone.utc) - datetime.fromisoformat(strategy_start)
).total_seconds() / 3600
effective_hours = max(hours_running, 24.0)
if effective_hours > 0:
annualized_roi_pct = round(roi_pct / effective_hours * 24 * 365, 2)
except (ValueError, TypeError):
pass
# Update roi_pct in position dashboards
for pd in dashboard_positions:
pd["roi_pct"] = roi_pct
summary = {
"total_invested": entry_total or current_total,
"current_value": current_total,
"total_pnl": total_pnl,
"roi_pct": round(roi_pct, 2),
"annualized_roi_pct": annualized_roi_pct,
"hl_balance": round(hl_bal, 2),
"bn_balance": round(bn_bal, 2),
"position_count": len(positions),
"max_positions": engine.max_positions,
}
# Opportunities
opps = opportunities or []
# Trade history
trades = _load_trade_history()
dashboard = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"summary": summary,
"positions": dashboard_positions,
"opportunities": opps[:20],
"trades": trades[-50:],
}
DASHBOARD_PATH.write_text(json.dumps(dashboard, indent=2, ensure_ascii=False))
except Exception as e:
emit_error("export_dashboard", e)
def tick() -> None:
"""Main loop tick: scan → manage positions (close/open/switch) + pulse."""
if not acquire_lock(LOCK_NAME):
emit("skip", {"reason": "another instance running"})
return
cb = _get_cb()
try:
if cb.is_open():
emit("skip", {"reason": "circuit breaker open"})
return
engine = _build_engine()
# Reconcile state vs actual exchange positions every tick
orphans = engine.reconcile_positions()
if orphans:
emit(
"reconciliation_alert",
{
"orphan_count": len(orphans),
"orphans": orphans,
"action": "manual intervention required",
},
notify=True,
tier="risk_alert",
)
state = engine._load()
positions = state.get("positions", [])
opportunities: list[dict] = []
# Always scan opportunities
opportunities = engine.scan_opportunities()
if not positions:
# No positions: stability check flow for first position
if not opportunities:
emit("tick", {"action": "idle", "reason": "no opportunities"})
export_dashboard(engine, opportunities)
cb.record_success()
return
engine.record_snapshot(opportunities)
stable_opp = engine.get_stable_opportunity()
if not stable_opp:
emit(
"tick",
{
"action": "accumulating",
"reason": "waiting for rate stability",
"top_coin": opportunities[0]["coin"],
"top_apr": opportunities[0]["estimated_apr"],
},
)
export_dashboard(engine, opportunities)
cb.record_success()
return
# Deep verification
direction = {
"long_exchange": stable_opp["long_exchange"],
"short_exchange": stable_opp["short_exchange"],
}
verification = engine.verify_opportunity(stable_opp["coin"], direction)
if not verification["valid"]:
emit(
"tick",
{
"action": "rejected",
"coin": stable_opp["coin"],
"reason": verification["reject_reason"],
},
)
export_dashboard(engine, opportunities)
cb.record_success()
return
emit(
"tick",
{
"action": "opening",
"coin": stable_opp["coin"],
"apr": verification["net_apr_after_costs"],
"direction": direction,
},
)
success = engine.open_position(stable_opp["coin"], direction)
if not success:
cb.record_error("open_position")
export_dashboard(engine, opportunities)
return
opened_pos = engine._get_position_by_coin(stable_opp["coin"])
log_trade(
"open", stable_opp["coin"], direction,
size=opened_pos.get("size", 0) if opened_pos else 0,
entry_price=opened_pos.get("entry_price", 0) if opened_pos else 0,
reason=f"APR {verification['net_apr_after_costs']:.1f}%",
)
else:
# Has positions: manage all (close unhealthy, open new, switch worst)
acted = engine.check_and_manage(opportunities)
if not acted:
health = engine.check_health()
report_data = engine.get_report()
snapshot = {**report_data, **health}
# Cache snapshot every tick for fast status queries
state = engine._load()
state["cached_snapshot"] = snapshot
state["cached_snapshot_ts"] = datetime.now(timezone.utc).isoformat()
# Push hourly pulse notification
if _should_pulse(state):
emit("tick", snapshot, tier="hourly_pulse")
state["last_pulse_ts"] = datetime.now(timezone.utc).isoformat()
else:
pos_coins = [p["coin"] for p in engine._get_positions()]
emit(
"tick",
{
"action": "hold",
"positions": pos_coins,
"position_count": len(pos_coins),
"healthy": health.get("healthy"),
},
)
engine._save(state)
# Export dashboard data every tick
export_dashboard(engine, opportunities)
cb.record_success()
except Exception as e:
emit_error("tick", e, notify=cb.record_error("tick"))
finally:
release_lock()
def report() -> None:
"""Generate and output report (with daily report notification card)."""
try:
engine = _build_engine()
data = engine.get_report()
emit("report", data, notify=True, tier="daily_report")
except Exception as e:
emit_error("report", e, notify=True)
def status() -> None:
"""Output current status card. Reads cache first (tick updates every 5 min)."""
try:
state = load_state("cross_funding")
cached = state.get("cached_snapshot")
if cached:
cached["_cached_ts"] = state.get("cached_snapshot_ts", "")
emit("status", cached, tier="hourly_pulse")
else:
engine = _build_engine()
data = engine.get_report()
emit("status", data, tier="hourly_pulse")
except Exception as e:
emit_error("status", e)
def main() -> None:
from dotenv import load_dotenv
script_dir = Path(__file__).resolve().parent
load_dotenv(script_dir / ".env")
parser = argparse.ArgumentParser(
description="Cross-exchange funding rate arbitrage (HL + Binance)"
)
parser.add_argument(
"command", choices=["tick", "report", "status"], help="subcommand to run"
)
args = parser.parse_args()
commands = {
"tick": tick,
"report": report,
"status": status,
}
commands[args.command]()
if __name__ == "__main__":
main()
FILE:references/funding-arb-algorithm.md
# 跨交易所资金费率套利算法详解
## 核心原理
永续合约通过资金费率(Funding Rate)锚定现货价格。不同交易所的费率由各自市场供需决定,经常出现显著差异。本策略利用这个差异:
```
策略 = 在费率低的交易所做多 + 在费率高的交易所做空
净收益 = (short_rate − long_rate) × 3次/天 × 365天 × 名义价值 − 往返成本
```
## 1. 机会扫描
### VarFunding API
VarFunding(varfunding.xyz)是第三方服务,预计算各交易所间的套利机会:
```
GET /api/funding?exchanges=hyperliquid,binance
```
返回的每个 market 包含:
- `baseAsset`:币种(如 FET, RENDER)
- `variational.rate`:参考交易所的费率
- `comparisons[].rate`:对比交易所的费率
- `arbitrageOpportunity`:计算好的方向、spread、估计 APR、置信度
### 过滤规则
```python
if estimated_apr >= min_apr_pct # 默认 10%
and confidence >= min_confidence: # 默认 medium
# 保留这个机会
```
## 2. 稳定性验证
防止瞬时费率异常导致错误开仓。
### 快照积累
每个 tick 记录 Top 5 机会的快照到 state.rate_snapshots(最多保留 20 条):
```json
{
"ts": "2026-03-25T10:00:00Z",
"coin": "FET",
"spread": 0.0006,
"estimated_apr": 66.5,
"long_exchange": "hyperliquid",
"short_exchange": "binance"
}
```
### 稳定性检查
按币种分组后,对同一币种的所有快照:
```python
count = len(snapshots) # 需要 >= stability_snapshots (3)
spreads = [s["spread"] for s in snapshots]
avg = mean(spreads)
std = stdev(spreads)
std_ratio = std / abs(avg) # 需要 < stability_max_std_ratio (0.3)
```
含义:费率差在多个 tick 间保持稳定(变异系数 < 30%),不是偶发异常。
## 3. 深度验证
VarFunding 数据可能滞后,需要独立验证。
### 实时费率
分别从 HL 和 Binance 获取实时 funding rate 和 mid price:
```python
hl_rate = hl_client.get_funding_rate("FET") # 8h 费率
bn_rate = bn_client.get_funding_rate("FET")
hl_price = hl_client.get_mid_price("FET")
bn_price = bn_client.get_mid_price("FET")
```
### 计算
```python
# 实际 spread(做空方费率 − 做多方费率)
actual_spread = rate_map[short_exchange] - rate_map[long_exchange]
# 年化 APR
gross_annual = actual_spread × 3 × 365 × 100 # 8h 费率 × 3次/天 × 365天
# 价格差异(影响 delta 中性)
price_basis_pct = |hl_price - bn_price| / avg_price × 100
# 净 APR
net_apr = gross_annual - round_trip_cost_pct # 默认扣 0.12%
```
### 拒绝条件
- `actual_spread <= 0` → 方向反转
- `price_basis_pct > 0.5%` → 两所价格差异过大
- `net_apr < 10%` → 扣成本后不够
## 4. 仓位计算
### Size 计算
```python
budget = min(hl_budget, bn_budget)
conservative = budget × 0.5 # 保守系数 50%(HL 分级保证金对小币要求高)
effective = conservative × 0.95 # 预留 5% 手续费
raw_size = effective × leverage / price
# 两所分别按精度向下取整,取较小值
hl_rounded = hl.round_size(coin, raw_size)
bn_rounded = bn.round_size(coin, raw_size)
final_size = min(hl_rounded, bn_rounded)
```
### 为什么保守系数 50%?
HL 的分级保证金对小币(如 FET, RENDER)要求远高于标准保证金。
budget $300 × leverage 3 = $900 名义,但 HL 可能只允许 $400-500 名义。
预留 50% 安全余量,失败后自动减半重试。
## 5. 原子开仓
### 执行顺序
```
1. HL 设杠杆(cross margin)
2. Binance 设杠杆
3. HL 下单(LIMIT IOC, 滑点 0.1%)
└→ Insufficient margin? → size ÷ 2,重试(最多 3 次)
4. 等待 1s(防止 rate limit)
5. Binance 下单(LIMIT IOC, 滑点 0.1%)
└→ 失败? → 回滚 HL(close_position)
```
### 为什么先 HL?
- HL 分级保证金限制更严,更容易失败
- HL 失败时还没开 Binance,损失仅手续费(~$0.05)
- 反过来:先 Binance 成功后 HL 失败,需要平 Binance 多花一次手续费
### 滑点为什么是 0.1%?
套利策略对成本极度敏感。常规交易 5% 滑点在这里会吞噬大部分利润。
0.1% 在流动性好的币上足够成交,同时保护利润。
## 6. PnL 计算
### 基于实际余额
```python
entry_total = entry_hl_balance + entry_bn_balance # 开仓时总余额
current_total = current_hl_balance + current_bn_balance
pnl = current_total - entry_total
roi_pct = pnl / entry_total × 100
# 年化
hours_held = (now - entry_time).total_seconds() / 3600
annualized_roi = roi_pct / hours_held × 24 × 365
```
这种方式包含了所有因素:funding 收入、手续费、资金冻结、标记价变动。
## 7. 健康检查
每个 tick 对持仓进行三重检查:
| 检查 | 指标 | 阈值 | 触发动作 |
|------|------|------|----------|
| Delta | `abs(long_size - short_size) / avg_size` | > 20% | 告警 |
| Spread | `short_rate - long_rate` | < 0.005% | 平仓 |
| 双腿 | `long_size > 0 and short_size > 0` | 任一为 0 | 平仓 |
## 8. 切仓逻辑
```python
# 1. 不健康 → 平仓
if not spread_favorable:
close_position()
if not has_both_legs:
close_position()
# 2. 平仓后下一 tick → 自动扫描新机会
# (state 清空 → tick 进入"无持仓"分支 → 重新扫描)
```
## 常见问题
### Q: 两所资金不平衡怎么办?
A: Size 用 `min(两所)` 计算,较大一侧的多余资金闲置。建议保持 HL:BN ≈ 2:3(HL 需要更少保证金因为 cross margin)。
### Q: 费率突然反转怎么办?
A: 每个 tick 检查 spread。反转后 spread < threshold → 自动平仓。最大损失 = 反转 tick 的 funding(5 分钟内的 funding 约 0.0001% 级别,忽略不计)+ 往返手续费。
### Q: Agent Wallet 是什么?
A: HL 支持通过主账户创建子密钥(Agent Wallet),可以下单但不能转账。策略代码自动检测并路由到正确地址。
### Q: Binance 时间戳报错?
A: 客户端内置自动时间同步。首次请求和 -1021 错误时自动校准。
FILE:references/requirements.txt
hyperliquid-python-sdk>=0.21.0
eth-account>=0.13.7
python-dotenv>=1.0.0
requests>=2.31.0
Uniswap V3 集中流动性 LP 自动调仓策略。基于波动率自适应范围宽度:低波动率收紧范围(高资本效率),高波动率放宽范围(减少调仓和 IL)。支持趋势不对称调整、多时间框架分析、自动 claim/remove/swap/deposit 全流程。适用于 EVM L2 链上 CL LP 管理、调仓、范围优化、...
---
name: cl-lp-rebalancer
description: "Uniswap V3 集中流动性 LP 自动调仓策略。基于波动率自适应范围宽度:低波动率收紧范围(高资本效率),高波动率放宽范围(减少调仓和 IL)。支持趋势不对称调整、多时间框架分析、自动 claim/remove/swap/deposit 全流程。适用于 EVM L2 链上 CL LP 管理、调仓、范围优化、手续费最大化场景。用户查询收益、PnL、仓位、头寸状态、LP 状况、年化、手续费、无常损失时,调用 status 子命令即可获取(每 5 分钟缓存一次,秒级响应)。"
license: Apache-2.0
metadata:
author: SynthThoughts
version: "3.9.2"
pattern: "pipeline, tool-wrapper"
steps: "5"
---
# CL LP Auto-Rebalancer v1
Cron 驱动的 Uniswap V3 集中流动性自动调仓机器人,运行在 EVM L2 链上,通过 `onchainos` CLI 执行 DeFi 操作。核心思路:**波动率决定范围宽度** — 低波动率时收紧范围提高资本效率,高波动率时放宽范围减少调仓频率和无常损失。
每个 tick:获取价格 → 波动率分析 → 范围计算 → 调仓决策 → 执行调仓 → 报告。
## Architecture
```
Cron (5min) → Python script → onchainos CLI → OKX Web3 API → Chain
↓ ↓
cl_lp_state.json Wallet (TEE signing)
↓
┌──────────────┐
│ Price Fetch │ ← onchainos swap quote / market price
│ K-line ATR │ ← onchainos market kline (1H × 24)
│ MTF Analysis │ ← price_history (288 bars = 24h)
└──────┬───────┘
↓
Range Calculation (vol-adaptive)
↓
Rebalance Decision
↓
┌──────────────┐
│ Claim Fees │ ← onchainos defi claim
│ Remove Liq │ ← onchainos defi redeem
│ Swap Ratio │ ← onchainos swap swap
│ Add Liq │ ← onchainos defi deposit
└──────┬───────┘
↓
Structured JSON output
```
**OKX Skill Dependencies** (via `onchainos` CLI — 处理认证、链解析、错误重试):
- Price: `onchainos market price --address <token> --chain <chain>`
- K-line: `onchainos market kline --address <token> --chain <chain> --bar 1H --limit 24`
- Quote: `onchainos swap quote --from <A> --to <B> --amount <amt> --chain <chain>`
- Swap: `onchainos swap swap --from <A> --to <B> --amount <amt> --chain <chain> --wallet <addr> --slippage <pct>`
- Approve: `onchainos swap approve --token <addr> --amount <amt> --chain <chain>`
- Pool Search: `onchainos defi search --chain <chain> --token "<token0>,<token1>" --product-group DEX_POOL`
- Pool Detail: `onchainos defi detail --investment-id <id> --chain <chain>`
- Calculate Entry: `onchainos defi calculate-entry --investment-id <id> --chain <chain> --tick-lower <tick> --tick-upper <tick>`
- Deposit: `onchainos defi deposit --investment-id <id> --chain <chain> --amount0 <amt> --amount1 <amt> --tick-lower <tick> --tick-upper <tick>`
- Redeem: `onchainos defi redeem --investment-id <id> --chain <chain> --token-id <id> --percent 100`
- Claim Fees: `onchainos defi claim --investment-id <id> --chain <chain> --token-id <id>`
- Positions: `onchainos defi positions --chain <chain>`
- Position Detail: `onchainos defi position-detail --investment-id <id> --chain <chain> --token-id <id>`
## Step 0: Pool Selection (First-Time Setup)
When user has no `config.json` or asks to set up a new pool, trigger this step.
**核心原则**:AI 应从用户的自然语言中提取意图,自动推断尽可能多的参数,只在信息不足时才追问。
### 0.1 Intent Recognition
从用户输入中提取:
| 信息 | 示例用户输入 | 提取结果 |
|------|------------|---------|
| 链 | "在 Base 上做 LP" | chain = base |
| 代币对 | "ETH/USDC 的流动性" | token0 = ETH, token1 = USDC |
| 风险偏好 | "稳定一点的" | pool_type = stablecoin |
| Fee tier | "0.3% 的池子" | fee_tier = 0.3% |
**缺失信息的默认推断**:
- 未指定链 → 推荐 Base(L2 gas 低,适合频繁调仓)
- 未指定代币对 → 必须追问(核心参数,无法推断)
- 未指定 fee tier → 根据代币对自动选 TVL 最大的池子
- 未指定风险偏好 → 从代币对自动分类
### 0.2 Pool Type Classification
根据代币对自动分类,**不需要问用户**:
| 类型 | 判断规则 | 默认参数集 |
|------|---------|-----------|
| **稳定币对** | 两个都是稳定币(USDC/USDT/DAI/FRAX) | 窄范围、低止损 |
| **Native/稳定币** | 一个是 ETH/WETH/WBTC,另一个是稳定币 | 标准参数 |
| **非稳定币对** | 两个都不是稳定币,但都是主流币 | 宽范围、高止损 |
| **含 Meme 币** | 代币不在主流币列表中(市值低、无 Coingecko 排名) | 极宽范围 + 强制风险确认 |
主流币白名单:ETH, WETH, WBTC, USDC, USDT, DAI, FRAX, ARB, OP, MATIC, BNB, AVAX, SOL
### 0.3 Meme Coin Risk Gate
**仅当检测到 meme/低市值代币时触发**。MUST display warning before proceeding:
```
⚠️ Meme 币 LP 额外风险:
1. 极端无常损失 — 价格可能单方向暴涨/暴跌 90%+
2. 流动性枯竭 — 池子 TVL 可能骤降,头寸无法退出
3. 合约风险 — 代币可能有 honeypot/税收/暂停转账等恶意机制
4. 调仓失败 — 低流动性导致 swap 滑点过大
```
Must get explicit user confirmation before proceeding.
### 0.4 Search, Rank & Auto-Select
```bash
onchainos defi search --chain <chain> --token "<token0>,<token1>" --product-group DEX_POOL
```
**自动选择逻辑**(用户无需手动选):
1. 按 TVL 降序排列
2. 如果用户指定了 fee tier → 直接匹配
3. 如果未指定 → 选 TVL 最大的池子(通常是最佳流动性)
4. 展示选择结果供用户确认:池名、fee tier、TVL、预估池 APY(`rate` 字段)
**Fee tier 参考**(仅在用户问及或多池需选择时展示):
- 0.01%: 稳定币对 · 0.05%: 高相关性对 · 0.3%: 主流对(推荐)· 1%: 高波动对
### 0.5 Generate config.json
自动 fetch detail (`onchainos defi detail`) 并生成 config,**无需用户手动填写**。
**字段映射**:
- `investment_id` ← search `investmentId`
- `chain_id` ← search `chainIndex`
- `platform_id` ← detail `analysisPlatformId`(注意不是 `platformId`)
- `fee_tier` ← search `feeRate`
- `tick_spacing` ← 根据 fee tier 推导:0.01%→1, 0.05%→10, 0.3%→60, 1%→200
- `token0/token1` ← detail `underlyingToken`。如果 token 是 native ETH(`0xeee...`),LP 合约用 WETH,需映射(Base: `0x4200000000000000000000000000000000000006`)
- `native_token` ← 始终 `0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee`(用于 swap 和余额查询)
- 其余参数 ← 根据 pool type 自动填入下表默认值
```json
{
"investment_id": "<auto>",
"pool_chain": "<auto>",
"chain_id": "<auto>",
"platform_id": "<auto>",
"native_token": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"fee_tier": "<auto>",
"tick_spacing": "<auto>",
"token0": { "symbol": "<auto>", "address": "<auto>", "decimals": "<auto>" },
"token1": { "symbol": "<auto>", "address": "<auto>", "decimals": "<auto>" },
"range_mult": { "low": 1.0, "medium": 1.2, "high": 1.5, "extreme": 2.0 },
"min_range_pct": 2,
"max_range_pct": 5,
"asym_factor": 0.3,
"min_position_age_seconds": 3600,
"max_rebalances_24h": 6,
"gas_to_fee_ratio": 0.5,
"max_il_tolerance_pct": 5.0,
"edge_proximity_threshold": 0.15,
"stop_loss_pct": 0.15,
"trailing_stop_pct": 0.1,
"slippage_pct": 1,
"gas_reserve_eth": 0.02,
"min_trade_usd": 5.0,
"quiet_interval_seconds": 1800,
"max_consecutive_errors": 5,
"cooldown_after_errors_seconds": 3600
}
```
**Pool-type-specific defaults**(自动应用,无需用户选择):
| Parameter | 稳定币对 | Native/稳定币 | 非稳定币 | Meme 池 |
|-----------|---------|-------------|---------|---------|
| `min_range_pct` | 0.5 | 2 | 3 | 5 |
| `max_range_pct` | 2 | 5 | 8 | 15 |
| `range_mult.low` | 0.5 | 1.0 | 1.2 | 1.5 |
| `range_mult.extreme` | 1.0 | 2.0 | 2.5 | 3.0 |
| `stop_loss_pct` | 0.05 | 0.15 | 0.20 | 0.30 |
| `trailing_stop_pct` | 0.03 | 0.10 | 0.15 | 0.20 |
| `max_il_tolerance_pct` | 1.0 | 5.0 | 8.0 | 15.0 |
| `gas_reserve_eth` | 0.005 | 0.02 | 0.02 | 0.02 |
### 0.6 Gate
- [ ] `config.json` written with valid `investment_id`, `token0`, `token1`
- [ ] `.env` configured (copy from `.env.example`, fill in API credentials + `WALLET_ADDR`)
- [ ] `onchainos wallet balance` returns valid balances for the target chain
- [ ] If meme pool: user has explicitly confirmed risk warning
---
## Pipeline: Runtime Steps
**CRITICAL RULE**: Steps MUST execute in order. Do NOT skip steps or proceed past a gate that has not been satisfied.
### Step 1: Data Acquisition
**Actions**:
1. Fetch current price via `onchainos market price` or `onchainos swap quote`
2. Fetch current position detail via `onchainos defi position-detail` (if position exists)
3. Update `price_history` (append, cap at 288 = 24h @ 5min)
4. Fetch on-chain balances via `onchainos wallet balance`
**Gate** (ALL must pass):
- [ ] Price is non-null and > 0
- [ ] Circuit breaker not active (`consecutive_errors < 5`)
- [ ] Stop not triggered (`stop_triggered == null`)
### Step 2: Volatility & Trend Analysis
**Actions**:
1. Fetch K-line data (1H candles, 24 bars) → compute ATR-based volatility (hourly cache)
2. Classify volatility: low (<1.5%), medium (1.5-3%), high (3-5%), extreme (>5%)
3. Compute multi-timeframe trend analysis (复用 grid-trading MTF):
- Short EMA (25min), Medium EMA (1h), Long EMA (4h)
- EMA alignment → trend direction (bullish/bearish/neutral) + strength (0-1)
- 8h structure detection (uptrend/downtrend/ranging)
4. Compute 1h and 4h momentum
**Output**: `atr_pct` float, `vol_class` string, `mtf` dict
**Gate**:
- [ ] `atr_pct` is non-null and > 0
- [ ] `vol_class` is one of: low, medium, high, extreme
- [ ] `mtf` dict has `trend` and `strength` fields (graceful fallback to neutral)
### Step 3: Range Calculation
**Actions**:
1. Compute range width based on volatility class:
- Low (<1.5%): `2 × ATR` each side → tight range, max capital efficiency
- Medium (1.5-3%): `3 × ATR` each side → balanced
- High (3-5%): `5 × ATR` each side → wide range, fewer rebalances
- Extreme (>5%): `8 × ATR` each side → safety first
2. Apply trend asymmetry (if trend strength > 0.3):
- Bullish: upper side wider, lower side tighter (跟随上涨空间)
- Bearish: lower side wider, upper side tighter (防御下跌空间)
3. Convert price range to tick range (aligned to pool's `tick_spacing`)
4. Compute capital efficiency estimate: `price / (upper - lower)`
**Output**: `tick_lower`, `tick_upper`, `range_width_pct`, `capital_efficiency`
**Gate**:
- [ ] `tick_lower < current_tick < tick_upper`
- [ ] Range width >= minimum (2 × tick_spacing)
- [ ] tick values aligned to pool's `tick_spacing`
### Step 4: Rebalance Decision
**Actions**:
1. If no existing position → always deploy (first run)
2. Check rebalance triggers:
- **Out of range**: price < lower or price > upper → MUST rebalance
3. Anti-churn checks:
- Position age >= `MIN_POSITION_AGE` (2h)
- Rebalance count < `MAX_REBALANCES_24H` (6/day)
- Gas cost < `GAS_TO_FEE_RATIO` × expected fees (50%)
- New range differs >5% from current range
4. Check stop conditions: stop-loss, trailing stop, IL tolerance
**Gate**:
- [ ] Rebalance trigger identified, OR no rebalance needed (skip to Step 5)
- [ ] All anti-churn checks passed (if rebalancing)
- [ ] No stop condition triggered
### Step 5: Execution & Notification
**Actions** (if rebalancing):
1. Claim accumulated fees: `onchainos defi claim`
2. Remove liquidity: `onchainos defi redeem --percent 100`
3. Calculate target token ratio for new range: `onchainos defi calculate-entry`
4. Swap to correct ratio: `onchainos swap swap` (if needed)
5. Deposit at new range: `onchainos defi deposit`
6. On failure at any sub-step: emergency fallback deploy at 3× normal width
7. Record rebalance in state, update position info
**Actions** (always):
8. Calculate performance metrics (PnL, fees claimed, IL, time-in-range)
9. Build structured notification output (see Notification Tiers below)
**Output**: tiered notification JSON (via `---JSON---` block)
## Tool Wrapper: onchainos CLI Reference
### Prerequisites
```bash
which onchainos # must be installed
# Auth via environment variables
OKX_API_KEY=...
OKX_SECRET_KEY=...
OKX_PASSPHRASE=...
```
### Core DeFi Operations
| Operation | Command | Key Parameters |
|---|---|---|
| Search Pools | `onchainos defi search --chain base --token "ETH,USDC" --product-group DEX_POOL` | chain, token, product-group |
| Pool Detail | `onchainos defi detail --investment-id <id> --chain base` | investment-id |
| Calculate Entry | `onchainos defi calculate-entry --investment-id <id> --chain base --tick-lower <t> --tick-upper <t>` | ticks, amounts |
| Deposit | `onchainos defi deposit --investment-id <id> --chain base --amount0 <a> --amount1 <a> --tick-lower <t> --tick-upper <t>` | amounts, ticks |
| Redeem | `onchainos defi redeem --investment-id <id> --chain base --token-id <nft> --percent 100` | token-id, percent |
| Claim Fees | `onchainos defi claim --investment-id <id> --chain base --token-id <nft>` | token-id |
| My Positions | `onchainos defi positions --chain base` | chain |
| Position Detail | `onchainos defi position-detail --investment-id <id> --chain base --token-id <nft>` | token-id |
### Market & Swap Operations
| Operation | Command | Key Parameters |
|---|---|---|
| Get Price | `onchainos market price --address <token> --chain base` | token address |
| Get K-line | `onchainos market kline --address <token> --chain base --bar 1H --limit 24` | bar size, limit |
| Swap Quote | `onchainos swap quote --from <A> --to <B> --amount <amt> --chain base` | tokens, amount |
| Execute Swap | `onchainos swap swap --from <A> --to <B> --amount <amt> --chain base --wallet <addr> --slippage 1` | wallet, slippage |
| Approve Token | `onchainos swap approve --token <addr> --amount <amt> --chain base` | token, amount |
### Error Handling Protocol
Every function returns `(result, failure_info)`. Failure info is structured:
```python
failure_info = {
"reason": str, # machine-readable: "claim_failed", "redeem_failed", "deposit_failed", etc.
"detail": str, # human-readable context
"retriable": bool, # safe to auto-retry?
"hint": str # "transient_api_error", "retry_with_fresh_quote", "insufficient_balance"
}
```
Auto-retry policy: 1 retry for `retriable=True` with 3s delay.
Rebalance failure fallback: if deposit fails after remove, emergency deploy at 3× normal width.
## Tunable Parameters
### Range Configuration
| Parameter | Default | Description |
|---|---|---|
| `VOL_MULTIPLIER_LOW` | `2.0` | ATR multiplier for low volatility (<1.5%) |
| `VOL_MULTIPLIER_MED` | `3.0` | ATR multiplier for medium volatility (1.5-3%) |
| `VOL_MULTIPLIER_HIGH` | `5.0` | ATR multiplier for high volatility (3-5%) |
| `VOL_MULTIPLIER_EXTREME` | `8.0` | ATR multiplier for extreme volatility (>5%) |
| `VOL_THRESHOLD_LOW` | `1.5` | Low/medium volatility boundary (%) |
| `VOL_THRESHOLD_HIGH` | `3.0` | Medium/high volatility boundary (%) |
| `VOL_THRESHOLD_EXTREME` | `5.0` | High/extreme volatility boundary (%) |
| `TREND_ASYM_FACTOR` | `0.3` | Max trend asymmetry ratio (0=symmetric, 1=fully asymmetric) |
| `TREND_ASYM_THRESHOLD` | `0.3` | Minimum trend strength to activate asymmetry |
### Rebalance Triggers
| Parameter | Default | Description |
|---|---|---|
| `MIN_RANGE_CHANGE_PCT` | `0.05` | Skip rebalance if new range <5% different |
### Anti-Churn Controls
| Parameter | Default | Description |
|---|---|---|
| `MIN_POSITION_AGE` | `7200` | 2h minimum position hold time (seconds) |
| `MAX_REBALANCES_24H` | `6` | Maximum rebalances per 24h period |
| `GAS_TO_FEE_RATIO` | `0.5` | Skip if gas > 50% of expected fees |
### Multi-Timeframe Analysis
| Parameter | Default | Description |
|---|---|---|
| `MTF_SHORT_PERIOD` | `5` | 5-bar EMA (25min @ 5min tick) |
| `MTF_MEDIUM_PERIOD` | `12` | 12-bar EMA (1h @ 5min tick) |
| `MTF_LONG_PERIOD` | `48` | 48-bar EMA (4h @ 5min tick) |
| `MTF_STRUCTURE_PERIOD` | `96` | 96-bar (8h) for structure detection |
### Risk Controls
| Parameter | Default | Description |
|---|---|---|
| `STOP_LOSS_PCT` | `0.15` | Stop if portfolio drops 15% below cost basis |
| `TRAILING_STOP_PCT` | `0.10` | Stop if portfolio drops 10% from peak |
| `MAX_IL_TOLERANCE_PCT` | `0.05` | Hard stop if IL exceeds 5% |
| `MAX_CONSECUTIVE_ERRORS` | `5` | Circuit breaker threshold |
| `COOLDOWN_AFTER_ERRORS` | `3600` | 1h cooldown after circuit breaker trips |
| `GAS_RESERVE` | `0.003` | Native token reserved for gas |
### Execution
| Parameter | Default | Description |
|---|---|---|
| `SLIPPAGE_PCT` | `1` | Slippage tolerance for swaps |
| `EMERGENCY_WIDTH_MULT` | `3.0` | Emergency fallback range = 3× normal width |
| `DRY_RUN` | `false` | Fetch real data but simulate operations |
## Risk Control Flow
```
[1] stop_triggered → refuse all operations, emit alert JSON
[2] circuit_breaker (consecutive_errors >= 5) → 1h cooldown, refuse
[3] data validation (price/balance/position null) → refuse
[4] stop-loss / trailing-stop / IL tolerance → set stop_triggered, alert
[5] rebalance frequency (>6/day) → skip rebalance
[6] position age (<2h) → skip rebalance
[7] gas cost check (>50% of expected fees) → skip rebalance
[8] minimum range change (<5%) → skip rebalance
[9] execute rebalance → success / failure with emergency fallback
```
## Operational Interface
### Sub-Commands
| Command | Purpose | Trigger | Notification |
|---|---|---|---|
| `tick` | 主循环:采集→分析→决策→执行,结果缓存到 `_cached_snapshot` | Cron 每 5min | 🔔 Trade Alert / ⚠️ Risk Alert / 📊 Hourly Pulse |
| `status` | **用户查询入口**:Portfolio 总览 + 头寸 + 收益 + 范围。优先读 5min 缓存(秒级响应),过期才实时查询 | 用户主动 | 终端输出(不推送) |
| `report` | 每日绩效报告 | Cron 每日 00:00 UTC | 📈 Daily Report |
| `history` | 调仓历史 | 用户主动 | 终端输出(不推送) |
| `analyze` | 详细 JSON 分析(波动率、范围、效率) | AI agent | 终端输出(不推送) |
| `reset` | 关闭头寸并重新部署 | 手动 | 🔔 Trade Alert |
| `close` | 完全退出头寸 | 手动 | 🔔 Trade Alert |
**`status` 触发词**(用户说以下任意内容时应调用 `status`):
- 查询收益、收益怎么样、赚了多少、PnL、盈亏
- 查询仓位、头寸状态、LP 状况、仓位情况
- 年化多少、APY、费用收入、手续费
- 无常损失、IL
- 总资产、Portfolio、余额
- LP 里有多少 ETH / USDC
```python
COMMANDS = {
"tick": tick, "status": status, "report": report,
"history": history_cmd, "reset": reset, "close": close,
"analyze": analyze
}
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "tick"
COMMANDS.get(cmd, tick)()
```
### Notification Tiers
脚本内部封装完整的消息格式化和可视化逻辑,通过 `---JSON---` 块输出结构化数据(含 `notification` 渲染块),由调度器路由到通信渠道。
| Tier | 触发条件 | 推送 |
|------|---------|------|
| **Trade Alert** | 调仓成功或失败 | 每次交易立即推送 |
| **Risk Alert** | 止损/熔断/连续错误 | 立即推送 |
| **Hourly Pulse** | 距上次推送 ≥ `QUIET_INTERVAL` 且无交易 | 每小时推送 |
| **Daily Report** | `report` 命令 (cron daily) | 每日推送 |
`status`/`analyze` 命令为用户主动查询,直接输出到终端,不推送。`status` 优先从 `_cached_snapshot`(每 5min tick 时写入)读取已计算好的数据,无需任何 API 调用即可返回完整状态(Portfolio 跨平台总览 + 头寸 + PnL + IL + APY + LP 代币拆分)。缓存过期(>360s)时自动 fallback 到实时查询。
脚本负责:消息标题、字段组织、范围位置可视化、颜色级别、footer 上下文。调度器只需透传 `notification` 块到通信平台。
## State Schema
```json
{
"version": 1,
"pool": {
"investment_id": "uniswap-v3-base-eth-usdc-3000",
"chain": "base",
"chain_id": 8453,
"token0": "0x4200000000000000000000000000000000000006",
"token1": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"fee_tier": 3000,
"tick_spacing": 60
},
"position": {
"token_id": null,
"tick_lower": null,
"tick_upper": null,
"price_lower": null,
"price_upper": null,
"created_at": null,
"created_atr_pct": null,
"created_vol_class": null
},
"price_history": [],
"vol_history": [],
"rebalance_history": [
{
"time": "ISO timestamp",
"trigger": "out_of_range",
"old_range": [-198120, -197400],
"new_range": [-198300, -197100],
"fees_claimed_usd": 1.25
}
],
"stats": {
"total_rebalances": 0,
"total_fees_claimed_usd": 0,
"unclaimed_fee_usd": 0,
"time_in_range_pct": 100,
"initial_portfolio_usd": null,
"total_deposits_usd": 0,
"portfolio_peak_usd": null,
"started_at": null,
"last_check": null
},
"errors": {
"consecutive": 0,
"cooldown_until": null
},
"stop_triggered": null,
"kline_cache": null,
"mtf_cache": null,
"last_quiet_report": null
}
```
Key fields:
- `pool`: target pool configuration (chain, tokens, fee tier, tick spacing)
- `position.token_id`: NFT position ID (null if no active position)
- `position.created_atr_pct`: ATR at position creation (for vol shift detection)
- `rebalance_history`: full audit trail of all rebalances with costs
- `stats.time_in_range_pct`: key performance metric — % of ticks where price was in range
- `stats.initial_portfolio_usd` + `total_deposits_usd`: PnL 计算的成本基准
- `stats.total_fees_claimed_usd` + `unclaimed_fee_usd`: LP 手续费总收入,用于推导 IL
- `stop_triggered`: string describing trigger condition, or null
## Core Algorithm
```
1. Fetch current price
2. Fetch position detail (if exists)
3. Update price_history (cap at 288 = 24h)
4. Fetch K-line data (1H × 24) → compute ATR volatility (hourly cache)
5. Classify volatility → vol_class (low/medium/high/extreme)
6. Multi-timeframe analysis → trend/strength/momentum/structure
7. Compute optimal range:
a. Base width = VOL_MULTIPLIER[vol_class] × ATR each side
b. Apply trend asymmetry (upper/lower sides)
c. Convert to ticks, align to tick_spacing
8. Check rebalance triggers:
a. Out of range → must rebalance
9. Anti-churn gates (position age, frequency, gas cost, range change)
10. If rebalancing:
a. Claim fees → remove liquidity → swap to ratio → deposit at new range
b. On failure: emergency fallback at 3× width
11. Check stop conditions (stop-loss, trailing stop, IL tolerance)
12. Calculate performance metrics
13. Report status (structured JSON)
```
## Deployment
### OpenClaw Cron (recommended)
```bash
# Register tick (every 5 minutes)
zeroclaw cron add '*/5 * * * *' \
'cd ~/.openclaw/skills/cl-lp-rebalancer/references && set -a && . ../.env && set +a && python3 cl_lp.py tick'
# Register daily report (08:00 CST = 00:00 UTC)
zeroclaw cron add '0 0 * * *' \
'cd ~/.openclaw/skills/cl-lp-rebalancer/references && set -a && . ../.env && set +a && python3 cl_lp.py report'
```
### Manual
```bash
# Single tick
python3 cl_lp.py tick
# Dry run (fetch real data, simulate operations)
DRY_RUN=true python3 cl_lp.py tick
# Status check
python3 cl_lp.py status
# Close position and exit
python3 cl_lp.py close
```
## Failure & Rollback
```
IF rebalance sub-step fails:
1. Log failure reason to cl_lp.log
2. Increment errors.consecutive
3. If errors.consecutive >= 5: trigger circuit breaker (1h cooldown)
4. If failure after remove liquidity: emergency deploy at 3× normal width
(priority: get funds back into a position, even if suboptimal)
5. Report failure via JSON output
6. On next tick: retry from last successful sub-step if possible
```
## Anti-Patterns
| Pattern | Problem |
|---|---|
| Rebalance every tick | Gas costs eat all fee income |
| Too tight range in high vol | Constant out-of-range, excessive rebalancing |
| Too wide range in low vol | Capital inefficiency, minimal fee capture |
| No minimum position age | Rapid back-and-forth rebalancing (churn) |
| Skip emergency fallback | Funds sit idle after failed rebalance (zero yield) |
| Ignore gas costs | L1 gas can exceed daily fee income |
| Symmetric range in trends | Miss upside in bull, excess downside in bear |
| No IL tracking | Cannot detect when IL exceeds fee income |
| Rebalance on vol change alone | Minor ATR fluctuations cause unnecessary churn — only rebalance when out of range |
| No time-in-range tracking | Cannot measure strategy effectiveness |
FILE:README.md
# CL LP Auto-Rebalancer
Uniswap V3 集中流动性自动调仓策略,运行在 EVM L2 链上,通过 OKX OnchainOS 执行 DeFi 操作。
**核心思路:用波动率决定范围宽度。** 低波动率收紧范围提高资本效率,高波动率放宽范围减少调仓和无常损失。结合趋势分析进行不对称调整——看涨时向上延伸范围,看跌时向下延伸。
## 特性
- **波动率自适应范围** — 基于 1H K 线 ATR 动态计算范围宽度
- **趋势不对称调整** — MTF 多时间框架分析,沿趋势方向偏移范围
- **完整调仓流水线** — claim fees → remove liquidity → swap ratio → add liquidity
- **防过度调仓** — 最小持仓时间、日频率限制、Gas 成本检查
- **风控体系** — 止损 / 追踪止损 / IL 容忍度 / 熔断 / 紧急回退部署
- **Gas 感知触发** — 仅在预期手续费收益超过 Gas 成本时调仓
## 架构
```
┌─────────────────────────────────────────────────────┐
│ AI Agent (Claude) │
│ 策略设计 → 回测验证 → 参数调优 → 复盘迭代 │
└──────────────────────┬──────────────────────────────┘
│ 生成 / 优化
┌──────────────────────▼──────────────────────────────┐
│ 策略脚本 (Python) │
│ 价格采集 → 波动率分析 → 范围计算 → 调仓决策 → 执行 │
└──────────────────────┬──────────────────────────────┘
│ 调用
┌──────────────────────▼──────────────────────────────┐
│ onchainos Skills │
│ 行情 · K线 · 钱包 · LP头寸 · DeFi操作 · TEE签名 │
└──────────────────────┬──────────────────────────────┘
│ 上链
┌──────────────────────▼──────────────────────────────┐
│ EVM L2 链上执行 │
│ Uniswap V3 池 → LP NFT 头寸 → 手续费收入 │
└─────────────────────────────────────────────────────┘
```
## 范围算法
```
当前价格 → 1H K线 (24根) → ATR% → 波动率分类 → 范围宽度
↓
趋势不对称调整 (MTF)
↓
Tick 对齐 → 最终范围
```
| 波动率 | ATR | 范围示例 (ETH@$2000) | 资本效率 |
|--------|-----|---------------------|----------|
| Low (<1.5%) | $25 | $1950–$2050 | ~20× |
| Medium (1.5–3%) | $45 | $1865–$2135 | ~7× |
| High (3–5%) | $70 | $1650–$2350 | ~3× |
| Extreme (>5%) | $120 | $1040–$2960 | ~1× |
详细算法说明见 [references/range-algorithm.md](references/range-algorithm.md)。
## 快速开始
```bash
# 1. 安装
openclaw skill install cl-lp-rebalancer
# 2. 配置
cd ~/.openclaw/skills/cl-lp-rebalancer/references
cp ../.env.example .env # 填入 API keys + 钱包地址
vi config.json # 调整池参数
# 3. 测试(只读,安全)
python3 cl_lp.py status
# 4. 注册 cron
zeroclaw cron add '*/5 * * * *' \
'cd ~/.openclaw/skills/cl-lp-rebalancer/references && set -a && . ../.env && set +a && python3 cl_lp.py tick'
```
## 命令
| 命令 | 用途 | 触发 |
|------|------|------|
| `tick` | 主循环:采集→分析→决策→执行 | Cron 每 5 分钟 |
| `status` | 头寸、范围可视化、收益、趋势 | 手动 |
| `report` | 每日报告 (JSON) | Cron 每天 |
| `history` | 调仓历史 | 手动 |
| `analyze` | 市场分析 + 调仓建议 | AI Agent |
| `reset` | 清除状态,从链上重新检测 | 手动 |
| `close` | 完全退出头寸 | 手动 |
| `deposit <amt>` | 记录外部存取款 | 手动 |
| `resume-trading` | 清除止损恢复交易 | 手动 |
## 目录结构
```
cl-lp-rebalancer/
├── SKILL.md # AI Agent 核心知识(流水线、状态机、参数)
├── README.md # 本文件
├── .env.example # 环境变量模板
└── references/
├── cl_lp.py # 策略代码(零第三方依赖)
├── config.json # 参考配置(所有可调参数)
└── range-algorithm.md # 范围算法详解
```
## 前置条件
- **onchainos** — OKX OnchainOS CLI,内置钱包管理、DEX 交易、DeFi 操作、TEE 签名等 Skills
- **OKX API Key** — DEX 交易 + DeFi 操作权限
- **Python 3.10+** — 零第三方依赖
## License
Apache-2.0
FILE:references/cl_lp.py
#!/usr/bin/env python3
"""
CL LP Auto-Rebalancer v1 — Uniswap V3 Concentrated Liquidity on Base
Dynamically adjusts tick range based on volatility and trend:
- Low volatility → narrow range (high fee capture)
- High volatility → wide range (less IL, fewer rebalances)
- Trend-adaptive asymmetric ranges
Uses OKX DEX API (via onchainos CLI) + OnchainOS Agentic Wallet (TEE signing).
Designed for OpenClaw cron integration.
"""
import fcntl
import json
import math
import os
import subprocess
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
# ── Load .env if present ────────────────────────────────────────────────────
def _load_env():
env_file = Path(__file__).parent / ".env"
if env_file.exists():
for line in env_file.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
k = k.strip()
if k.startswith("export "):
k = k[7:].strip()
os.environ.setdefault(k, v.strip())
_load_env()
# ── Load Config ─────────────────────────────────────────────────────────────
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "config.json"
if CONFIG_FILE.exists():
CFG = json.loads(CONFIG_FILE.read_text())
else:
print("ERROR: config.json not found", file=sys.stderr)
sys.exit(1)
# ── API Keys ────────────────────────────────────────────────────────────────
OKX_API_KEY = os.environ.get("OKX_API_KEY", "")
OKX_SECRET = os.environ.get("OKX_SECRET_KEY", "")
OKX_PASSPHRASE = os.environ.get("OKX_PASSPHRASE", "")
# ── Config values ───────────────────────────────────────────────────────────
INVESTMENT_ID = CFG["investment_id"]
POOL_CHAIN = CFG["pool_chain"]
FEE_TIER = CFG["fee_tier"]
TICK_SPACING = CFG["tick_spacing"]
TOKEN0 = CFG["token0"]
TOKEN1 = CFG["token1"]
ETH_ADDR = TOKEN0["address"]
USDC_ADDR = TOKEN1["address"]
CHAIN_ID = CFG.get("chain_id", "8453")
PLATFORM_ID = CFG.get("platform_id", "68") # onchainos defi platform ID
NATIVE_TOKEN = CFG.get("native_token", "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")
PAIR_NAME = f"{TOKEN0['symbol']}/{TOKEN1['symbol']}"
CHAIN_LABEL = POOL_CHAIN.capitalize()
_rm = CFG["range_mult"]
RANGE_MULT = (
_rm
if isinstance(_rm, dict)
else {"low": _rm * 0.75, "medium": _rm, "high": _rm * 1.25, "extreme": _rm * 1.5}
)
MIN_RANGE_PCT = CFG["min_range_pct"]
MAX_RANGE_PCT = CFG["max_range_pct"]
ASYM_FACTOR = CFG["asym_factor"]
MIN_POSITION_AGE = CFG["min_position_age_seconds"]
MAX_REBALANCES_24H = CFG["max_rebalances_24h"]
GAS_TO_FEE_RATIO = CFG["gas_to_fee_ratio"]
MAX_IL_TOLERANCE_PCT = CFG["max_il_tolerance_pct"]
STOP_LOSS_PCT = CFG["stop_loss_pct"]
TRAILING_STOP_PCT = CFG["trailing_stop_pct"]
SLIPPAGE_PCT = CFG["slippage_pct"]
GAS_RESERVE_ETH = CFG["gas_reserve_eth"]
MIN_TRADE_USD = CFG["min_trade_usd"]
QUIET_INTERVAL = CFG["quiet_interval_seconds"]
MAX_CONSECUTIVE_ERRORS = CFG["max_consecutive_errors"]
COOLDOWN_AFTER_ERRORS = CFG["cooldown_after_errors_seconds"]
# ── Notification credentials ───────────────────────────────────────────────
def _resolve_discord_channel_id() -> str:
"""Resolve Discord channel ID: env override > openclaw.json guilds config."""
env_id = os.environ.get("DISCORD_CHANNEL_ID", "")
if env_id:
return env_id
try:
cfg_path = Path.home() / ".openclaw" / "openclaw.json"
if cfg_path.exists():
cfg = json.loads(cfg_path.read_text())
guilds = cfg.get("channels", {}).get("discord", {}).get("guilds", {})
for _gid, guild_cfg in guilds.items():
channels = guild_cfg.get("channels", {})
for ch_id, ch_cfg in channels.items():
if ch_cfg.get("allow"):
return ch_id
except Exception:
pass
return ""
DISCORD_CHANNEL_ID = _resolve_discord_channel_id()
def _get_discord_token() -> str:
env_token = os.environ.get("DISCORD_BOT_TOKEN", "")
if env_token:
return env_token
cfg_path = Path.home() / ".openclaw" / "openclaw.json"
if cfg_path.exists():
try:
cfg = json.loads(cfg_path.read_text())
return cfg.get("channels", {}).get("discord", {}).get("token", "")
except Exception:
pass
return ""
def _get_telegram_config() -> tuple:
"""Return (bot_token, chat_id). Checks env first, then zeroclaw config."""
token = os.environ.get("TELEGRAM_BOT_TOKEN", "")
chat_id = os.environ.get("TELEGRAM_CHAT_ID", "")
if token and chat_id:
return token, chat_id
for cfg_dir in ["zeroclaw-strategy", "zeroclaw", "openclaw"]:
cfg_path = Path.home() / f".{cfg_dir}" / "config.toml"
if cfg_path.exists():
try:
text = cfg_path.read_text()
in_tg = False
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("[") and "telegram" in stripped.lower():
in_tg = True
continue
if stripped.startswith("[") and in_tg:
break
if in_tg and "=" in stripped:
k, v = stripped.split("=", 1)
k, v = k.strip(), v.strip().strip('"').strip("'")
if k == "bot_token" and not token:
token = v
if k == "chat_id" and not chat_id:
chat_id = v
except Exception:
pass
return token, chat_id
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID = _get_telegram_config()
# ── External portfolio (optional) ─────────────────────────────────────────
HL_WALLET_ADDR = os.environ.get("HL_WALLET_ADDR", "")
BINANCE_API_KEY = os.environ.get("BINANCE_API_KEY", "")
BINANCE_SECRET_KEY = os.environ.get("BINANCE_SECRET_KEY", "")
def _query_hl_balance() -> float:
"""Query Hyperliquid account value via public info API (no auth needed)."""
if not HL_WALLET_ADDR:
return 0.0
try:
import urllib.request
payload = json.dumps(
{"type": "clearinghouseState", "user": HL_WALLET_ADDR}
).encode()
req = urllib.request.Request(
"https://api.hyperliquid.xyz/info",
data=payload,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
value = float(data.get("marginSummary", {}).get("accountValue", 0))
# Also check spot balances
try:
spot_payload = json.dumps(
{"type": "spotClearinghouseState", "user": HL_WALLET_ADDR}
).encode()
spot_req = urllib.request.Request(
"https://api.hyperliquid.xyz/info",
data=spot_payload,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(spot_req, timeout=10) as resp2:
spot_data = json.loads(resp2.read())
for bal in spot_data.get("balances", []):
if bal.get("coin") == "USDC":
value += float(bal.get("total", 0))
except Exception:
pass
return value
except Exception:
return 0.0
def _query_binance_balance() -> float:
"""Query Binance Futures USDT balance (signed request)."""
if not BINANCE_API_KEY or not BINANCE_SECRET_KEY:
return 0.0
try:
import hashlib
import hmac
import urllib.request
ts = int(time.time() * 1000)
query = f"timestamp={ts}&recvWindow=10000"
sig = hmac.new(
BINANCE_SECRET_KEY.encode(), query.encode(), hashlib.sha256
).hexdigest()
url = f"https://fapi.binance.com/fapi/v2/balance?{query}&signature={sig}"
req = urllib.request.Request(url, headers={"X-MBX-APIKEY": BINANCE_API_KEY})
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
for asset in data:
if asset.get("asset") == "USDT":
return float(asset.get("balance", 0))
return 0.0
except Exception:
return 0.0
def query_external_portfolio() -> dict:
"""Query all external platform balances. Returns {platform: usd_value}."""
result = {}
hl = _query_hl_balance()
if hl > 0:
result["HL"] = round(hl, 2)
bn = _query_binance_balance()
if bn > 0:
result["Binance"] = round(bn, 2)
return result
# ── Multi-Timeframe settings (from grid-trading) ───────────────────────────
MTF_SHORT_PERIOD = 5
MTF_MEDIUM_PERIOD = 12
MTF_LONG_PERIOD = 48
MTF_STRUCTURE_PERIOD = 96
EMA_PERIOD = 20
# ── Paths ───────────────────────────────────────────────────────────────────
STATE_FILE = SCRIPT_DIR / "cl_lp_state.json"
LOG_FILE = SCRIPT_DIR / "cl_lp.log"
MAX_LOG_BYTES = 1_000_000
# ── Logging ─────────────────────────────────────────────────────────────────
def log(msg: str):
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{ts}] {msg}"
print(line, file=sys.stderr)
try:
if LOG_FILE.exists() and LOG_FILE.stat().st_size > MAX_LOG_BYTES:
content = LOG_FILE.read_text()
lines = content.splitlines()
# Atomic log rotation
fd, tmp = tempfile.mkstemp(dir=LOG_FILE.parent, suffix=".log.tmp")
try:
with os.fdopen(fd, "w") as f:
f.write("\n".join(lines[len(lines) // 2 :]) + "\n")
os.replace(tmp, LOG_FILE)
except Exception as e:
print(f"WARNING: log rotation failed: {e}", file=sys.stderr)
try:
os.unlink(tmp)
except OSError:
pass
with open(LOG_FILE, "a") as f:
f.write(line + "\n")
except Exception as e:
print(f"WARNING: log write failed: {e}", file=sys.stderr)
# ── Safe datetime parsing ──────────────────────────────────────────────────
def _safe_isoparse(s: str, default: datetime | None = None) -> datetime | None:
"""Parse ISO datetime string safely. Returns default on failure."""
if not s:
return default
try:
return datetime.fromisoformat(s)
except (ValueError, TypeError):
return default
# ── Process lock ───────────────────────────────────────────────────────────
LOCK_FILE = SCRIPT_DIR / ".cl_lp.lock"
_lock_fd = None
def _acquire_lock() -> bool:
"""Acquire exclusive process lock. Returns False if another instance is running."""
global _lock_fd
try:
_lock_fd = open(LOCK_FILE, "w")
fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
_lock_fd.write(str(os.getpid()))
_lock_fd.flush()
return True
except (OSError, IOError):
if _lock_fd:
_lock_fd.close()
_lock_fd = None
return False
def _release_lock():
"""Release process lock."""
global _lock_fd
if _lock_fd:
try:
fcntl.flock(_lock_fd, fcntl.LOCK_UN)
_lock_fd.close()
except (OSError, IOError):
pass
_lock_fd = None
try:
LOCK_FILE.unlink(missing_ok=True)
except OSError:
pass
# ── onchainos CLI wrapper ───────────────────────────────────────────────────
def onchainos_cmd(args: list[str], timeout: int = 30) -> dict | None:
"""Run onchainos CLI command, return parsed JSON."""
env = os.environ.copy()
env.setdefault("OKX_API_KEY", OKX_API_KEY)
env.setdefault("OKX_SECRET_KEY", OKX_SECRET)
env.setdefault("OKX_PASSPHRASE", OKX_PASSPHRASE)
try:
result = subprocess.run(
["onchainos"] + args,
capture_output=True,
text=True,
timeout=timeout,
env=env,
)
output = result.stdout.strip() if result.stdout else ""
if output:
try:
data = json.loads(output)
if isinstance(data, dict) and "ok" in data:
return data
return {
"ok": True,
"data": data if isinstance(data, list) else [data],
}
except json.JSONDecodeError:
log(
f"onchainos invalid JSON: {' '.join(args[:3])} output={output[:100]}"
)
if result.returncode != 0:
stderr = result.stderr.strip() if result.stderr else ""
log(
f"onchainos rc={result.returncode}: {' '.join(args[:3])} "
f"{'stderr=' + stderr[:150] if stderr else 'no output'}"
)
except subprocess.TimeoutExpired:
log(f"onchainos timeout: {' '.join(args[:3])}")
except Exception as e:
log(f"onchainos error: {e}")
return None
# ── Wallet Address ──────────────────────────────────────────────────────────
# Auto-switch to the correct account if ACCOUNT_ID is set in config
_cfg_account_id = (
CFG.get("account_id", "")
or os.environ.get("ONCHAINOS_ACCOUNT_ID", "")
or os.environ.get("ACCOUNT_ID", "")
)
if _cfg_account_id:
try:
result = subprocess.run(
["onchainos", "wallet", "switch", _cfg_account_id],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
print(
f"WARNING: wallet switch to {_cfg_account_id} failed: "
f"{result.stderr.strip()[:100] if result.stderr else 'unknown error'}",
file=sys.stderr,
)
except Exception as e:
print(f"WARNING: wallet switch failed: {e}", file=sys.stderr)
def _resolve_wallet_addr() -> str:
env_addr = os.environ.get("WALLET_ADDR", "")
if env_addr:
return env_addr
try:
result = subprocess.run(
["onchainos", "wallet", "addresses"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0 and result.stdout.strip():
data = json.loads(result.stdout.strip())
if data.get("ok") and data.get("data", {}).get("evm"):
evm_addrs = data["data"]["evm"]
for entry in evm_addrs:
if entry.get("chainIndex") == CHAIN_ID:
return entry["address"]
return evm_addrs[0]["address"]
except Exception as e:
print(f"WARNING: wallet address resolution failed: {e}", file=sys.stderr)
return ""
WALLET_ADDR = _resolve_wallet_addr()
if not WALLET_ADDR:
print(
"ERROR: No wallet address found. Login with `onchainos wallet login` or set WALLET_ADDR env.",
file=sys.stderr,
)
# ── Price & Balance ─────────────────────────────────────────────────────────
def get_eth_price() -> float | None:
"""Get ETH price from market kline (last close). More reliable than swap quote."""
data = onchainos_cmd(
[
"market",
"kline",
"--address",
ETH_ADDR,
"--chain",
POOL_CHAIN,
"--bar",
"1m",
"--limit",
"1",
],
timeout=10,
)
if data and data.get("ok") and data.get("data"):
candle = data["data"][0]
try:
# candle is [ts, open, high, low, close, vol, ...]
if isinstance(candle, list) and len(candle) >= 5:
return float(candle[4]) # close price
elif isinstance(candle, dict):
return float(candle.get("c", 0) or candle.get("close", 0))
except (ValueError, TypeError, IndexError):
pass
return None
def get_balances(force: bool = False) -> tuple[float, float, bool]:
"""Get ETH and USDC balances via --all to avoid wallet-switch race condition."""
account_id = _cfg_account_id
base_args = ["wallet", "balance", "--chain", CHAIN_ID]
if force:
base_args.append("--force")
if account_id:
data = onchainos_cmd(base_args + ["--all"], timeout=15)
else:
data = onchainos_cmd(base_args, timeout=15)
if not data or not data.get("ok") or not data.get("data"):
log(f"Balance query failed, raw: {json.dumps(data)[:200] if data else 'None'}")
return 0.0, 0.0, True
eth, usdc = 0.0, 0.0
if account_id:
# --all returns {details: {accountId: {data: [{tokenAssets: [...]}]}}}
acct_data = data["data"].get("details", {}).get(account_id)
if not acct_data or not acct_data.get("data"):
log(f"Balance: account {account_id} not found in --all response")
return 0.0, 0.0, True
for chain_detail in acct_data["data"]:
for token in chain_detail.get("tokenAssets", []):
if token.get("tokenAddress") == "" and token.get("symbol") == "ETH":
eth = float(token.get("balance", "0"))
elif token.get("tokenAddress", "").lower() == USDC_ADDR.lower():
usdc = float(token.get("balance", "0"))
else:
details = data["data"].get("details", [])
for chain_detail in details:
for token in chain_detail.get("tokenAssets", []):
if token.get("tokenAddress") == "" and token.get("symbol") == "ETH":
eth = float(token.get("balance", "0"))
elif token.get("tokenAddress", "").lower() == USDC_ADDR.lower():
usdc = float(token.get("balance", "0"))
return eth, usdc, False
def get_position_detail(token_id: str) -> dict:
"""Get LP position value and unclaimed fees via defi position-detail.
Returns {"value": float, "unclaimed_fee_usd": float, "assets": list}.
"""
result = {"value": 0.0, "unclaimed_fee_usd": 0.0, "assets": []}
if not token_id or not WALLET_ADDR:
return result
data = onchainos_cmd(
[
"defi",
"position-detail",
"--address",
WALLET_ADDR,
"--chain",
POOL_CHAIN,
"--platform-id",
PLATFORM_ID,
],
timeout=20,
)
if not data or not data.get("ok") or not data.get("data"):
return result
try:
for platform in data["data"]:
for wallet in platform.get("walletIdPlatformDetailList", []):
for network in wallet.get("networkHoldVoList", []):
for invest in network.get("investTokenBalanceVoList", []):
for pos in invest.get("positionList", []):
if str(pos.get("tokenId")) == str(token_id):
result["value"] = float(pos.get("totalValue", 0))
result["assets"] = pos.get("assetsTokenList", [])
# Sum unclaimed fees
for fee_info in pos.get("unclaimFeesDefiTokenInfo", []):
result["unclaimed_fee_usd"] += float(
fee_info.get("currencyAmount", 0)
)
return result
except (KeyError, ValueError, TypeError):
pass
return result
def get_position_value(token_id: str) -> float:
"""Get current LP position value in USD (backward compat wrapper)."""
return get_position_detail(token_id)["value"]
def _query_all_positions() -> list[dict]:
"""Query all LP positions for this pool. Returns list of {tokenId, assets, value}."""
if not WALLET_ADDR:
return []
data = onchainos_cmd(
[
"defi",
"position-detail",
"--address",
WALLET_ADDR,
"--chain",
POOL_CHAIN,
"--platform-id",
PLATFORM_ID,
],
timeout=20,
)
if not data or not data.get("ok") or not data.get("data"):
return []
result = []
try:
for platform in data["data"]:
for wallet in platform.get("walletIdPlatformDetailList", []):
for network in wallet.get("networkHoldVoList", []):
for invest in network.get("investTokenBalanceVoList", []):
for pos in invest.get("positionList", []):
tid = str(pos.get("tokenId", ""))
assets = pos.get("assetsTokenList", [])
value = sum(
float(a.get("currencyAmount", 0)) for a in assets
)
result.append(
{"tokenId": tid, "assets": assets, "value": value}
)
except (KeyError, ValueError, TypeError):
pass
return result
def find_latest_token_id(after_token_id: str = "") -> str:
"""Query position-detail to find the latest LP token ID for this pool.
If after_token_id is provided, only return a token_id numerically greater
than it (i.e. newly created after the reference point). This prevents
adopting old positions from previous deploys.
"""
positions = _query_all_positions()
if not positions:
return ""
if after_token_id:
try:
threshold = int(after_token_id)
candidates = [p for p in positions if int(p["tokenId"]) > threshold]
if candidates:
return candidates[-1]["tokenId"]
return ""
except (ValueError, TypeError):
pass
return positions[-1]["tokenId"]
def cleanup_residual_positions(keep_token_id: str) -> int:
"""Redeem all positions except keep_token_id. Returns count of cleaned positions."""
positions = _query_all_positions()
cleaned = 0
for pos in positions:
tid = pos["tokenId"]
if tid == keep_token_id or not tid:
continue
val = pos["value"]
log(f" Cleaning residual position #{tid} (.2f)")
ok = defi_redeem(tid)
if ok:
cleaned += 1
log(f" Redeemed #{tid}")
else:
log(f" WARN: failed to redeem #{tid}")
if cleaned:
log(f" Cleaned {cleaned} residual position(s)")
return cleaned
# ── K-line / OHLC Data ──────────────────────────────────────────────────────
def get_kline_data(bar: str = "1H", limit: int = 24) -> list[dict] | None:
data = onchainos_cmd(
[
"market",
"kline",
"--address",
ETH_ADDR,
"--chain",
POOL_CHAIN,
"--bar",
bar,
"--limit",
str(limit),
],
timeout=15,
)
if data and data.get("ok") and data.get("data"):
candles = []
for c in data["data"]:
try:
if isinstance(c, list) and len(c) >= 6:
candles.append(
{
"ts": int(c[0]),
"open": float(c[1]),
"high": float(c[2]),
"low": float(c[3]),
"close": float(c[4]),
"volume": float(c[5]),
}
)
elif isinstance(c, dict):
candles.append(
{
"ts": int(c.get("ts", 0)),
"open": float(c.get("o", 0) or c.get("open", 0)),
"high": float(c.get("h", 0) or c.get("high", 0)),
"low": float(c.get("l", 0) or c.get("low", 0)),
"close": float(c.get("c", 0) or c.get("close", 0)),
"volume": float(c.get("vol", 0) or c.get("volume", 0)),
}
)
except (ValueError, TypeError, IndexError):
continue
return candles if candles else None
return None
def calc_kline_volatility(candles: list[dict]) -> float:
"""ATR as percentage of price."""
if not candles or len(candles) < 2:
return 0.0
true_ranges = []
for i in range(1, len(candles)):
hi = candles[i]["high"]
lo = candles[i]["low"]
pc = candles[i - 1]["close"]
tr = max(hi - lo, abs(hi - pc), abs(lo - pc))
true_ranges.append(tr)
atr = sum(true_ranges) / len(true_ranges)
avg_price = sum(c["close"] for c in candles) / len(candles)
return (atr / avg_price) * 100 if avg_price > 0 else 0.0
# ── EMA / Volatility / Multi-Timeframe ──────────────────────────────────────
def calc_ema(prices: list[float], period: int) -> float:
if not prices:
return 0.0
if len(prices) < period:
return sum(prices) / len(prices)
k = 2 / (period + 1)
ema = sum(prices[:period]) / period
for p in prices[period:]:
ema = p * k + ema * (1 - k)
return ema
def calc_volatility(prices: list[float]) -> float:
if len(prices) < 2:
return 0.0
mean = sum(prices) / len(prices)
variance = sum((p - mean) ** 2 for p in prices) / len(prices)
return math.sqrt(variance)
def analyze_multi_timeframe(history: list[float], price: float) -> dict:
result = {
"trend": "neutral",
"strength": 0.0,
"momentum_1h": 0.0,
"momentum_4h": 0.0,
"structure": "ranging",
"ema_short": price,
"ema_medium": price,
"ema_long": price,
}
if len(history) < MTF_SHORT_PERIOD:
return result
ema_short = calc_ema(history, min(MTF_SHORT_PERIOD, len(history)))
ema_medium = calc_ema(history, min(MTF_MEDIUM_PERIOD, len(history)))
ema_long = calc_ema(history, min(MTF_LONG_PERIOD, len(history)))
result["ema_short"] = round(ema_short, 2)
result["ema_medium"] = round(ema_medium, 2)
result["ema_long"] = round(ema_long, 2)
if len(history) >= 12 and history[-12] > 0:
result["momentum_1h"] = round((price - history[-12]) / history[-12] * 100, 3)
if len(history) >= 48 and history[-48] > 0:
result["momentum_4h"] = round((price - history[-48]) / history[-48] * 100, 3)
if ema_long > 0:
if ema_short > ema_medium > ema_long:
result["trend"] = "bullish"
spread = (ema_short - ema_long) / ema_long * 100
result["strength"] = round(min(spread / 2.0, 1.0), 3)
elif ema_short < ema_medium < ema_long:
result["trend"] = "bearish"
spread = (ema_long - ema_short) / ema_long * 100
result["strength"] = round(min(spread / 2.0, 1.0), 3)
if len(history) >= MTF_STRUCTURE_PERIOD:
seg_len = MTF_STRUCTURE_PERIOD // 4
segments = [
history[-(i + 1) * seg_len : -i * seg_len or None] for i in range(3, -1, -1)
]
seg_highs = [max(s) for s in segments if s]
seg_lows = [min(s) for s in segments if s]
hh = all(seg_highs[i] >= seg_highs[i - 1] for i in range(1, len(seg_highs)))
hl = all(seg_lows[i] >= seg_lows[i - 1] for i in range(1, len(seg_lows)))
lh = all(seg_highs[i] <= seg_highs[i - 1] for i in range(1, len(seg_highs)))
ll = all(seg_lows[i] <= seg_lows[i - 1] for i in range(1, len(seg_lows)))
if hh and hl:
result["structure"] = "uptrend"
elif lh and ll:
result["structure"] = "downtrend"
return result
# ── Tick Math ───────────────────────────────────────────────────────────────
def _decimal_adjustment() -> float:
"""10^(token1_decimals - token0_decimals) for tick <-> human price conversion."""
return 10 ** (TOKEN1["decimals"] - TOKEN0["decimals"])
def price_to_tick(price: float, tick_spacing: int = TICK_SPACING) -> int:
"""Convert human-readable price (token1/token0, e.g. USDC/WETH) to nearest valid tick."""
if price <= 0:
return 0
raw_price = price * _decimal_adjustment() # Adjust for decimal difference
raw = math.floor(math.log(raw_price) / math.log(1.0001))
return (raw // tick_spacing) * tick_spacing
def tick_to_price(tick: int) -> float:
"""Convert tick to human-readable price (token1/token0)."""
return 1.0001**tick / _decimal_adjustment()
# ── Volatility Regime → Range Calculation ───────────────────────────────────
def classify_volatility(atr_pct: float) -> str:
if atr_pct < 1.5:
return "low"
elif atr_pct < 3.0:
return "medium"
elif atr_pct < 5.0:
return "high"
else:
return "extreme"
def calc_optimal_range(price: float, atr_pct: float, mtf: dict | None = None) -> dict:
"""Calculate optimal tick range based on volatility and trend.
Returns {lower_price, upper_price, tick_lower, tick_upper, regime, half_width_pct}."""
regime = classify_volatility(atr_pct)
mult = RANGE_MULT.get(regime, 3.0)
half_width_pct = atr_pct * mult
# Clamp to min/max
half_width_pct = max(MIN_RANGE_PCT, min(MAX_RANGE_PCT, half_width_pct))
# Trend-adaptive asymmetry
lower_pct = half_width_pct
upper_pct = half_width_pct
if mtf:
trend = mtf.get("trend", "neutral")
strength = mtf.get("strength", 0)
if strength > 0.2:
asym = ASYM_FACTOR * strength
if trend == "bullish":
# Widen upside, narrow downside
upper_pct = half_width_pct * (1 + asym)
lower_pct = half_width_pct * (1 - asym)
elif trend == "bearish":
# Widen downside, narrow upside
lower_pct = half_width_pct * (1 + asym)
upper_pct = half_width_pct * (1 - asym)
lower_price = price * (1 - lower_pct / 100)
upper_price = price * (1 + upper_pct / 100)
tick_lower = price_to_tick(lower_price)
tick_upper = price_to_tick(upper_price)
# Ensure tick_upper > tick_lower by at least 1 tick_spacing
if tick_upper <= tick_lower:
tick_upper = tick_lower + TICK_SPACING
return {
"lower_price": round(lower_price, 2),
"upper_price": round(upper_price, 2),
"tick_lower": tick_lower,
"tick_upper": tick_upper,
"regime": regime,
"half_width_pct": round(half_width_pct, 2),
"lower_pct": round(lower_pct, 2),
"upper_pct": round(upper_pct, 2),
}
# ── Rebalance Trigger Logic ────────────────────────────────────────────────
def check_rebalance_triggers(
price: float,
state: dict,
atr_pct: float,
mtf: dict | None = None,
) -> dict | None:
"""Check if rebalancing is needed. Returns trigger info or None."""
position = state.get("position")
if not position or not position.get("tick_lower"):
return None
tick_lower = position["tick_lower"]
tick_upper = position["tick_upper"]
lower_price = tick_to_price(tick_lower)
upper_price = tick_to_price(tick_upper)
# [1] Out of range — mandatory
if price < lower_price or price > upper_price:
side = "below" if price < lower_price else "above"
return {"trigger": "out_of_range", "priority": "mandatory", "detail": side}
# [2] Higher yield pool detected — TODO: requires hourly yield API
# Trigger when another pool consistently outperforms for 1+ hour.
# Data source not yet available; placeholder for future implementation.
return None
# ── Risk Checks (layered) ──────────────────────────────────────────────────
def run_risk_checks(
state: dict,
price: float,
total_usd: float,
trigger: dict | None,
) -> str | None:
"""Run layered risk checks. Returns skip/reject reason or None (pass)."""
# [1] Stop triggered
if state.get("stop_triggered"):
return f"stop_active: {state['stop_triggered']}"
# [2] Circuit breaker
errors = state.get("errors", {})
if not isinstance(errors, dict):
errors = {"consecutive": 0, "cooldown_until": None}
state["errors"] = errors
if errors.get("consecutive", 0) >= MAX_CONSECUTIVE_ERRORS:
cooldown_dt = _safe_isoparse(errors.get("cooldown_until", ""))
if cooldown_dt and cooldown_dt > datetime.now():
remaining = int((cooldown_dt - datetime.now()).total_seconds()) // 60
return f"circuit_breaker ({remaining}min remaining)"
else:
errors["consecutive"] = 0
errors["cooldown_until"] = None
# [3] Data validation
if not price or price <= 0:
return "invalid_price"
if total_usd <= 0:
return "zero_balance"
# [4] Stop-loss / trailing-stop / IL
# Use smoothed portfolio value to avoid API glitch triggers
value_history = state.get("_value_history", [])
stats = state.get("stats", {})
if stats.get("initial_portfolio_usd") and len(value_history) >= 3:
# Median of recent values for stop decisions
smooth_usd = sorted(value_history[-5:])[len(value_history[-5:]) // 2]
pnl = calc_pnl(stats, smooth_usd, price)
peak = stats.get("portfolio_peak_usd", pnl["cost_basis"])
# Peak only updates if confirmed by 2 consecutive readings above old peak
prev_val = value_history[-2] if len(value_history) >= 2 else 0
if smooth_usd > peak and prev_val > peak:
peak = smooth_usd
stats["portfolio_peak_usd"] = round(peak, 2)
pnl_pct = pnl["pnl_pct"] / 100 # convert to fraction for comparison
if STOP_LOSS_PCT > 0 and pnl_pct <= -STOP_LOSS_PCT:
state["stop_triggered"] = (
f"stop_loss ({pnl_pct * 100:+.1f}% <= -{STOP_LOSS_PCT * 100:.0f}%)"
)
return state["stop_triggered"]
if TRAILING_STOP_PCT > 0 and peak > 0:
drawdown = (peak - smooth_usd) / peak
if drawdown >= TRAILING_STOP_PCT:
state["stop_triggered"] = (
f"trailing_stop (drawdown {drawdown * 100:.1f}% from peak .0f)"
)
return state["stop_triggered"]
# IL check
il_pct = stats.get("estimated_il_pct", 0)
if abs(il_pct) > MAX_IL_TOLERANCE_PCT:
state["stop_triggered"] = f"il_limit ({il_pct:.1f}% > {MAX_IL_TOLERANCE_PCT}%)"
return state["stop_triggered"]
# [5] Rebalance frequency
rebalance_history = state.get("rebalance_history", [])
now = datetime.now()
recent_24h = []
for r in rebalance_history:
r_dt = _safe_isoparse(r.get("time", ""))
if r_dt and (now - r_dt).total_seconds() < 86400:
recent_24h.append(r)
if len(recent_24h) >= MAX_REBALANCES_24H:
return f"max_rebalances ({len(recent_24h)}/{MAX_REBALANCES_24H} in 24h)"
# [6] Position age
position = state.get("position")
if position and position.get("created_at"):
created_dt = _safe_isoparse(position["created_at"])
age = (now - created_dt).total_seconds() if created_dt else MIN_POSITION_AGE + 1
if age < MIN_POSITION_AGE:
remaining = int(MIN_POSITION_AGE - age)
return f"position_too_young ({remaining}s remaining)"
# [7] Gas cost check (skip for mandatory/maintenance triggers)
if trigger and trigger.get("priority") not in ("mandatory", "maintenance"):
# Estimate: Base L2 gas ~$0.01-0.05 per tx, rebalance = ~4 txs
estimated_gas_usd = 0.15
# Rough fee estimate: position_value * fee_rate * time_in_range
position_value = total_usd
daily_fee_estimate = position_value * FEE_TIER * 0.5 # 50% utilization
hourly_fee = daily_fee_estimate / 24
expected_fee_until_next = hourly_fee * 4 # assume 4h until next rebalance
if (
expected_fee_until_next > 0
and estimated_gas_usd > expected_fee_until_next * GAS_TO_FEE_RATIO
):
return f"gas_too_high (gas .2f > {GAS_TO_FEE_RATIO:.0%} of fee .2f)"
# [8] Minimum range change
if trigger and trigger.get("trigger") == "volatility_shift":
# Only rebalance if the new range would differ by >5%
pass # Checked after new range calculation in tick()
return None
# ── DeFi Operations (onchainos defi commands) ──────────────────────────────
def _verify_tx_receipt(order_id: str, retries: int = 3) -> bool:
"""Poll gateway orders to verify TX was mined successfully."""
for attempt in range(retries):
time.sleep(4 * (attempt + 1)) # 4s, 8s, 12s
data = onchainos_cmd(
[
"gateway",
"orders",
"--address",
WALLET_ADDR,
"--chain",
POOL_CHAIN,
"--order-id",
order_id,
],
timeout=15,
)
if not data or not data.get("ok"):
continue
orders = data.get("data", [])
if not orders:
continue
order = orders[0] if isinstance(orders, list) else orders
status = str(order.get("txStatus", "") or order.get("status", "")).lower()
if status in ("success", "1", "confirmed"):
return True
if status in ("failed", "0", "reverted"):
return False
# Could not confirm — treat as uncertain
return True # optimistic fallback to avoid blocking on API lag
def _broadcast_defi_txs(data: dict, label: str) -> bool:
"""Broadcast all transactions from a defi command response.
Returns True if all transactions were broadcast and confirmed on-chain."""
result = data.get("data")
if not result:
log(f" {label}: no tx data to broadcast")
return False
# Handle both {"dataList": [...]} and direct list formats
tx_list = []
if isinstance(result, dict):
tx_list = result.get("dataList", [])
elif isinstance(result, list):
tx_list = result
if not tx_list:
log(f" {label}: empty tx list")
return False
for i, tx in enumerate(tx_list):
tx_type = tx.get("callDataType", f"tx_{i}")
to_addr = tx.get("to", "")
calldata = tx.get("serializedData", "") or tx.get("data", "0x")
value = tx.get("value", "0x0")
if not to_addr or not calldata:
log(f" {label} [{tx_type}]: missing to/data, skip")
continue
# Convert hex value to decimal wei
value_wei = str(int(value, 16) if value.startswith("0x") else int(value))
broadcast_data = onchainos_cmd(
[
"wallet",
"contract-call",
"--to",
to_addr,
"--chain",
CHAIN_ID,
"--input-data",
calldata,
"--amt",
value_wei,
],
timeout=60,
)
if broadcast_data and broadcast_data.get("ok") and broadcast_data.get("data"):
r = broadcast_data["data"]
if isinstance(r, list):
r = r[0] if r else {}
tx_hash = r.get("txHash") or r.get("hash") or r.get("orderId")
log(f" {label} [{tx_type}] broadcast OK: {tx_hash}")
# Verify on-chain execution
if tx_hash:
confirmed = _verify_tx_receipt(tx_hash)
if not confirmed:
log(f" {label} [{tx_type}] TX REVERTED on-chain: {tx_hash}")
return False
else:
detail = (
json.dumps(broadcast_data)[:200] if broadcast_data else "no response"
)
log(f" {label} [{tx_type}] broadcast failed: {detail}")
return False
return True
def defi_claim_fees(token_id: str) -> bool:
"""Claim accumulated fees from V3 position."""
if not token_id:
return False
data = onchainos_cmd(
[
"defi",
"claim",
"--address",
WALLET_ADDR,
"--chain",
POOL_CHAIN,
"--reward-type",
"V3_FEE",
"--id",
INVESTMENT_ID,
"--token-id",
token_id,
],
timeout=45,
)
if data and data.get("ok"):
log(f"Fees claim calldata for token_id={token_id}")
return _broadcast_defi_txs(data, "claim")
log(f"Claim fees failed: {json.dumps(data)[:200] if data else 'no response'}")
return False
def defi_redeem(token_id: str) -> bool:
"""Remove all liquidity from V3 position using --ratio 1 (full exit)."""
if not token_id:
return False
data = onchainos_cmd(
[
"defi",
"redeem",
"--id",
INVESTMENT_ID,
"--address",
WALLET_ADDR,
"--chain",
POOL_CHAIN,
"--token-id",
token_id,
"--ratio",
"1",
],
timeout=60,
)
if data and data.get("ok"):
log(f"Redeem calldata for token_id={token_id}")
return _broadcast_defi_txs(data, "redeem")
log(f"Redeem failed: {json.dumps(data)[:200] if data else 'no response'}")
return False
def defi_calculate_entry(
input_token: str,
input_amount: str,
token_decimal: int,
tick_lower: int,
tick_upper: int,
) -> dict | None:
"""Calculate deposit parameters for V3 position."""
data = onchainos_cmd(
[
"defi",
"calculate-entry",
"--id",
INVESTMENT_ID,
"--address",
WALLET_ADDR,
"--chain",
POOL_CHAIN,
"--input-token",
input_token,
"--input-amount",
input_amount,
"--token-decimal",
str(token_decimal),
"--tick-lower",
str(tick_lower),
"--tick-upper",
str(tick_upper),
],
timeout=30,
)
if data and data.get("ok"):
result = data.get("data")
# Extract investWithTokenList for deposit --user-input
if isinstance(result, dict) and "investWithTokenList" in result:
return result["investWithTokenList"]
return result
log(f"Calculate entry failed: {json.dumps(data)[:200] if data else 'no response'}")
return None
def defi_deposit(user_input: str, tick_lower: int, tick_upper: int) -> bool:
"""Deposit liquidity into V3 position at specified tick range."""
# Filter out zero-amount tokens — API rejects coinAmount "0"
try:
tokens = json.loads(user_input)
if isinstance(tokens, list):
tokens = [t for t in tokens if float(t.get("coinAmount", 0)) > 0]
if not tokens:
log("Deposit skipped: all token amounts are zero")
return False
user_input = json.dumps(tokens)
except (json.JSONDecodeError, ValueError):
pass
data = onchainos_cmd(
[
"defi",
"deposit",
"--investment-id",
INVESTMENT_ID,
"--address",
WALLET_ADDR,
"--chain",
POOL_CHAIN,
"--user-input",
user_input,
"--tick-lower",
str(tick_lower),
"--tick-upper",
str(tick_upper),
],
timeout=60,
)
if data and data.get("ok"):
log("Deposit calldata received")
return _broadcast_defi_txs(data, "deposit")
log(f"Deposit failed: {json.dumps(data)[:200] if data else 'no response'}")
return False
# ── Swap (reused from grid-trading) ─────────────────────────────────────────
def _wallet_contract_call(tx: dict) -> tuple[str | None, dict | None]:
value_wei = str(int(tx.get("value", "0")))
args = [
"wallet",
"contract-call",
"--to",
tx["to"],
"--chain",
CHAIN_ID,
"--input-data",
tx.get("data", "0x"),
"--amt",
value_wei,
]
try:
data = onchainos_cmd(args, timeout=45)
if data and data.get("ok") and data.get("data"):
result = (
data["data"]
if isinstance(data["data"], dict)
else (
data["data"][0] if isinstance(data["data"], list) else data["data"]
)
)
tx_hash = (
result.get("txHash") or result.get("hash") or result.get("orderId")
)
if tx_hash:
log(f" Broadcast OK: {tx_hash}")
return tx_hash, None
return None, {
"reason": "no_hash",
"detail": json.dumps(result)[:200],
"retriable": True,
}
detail = json.dumps(data)[:200] if data else "no response"
return None, {
"reason": "contract_call_failed",
"detail": detail,
"retriable": True,
}
except Exception as e:
return None, {"reason": "exception", "detail": str(e), "retriable": True}
def simulate_tx(tx: dict) -> dict | None:
data = onchainos_cmd(
[
"gateway",
"simulate",
"--from",
WALLET_ADDR,
"--to",
tx["to"],
"--data",
tx.get("data", "0x"),
"--amount",
tx.get("value", "0"),
"--chain",
POOL_CHAIN,
],
timeout=15,
)
if data and data.get("ok") and data.get("data"):
sim = data["data"][0] if isinstance(data["data"], list) else data["data"]
fail_reason = sim.get("failReason", "")
gas_used = sim.get("gasUsed", "")
success = not fail_reason
log(
f" Simulation: {'OK' if success else 'FAIL'} gasUsed={gas_used}"
+ (f" reason={fail_reason}" if fail_reason else "")
)
return {"success": success, "failReason": fail_reason, "gasUsed": gas_used}
return None
def ensure_approval(token_addr: str, spender: str, amount: int) -> bool:
state = load_state()
approved_routers = state.get("approved_routers", [])
key = f"{token_addr}:{spender}".lower()
if key in [r.lower() for r in approved_routers]:
return True
log(f"Approval needed for {token_addr[:10]}... to {spender[:10]}...")
max_approval = (
"115792089237316195423570985008687907853269984665640564039457584007913129639935"
)
data = onchainos_cmd(
[
"swap",
"approve",
"--token",
token_addr,
"--amount",
max_approval,
"--chain",
POOL_CHAIN,
]
)
if not data or not data.get("ok") or not data.get("data"):
log(f"Approve API failed: {json.dumps(data)[:200] if data else 'no response'}")
return False
approve_tx = data["data"][0]
approve_tx["to"] = token_addr
tx_hash, fail = _wallet_contract_call(approve_tx)
if not tx_hash:
log(f"Approval failed: {fail}")
return False
log(f"Approval TX: {tx_hash}")
time.sleep(5)
approved_routers.append(key)
state["approved_routers"] = approved_routers
save_state(state)
return True
def execute_swap(
from_token: str,
to_token: str,
amount: int,
price: float,
) -> tuple[str | None, dict | None]:
"""Execute a token swap via onchainos."""
for attempt in range(2):
swap_data = onchainos_cmd(
[
"swap",
"swap",
"--from",
from_token,
"--to",
to_token,
"--amount",
str(amount),
"--chain",
POOL_CHAIN,
"--wallet",
WALLET_ADDR,
"--slippage",
str(SLIPPAGE_PCT),
]
)
if not swap_data or not swap_data.get("ok") or not swap_data.get("data"):
detail = json.dumps(swap_data)[:200] if swap_data else "no response"
log(f"Swap quote failed (attempt {attempt + 1}): {detail}")
if attempt == 0:
time.sleep(3)
continue
return None, {
"reason": "swap_quote_failed",
"detail": detail,
"retriable": True,
}
tx = swap_data["data"][0]["tx"]
log(
f" Swap: to={tx['to'][:10]}... value={tx.get('value', '0')} "
f"gas={tx.get('gas', 'N/A')}"
)
simulate_tx(tx)
# Approve if selling a token (not native ETH)
if from_token.lower() != ETH_ADDR.lower():
router_addr = tx["to"]
if not ensure_approval(from_token, router_addr, amount):
return None, {
"reason": "approval_failed",
"detail": f"router {router_addr}",
"retriable": True,
}
tx_hash, fail = _wallet_contract_call(tx)
if tx_hash:
return tx_hash, None
log(f"Swap failed (attempt {attempt + 1}): {fail}")
if attempt == 0 and fail and fail.get("retriable"):
time.sleep(3)
continue
return None, fail
return None, {"reason": "max_retries", "detail": "exhausted", "retriable": True}
def _calc_balanced_deposit(
available_eth: float,
usdc_bal: float,
price: float,
tick_lower: int,
tick_upper: int,
) -> str | None:
"""Calculate deposit amounts, pre-swapping ETH↔USDC if ratio is unbalanced.
Returns JSON string for defi_deposit --user-input, or None on failure.
"""
deposit_eth_wei = int(available_eth * 0.95 * (10 ** TOKEN0["decimals"]))
# First call: probe the ratio using ETH as input
calculated = defi_calculate_entry(
input_token=NATIVE_TOKEN,
input_amount=str(deposit_eth_wei),
token_decimal=TOKEN0["decimals"],
tick_lower=tick_lower,
tick_upper=tick_upper,
)
if not calculated or not isinstance(calculated, list):
log(" calculate-entry failed")
return None
# Check if USDC needed exceeds wallet balance
usdc_needed = 0.0
for item in calculated:
addr = item.get("tokenAddress", "").lower()
if addr == USDC_ADDR.lower():
usdc_needed = float(item.get("coinAmount", 0)) / (10 ** TOKEN1["decimals"])
break
if usdc_needed <= usdc_bal * 0.98:
# Wallet has enough USDC — use as-is
log(f" Ratio OK: need {usdc_needed:.2f} USDC, have {usdc_bal:.2f}")
return json.dumps(calculated)
# Need to swap some ETH → USDC to balance
usdc_gap = usdc_needed - usdc_bal
# Swap enough ETH to cover the gap + 3% buffer for slippage/fees
swap_eth = usdc_gap / price * 1.03
if swap_eth > available_eth * 0.9:
# Safety: don't swap more than 90% of available ETH
swap_eth = available_eth * 0.45 # swap roughly half
swap_eth_wei = int(swap_eth * (10 ** TOKEN0["decimals"]))
log(f" Pre-swap: {swap_eth:.6f} ETH → USDC (need {usdc_needed:.2f}, have {usdc_bal:.2f})")
tx_hash, fail = execute_swap(NATIVE_TOKEN, USDC_ADDR, swap_eth_wei, price)
if not tx_hash:
log(f" Pre-swap failed: {fail}")
return None
log(f" Pre-swap OK: {tx_hash}")
# Wait for swap to settle, then re-check balances with retry
new_eth, new_usdc, bal_failed = 0.0, usdc_bal, True
for wait in [12, 10, 10]:
time.sleep(wait)
new_eth, new_usdc, bal_failed = get_balances(force=True)
if not bal_failed and new_usdc > usdc_bal + 1:
# USDC increased — swap reflected
break
log(f" Waiting for swap to reflect (USDC={new_usdc:.2f}, was {usdc_bal:.2f})")
if bal_failed:
log(" Balance check failed after swap")
return None
if new_usdc <= usdc_bal + 1:
log(f" Swap may have reverted (USDC unchanged: {new_usdc:.2f}) — falling back to USDC-base")
return _calc_entry_usdc_base(new_usdc, tick_lower, tick_upper)
new_available = new_eth - GAS_RESERVE_ETH
if new_available < 0:
new_available = 0
log(f" Post-swap balances: ETH={new_eth:.6f} USDC={new_usdc:.2f}")
# Recalculate with updated ETH balance
new_eth_wei = int(new_available * 0.95 * (10 ** TOKEN0["decimals"]))
recalculated = defi_calculate_entry(
input_token=NATIVE_TOKEN,
input_amount=str(new_eth_wei),
token_decimal=TOKEN0["decimals"],
tick_lower=tick_lower,
tick_upper=tick_upper,
)
if recalculated and isinstance(recalculated, list):
# Verify USDC fits now
for item in recalculated:
addr = item.get("tokenAddress", "").lower()
if addr == USDC_ADDR.lower():
new_usdc_needed = float(item.get("coinAmount", 0)) / (10 ** TOKEN1["decimals"])
if new_usdc_needed > new_usdc:
log(f" Still short on USDC after swap ({new_usdc_needed:.2f} > {new_usdc:.2f})")
# Last resort: use USDC as input
return _calc_entry_usdc_base(new_usdc, tick_lower, tick_upper)
break
return json.dumps(recalculated)
log(" Recalculate-entry failed after swap")
return _calc_entry_usdc_base(new_usdc, tick_lower, tick_upper)
def _calc_entry_usdc_base(
usdc_bal: float, tick_lower: int, tick_upper: int,
) -> str | None:
"""Fallback: swap remaining ETH → USDC, then use total USDC as input."""
# Check if there's ETH worth swapping
eth_bal, _, _ = get_balances(force=True)
available_eth = eth_bal - GAS_RESERVE_ETH
price = get_eth_price()
if available_eth > 0 and price > 0:
eth_usd_value = available_eth * price
if eth_usd_value >= MIN_TRADE_USD:
# Swap all available ETH to USDC (keep gas reserve)
swap_eth_wei = int(available_eth * 0.95 * (10 ** TOKEN0["decimals"]))
log(f" USDC-base: swapping {available_eth * 0.95:.6f} ETH (~.2f) → USDC")
tx_hash, fail = execute_swap(NATIVE_TOKEN, USDC_ADDR, swap_eth_wei, price)
if tx_hash:
# Wait for swap to settle
for wait in [10, 10, 10]:
time.sleep(wait)
_, new_usdc, bal_fail = get_balances(force=True)
if not bal_fail and new_usdc > usdc_bal + 1:
usdc_bal = new_usdc
log(f" USDC-base: swap settled, total USDC={usdc_bal:.2f}")
break
log(f" USDC-base: waiting for swap (USDC={new_usdc:.2f})")
else:
log(" USDC-base: swap may not have settled, proceeding with current balance")
_, new_usdc, _ = get_balances(force=True)
if new_usdc > usdc_bal:
usdc_bal = new_usdc
else:
log(f" USDC-base: ETH swap failed ({fail}), proceeding with USDC only")
usdc_amount = int(usdc_bal * 0.90 * (10 ** TOKEN1["decimals"]))
calculated = defi_calculate_entry(
input_token=USDC_ADDR,
input_amount=str(usdc_amount),
token_decimal=TOKEN1["decimals"],
tick_lower=tick_lower,
tick_upper=tick_upper,
)
if calculated and isinstance(calculated, list):
log(f" Using USDC-base fallback: {usdc_bal * 0.90:.2f} USDC")
return json.dumps(calculated)
log(" USDC-base calculate-entry also failed")
return None
# ── Rebalance Execution ────────────────────────────────────────────────────
def execute_rebalance(
state: dict,
price: float,
new_range: dict,
trigger: dict,
) -> bool:
"""Execute full rebalance: claim -> redeem -> (swap) -> deposit.
Returns True on success."""
position = state.get("position") or {}
token_id = position.get("token_id", "")
old_tick_lower = position.get("tick_lower")
old_tick_upper = position.get("tick_upper")
new_tick_lower = new_range["tick_lower"]
new_tick_upper = new_range["tick_upper"]
log(
f"REBALANCE: {trigger['trigger']} ({trigger.get('detail', '')}) "
f"ticks [{old_tick_lower},{old_tick_upper}] -> [{new_tick_lower},{new_tick_upper}]"
)
# Step 1: Claim fees (skip if unclaimed < $5 to save gas)
# Refetch position detail for fresh unclaimed amount before claiming
unclaimed = 0.0
if token_id:
pre_claim_detail = get_position_detail(token_id)
unclaimed = pre_claim_detail.get("unclaimed_fee_usd", 0)
state["stats"]["unclaimed_fee_usd"] = round(unclaimed, 4)
if token_id and unclaimed >= MIN_TRADE_USD:
claimed = defi_claim_fees(token_id)
if claimed:
state["stats"]["total_fees_claimed_usd"] = round(
state["stats"].get("total_fees_claimed_usd", 0) + unclaimed, 2
)
state["stats"]["unclaimed_fee_usd"] = 0.0 # just claimed
log(
f" Fees claimed: .2f (total: .2f)"
)
elif token_id:
log(f" Skip claim: unclaimed .2f < .0f")
# Redeem will auto-collect fees, so count them as claimed
if unclaimed > 0:
state["stats"]["total_fees_claimed_usd"] = round(
state["stats"].get("total_fees_claimed_usd", 0) + unclaimed, 2
)
state["stats"]["unclaimed_fee_usd"] = 0.0
log(f" (will be collected via redeem, recorded: .2f)")
# Step 2: Remove liquidity
if token_id:
redeemed = defi_redeem(token_id)
if not redeemed:
log(" Redeem failed — will retry next tick")
return False
# Mark position as redeemed immediately to prevent stale state
state["_rebalance_in_progress"] = True
state["position"] = {
"token_id": "",
"tick_lower": None,
"tick_upper": None,
"lower_price": None,
"upper_price": None,
"created_at": None,
"created_atr_pct": 0,
"_redeemed_from": token_id,
}
save_state(state)
# Wait for redeem funds to arrive — poll until balance increases or timeout
pre_eth, pre_usdc, _ = get_balances(force=True)
log(f" Pre-redeem balances: ETH={pre_eth:.6f} USDC={pre_usdc:.2f}")
for poll_i, poll_wait in enumerate([5, 8, 10, 12, 15, 15], 1):
time.sleep(poll_wait)
post_eth, post_usdc, post_fail = get_balances(force=True)
if post_fail:
log(f" Redeem poll {poll_i}: balance query failed, retrying...")
continue
gained = (post_eth - pre_eth) * price + (post_usdc - pre_usdc)
if gained > MIN_TRADE_USD:
log(f" Redeem funds arrived (poll {poll_i}): ETH={post_eth:.6f} USDC={post_usdc:.2f} (+.2f)")
break
log(f" Redeem poll {poll_i}: ETH={post_eth:.6f} USDC={post_usdc:.2f} (gained .2f, waiting...)")
else:
log(" Redeem funds not detected after 65s — proceeding with current balances")
# Step 3: Get current balances after redeem (force refresh to bypass cache)
eth_bal, usdc_bal, bal_failed = get_balances(force=True)
if bal_failed:
log(" Balance query failed after redeem — funds may be idle")
time.sleep(10)
eth_bal, usdc_bal, bal_failed = get_balances(force=True)
if bal_failed:
log(" Balance still unavailable — aborting, funds sitting idle in wallet")
return False
available_eth = eth_bal - GAS_RESERVE_ETH
if available_eth < 0:
available_eth = 0
total_usd = available_eth * price + usdc_bal
if total_usd < MIN_TRADE_USD:
log(f" Balance too low after redeem: .2f")
return False
# Step 4: Calculate dual-token deposit — swap to balance if needed
log(f" Balances: ETH={eth_bal:.6f} USDC={usdc_bal:.2f}")
user_input_json = _calc_balanced_deposit(
available_eth, usdc_bal, price,
new_tick_lower, new_tick_upper,
)
if not user_input_json:
log(" Deposit calculation failed — aborting")
return False
# Step 5: Deposit at new range
# Record current max token_id so we only find positions created AFTER deposit
pre_deposit_max_tid = ""
existing = _query_all_positions()
if existing:
pre_deposit_max_tid = max(existing, key=lambda p: int(p["tokenId"]))["tokenId"]
log(f" Deposit input: {user_input_json[:200]}")
deposit_result = defi_deposit(user_input_json, new_tick_lower, new_tick_upper)
if not deposit_result:
dep_fails = state.get("_consecutive_deposit_failures", 0) + 1
state["_consecutive_deposit_failures"] = dep_fails
save_state(state)
log(f" Deposit failed (consecutive: {dep_fails}/{MAX_DEPOSIT_FAILURES}) — will retry next tick")
return False
# Deposit returns bool; recover token_id and verify on-chain value
# Only look for token_ids created after pre_deposit_max_tid
new_token_id = ""
lp_value = 0.0
for verify_attempt, delay in enumerate([8, 15, 25], 1):
time.sleep(delay)
new_token_id = find_latest_token_id(after_token_id=pre_deposit_max_tid)
if new_token_id:
detail = get_position_detail(new_token_id)
lp_value = detail.get("value", 0)
if lp_value >= MIN_TRADE_USD:
log(
f" Deposit verified (attempt {verify_attempt}): "
f"token_id={new_token_id} value=.2f"
)
break
log(
f" Verify attempt {verify_attempt}: token_id={new_token_id} "
f"value=.2f (too low)"
)
else:
log(f" Verify attempt {verify_attempt}: no token_id found yet")
else:
# All verification attempts exhausted
if new_token_id:
# Token exists on-chain but value reads low (indexing lag).
# Adopt it instead of creating another position that becomes dust.
log(
f" Deposit value unconfirmed but token_id={new_token_id} exists "
f"on-chain — adopting (value=.2f, may update next tick)"
)
else:
# No token_id found at all — deposit likely reverted
dep_fails = state.get("_consecutive_deposit_failures", 0) + 1
state["_consecutive_deposit_failures"] = dep_fails
log(
f" Deposit verification FAILED: no token_id found after 3 attempts "
f"(consecutive failures: {dep_fails}/{MAX_DEPOSIT_FAILURES})"
)
if dep_fails >= MAX_DEPOSIT_FAILURES:
state["stop_triggered"] = (
f"deposit_failures ({dep_fails} consecutive deposit verifications failed)"
)
save_state(state)
emit(
"stop_triggered",
{
"status": "stop_triggered",
"trigger": state["stop_triggered"],
"price": round(price, 2),
},
notify=True,
tier="risk_alert",
)
log(
f" AUTO-PAUSED: {dep_fails} consecutive deposit failures — "
f"manual intervention required (resume-trading to restart)"
)
else:
save_state(state)
return False
# Have a valid token_id (verified or adopted) — reset failure counter
state["_consecutive_deposit_failures"] = 0
if new_token_id:
cleanup_residual_positions(new_token_id)
# Update state
now_iso = datetime.now().isoformat()
candles = get_kline_data("1H", 24)
current_atr = calc_kline_volatility(candles) if candles else 0
state.pop("_rebalance_in_progress", None)
# Get current price for IL tracking
rebal_price = get_eth_price() or 0
state["position"] = {
"token_id": new_token_id,
"tick_lower": new_tick_lower,
"tick_upper": new_tick_upper,
"lower_price": new_range["lower_price"],
"upper_price": new_range["upper_price"],
"created_at": now_iso,
"created_atr_pct": round(current_atr, 3),
"entry_price": round(rebal_price, 2),
}
# Record rebalance
rebalance_record = {
"time": now_iso,
"trigger": trigger["trigger"],
"detail": trigger.get("detail", ""),
"price": round(rebal_price, 2),
"old_range": [old_tick_lower, old_tick_upper],
"new_range": [new_tick_lower, new_tick_upper],
}
rebalances = state.get("rebalance_history", [])
rebalances.append(rebalance_record)
if len(rebalances) > 50:
rebalances = rebalances[-50:]
state["rebalance_history"] = rebalances
state["stats"]["total_rebalances"] = state["stats"].get("total_rebalances", 0) + 1
log(
f" Rebalance complete: [{new_tick_lower},{new_tick_upper}] "
f"(.2f-.2f) "
f"token_id={new_token_id}"
)
return True
# ── State Management ────────────────────────────────────────────────────────
def load_state() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except (json.JSONDecodeError, OSError) as e:
log(f"State file corrupted: {e}")
# Try backup
bak = STATE_FILE.with_suffix(".json.bak")
if bak.exists():
try:
state = json.loads(bak.read_text())
log("Recovered state from backup")
return state
except (json.JSONDecodeError, OSError):
log("Backup also corrupted — starting fresh")
return {
"version": 1,
"wallet_address": WALLET_ADDR,
"pool": {
"investment_id": INVESTMENT_ID,
"chain": POOL_CHAIN,
"token0": TOKEN0,
"token1": TOKEN1,
"fee_tier": FEE_TIER,
"tick_spacing": TICK_SPACING,
},
"position": None,
"price_history": [],
"vol_history": [],
"rebalance_history": [],
"stats": {
"total_rebalances": 0,
"total_fees_claimed_usd": 0.0,
"total_gas_spent_usd": 0.0,
"time_in_range_pct": 100.0,
"net_yield_usd": 0.0,
"initial_portfolio_usd": None,
"initial_eth_price": None,
"started_at": datetime.now().isoformat(),
"last_check": None,
"total_deposits_usd": 0.0,
"deposit_history": [],
"estimated_il_pct": 0.0,
},
"errors": {"consecutive": 0, "cooldown_until": None},
"stop_triggered": None,
"approved_routers": [],
}
def save_state(state: dict):
"""Atomic state save: write to temp file, then rename."""
if STATE_FILE.exists():
try:
STATE_FILE.with_suffix(".json.bak").write_text(STATE_FILE.read_text())
except OSError:
pass
content = json.dumps(state, indent=2)
fd, tmp_path = tempfile.mkstemp(
dir=STATE_FILE.parent, suffix=".json.tmp", prefix=".cl_lp_"
)
try:
with os.fdopen(fd, "w") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, STATE_FILE)
os.chmod(STATE_FILE, 0o644)
except Exception as e:
try:
os.unlink(tmp_path)
except OSError:
pass
log(f"CRITICAL: Failed to save state: {e}")
# ── Structured Event Output ─────────────────────────────────────────────────
def _range_visual(price: float, lower: float, upper: float, width: int = 20) -> str:
"""Build ASCII range position indicator.
Example: [$1,950 ·····●····· $2,150] ← $2,090
"""
if not lower or not upper or upper <= lower:
return ""
clamped = max(lower, min(upper, price))
pos = int((clamped - lower) / (upper - lower) * width)
bar = "·" * pos + "●" + "·" * (width - pos)
return f"[,.0f {bar} ,.0f] ← ,.0f"
def _build_notification(tier: str, data: dict) -> dict:
"""Build dual-format notification block (discord embed + text markdown).
Returns {"tier": str, "discord": {...}, "text": str} or None if silent.
"""
price = data.get("price", 0)
status = data.get("status", "")
# ── Trade Alert ──────────────────────────────────────────────────────
if tier == "trade_alert":
success = status in ("rebalanced", "first_deploy")
pos = data.get("position", {})
lower = pos.get("lower_price", 0)
upper = pos.get("upper_price", 0)
pnl_usd = data.get("pnl_usd", 0)
fees = data.get("fees_claimed_usd", 0)
trigger = data.get("trigger", "")
tx = data.get("tx_hash", "")
icon = "🔄" if success else "❌"
verb = "调仓成功" if success else "调仓失败"
color = 0x00CC66 if success else 0xFF6600 # green / orange
fields_discord = [
{"name": "价格", "value": f",.2f", "inline": True},
{"name": "范围", "value": f",.0f — ,.0f", "inline": True},
{"name": "触发", "value": trigger or "—", "inline": True},
{"name": "Fee Claimed", "value": f",.2f", "inline": True},
{"name": "PnL", "value": f"+,.2f", "inline": True},
]
visual = _range_visual(price, lower, upper) if lower and upper else ""
footer = f"tx: {tx[:10]}...{tx[-6:]}" if tx else ""
text_lines = [
f"{icon} **{verb} · {PAIR_NAME} · {CHAIN_LABEL}**",
f"💰 价格: `,.2f`",
f"📐 范围: `,.0f — ,.0f`",
f"🎯 触发: `{trigger}`" if trigger else None,
f"💵 Fee Claimed: `,.2f`",
f"📈 PnL: `+,.2f`",
f"`{visual}`" if visual else None,
f"_tx: {tx[:10]}...{tx[-6:]}_" if tx else None,
]
discord_embed = {
"title": f"{icon} {verb} · {PAIR_NAME} · {CHAIN_LABEL}",
"color": color,
"fields": fields_discord,
"footer": {"text": footer},
}
if visual:
discord_embed["description"] = f"`{visual}`"
return {
"tier": "trade_alert",
"discord": discord_embed,
"text": "\n".join(ln for ln in text_lines if ln),
}
# ── Risk Alert ───────────────────────────────────────────────────────
if tier == "risk_alert":
trigger_msg = data.get("trigger", status)
portfolio = data.get("portfolio_usd", 0)
auto_resume = data.get("auto_resume", False)
fields_discord = [
{"name": "原因", "value": trigger_msg, "inline": False},
{"name": "价格", "value": f",.2f", "inline": True},
{"name": "组合价值", "value": f",.2f", "inline": True},
]
footer = "自动恢复已启用" if auto_resume else "使用 resume-trading 恢复"
text_lines = [
f"🛑 **交易停止 · {PAIR_NAME} · {CHAIN_LABEL}**",
f"⚠️ 原因: `{trigger_msg}`",
f"💰 价格: `,.2f` | 组合: `,.2f`",
f"_{footer}_",
]
return {
"tier": "risk_alert",
"discord": {
"title": f"🛑 交易停止 · {PAIR_NAME} · {CHAIN_LABEL}",
"color": 0xFF0000,
"fields": fields_discord,
"footer": {"text": footer},
},
"text": "\n".join(text_lines),
}
# ── Hourly Pulse ─────────────────────────────────────────────────────
if tier == "hourly_pulse":
pos = data.get("position", {})
lower = pos.get("lower_price", 0)
upper = pos.get("upper_price", 0)
atr = data.get("atr_pct", 0)
regime = data.get("regime", "")
trend = data.get("trend", "neutral")
strength = data.get("trend_strength", 0)
tir = data.get("time_in_range_pct", 0)
rebal = data.get("total_rebalances", 0)
pnl_usd = data.get("pnl_usd", 0)
pnl_pct = data.get("pnl_pct", 0)
pnl_valid = data.get("pnl_valid", False)
unclaimed = data.get("unclaimed_fee_usd", 0)
portfolio = data.get("portfolio_usd", 0)
fee_apy = data.get("fee_apy", 0)
net_apy = data.get("net_apy", 0)
visual = _range_visual(price, lower, upper) if lower and upper else ""
# Token breakdown (wallet + LP)
bals = data.get("balances", {})
eth_wallet = bals.get("eth", 0)
usdc_wallet = bals.get("usdc", 0)
lp_assets = bals.get("lp_assets", [])
# Parse LP token amounts
lp_eth = 0.0
lp_usdc = 0.0
for a in lp_assets:
sym = a.get("symbol", "").upper()
amt = a.get("amount", 0)
if sym in ("WETH", "ETH"):
lp_eth = amt
elif sym in ("USDC", "USDT"):
lp_usdc = amt
total_eth = eth_wallet + lp_eth
total_usdc = usdc_wallet + lp_usdc
pnl_str = f"+,.2f ({pnl_pct:+.1f}%)" if pnl_valid else "—"
apy_str = f"Fee {fee_apy:+.1f}% / Net {net_apy:+.1f}%" if pnl_valid else "—"
fields_discord = [
{
"name": "ETH",
"value": f"{total_eth:.4f} (,.0f)",
"inline": True,
},
{"name": "USDC", "value": f",.2f", "inline": True},
{"name": "总价值", "value": f",.0f", "inline": True},
{"name": "PnL", "value": pnl_str, "inline": True},
{"name": "年化 APY", "value": apy_str, "inline": True},
{"name": "待领费用", "value": f",.2f", "inline": True},
]
footer = f"钱包 {eth_wallet:.4f} ETH + ,.0f | LP {lp_eth:.4f} ETH + ,.0f · {regime} · {trend}"
text_lines = [
f"📊 **{PAIR_NAME} · {CHAIN_LABEL} · 运行中**",
f"`{visual}`" if visual else None,
f"💰 `{total_eth:.4f}` ETH (`,.0f`) + `,.2f` USDC = **`,.0f`**",
f" 钱包: `{eth_wallet:.4f}` ETH + `,.2f` | LP: `{lp_eth:.4f}` ETH + `,.0f` USDC",
f"📈 PnL `{pnl_str}` | APY `{apy_str}`" if pnl_valid else None,
f"💵 待领费用 `,.2f`",
f"_{footer}_",
]
discord_embed = {
"title": f"📊 {PAIR_NAME} · {CHAIN_LABEL} · 运行中",
"color": 0x808080,
"fields": fields_discord,
"footer": {"text": footer},
}
if visual:
discord_embed["description"] = f"`{visual}`"
return {
"tier": "hourly_pulse",
"discord": discord_embed,
"text": "\n".join(ln for ln in text_lines if ln),
}
# ── Daily Report ─────────────────────────────────────────────────────
if tier == "daily_report":
pnl_usd = data.get("pnl_usd", 0)
pnl_pct = data.get("pnl_pct", 0)
pnl_valid = data.get("pnl_valid", False)
tir = data.get("time_in_range_pct", 0)
rebal = data.get("total_rebalances", 0)
atr = data.get("atr_pct", 0)
regime = data.get("regime", "")
trend = data.get("trend", "neutral")
strength = data.get("trend_strength", 0)
fees_claimed = data.get("total_fees_claimed_usd", 0)
unclaimed = data.get("unclaimed_fee_usd", 0)
il_pct = data.get("il_pct", 0)
il_usd = data.get("il_usd", 0)
fee_apy = data.get("fee_apy", 0)
net_apy = data.get("net_apy", 0)
days_running = data.get("days_running", 0)
cost_basis = data.get("cost_basis", 0)
today_rebal = data.get("today_rebalances", [])
portfolio = data.get("portfolio_usd", 0)
today = datetime.now().date().isoformat()
pnl_str = f"+,.2f ({pnl_pct:+.1f}%)" if pnl_valid else "数据不足"
il_str = f",.2f ({il_pct:.2f}%)" if il_pct else "—"
apy_str = f"Fee {fee_apy:+.1f}% / Net {net_apy:+.1f}%" if pnl_valid else "—"
fields_discord = [
{"name": "💰 组合价值", "value": f",.2f", "inline": True},
{"name": "📈 PnL", "value": pnl_str, "inline": True},
{"name": "📊 年化 APY", "value": apy_str, "inline": True},
{
"name": "💵 LP 费用",
"value": f",.2f (已领 ,.2f)",
"inline": True,
},
{"name": "📉 无常损失", "value": il_str, "inline": True},
{"name": "🔄 今日调仓", "value": f"{len(today_rebal)} 次", "inline": True},
{"name": "📊 范围内", "value": f"{tir:.0f}%", "inline": True},
{"name": "📉 波动率", "value": f"{regime} ({atr:.1f}%)", "inline": True},
{"name": "📈 趋势", "value": f"{trend} ({strength:.2f})", "inline": True},
]
footer = f"运行 {days_running:.1f} 天 · 本金 ,.0f · 累计调仓 {rebal} 次"
text_lines = [
f"📈 **日报 · {PAIR_NAME} · {today}**",
"",
"**收益**",
f" PnL: `{pnl_str}` | APY: `{apy_str}`",
f" LP 费用: `,.2f` (已领 `,.2f` + 待领 `,.2f`)",
f" 无常损失: `{il_str}`" if il_pct else None,
"",
"**运营**",
f" 今日调仓: `{len(today_rebal)}` 次 | 范围内: `{tir:.0f}%`",
f" 组合价值: `,.2f`",
"",
"**市场**",
f" 价格: `,.2f` | 波动: `{regime}` | 趋势: `{trend}`",
"",
f"_{footer}_",
]
# Range visualization for daily report
pos = data.get("position", {})
d_lower = pos.get("lower_price", 0)
d_upper = pos.get("upper_price", 0)
daily_visual = (
_range_visual(price, d_lower, d_upper) if d_lower and d_upper else ""
)
discord_embed = {
"title": f"📈 日报 · {PAIR_NAME} · {today}",
"color": 0x3399FF,
"fields": fields_discord,
"footer": {"text": footer},
}
if daily_visual:
discord_embed["description"] = f"`{daily_visual}`"
return {
"tier": "daily_report",
"discord": discord_embed,
"text": "\n".join(ln for ln in text_lines if ln),
}
return None
# ── Notification sending ───────────────────────────────────────────────────
def _send_telegram(text: str) -> bool:
"""Send a message via Telegram Bot API."""
import urllib.error
import urllib.request
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
return False
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
payload = {
"chat_id": TELEGRAM_CHAT_ID,
"text": text,
"parse_mode": "Markdown",
"disable_web_page_preview": True,
}
req = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
try:
urllib.request.urlopen(req, timeout=10)
return True
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc:
log(f"Telegram send error: {exc}")
return False
def _send_notification(notif: dict):
"""Send notification to Discord (embed) and Telegram (text)."""
import urllib.error
import urllib.request
# Discord embed
discord_ok = False
token = _get_discord_token()
embed = notif.get("discord", {})
if token and DISCORD_CHANNEL_ID and embed:
url = f"https://discord.com/api/v10/channels/{DISCORD_CHANNEL_ID}/messages"
payload = {"embeds": [embed]}
req = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bot {token}",
"Content-Type": "application/json",
"User-Agent": "DiscordBot (https://openclaw.ai, 1.0)",
},
)
try:
urllib.request.urlopen(req, timeout=10)
discord_ok = True
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc:
log(f"Discord embed error: {exc}")
# Telegram text
tg_ok = False
text = notif.get("text", "")
if text and (TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID):
tg_ok = _send_telegram(text)
if not discord_ok and not tg_ok:
log("No notification channel available (Discord + Telegram both failed)")
def emit(event_type: str, data: dict, notify: bool = False, tier: str = ""):
"""Emit structured JSON event to stdout (one JSON line per event).
If tier is provided, builds dual-format notification and sends to
Discord (embed) + Telegram (markdown text).
"""
payload = {
"type": event_type,
"ts": datetime.now(timezone.utc).isoformat(),
"notify": notify,
**data,
}
if tier:
notif = _build_notification(tier, data)
if notif:
payload["notification"] = notif
_send_notification(notif)
print(json.dumps(payload, ensure_ascii=False), flush=True)
# ── IL Estimation ───────────────────────────────────────────────────────────
def calc_pnl(stats: dict, current_usd: float, current_price: float = 0) -> dict:
"""Calculate PnL in both USD and ETH terms.
USD: cost_basis = initial_portfolio_usd + deposits; pnl = current - cost_basis
ETH: cost_basis_eth = initial_portfolio_eth + deposits_eth; pnl = current_eth - cost_basis_eth
"""
initial = stats.get("initial_portfolio_usd")
if not initial or initial <= 0:
return {
"pnl_usd": 0,
"pnl_pct": 0,
"cost_basis": 0,
"pnl_eth": 0.0,
"pnl_eth_pct": 0,
"cost_basis_eth": 0.0,
"fee_apy": 0,
"net_apy": 0,
"net_apy_eth": 0,
"days_running": 0,
"valid": False,
}
deposits = stats.get("total_deposits_usd", 0)
cost_basis = initial + deposits
pnl_usd = round(current_usd - cost_basis, 2)
pnl_pct = (pnl_usd / cost_basis * 100) if cost_basis > 0 else 0
# ETH-denominated PnL
initial_eth = stats.get("initial_portfolio_eth", 0)
if not initial_eth and stats.get("initial_eth_price"):
initial_eth = initial / stats["initial_eth_price"]
deposits_eth = stats.get("total_deposits_eth", 0)
cost_basis_eth = initial_eth + deposits_eth
current_eth = current_usd / current_price if current_price > 0 else 0
pnl_eth = round(current_eth - cost_basis_eth, 6) if cost_basis_eth > 0 else 0.0
pnl_eth_pct = (pnl_eth / cost_basis_eth * 100) if cost_basis_eth > 0 else 0
# Annualized yields
started = stats.get("started_at", "")
started_dt = _safe_isoparse(started) if started else None
days = (datetime.now(timezone.utc) - started_dt).total_seconds() / 86400 if started_dt else 0
days = max(days, 0.01)
total_fees = stats.get("total_fees_claimed_usd", 0) + stats.get(
"unclaimed_fee_usd", 0
)
fee_apy = (total_fees / cost_basis / days * 365 * 100) if cost_basis > 0 else 0
net_apy = (pnl_usd / cost_basis / days * 365 * 100) if cost_basis > 0 else 0
net_apy_eth = (pnl_eth / cost_basis_eth / days * 365 * 100) if cost_basis_eth > 0 else 0
return {
"pnl_usd": pnl_usd,
"pnl_pct": round(pnl_pct, 2),
"cost_basis": cost_basis,
"pnl_eth": pnl_eth,
"pnl_eth_pct": round(pnl_eth_pct, 2),
"cost_basis_eth": round(cost_basis_eth, 6),
"fee_apy": round(fee_apy, 1),
"net_apy": round(net_apy, 1),
"net_apy_eth": round(net_apy_eth, 1),
"days_running": round(days, 1),
"valid": True,
}
def estimate_il(
entry_price: float,
current_price: float,
range_lower: float = 0,
range_upper: float = 0,
) -> float:
"""Exact V3 impermanent loss for concentrated liquidity position.
Compares LP position value vs HODL value at current price.
If range bounds not provided, falls back to V2 full-range formula.
Handles in-range, below-range, and above-range cases.
Returns negative percentage (loss) or 0.
"""
if entry_price <= 0 or current_price <= 0:
return 0.0
# V2 fallback if no range provided
if not (range_lower > 0 and range_upper > range_lower):
r = current_price / entry_price
il = 2 * math.sqrt(r) / (1 + r) - 1
return round(il * 100, 2)
sp0 = math.sqrt(entry_price) # sqrt of entry price
sp1 = math.sqrt(current_price) # sqrt of current price
spa = math.sqrt(range_lower) # sqrt of range lower
spb = math.sqrt(range_upper) # sqrt of range upper
# Clamp entry price to range (should be in-range at deposit time)
sp0 = max(spa, min(spb, sp0))
# Token amounts at entry (per unit liquidity L=1)
x0 = 1 / sp0 - 1 / spb # token0 (ETH)
y0 = sp0 - spa # token1 (USDC)
# HODL value at current price
v_hold = x0 * current_price + y0
if v_hold <= 0:
return 0.0
# LP position value at current price (handle out-of-range)
if current_price <= range_lower:
# All token0 (ETH), no token1
x1 = 1 / spa - 1 / spb
v_lp = x1 * current_price
elif current_price >= range_upper:
# All token1 (USDC), no token0
y1 = spb - spa
v_lp = y1
else:
# In range
v_lp = 2 * sp1 - current_price / spb - spa
il = v_lp / v_hold - 1
return round(il * 100, 2)
# ── Core Logic: tick ────────────────────────────────────────────────────────
STOP_AUTO_RESUME = CFG.get("stop_auto_resume", True)
STOP_RESUME_COOLDOWN = CFG.get("stop_resume_cooldown_seconds", 3600) # 1h default
STOP_RESUME_REBOUND_PCT = CFG.get("stop_resume_rebound_pct", 0.02) # 2% default
MAX_AUTO_RESUMES_24H = CFG.get("max_auto_resumes_24h", 2) # max 2 auto-resumes per 24h
MAX_DEPOSIT_FAILURES = CFG.get("max_deposit_failures", 3) # pause after N consecutive deposit fails
MAX_BALANCE_FAILURES = CFG.get("max_balance_failures", 5)
def tick():
"""Main loop: check position, decide rebalance, execute."""
# Process lock — prevent concurrent ticks
if not _acquire_lock():
log("Another tick is already running — skipping")
emit("tick", {"status": "locked", "retriable": False})
return
try:
_tick_inner()
finally:
_release_lock()
def _tick_inner():
"""Actual tick logic (called under process lock)."""
state = load_state()
# Ensure wallet_address is stored in state
if WALLET_ADDR and state.get("wallet_address") != WALLET_ADDR:
state["wallet_address"] = WALLET_ADDR
# Check for in-progress rebalance from crashed previous tick
if state.get("_rebalance_in_progress"):
log(
"Previous rebalance was interrupted — clearing position, next tick will re-deposit"
)
state.pop("_rebalance_in_progress", None)
state["position"] = None
save_state(state)
# Circuit breaker
errors = state.get("errors", {})
if not isinstance(errors, dict):
errors = {"consecutive": 0, "cooldown_until": None}
state["errors"] = errors
if errors.get("consecutive", 0) >= MAX_CONSECUTIVE_ERRORS:
cooldown_dt = _safe_isoparse(errors.get("cooldown_until", ""))
if cooldown_dt and cooldown_dt > datetime.now():
remaining = int((cooldown_dt - datetime.now()).total_seconds()) // 60
log(f"CIRCUIT BREAKER: cooldown {remaining}min remaining")
emit(
"tick",
{
"status": "circuit_breaker",
"retriable": False,
"remaining_min": remaining,
},
)
return
else:
errors["consecutive"] = 0
errors["cooldown_until"] = None
# Get price
price = get_eth_price()
if not price:
errors["consecutive"] = errors.get("consecutive", 0) + 1
if errors["consecutive"] >= MAX_CONSECUTIVE_ERRORS:
errors["cooldown_until"] = (
datetime.now() + timedelta(seconds=COOLDOWN_AFTER_ERRORS)
).isoformat()
log(f"CIRCUIT BREAKER TRIGGERED after {errors['consecutive']} errors")
state["errors"] = errors
save_state(state)
log("Failed to get price")
emit(
"tick",
{"status": "error", "reason": "price_fetch_failed", "retriable": True},
)
return
errors["consecutive"] = 0
state["errors"] = errors
# Update price history (keep 288 = 24h @ 5min)
history = state.get("price_history", [])
history.append(price)
if len(history) > 288:
history = history[-288:]
state["price_history"] = history
# Balances (wallet + LP position)
eth_bal, usdc_bal, balance_failed = get_balances()
if balance_failed:
consec_bal_fail = state.get("_consecutive_balance_failures", 0) + 1
state["_consecutive_balance_failures"] = consec_bal_fail
if consec_bal_fail >= MAX_BALANCE_FAILURES:
log(
f"Balance query failed {consec_bal_fail} consecutive times — "
f"pausing trading until balance recovers"
)
save_state(state)
emit(
"tick",
{
"status": "balance_unavailable",
"consecutive_failures": consec_bal_fail,
"price": round(price, 2),
},
notify=(consec_bal_fail == MAX_BALANCE_FAILURES),
)
return
# Use last known balances for non-critical operations
last_bal = state.get("last_balances", {})
if last_bal.get("eth", 0) > 0 or last_bal.get("usdc", 0) > 0:
eth_bal = last_bal.get("eth", 0)
usdc_bal = last_bal.get("usdc", 0)
log(
f"Balance query failed ({consec_bal_fail}x) — using last known: ETH={eth_bal}, USDC={usdc_bal}"
)
else:
if state.get("_consecutive_balance_failures", 0) > 0:
log(
f"Balance query recovered after {state['_consecutive_balance_failures']} failures"
)
state["_consecutive_balance_failures"] = 0
wallet_usd = eth_bal * price + usdc_bal
position = state.get("position")
lp_value = 0.0
unclaimed_fee = 0.0
lp_assets = []
# Recover token_id if missing but position exists
if position and not position.get("token_id") and position.get("tick_lower"):
recovered_id = find_latest_token_id()
if recovered_id:
position["token_id"] = recovered_id
log(f"Recovered token_id: {recovered_id}")
# Clean up residual positions (from failed rebalances)
if position and position.get("token_id"):
cleanup_residual_positions(position["token_id"])
if position and position.get("token_id"):
pos_detail = get_position_detail(position["token_id"])
lp_value = pos_detail["value"]
unclaimed_fee = pos_detail["unclaimed_fee_usd"]
lp_assets = pos_detail.get("assets", [])
if lp_value == 0.0 and position.get("tick_lower"):
# Position exists in state but API returned 0 — treat as query failure
balance_failed = True
log("LP position query returned 0 value — treating as query failure")
total_usd = wallet_usd + lp_value
# Track unclaimed fees in stats
state.setdefault("stats", {})["unclaimed_fee_usd"] = round(unclaimed_fee, 2)
# Track portfolio value history for smoothing (only when data is reliable)
if not balance_failed:
value_history = state.get("_value_history", [])
value_history.append(round(total_usd, 2))
if len(value_history) > 12: # keep ~1h @ 5min ticks
value_history = value_history[-12:]
state["_value_history"] = value_history
# Initial snapshot — config override > runtime snapshot
if state["stats"].get("initial_portfolio_usd") is None and not balance_failed:
cfg_initial = CFG.get("initial_investment_usd")
if cfg_initial and cfg_initial > 0:
state["stats"]["initial_portfolio_usd"] = float(cfg_initial)
state["stats"]["initial_eth_price"] = round(price, 2)
state["stats"]["initial_portfolio_eth"] = round(float(cfg_initial) / price, 6)
log(f"Initial portfolio (config): cfg_initial @ ETH .2f")
elif total_usd > 0:
state["stats"]["initial_portfolio_usd"] = round(total_usd, 2)
state["stats"]["initial_eth_price"] = round(price, 2)
state["stats"]["initial_portfolio_eth"] = round(total_usd / price, 6)
log(f"Initial portfolio (snapshot): .2f @ ETH .2f")
# MTF analysis
mtf = analyze_multi_timeframe(history, price)
# K-line volatility (refresh hourly)
kline_vol = None
kline_cache = state.get("kline_cache")
kline_stale = True
if kline_cache and kline_cache.get("fetched_at"):
fetched_dt = _safe_isoparse(kline_cache["fetched_at"])
if fetched_dt:
elapsed = (datetime.now() - fetched_dt).total_seconds()
kline_stale = elapsed > 3600
if kline_stale:
candles = get_kline_data("1H", 24)
if candles:
kline_vol = calc_kline_volatility(candles)
state["kline_cache"] = {
"atr_pct": round(kline_vol, 3),
"candles_count": len(candles),
"fetched_at": datetime.now().isoformat(),
}
else:
kline_vol = kline_cache.get("atr_pct") if kline_cache else None
else:
kline_vol = kline_cache.get("atr_pct") if kline_cache else None
atr_pct = kline_vol if kline_vol else 2.0 # default medium
# Update vol history
vol_history = state.get("vol_history", [])
vol_history.append(
{"time": datetime.now().isoformat(), "atr_pct": round(atr_pct, 3)}
)
if len(vol_history) > 288:
vol_history = vol_history[-288:]
state["vol_history"] = vol_history
# Stop check — with auto-resume and log dedup
if state.get("stop_triggered"):
trigger_msg = state["stop_triggered"]
# Auto-resume logic
resumed = False
if STOP_AUTO_RESUME and "trailing_stop" in trigger_msg:
# Check 24h resume count limit
resume_log = state.get("_auto_resume_log", [])
cutoff = (datetime.now() - timedelta(hours=24)).isoformat()
resume_log = [t for t in resume_log if t > cutoff]
if len(resume_log) >= MAX_AUTO_RESUMES_24H:
log(
f"AUTO-RESUME BLOCKED: {len(resume_log)} resumes in 24h "
f"(limit={MAX_AUTO_RESUMES_24H})"
)
else:
stop_time = _safe_isoparse(state.get("stop_triggered_at", ""))
cooldown_met = (
not stop_time
or (datetime.now() - stop_time).total_seconds()
> STOP_RESUME_COOLDOWN
)
# Check drawdown recovery (use smoothed value)
stats = state.get("stats", {})
peak = stats.get("portfolio_peak_usd", 0)
value_history = state.get("_value_history", [])
smooth_usd = (
sorted(value_history[-5:])[len(value_history[-5:]) // 2]
if len(value_history) >= 3
else total_usd
)
current_drawdown = (
(peak - smooth_usd) / peak if peak > 0 else 1.0
)
# Resume if drawdown recovered below threshold with margin
resume_threshold = (
TRAILING_STOP_PCT * 0.7
) # must recover to 70% of stop level
if cooldown_met and current_drawdown < resume_threshold:
log(
f"AUTO-RESUME: drawdown {current_drawdown:.1%} < "
f"{resume_threshold:.1%} threshold, cooldown met "
f"(resume {len(resume_log)+1}/{MAX_AUTO_RESUMES_24H})"
)
state.pop("stop_triggered", None)
state.pop("stop_notified", None)
state.pop("stop_triggered_at", None)
# Reset peak to current value to prevent immediate re-trigger
stats["portfolio_peak_usd"] = round(smooth_usd, 2)
# Record this resume
resume_log.append(datetime.now().isoformat())
state["_auto_resume_log"] = resume_log
save_state(state)
resumed_data = {
"status": "stop_resumed",
"previous_trigger": trigger_msg,
"drawdown_pct": round(current_drawdown * 100, 2),
"price": round(price, 2),
"resume_count_24h": len(resume_log),
}
emit(
"stop_resumed",
resumed_data,
notify=True,
tier="trade_alert",
)
resumed = True
if not resumed:
# Log dedup: only log stop message once per hour
last_stop_log = _safe_isoparse(state.get("_last_stop_log", ""))
if (
not last_stop_log
or (datetime.now() - last_stop_log).total_seconds() > 3600
):
log(f"STOP ACTIVE: {trigger_msg}")
state["_last_stop_log"] = datetime.now().isoformat()
first_notify = not state.get("stop_notified")
if first_notify:
state["stop_notified"] = True
state["stop_triggered_at"] = datetime.now().isoformat()
save_state(state)
stop_data = {
"status": "stop_triggered" if first_notify else "stopped",
"trigger": trigger_msg,
"price": round(price, 2),
"portfolio_usd": round(total_usd, 2),
"auto_resume": STOP_AUTO_RESUME,
}
emit(
"stop_triggered" if first_notify else "stopped",
stop_data,
notify=first_notify,
tier="risk_alert" if first_notify else "",
)
return
# IL estimation (use position entry_price, fallback to initial)
position = state.get("position")
if position and position.get("created_at"):
entry_price = position.get("entry_price") or state["stats"].get(
"initial_eth_price", price
)
il_pct = estimate_il(
entry_price,
price,
(position.get("lower_price") or 0),
(position.get("upper_price") or 0),
)
state["stats"]["estimated_il_pct"] = il_pct
# Risk checks (pre-trigger) — skip if balance query failed
trigger = check_rebalance_triggers(price, state, atr_pct, mtf)
if balance_failed:
log("Balance query failed — skipping risk checks this tick")
risk_reject = None
else:
risk_reject = run_risk_checks(state, price, total_usd, trigger)
tick_status = "no_action"
rebalanced = False
if not position or not position.get("tick_lower"):
# No position — clean up any orphaned positions before creating new one
residual_cleaned = cleanup_residual_positions("")
if residual_cleaned:
log(f"Cleaned {residual_cleaned} orphaned position(s) before initial deposit")
eth_bal, usdc_bal, balance_failed = get_balances(force=True)
total_usd = eth_bal * price + usdc_bal
log("No active position — creating initial LP position")
new_range = calc_optimal_range(price, atr_pct, mtf)
log(
f"Initial range: .2f-.2f "
f"({new_range['regime']}, width {new_range['half_width_pct']:.1f}%)"
)
# For initial deposit, skip risk checks except data validation
initial_trigger = {
"trigger": "initial_deposit",
"priority": "mandatory",
"detail": "first position",
}
if total_usd < MIN_TRADE_USD:
log(f"Balance too low for initial deposit: .2f")
tick_status = "insufficient_balance"
else:
rebalanced = execute_rebalance(state, price, new_range, initial_trigger)
tick_status = "initial_deposit" if rebalanced else "initial_deposit_failed"
elif risk_reject:
log(f"Risk check: {risk_reject}")
tick_status = "risk_rejected"
elif trigger:
# Check minimum range change for non-mandatory triggers
if trigger["priority"] != "mandatory":
new_range = calc_optimal_range(price, atr_pct, mtf)
old_lower = tick_to_price(position["tick_lower"])
old_upper = tick_to_price(position["tick_upper"])
old_width = old_upper - old_lower
new_width = new_range["upper_price"] - new_range["lower_price"]
if old_width > 0:
width_change = abs(new_width - old_width) / old_width
if width_change < 0.05:
# Log at most once per 4h to avoid spam
last_skip = _safe_isoparse(state.get("_last_skip_log", ""))
now = datetime.now()
if not last_skip or (now - last_skip).total_seconds() > 3600:
log(
f"Range change too small ({width_change:.1%} < 5%)"
f" — skipping [{trigger['trigger']}]"
)
state["_last_skip_log"] = now.isoformat()
trigger = None
tick_status = "skip_small_change"
if trigger:
new_range = calc_optimal_range(price, atr_pct, mtf)
log(
f"Rebalance triggered: {trigger['trigger']} ({trigger.get('detail', '')}) "
f"-> .2f-.2f "
f"({new_range['regime']})"
)
rebalanced = execute_rebalance(state, price, new_range, trigger)
if rebalanced:
tick_status = "rebalanced"
errors["consecutive"] = 0
else:
tick_status = "rebalance_failed"
errors["consecutive"] = errors.get("consecutive", 0) + 1
n = errors["consecutive"]
# Exponential backoff: 10min, 20min, 40min, ... capped at COOLDOWN_AFTER_ERRORS
backoff = min(600 * (2 ** (n - 1)), COOLDOWN_AFTER_ERRORS)
errors["cooldown_until"] = (
datetime.now() + timedelta(seconds=backoff)
).isoformat()
log(f"Rebalance failed ({n} consecutive) — cooldown {backoff // 60}min")
state["errors"] = errors
else:
tick_status = "in_range"
# Update time-in-range
position = state.get("position")
if position and position.get("tick_lower"):
lower_p = tick_to_price(position["tick_lower"])
upper_p = tick_to_price(position["tick_upper"])
in_range = lower_p <= price <= upper_p
# Running average
checks = len(history)
old_pct = state["stats"].get("time_in_range_pct", 100.0)
if checks > 1:
state["stats"]["time_in_range_pct"] = round(
old_pct * (checks - 1) / checks + (100.0 if in_range else 0.0) / checks,
1,
)
state["stats"]["last_check"] = datetime.now().isoformat()
# Save balance snapshot for fallback
if not balance_failed:
state["last_balances"] = {
"eth": round(eth_bal, 6),
"usdc": round(usdc_bal, 2),
"time": datetime.now().isoformat(),
}
save_state(state)
# Emit tick event
is_trade = tick_status in (
"rebalanced",
"first_deploy",
"initial_deposit",
)
is_quiet = tick_status in (
"in_range",
"no_action",
"risk_rejected",
"skip_small_change",
)
stats = state.get("stats", {})
pnl = calc_pnl(stats, total_usd, price)
unclaimed_fee_usd = stats.get("unclaimed_fee_usd", 0)
claimed_fee_usd = stats.get("total_fees_claimed_usd", 0)
# IL estimation: exact V3 formula, position entry price > initial price
pos_entry = (position.get("entry_price") or 0) if position else 0
entry_price = pos_entry or stats.get("initial_eth_price", 0)
pos_lower = (position.get("lower_price") or 0) if position else 0
pos_upper = (position.get("upper_price") or 0) if position else 0
il_pct = (
estimate_il(entry_price, price, pos_lower, pos_upper) if entry_price else 0.0
)
il_usd = round(il_pct / 100 * total_usd, 2) if il_pct else 0.0
tick_data = {
"status": tick_status,
"price": round(price, 2),
"atr_pct": round(atr_pct, 2),
"regime": classify_volatility(atr_pct),
"trend": mtf.get("trend", "neutral"),
"trend_strength": round(mtf.get("strength", 0), 2),
"portfolio_usd": round(total_usd, 2),
"pnl_usd": pnl["pnl_usd"],
"pnl_pct": round(pnl["pnl_pct"], 2),
"pnl_eth": pnl["pnl_eth"],
"pnl_eth_pct": round(pnl["pnl_eth_pct"], 2),
"pnl_valid": pnl["valid"],
"unclaimed_fee_usd": round(unclaimed_fee_usd, 2),
"total_fees_claimed_usd": round(claimed_fee_usd, 2),
"il_pct": round(il_pct, 2),
"il_usd": il_usd,
"fee_apy": pnl["fee_apy"],
"net_apy": pnl["net_apy"],
"net_apy_eth": pnl["net_apy_eth"],
"days_running": pnl["days_running"],
"cost_basis": pnl["cost_basis"],
"cost_basis_eth": pnl["cost_basis_eth"],
"balances": {
"eth": round(eth_bal, 6),
"usdc": round(usdc_bal, 2),
"lp_usd": round(lp_value, 2),
"lp_assets": [
{
"symbol": a.get("tokenSymbol", ""),
"amount": float(a.get("coinAmount", 0)),
}
for a in lp_assets
if float(a.get("coinAmount", 0)) > 0
],
},
"time_in_range_pct": stats.get("time_in_range_pct", 0),
"total_rebalances": stats.get("total_rebalances", 0),
}
if position and position.get("tick_lower"):
tick_data["position"] = {
"tick_lower": position["tick_lower"],
"tick_upper": position["tick_upper"],
"lower_price": position.get("lower_price"),
"upper_price": position.get("upper_price"),
}
if trigger:
tick_data["trigger"] = trigger.get("trigger", str(trigger))
# Determine notification tier
tier = ""
if is_trade:
tier = "trade_alert"
elif not is_quiet:
pass # non-quiet non-trade: emit without notification
else:
# Hourly pulse: push if QUIET_INTERVAL elapsed since last notification
last_notify = _safe_isoparse(state.get("_last_notify_ts", ""))
if (
not last_notify
or (datetime.now() - last_notify).total_seconds() >= QUIET_INTERVAL
):
tier = "hourly_pulse"
state["_last_notify_ts"] = datetime.now().isoformat()
save_state(state)
notify = tier != ""
emit("tick", tick_data, notify=notify, tier=tier)
# Cache snapshot for fast status queries (includes external portfolio)
tick_data["_cached_at"] = datetime.now().isoformat()
tick_data["external_portfolio"] = query_external_portfolio()
state["_cached_snapshot"] = tick_data
save_state(state)
# ── Sub-commands ────────────────────────────────────────────────────────────
def _print_status_from_snapshot(snap: dict, state: dict, cached_age_s: float = 0):
"""Render status text from a tick_data snapshot (cached or live)."""
position = state.get("position")
stats = state.get("stats", {})
price = snap.get("price", 0)
bals = snap.get("balances", {})
eth_bal = bals.get("eth", 0)
usdc_bal = bals.get("usdc", 0)
lp_value = bals.get("lp_usd", 0)
lp_assets = bals.get("lp_assets", [])
total_usd = snap.get("portfolio_usd", 0)
unclaimed_fee = snap.get("unclaimed_fee_usd", 0)
# ── Portfolio (cross-platform) ──
ext = snap.get("external_portfolio", {})
if ext or total_usd > 0:
print("**Portfolio**")
grand_total = 0.0
for platform, value in ext.items():
print(f"> {platform}: `,.2f`")
grand_total += value
if total_usd > 0:
print(f"> Base (CL-LP): `,.2f`")
grand_total += total_usd
print(f"> **Total: `,.2f`**")
print()
header = "**CL LP Auto-Rebalancer v1 -- 状态**"
if cached_age_s > 0:
header += f" (缓存 {cached_age_s:.0f}s 前)"
print(header)
print(f"> 价格: `.2f`" if price else "> 价格: 不可用")
# Wallet + LP breakdown
wallet_usd = eth_bal * price + usdc_bal
print(f"> 钱包: `{eth_bal:.6f}` ETH + `.2f` USDC (`.0f`)")
if lp_value > 0:
lp_detail_parts = []
for a in lp_assets:
sym = a.get("symbol", "")
amt = a.get("amount", 0)
if sym and amt > 0:
usd_val = amt * price if sym.upper() in ("WETH", "ETH") else amt
lp_detail_parts.append(f"{amt:.4f} {sym} (,.0f)")
lp_detail = " + ".join(lp_detail_parts) if lp_detail_parts else ""
lp_line = f"> LP 头寸: `.0f`"
if lp_detail:
lp_line += f" ({lp_detail})"
if unclaimed_fee > 0.01:
lp_line += f" | 待领手续费: `.2f`"
print(lp_line)
print(f"> **总价值: `.0f`**")
# Position range
pos_snap = snap.get("position")
if pos_snap:
lower_p = pos_snap.get("lower_price", 0)
upper_p = pos_snap.get("upper_price", 0)
if lower_p and upper_p:
in_range = lower_p <= (price or 0) <= upper_p
status_emoji = "✅" if in_range else "⚠️"
status_str = "范围内" if in_range else "范围外"
edge_str = ""
if in_range and price:
edge_dist = min(
(price - lower_p) / (upper_p - lower_p),
(upper_p - price) / (upper_p - lower_p),
)
edge_str = f" | 边缘距离: `{edge_dist:.0%}`"
print(
f"> {status_emoji} 范围: `.2f` - `.2f` ({status_str}{edge_str})"
)
if price:
bar_w = 30
pos_ratio = max(0.0, min(1.0, (price - lower_p) / (upper_p - lower_p)))
cursor = round(pos_ratio * (bar_w - 1))
bar = list("░" * bar_w)
if 0 <= cursor < bar_w:
bar[cursor] = "█"
print(f"> `.0f [{''.join(bar)}] .0f`")
print(f"> `{' ' * (len(f'.0f [') + cursor)}▲.0f`")
elif position and position.get("tick_lower"):
lower_p = position.get("lower_price", tick_to_price(position["tick_lower"]))
upper_p = position.get("upper_price", tick_to_price(position["tick_upper"]))
print(f"> 范围: `.2f` - `.2f`")
else:
print("> 头寸: 未建立")
if position and position.get("created_at"):
created_dt = _safe_isoparse(position["created_at"])
if created_dt:
age_h = (datetime.now() - created_dt).total_seconds() / 3600
rebal_count = stats.get("total_rebalances", 0)
print(
f"> 头寸年龄: `{age_h:.1f}h` | 调仓: `{rebal_count}` 次 | token_id: `{position.get('token_id', 'N/A')}`"
)
# ATR + Trend
atr_pct = snap.get("atr_pct", 0)
regime = snap.get("regime", "")
if atr_pct:
print(f"\n> ATR(1H): `{atr_pct:.2f}%` ({regime})")
trend = snap.get("trend", "")
trend_str = snap.get("trend_strength", 0)
if trend:
print(f"> 趋势: `{trend}` ({trend_str:.0%})")
# PnL & Yield
print("\n**收益**")
if snap.get("pnl_valid") and price:
print(f"> PnL: **`+.2f`** (`{snap['pnl_pct']:+.1f}%`)")
print(
f"> 年化 APY: Fee `{snap.get('fee_apy', 0):+.1f}%` | Net `{snap.get('net_apy', 0):+.1f}%` (运行 `{snap.get('days_running', 0):.1f}` 天)"
)
else:
print("> PnL: 数据不足")
claimed_fee = snap.get("total_fees_claimed_usd", 0)
total_fees = claimed_fee + unclaimed_fee
if total_fees > 0.01:
print(
f"> LP 手续费: `.2f` (已领 `.2f` + 待领 `.2f`)"
)
il_pct = snap.get("il_pct", 0)
il_usd = snap.get("il_usd", 0)
if il_pct:
print(f"> 无常损失: `.2f` (`{il_pct:.2f}%`)")
print("\n**运行**")
tir = snap.get("time_in_range_pct", 0)
rebalances = snap.get("total_rebalances", 0)
print(f"> 范围内时间: `{tir:.0f}%` | 调仓次数: `{rebalances}`")
stop = state.get("stop_triggered")
if stop:
print(f"\n> 🔴 **交易已停止**: `{stop}`")
print("> 使用 `resume-trading` 恢复")
# Maximum cache age: 360s (slightly over 5-min tick interval)
_SNAPSHOT_MAX_AGE_S = 360
def status():
"""Print current status — uses cached tick snapshot if fresh, else queries live."""
state = load_state()
# Try cached snapshot first
cached = state.get("_cached_snapshot")
if cached:
cached_at = _safe_isoparse(cached.get("_cached_at", ""))
if cached_at:
age_s = (datetime.now() - cached_at).total_seconds()
if age_s < _SNAPSHOT_MAX_AGE_S:
_print_status_from_snapshot(cached, state, cached_age_s=age_s)
return
# Cache stale or missing — live query (force refresh for accurate display)
price = get_eth_price()
eth_bal, usdc_bal, bal_failed = get_balances(force=True)
if bal_failed:
print("> 余额查询失败,显示的余额可能不准确")
wallet_usd = eth_bal * (price or 0) + usdc_bal
position = state.get("position")
lp_value = 0.0
unclaimed_fee = 0.0
lp_assets_raw = []
if position and position.get("token_id"):
pos_detail = get_position_detail(position["token_id"])
lp_value = pos_detail["value"]
unclaimed_fee = pos_detail["unclaimed_fee_usd"]
lp_assets_raw = pos_detail.get("assets", [])
total_usd = wallet_usd + lp_value
stats = state.get("stats", {})
history = state.get("price_history", [])
# Build a live snapshot in tick_data format for the shared renderer
stats["unclaimed_fee_usd"] = round(unclaimed_fee, 2)
pnl = calc_pnl(stats, total_usd, price)
pos_entry = (position.get("entry_price") or 0) if position else 0
entry_price = pos_entry or stats.get("initial_eth_price", 0)
pos_lower = (position.get("lower_price") or 0) if position else 0
pos_upper = (position.get("upper_price") or 0) if position else 0
il_pct = (
estimate_il(entry_price, price, pos_lower, pos_upper) if entry_price else 0.0
)
il_usd = round(il_pct / 100 * total_usd, 2) if il_pct else 0.0
# MTF
mtf = {}
if price and len(history) >= MTF_SHORT_PERIOD:
mtf = analyze_multi_timeframe(history, price)
# ATR
kline_cache = state.get("kline_cache")
atr_pct = kline_cache.get("atr_pct", 0) if kline_cache else 0
live_snap = {
"price": round(price, 2) if price else 0,
"atr_pct": round(atr_pct, 2),
"regime": classify_volatility(atr_pct),
"trend": mtf.get("trend", ""),
"trend_strength": round(mtf.get("strength", 0), 2),
"portfolio_usd": round(total_usd, 2),
"pnl_usd": pnl["pnl_usd"],
"pnl_pct": round(pnl["pnl_pct"], 2),
"pnl_eth": pnl["pnl_eth"],
"pnl_eth_pct": round(pnl["pnl_eth_pct"], 2),
"pnl_valid": pnl["valid"],
"unclaimed_fee_usd": round(unclaimed_fee, 2),
"total_fees_claimed_usd": round(stats.get("total_fees_claimed_usd", 0), 2),
"il_pct": round(il_pct, 2),
"il_usd": il_usd,
"fee_apy": pnl["fee_apy"],
"net_apy": pnl["net_apy"],
"net_apy_eth": pnl["net_apy_eth"],
"days_running": pnl["days_running"],
"cost_basis": pnl["cost_basis"],
"cost_basis_eth": pnl["cost_basis_eth"],
"balances": {
"eth": round(eth_bal, 6),
"usdc": round(usdc_bal, 2),
"lp_usd": round(lp_value, 2),
"lp_assets": [
{
"symbol": a.get("tokenSymbol", ""),
"amount": float(a.get("coinAmount", 0)),
}
for a in lp_assets_raw
if float(a.get("coinAmount", 0)) > 0
],
},
"time_in_range_pct": stats.get("time_in_range_pct", 0),
"total_rebalances": stats.get("total_rebalances", 0),
}
if position and position.get("tick_lower"):
live_snap["position"] = {
"tick_lower": position["tick_lower"],
"tick_upper": position["tick_upper"],
"lower_price": position.get("lower_price"),
"upper_price": position.get("upper_price"),
}
live_snap["external_portfolio"] = query_external_portfolio()
_print_status_from_snapshot(live_snap, state)
def report():
"""Daily report."""
state = load_state()
price = get_eth_price()
eth_bal, usdc_bal, _ = get_balances()
total_usd = eth_bal * (price or 0) + usdc_bal
stats = state.get("stats", {})
position = state.get("position")
history = state.get("price_history", [])
rebalances = state.get("rebalance_history", [])
kline_cache = state.get("kline_cache")
atr = kline_cache.get("atr_pct", 0) if kline_cache else 0
regime = classify_volatility(atr)
tir = stats.get("time_in_range_pct", 0)
total_rebal = stats.get("total_rebalances", 0)
today = datetime.now().date().isoformat()
today_rebal = [r for r in rebalances if r["time"].startswith(today)]
mtf = {}
if price and len(history) >= MTF_SHORT_PERIOD:
mtf = analyze_multi_timeframe(history, price)
# Refetch position for fresh unclaimed fees (report runs independently)
unclaimed_fee = 0.0
lp_value = 0.0
if position and position.get("token_id"):
pos_detail = get_position_detail(position["token_id"])
unclaimed_fee = pos_detail.get("unclaimed_fee_usd", 0)
lp_value = pos_detail.get("value", 0)
total_usd = eth_bal * (price or 0) + usdc_bal + lp_value
# Update unclaimed in stats so calc_pnl picks it up for APY
stats["unclaimed_fee_usd"] = round(unclaimed_fee, 2)
claimed_fee = stats.get("total_fees_claimed_usd", 0)
# Recalculate PnL with fresh total_usd (includes LP value)
pnl = calc_pnl(stats, total_usd, price)
# IL: exact V3 formula, position entry price > initial price
pos_entry = (position.get("entry_price") or 0) if position else 0
entry_price = pos_entry or stats.get("initial_eth_price")
pos_lower = (position.get("lower_price") or 0) if position else 0
pos_upper = (position.get("upper_price") or 0) if position else 0
il_pct = (
estimate_il(entry_price, price, pos_lower, pos_upper)
if entry_price and price
else 0.0
)
il_usd = round(il_pct / 100 * total_usd, 2) if il_pct else 0.0
report_data = {
"price": round(price, 2) if price else None,
"atr_pct": round(atr, 2),
"regime": regime,
"balances": {"eth": round(eth_bal, 6), "usdc": round(usdc_bal, 2)},
"portfolio_usd": round(total_usd, 2),
"pnl_usd": pnl["pnl_usd"],
"pnl_pct": round(pnl["pnl_pct"], 2),
"pnl_eth": pnl["pnl_eth"],
"pnl_eth_pct": round(pnl["pnl_eth_pct"], 2),
"pnl_valid": pnl["valid"],
"fee_apy": pnl["fee_apy"],
"net_apy": pnl["net_apy"],
"net_apy_eth": pnl["net_apy_eth"],
"days_running": pnl["days_running"],
"cost_basis": pnl["cost_basis"],
"cost_basis_eth": pnl["cost_basis_eth"],
"total_fees_claimed_usd": round(claimed_fee, 2),
"unclaimed_fee_usd": round(unclaimed_fee, 2),
"il_pct": round(il_pct, 2),
"il_usd": il_usd,
"time_in_range_pct": round(tir, 1),
"total_rebalances": total_rebal,
"today_rebalances": today_rebal[-5:],
"trend": mtf.get("trend", "neutral"),
"trend_strength": round(mtf.get("strength", 0), 2),
"started_at": stats.get("started_at", ""),
}
if position and position.get("lower_price"):
in_range = position["lower_price"] <= (price or 0) <= position["upper_price"]
report_data["position"] = {
"lower_price": position["lower_price"],
"upper_price": position["upper_price"],
"in_range": in_range,
}
emit("report", report_data, notify=True, tier="daily_report")
def history_cmd():
"""Show rebalance history."""
state = load_state()
rebalances = state.get("rebalance_history", [])
if not rebalances:
print("暂无调仓记录")
return
print(f"**最近 `{len(rebalances)}` 次调仓**")
for r in rebalances:
old = r.get("old_range", [None, None])
new = r.get("new_range", [None, None])
old_str = f"[{old[0]},{old[1]}]" if old[0] else "N/A"
new_str = f"[{new[0]},{new[1]}]" if new[0] else "N/A"
print(
f"> `{r['time'][:19]}` | {r['trigger']} ({r.get('detail', '')}) "
f"| {old_str} -> {new_str}"
)
def reset():
"""Reset state, close position if active."""
state = load_state()
position = state.get("position")
# Try to close existing position — record fees before wiping state
carried_fees = 0.0
if position and position.get("token_id"):
log("Resetting: closing existing position...")
pre_claim = get_position_detail(position["token_id"])
unclaimed = pre_claim.get("unclaimed_fee_usd", 0)
claimed = defi_claim_fees(position["token_id"])
if claimed and unclaimed > 0:
carried_fees = round(
state["stats"].get("total_fees_claimed_usd", 0) + unclaimed, 2
)
log(f" Fees claimed on reset: .2f (carried: .2f)")
else:
carried_fees = round(state["stats"].get("total_fees_claimed_usd", 0), 2)
defi_redeem(position["token_id"])
new_state = (
load_state.__wrapped__()
if hasattr(load_state, "__wrapped__")
else {
"version": 1,
"wallet_address": WALLET_ADDR,
"pool": {
"investment_id": INVESTMENT_ID,
"chain": POOL_CHAIN,
"token0": TOKEN0,
"token1": TOKEN1,
"fee_tier": FEE_TIER,
"tick_spacing": TICK_SPACING,
},
"position": None,
"price_history": [],
"vol_history": [],
"rebalance_history": [],
"stats": {
"total_rebalances": 0,
"total_fees_claimed_usd": carried_fees,
"total_gas_spent_usd": 0.0,
"time_in_range_pct": 100.0,
"net_yield_usd": 0.0,
"initial_portfolio_usd": None,
"initial_eth_price": None,
"started_at": datetime.now().isoformat(),
"last_check": None,
"total_deposits_usd": 0.0,
"deposit_history": [],
"estimated_il_pct": 0.0,
},
"errors": {"consecutive": 0, "cooldown_until": None},
"stop_triggered": None,
"approved_routers": [],
}
)
save_state(new_state)
price = get_eth_price()
eth_bal, usdc_bal, _ = get_balances()
total = eth_bal * (price or 0) + usdc_bal
print(f"LP 已重置。价格: `.2f`, 余额: `.0f`")
print("下次 tick 将重新建仓。")
def close():
"""Close position completely and exit."""
state = load_state()
position = state.get("position")
if not position or not position.get("token_id"):
print("无活跃头寸")
return
token_id = position["token_id"]
log(f"Closing position token_id={token_id}")
# Record unclaimed fees before claiming
pre_claim = get_position_detail(token_id)
unclaimed = pre_claim.get("unclaimed_fee_usd", 0)
claimed = defi_claim_fees(token_id)
if claimed and unclaimed > 0:
state["stats"]["total_fees_claimed_usd"] = round(
state["stats"].get("total_fees_claimed_usd", 0) + unclaimed, 2
)
state["stats"]["unclaimed_fee_usd"] = 0.0
log(f" Fees claimed on close: .2f (total: .2f)")
redeemed = defi_redeem(token_id)
if redeemed:
state["position"] = None
state["stop_triggered"] = "manual_close"
save_state(state)
eth_bal, usdc_bal, _ = get_balances()
price = get_eth_price()
total = eth_bal * (price or 0) + usdc_bal
emit(
"position_closed",
{
"token_id": token_id,
"portfolio_usd": round(total, 2),
"balances": {
"eth": round(eth_bal, 6),
"usdc": round(usdc_bal, 2),
},
},
notify=True,
)
else:
print("关闭失败 — 请手动检查")
def analyze():
"""Detailed JSON analysis for AI agent."""
state = load_state()
price = get_eth_price()
eth_bal, usdc_bal, _ = get_balances()
history = state.get("price_history", [])
position = state.get("position")
stats = state.get("stats", {})
rebalances = state.get("rebalance_history", [])
if not price:
print(json.dumps({"error": "price_unavailable"}))
return
total_usd = eth_bal * price + usdc_bal
mtf = analyze_multi_timeframe(history, price)
candles = get_kline_data("1H", 24)
kline_vol = calc_kline_volatility(candles) if candles else None
atr_pct = kline_vol if kline_vol else 2.0
# Optimal range if we were to rebalance now
optimal = calc_optimal_range(price, atr_pct, mtf)
# Current trigger status
trigger = check_rebalance_triggers(price, state, atr_pct, mtf)
# IL (exact V3, position entry price > initial price)
position = state.get("position")
pos_entry = (position.get("entry_price") or 0) if position else 0
initial_price = pos_entry or stats.get("initial_eth_price", price)
pos_lower = (position.get("lower_price") or 0) if position else 0
pos_upper = (position.get("upper_price") or 0) if position else 0
il_pct = estimate_il(initial_price, price, pos_lower, pos_upper)
analysis = {
"version": "1.0",
"timestamp": datetime.now().isoformat(),
"market": {
"price": round(price, 2),
"atr_pct": round(atr_pct, 2),
"regime": classify_volatility(atr_pct),
},
"multi_timeframe": mtf,
"portfolio": {
"eth": round(eth_bal, 6),
"usdc": round(usdc_bal, 2),
"total_usd": round(total_usd, 2),
"eth_pct": round((eth_bal * price / total_usd) * 100, 1)
if total_usd > 0
else 0,
},
"position": {
"tick_lower": position["tick_lower"] if position else None,
"tick_upper": position["tick_upper"] if position else None,
"lower_price": position.get("lower_price") if position else None,
"upper_price": position.get("upper_price") if position else None,
"token_id": position.get("token_id") if position else None,
"age_hours": round(
(
datetime.now() - _safe_isoparse(position["created_at"])
).total_seconds()
/ 3600,
1,
)
if position
and position.get("created_at")
and _safe_isoparse(position["created_at"])
else None,
},
"optimal_range": optimal,
"trigger": trigger,
"stats": {
"total_rebalances": stats.get("total_rebalances", 0),
"time_in_range_pct": stats.get("time_in_range_pct", 0),
"estimated_il_pct": il_pct,
"total_pnl": calc_pnl(stats, total_usd, price)["pnl_usd"],
},
"rebalance_history": rebalances[-10:],
}
print(json.dumps(analysis, indent=2))
def deposit():
"""Manually record deposit/withdrawal."""
if len(sys.argv) < 3:
print("用法: cl_lp.py deposit <金额USD>")
print("正数=存入, 负数=取出")
return
try:
amount = float(sys.argv[2])
except ValueError:
print("无效金额")
return
state = load_state()
event = {
"time": datetime.now().isoformat(),
"usd_value": round(amount, 2),
"type": "manual_deposit" if amount > 0 else "manual_withdrawal",
}
dep_history = state["stats"].get("deposit_history", [])
dep_history.append(event)
state["stats"]["deposit_history"] = dep_history
state["stats"]["total_deposits_usd"] = round(
state["stats"].get("total_deposits_usd", 0) + amount, 2
)
save_state(state)
type_cn = "存入" if amount > 0 else "取出"
print(f"已记录{type_cn}: .2f")
def resume_trading():
"""Clear stop and resume."""
state = load_state()
if not state.get("stop_triggered"):
print("交易未停止,无需恢复")
return
old_trigger = state["stop_triggered"]
state.pop("stop_triggered", None)
state.pop("stop_notified", None)
state.pop("stop_triggered_at", None)
# Reset failure counters and resume log on manual resume
state["_consecutive_deposit_failures"] = 0
state["_auto_resume_log"] = []
# Reset peak to current portfolio to prevent immediate re-trigger
eth_bal, usdc_bal, _ = get_balances(force=True)
price = get_eth_price()
if price:
current_usd = eth_bal * price + usdc_bal
state.setdefault("stats", {})["portfolio_peak_usd"] = round(current_usd, 2)
save_state(state)
log(f"Trading resumed (was: {old_trigger})")
emit(
"trading_resumed",
{"previous_trigger": old_trigger},
notify=True,
)
# ── Main ────────────────────────────────────────────────────────────────────
COMMANDS = {
"tick": tick,
"status": status,
"report": report,
"history": history_cmd,
"reset": reset,
"close": close,
"analyze": analyze,
"deposit": deposit,
"resume-trading": resume_trading,
}
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "tick"
handler = COMMANDS.get(cmd)
if handler:
handler()
else:
print(f"未知命令: {cmd}")
print(f"可用命令: {', '.join(COMMANDS.keys())}")
sys.exit(1)
FILE:references/config.json
{
"investment_id": "326890603",
"pool_chain": "base",
"chain_id": "8453",
"platform_id": "68",
"native_token": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"fee_tier": 0.003,
"tick_spacing": 60,
"token0": {
"symbol": "WETH",
"address": "0x4200000000000000000000000000000000000006",
"decimals": 18
},
"token1": {
"symbol": "USDC",
"address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"decimals": 6
},
"range_mult": {
"low": 0.5,
"medium": 0.8,
"high": 1.2,
"extreme": 1.5
},
"min_range_pct": 0,
"max_range_pct": 5,
"asym_factor": 0.3,
"min_position_age_seconds": 3600,
"max_rebalances_24h": 6,
"gas_to_fee_ratio": 0.5,
"max_il_tolerance_pct": 5.0,
"edge_proximity_threshold": 0.15,
"stop_loss_pct": 0.15,
"trailing_stop_pct": 0.1,
"slippage_pct": 1,
"gas_reserve_eth": 0.02,
"min_trade_usd": 5.0,
"quiet_interval_seconds": 1800,
"max_consecutive_errors": 5,
"cooldown_after_errors_seconds": 3600,
"initial_investment_usd": 440
}
FILE:references/range-algorithm.md
# Range Algorithm Documentation
CL LP Auto-Rebalancer 的核心范围计算算法详解。
## Overview
核心思想:**波动率决定范围宽度**。
```
ATR(24h) → 波动率分类 → 范围乘数 → 趋势不对称调整 → tick 对齐 → 最终范围
```
低波动率时收紧范围以提高资本效率(更多手续费捕获),高波动率时放宽范围以减少调仓频率和无常损失。
## Step 1: ATR Calculation
使用 1H K 线(24 根)计算 Average True Range:
```python
def calc_atr(candles):
"""Calculate ATR from 1H OHLCV candles."""
true_ranges = []
for i in range(1, len(candles)):
high = float(candles[i]["high"])
low = float(candles[i]["low"])
prev_close = float(candles[i-1]["close"])
tr = max(
high - low, # 当前 bar 振幅
abs(high - prev_close), # 跳空高开
abs(low - prev_close) # 跳空低开
)
true_ranges.append(tr)
return sum(true_ranges) / len(true_ranges) if true_ranges else 0
# ATR percentage (relative to current price)
atr_pct = (atr / current_price) * 100
```
**Why ATR over stddev?** ATR 使用 OHLC 数据(包含 bar 内波动),比收盘价标准差更准确地反映真实波动幅度。特别是在有大影线但收盘价变化不大的情况下,ATR 能捕获到 stddev 忽略的波动。
## Step 2: Volatility Classification
将 ATR 百分比映射到波动率等级:
```python
VOL_THRESHOLD_LOW = 1.5 # %
VOL_THRESHOLD_HIGH = 3.0 # %
VOL_THRESHOLD_EXTREME = 5.0 # %
def classify_volatility(atr_pct):
if atr_pct < VOL_THRESHOLD_LOW:
return "low"
elif atr_pct < VOL_THRESHOLD_HIGH:
return "medium"
elif atr_pct < VOL_THRESHOLD_EXTREME:
return "high"
else:
return "extreme"
```
| Volatility Class | ATR Range | Market Condition |
|---|---|---|
| Low | < 1.5% | 横盘/低波动,典型的低流动性时段 |
| Medium | 1.5% - 3% | 正常交易,常见日间波动 |
| High | 3% - 5% | 活跃行情,新闻驱动的波动 |
| Extreme | > 5% | 极端事件,黑天鹅,闪崩 |
## Step 3: Base Range Width
每个波动率等级对应一个 ATR 乘数:
```python
VOL_MULTIPLIERS = {
"low": 1.0, # 紧凑范围 → 最高资本效率 (Base gas ≈ 0)
"medium": 2.0, # 平衡
"high": 3.5, # 宽范围 → 减少调仓
"extreme": 6.0, # 极宽 → 安全优先
}
def calc_base_range(price, atr, vol_class):
multiplier = VOL_MULTIPLIERS[vol_class]
half_width = atr * multiplier
return (price - half_width, price + half_width)
```
**Examples** (ETH at $2000):
| Vol Class | ATR | Multiplier | Half Width | Range | Width % | Capital Efficiency |
|---|---|---|---|---|---|---|
| Low | $25 (1.25%) | 2× | $50 | $1950-$2050 | 5.0% | 20× |
| Medium | $45 (2.25%) | 3× | $135 | $1865-$2135 | 13.5% | 7.4× |
| High | $70 (3.5%) | 5× | $350 | $1650-$2350 | 35.0% | 2.9× |
| Extreme | $120 (6%) | 8× | $960 | $1040-$2960 | 96.0% | 1.0× |
**Capital Efficiency** = `price / range_width`。越高表示同样的流动性能捕获更多手续费,但出范围的风险也更高。
## Step 4: Trend Asymmetry
复用 grid-trading 的多时间框架 (MTF) 趋势分析,根据趋势方向调整上下范围的不对称性:
```python
TREND_ASYM_FACTOR = 0.3 # 最大不对称比例
TREND_ASYM_THRESHOLD = 0.3 # 激活不对称的最小趋势强度
def apply_trend_asymmetry(lower, upper, price, mtf):
"""Adjust range asymmetrically based on trend direction."""
trend = mtf.get("trend", "neutral")
strength = mtf.get("strength", 0)
if strength < TREND_ASYM_THRESHOLD:
return lower, upper # 弱趋势 → 保持对称
asym = TREND_ASYM_FACTOR * strength
half_width = (upper - lower) / 2
if trend == "bullish":
# 看涨:上方放宽(跟随上涨空间),下方收紧(减少下方暴露)
new_upper = price + half_width * (1 + asym)
new_lower = price - half_width * (1 - asym)
elif trend == "bearish":
# 看跌:下方放宽(防御下跌空间),上方收紧
new_upper = price + half_width * (1 - asym)
new_lower = price - half_width * (1 + asym)
else:
return lower, upper
return new_lower, new_upper
```
**Asymmetry Examples** (ETH at $2000, medium vol, half_width=$135):
| Trend | Strength | Asym | Lower | Upper | Effect |
|---|---|---|---|---|---|
| Neutral | 0.1 | 0 | $1865 | $2135 | Symmetric |
| Bullish | 0.5 | 0.15 | $1885 | $2155 | Upper wider, lower tighter |
| Bullish | 0.9 | 0.27 | $1899 | $2171 | Max upper stretch |
| Bearish | 0.5 | 0.15 | $1845 | $2115 | Lower wider, upper tighter |
| Bearish | 0.9 | 0.27 | $1829 | $2101 | Max lower stretch |
**Why asymmetric?**
- 看涨趋势中,价格更可能向上突破。上方放宽减少向上出范围的概率,延长持仓时间。
- 看跌趋势中,价格更可能向下突破。下方放宽提供更多缓冲,延迟被迫调仓的时机。
## Step 5: Tick Math & Alignment
Uniswap V3 使用 tick 系统表示价格,tick 和价格的关系:
```
price = 1.0001 ^ tick
tick = log(price) / log(1.0001)
```
每个池有固定的 `tick_spacing`(如 0.3% fee tier → tick_spacing=60),头寸的 tick_lower 和 tick_upper 必须是 tick_spacing 的整数倍。
```python
import math
def price_to_tick(price):
"""Convert price to Uniswap V3 tick."""
return int(math.floor(math.log(price) / math.log(1.0001)))
def tick_to_price(tick):
"""Convert tick to price."""
return 1.0001 ** tick
def align_tick(tick, tick_spacing, direction="down"):
"""Align tick to nearest valid tick_spacing multiple."""
if direction == "down":
return (tick // tick_spacing) * tick_spacing
else:
return ((tick + tick_spacing - 1) // tick_spacing) * tick_spacing
def calc_tick_range(price_lower, price_upper, tick_spacing):
"""Convert price range to aligned tick range."""
tick_lower = align_tick(price_to_tick(price_lower), tick_spacing, "down")
tick_upper = align_tick(price_to_tick(price_upper), tick_spacing, "up")
# Ensure minimum range
if tick_upper - tick_lower < 2 * tick_spacing:
tick_upper = tick_lower + 2 * tick_spacing
return tick_lower, tick_upper
```
**Tick Spacing by Fee Tier**:
| Fee Tier | Fee | tick_spacing | Min Range (2 × spacing) |
|---|---|---|---|
| 100 | 0.01% | 1 | ~0.02% |
| 500 | 0.05% | 10 | ~0.20% |
| 3000 | 0.30% | 60 | ~1.20% |
| 10000 | 1.00% | 200 | ~4.04% |
**Important**: ETH/USDC 0.3% fee tier 使用 tick_spacing=60。每 60 ticks 约 0.6% 价格变化。
## Step 6: Complete Range Calculation
将以上步骤组合成完整的范围计算函数:
```python
def calc_optimal_range(price, candles, mtf, tick_spacing):
"""
Calculate optimal LP range based on volatility and trend.
Returns: (tick_lower, tick_upper, range_info)
"""
# Step 1: ATR
atr = calc_atr(candles)
atr_pct = (atr / price) * 100
# Step 2: Classify
vol_class = classify_volatility(atr_pct)
# Step 3: Base range
lower, upper = calc_base_range(price, atr, vol_class)
# Step 4: Trend asymmetry
lower, upper = apply_trend_asymmetry(lower, upper, price, mtf)
# Step 5: Tick alignment
tick_lower, tick_upper = calc_tick_range(lower, upper, tick_spacing)
# Actual prices after alignment
price_lower = tick_to_price(tick_lower)
price_upper = tick_to_price(tick_upper)
# Capital efficiency
range_width = price_upper - price_lower
capital_efficiency = price / range_width if range_width > 0 else 0
range_info = {
"atr": atr,
"atr_pct": atr_pct,
"vol_class": vol_class,
"multiplier": VOL_MULTIPLIERS[vol_class],
"price_lower": price_lower,
"price_upper": price_upper,
"range_width_pct": (range_width / price) * 100,
"capital_efficiency": capital_efficiency,
"trend": mtf.get("trend", "neutral"),
"trend_strength": mtf.get("strength", 0),
"asymmetric": mtf.get("strength", 0) >= TREND_ASYM_THRESHOLD,
}
return tick_lower, tick_upper, range_info
```
## Rebalance Trigger Conditions
### Trigger 1: Out of Range (priority: MUST)
当前价格超出头寸范围。此时 LP 完全停止赚取手续费,必须调仓。
```python
def check_out_of_range(price, position):
return price < position["price_lower"] or price > position["price_upper"]
```
### Trigger 2: Volatility Shift (priority: ADAPTIVE)
波动率显著变化,当前范围不再最优。
```python
VOL_SHIFT_THRESHOLD = 0.30 # 30%
def check_vol_shift(current_atr_pct, position_atr_pct):
if position_atr_pct == 0:
return False
shift = abs(current_atr_pct - position_atr_pct) / position_atr_pct
return shift > VOL_SHIFT_THRESHOLD
```
**Scenarios**:
- 波动率增大 30%+:当前范围太窄,出范围风险增加 → 放宽
- 波动率减小 30%+:当前范围太宽,资本效率浪费 → 收紧
### Trigger 3: Time Decay (priority: MAINTENANCE)
头寸持有超过最大时间,即使没有其他触发条件也进行维护性调仓。
```python
MAX_POSITION_AGE_H = 24 # hours
def check_time_decay(position):
age_hours = (now() - position["created_at"]).total_seconds() / 3600
return age_hours > MAX_POSITION_AGE_H
```
**Why?** 即使价格在范围内,长时间持有的头寸可能基于过时的波动率估计。定期刷新确保范围始终反映当前市场状况。
## Anti-Churn Gates
调仓有成本(gas + swap slippage + 短暂的零收益期),因此需要防止过度调仓:
```python
def should_rebalance(trigger, position, state, new_range, expected_fees):
"""Check all anti-churn conditions. Returns (should, reason)."""
# Gate 1: Minimum position age
age_seconds = (now() - position["created_at"]).total_seconds()
if age_seconds < MIN_POSITION_AGE: # 7200s = 2h
if trigger != "out_of_range": # out-of-range overrides age check
return False, f"position_too_young ({age_seconds}s < {MIN_POSITION_AGE}s)"
# Gate 2: Daily frequency limit
recent_rebalances = count_rebalances_last_24h(state["rebalance_history"])
if recent_rebalances >= MAX_REBALANCES_24H: # 6
return False, f"daily_limit ({recent_rebalances} >= {MAX_REBALANCES_24H})"
# Gate 3: Gas cost ratio
estimated_gas = estimate_rebalance_gas()
if estimated_gas > expected_fees * GAS_TO_FEE_RATIO: # 50%
return False, f"gas_too_high ({estimated_gas:.2f} > {expected_fees * GAS_TO_FEE_RATIO:.2f})"
# Gate 4: Minimum range change
old_width = position["price_upper"] - position["price_lower"]
new_width = new_range["price_upper"] - new_range["price_lower"]
range_change = abs(new_width - old_width) / old_width
if range_change < MIN_RANGE_CHANGE_PCT: # 5%
return False, f"range_change_too_small ({range_change:.1%} < {MIN_RANGE_CHANGE_PCT:.0%})"
return True, trigger
```
**Priority override**: `out_of_range` 触发跳过 position age gate(因为出范围意味着零收益,等待更糟)。
## Capital Efficiency Formula
Uniswap V3 的核心优势是集中流动性带来的资本效率提升。
**Full-range LP** (V2-style): 流动性均匀分布在 (0, ∞)
**Concentrated LP** (V3): 流动性集中在 [p_lower, p_upper]
```
Capital Efficiency = sqrt(p_upper / p_lower) / (sqrt(p_upper / p_lower) - 1)
Simplified approximation (for narrow ranges):
Capital Efficiency ≈ current_price / range_width
```
**Capital Efficiency by Range Width**:
| Range Width | Capital Efficiency | Fee Multiplier vs V2 |
|---|---|---|
| 2% | ~50× | 50× more fees per $ |
| 5% | ~20× | 20× more fees per $ |
| 10% | ~10× | 10× more fees per $ |
| 20% | ~5× | 5× more fees per $ |
| 50% | ~2× | 2× more fees per $ |
| 100% (full range) | 1× | Same as V2 |
**Trade-off**: 更高的资本效率意味着更窄的范围,出范围的概率更高,调仓更频繁。波动率自适应算法的目标就是在这个 trade-off 中找到最优平衡点。
## Impermanent Loss (IL) Tracking
每次调仓时记录实现的无常损失:
```python
def calc_il(initial_price, final_price, amount0, amount1):
"""
Calculate IL for a concentrated LP position.
IL = 2 * sqrt(price_ratio) / (1 + price_ratio) - 1
where price_ratio = final_price / initial_price
"""
ratio = final_price / initial_price
il_pct = 2 * math.sqrt(ratio) / (1 + ratio) - 1
return abs(il_pct) * 100 # as percentage
# IL reference table
# Price change | IL (full range) | IL (5% range) | IL (2% range)
# ±1% | 0.00% | ~0.01% | ~0.02%
# ±5% | 0.06% | ~0.12% | ~0.30%
# ±10% | 0.23% | ~0.46% | ~1.15%
# ±20% | 0.94% | ~1.88% | ~4.70%
# ±50% | 5.72% | N/A (out) | N/A (out)
```
**Note**: 集中流动性的 IL 比全范围 LP 更大(相同价格变化下),因为流动性更集中。这就是为什么 `MAX_IL_TOLERANCE_PCT=5%` 作为硬停保护。
## Net Yield Calculation
```python
def calc_net_yield(stats):
"""
Net Yield = Fees Claimed - Gas Spent - IL Realized
Annualized = (Net Yield / Portfolio Value) * (365 / days_running) * 100
"""
net = stats["total_fees_claimed_usd"] - stats["total_gas_spent_usd"]
# IL is tracked per-rebalance in rebalance_history
total_il = sum(r.get("il_realized_usd", 0) for r in state["rebalance_history"])
net -= total_il
days = (now() - stats["started_at"]).total_seconds() / 86400
if days > 0 and stats["initial_portfolio_usd"] > 0:
annualized = (net / stats["initial_portfolio_usd"]) * (365 / days) * 100
else:
annualized = 0
return net, annualized
```
## Emergency Fallback
如果调仓过程中途失败(例如 remove 成功但 deposit 失败),资金处于裸露状态(不在任何 LP 头寸中,零收益)。
```python
EMERGENCY_WIDTH_MULT = 3.0
def emergency_deploy(price, atr, vol_class, tick_spacing):
"""Deploy at 3× normal width as safety net."""
normal_mult = VOL_MULTIPLIERS[vol_class]
emergency_half = atr * normal_mult * EMERGENCY_WIDTH_MULT
lower = price - emergency_half
upper = price + emergency_half
tick_lower, tick_upper = calc_tick_range(lower, upper, tick_spacing)
return tick_lower, tick_upper
```
**Why 3×?** 极宽范围几乎不会出范围,确保资金在下次正常调仓前持续产生(少量)手续费,而不是完全闲置。
FILE:references/test_triggers.py
"""Tests for rebalance trigger logic."""
import unittest
from datetime import datetime, timedelta
def tick_to_price(tick: int, decimal_adj: float = 1e12) -> float:
return 1.0001**tick / decimal_adj
def check_rebalance_triggers(
price: float,
state: dict,
atr_pct: float,
mtf: dict | None = None,
) -> dict | None:
"""Extracted trigger logic matching cl_lp.py."""
position = state.get("position")
if not position or not position.get("tick_lower"):
return None
tick_lower = position["tick_lower"]
tick_upper = position["tick_upper"]
lower_price = tick_to_price(tick_lower)
upper_price = tick_to_price(tick_upper)
# [1] Out of range — mandatory
if price < lower_price or price > upper_price:
side = "below" if price < lower_price else "above"
return {"trigger": "out_of_range", "priority": "mandatory", "detail": side}
# [2] Higher yield pool — TODO: requires hourly yield API
return None
class TestOutOfRange(unittest.TestCase):
"""Only out_of_range should trigger rebalance."""
def _make_state(self, tick_lower: int, tick_upper: int, age_hours: float = 0):
state = {
"position": {
"tick_lower": tick_lower,
"tick_upper": tick_upper,
"created_atr_pct": 2.0,
}
}
if age_hours:
state["position"]["created_at"] = (
datetime.now() - timedelta(hours=age_hours)
).isoformat()
return state
def _range_prices(self, tick_lower, tick_upper):
return tick_to_price(tick_lower), tick_to_price(tick_upper)
def test_in_range_center_no_trigger(self):
"""Price at center of range → no trigger."""
tl, tu = -200220, -199200
lower, upper = self._range_prices(tl, tu)
state = self._make_state(tl, tu, age_hours=100)
result = check_rebalance_triggers((lower + upper) / 2, state, 2.0)
self.assertIsNone(result)
def test_in_range_near_edge_no_trigger(self):
"""Price at 5% of range but still in range → no trigger."""
tl, tu = -200220, -199200
lower, upper = self._range_prices(tl, tu)
price = lower + 0.05 * (upper - lower)
state = self._make_state(tl, tu, age_hours=100)
result = check_rebalance_triggers(price, state, 2.0)
self.assertIsNone(result)
def test_below_range_triggers(self):
"""Price below lower → out_of_range."""
tl, tu = -200220, -199200
lower, _ = self._range_prices(tl, tu)
state = self._make_state(tl, tu)
result = check_rebalance_triggers(lower * 0.95, state, 2.0)
self.assertIsNotNone(result)
self.assertEqual(result["trigger"], "out_of_range")
self.assertEqual(result["detail"], "below")
def test_above_range_triggers(self):
"""Price above upper → out_of_range."""
tl, tu = -200220, -199200
_, upper = self._range_prices(tl, tu)
state = self._make_state(tl, tu)
result = check_rebalance_triggers(upper * 1.05, state, 2.0)
self.assertIsNotNone(result)
self.assertEqual(result["trigger"], "out_of_range")
self.assertEqual(result["detail"], "above")
def test_no_position_no_trigger(self):
"""No position → no trigger."""
result = check_rebalance_triggers(2000.0, {}, 2.0)
self.assertIsNone(result)
def test_high_volatility_no_trigger_if_in_range(self):
"""Volatility shift alone should NOT trigger (removed)."""
tl, tu = -200220, -199200
lower, upper = self._range_prices(tl, tu)
state = self._make_state(tl, tu, age_hours=48)
# atr_pct=5.0 vs created_atr=2.0 → 150% change, old logic would trigger
result = check_rebalance_triggers((lower + upper) / 2, state, 5.0)
self.assertIsNone(result)
def test_old_position_no_trigger_if_in_range(self):
"""Old position (100h) in range should NOT trigger (time_decay removed)."""
tl, tu = -200220, -199200
lower, upper = self._range_prices(tl, tu)
price = lower + 0.10 * (upper - lower) # near edge but in range
state = self._make_state(tl, tu, age_hours=100)
result = check_rebalance_triggers(price, state, 2.0)
self.assertIsNone(result)
class TestExponentialBackoff(unittest.TestCase):
"""Test that backoff grows exponentially and caps correctly."""
def test_backoff_progression(self):
COOLDOWN_AFTER_ERRORS = 3600
for n, expected_min in [(1, 10), (2, 20), (3, 40), (4, 60), (5, 60), (6, 60)]:
backoff = min(600 * (2 ** (n - 1)), COOLDOWN_AFTER_ERRORS)
self.assertEqual(backoff, expected_min * 60, f"n={n}")
def test_backoff_cap(self):
COOLDOWN_AFTER_ERRORS = 3600
for n in range(1, 20):
backoff = min(600 * (2 ** (n - 1)), COOLDOWN_AFTER_ERRORS)
self.assertLessEqual(backoff, COOLDOWN_AFTER_ERRORS)
if __name__ == "__main__":
unittest.main()
Agent Team 工厂:协调 5 个 AI Agent(Strategy/Backtest/Infra/Publish/Iteration)完成 OKX OnchainOS 链上交易策略的全生命周期——开发、回测、部署、发布、迭代。支持多策略并行,每个策略独立状态管理。触发词:策略开发、agent team、...
---
name: okx-strategy-factory
description: "Agent Team 工厂:协调 5 个 AI Agent(Strategy/Backtest/Infra/Publish/Iteration)完成 OKX OnchainOS 链上交易策略的全生命周期——开发、回测、部署、发布、迭代。支持多策略并行,每个策略独立状态管理。触发词:策略开发、agent team、回测、迭代策略、发布 skill、部署策略。"
license: Apache-2.0
metadata:
author: SynthThoughts
version: "2.0.0"
pattern: "pipeline"
steps: "5"
---
# OKX Strategy Factory — Agent Team
协调 5 个专家 Agent 完成交易策略全生命周期。Lead 只协调不写代码。
```
Strategy → Backtest → Infra(deploy) → LIVE
↑ ↑ (parallel)
│ Publish → GitHub Release
│
Iteration ← (定时/手动复盘)
```
**这是文件夹 Skill**。按需读取 `roles/`、`references/`、`assets/`,不要一次性加载全部。
## Strategy Selection
Lead 启动 Pipeline 时**必须**指定策略名称 `{strategy}`,所有路径和状态按策略隔离。
- **已有策略**: `grid-trading`、`cl-lp-rebalancer`
- **新策略**: 指定新名称即可,自动创建 `Strategy/{strategy}/` 工作空间
- 每个策略拥有独立的 `Strategy/{strategy}/state.json`,互不干扰
- 同一时间可有多个策略处于不同阶段(如 grid-trading 在 LIVE,cl-lp-rebalancer 在 BACKTEST)
## When to Use
- 开发新交易策略 → Lead 指定 `{strategy}` 名称,读 `roles/lead.md`,spawn strategy + backtest
- 回测 → spawn backtest,读 `roles/backtest.md`,指定 `{strategy}`
- 部署到 VPS → spawn infra,读 `roles/infra.md`,指定 `{strategy}`
- 发布为独立 Skill → spawn publish,读 `roles/publish.md`,指定 `{strategy}`
- 迭代/复盘 → spawn iteration,读 `roles/iteration.md`,指定 `{strategy}`
- 全流程 → spawn 全部 teammate,Lead 指定 `{strategy}` 后协调
**示例**:
```
"启动 grid-trading 策略的回测"
"为 cl-lp-rebalancer 执行全流程 Pipeline"
"新建策略 momentum-breakout,从 Step 1 开始"
```
## Pipeline: Execution Steps
**CRITICAL RULE**: Steps MUST execute in order. Do NOT skip steps or proceed past a gate.
### Step 1: Strategy Development
**Load**: `roles/lead.md`(第一跳流程)+ `roles/strategy.md` + `references/api-interfaces.md` + `references/strategy-lessons.md`
**Actions**:
1. Lead 从主窗口讨论中提炼需求,填写 `templates/requirements.md` 模板,写入 `Strategy/{strategy}/requirements.md`
2. Lead 展示需求给用户确认(用户可修正)
3. 确认后 spawn strategy teammate,prompt 指向需求文件
4. Strategy agent 输出到 `Strategy/{strategy}/Script/v{version}/`
5. Lead 验证产出完整性
**Gate** (ALL must pass):
- [ ] `strategy.js` 或 `.ts` 存在,无硬编码参数
- [ ] `config.json` 存在,所有可调参数已外置
- [ ] `risk-profile.json` 存在且字段完整(校验 `references/risk-schema.json`)
- [ ] `README.md` 存在,含收益预期和适用市场条件
### Step 2: Backtest Validation
**Load**: `roles/backtest.md`
**Input**: Step 1 输出的 `Strategy/{strategy}/Script/v{version}/`
**Actions**:
1. Spawn backtest teammate
2. 拉取历史行情数据
3. 运行回测,输出到 `Strategy/{strategy}/Backtest/v{version}/`
4. 执行 Compliance Check:实际指标 vs `risk-profile.json` 声明值
**Gate**:
- [ ] Compliance 全部 PASS + Sharpe > 1.0 + Win Rate > 40% → **PASS**
- [ ] 任一 Compliance FAIL → **FAIL**,退回 Step 1 附失败详情
- [ ] Compliance PASS 但指标 borderline → **CONDITIONAL**,请用户决定
### Step 3: Local Validate + Deploy to VPS
**Load**: `roles/infra.md`
**Input**: 通过回测的策略版本
**Actions**:
1. Spawn infra teammate
2. **本地验证**: `./deploy.sh {strategy} validate` — 3 tick dry-run,验证启动 + RPC + 钱包
3. 本地验证通过后,`./deploy.sh {strategy} production` — 部署到 VPS
4. 健康检查通过后更新 `VERSION`
**Gate (Local)**:
- [ ] 本地 3 tick dry-run 全部成功
- [ ] onchainos 连接 + 价格/余额查询正常
- [ ] 失败 → 退回 Step 1 修复
**Gate (Production)**:
- [ ] 进程存活(pm2 status → "online")
- [ ] 启动 10s 内无错误日志
- [ ] 失败 → 自动回滚到上一版本
### Step 4: Publish as Skill
**Load**: `roles/publish.md` + `assets/product-skill-template/`
**Input**: 通过回测的策略 + deploy 成功确认
**Actions**:
1. Spawn publish teammate(可在 Step 3 并行开始抽象)
2. 从 `assets/product-skill-template/` 读取产品 Skill 模板
3. 生成独立 Skill 包到 `{strategy}/`(仓库根目录下,与策略同名)
4. GitHub release 等待 Step 3 成功后执行
**Gate**:
- [ ] `manifest.json` 存在(Single Source of Truth)
- [ ] 三平台 adapter 均已生成(SKILL.md / agents.md / openclaw.yaml)
- [ ] `install.sh` 存在且可执行
- [ ] GitHub tag + release 创建成功
### Step 5: Iteration (Post-LIVE)
**Load**: `roles/iteration.md`
**Input**: 链上交易记录 + 行情数据
**Actions**:
1. Spawn iteration teammate(定时或手动触发)
2. 分析表现、提取因果关系、输出优化方案
3. 输出到 `Strategy/{strategy}/Iteration/v{version}-review-{date}.md`
4. **用户确认后** → 回到 Step 1 生成新版本 → 必须重走 Step 2
**Gate**:
- [ ] 优化方案已输出
- [ ] 用户在聊天中确认 — **绝不自动执行**
- [ ] 新版本回到 Step 1,必须走完整 Pipeline
## State Machine
每个策略独立维护状态,互不影响。
```
DRAFT → BACKTEST → PASSED → LOCAL_VALIDATING → LOCAL_VALIDATED → DEPLOYING → LIVE → ITERATION_REVIEW
→ FAILED → DRAFT (revision)
→ CONDITIONAL → (user decides)
LOCAL_VALIDATING → LOCAL_FAILED → DRAFT (fix issues)
DEPLOYING → DEPLOY_FAILED → rollback + DRAFT or retry
ITERATION_REVIEW → APPROVED → DRAFT (new version, must re-backtest)
→ REJECTED → LIVE (keep current)
```
Track in `Strategy/{strategy}/state.json`。Log every transition: `[STATE] {strategy} v{ver}: {OLD} → {NEW} | {reason}`
## Failure & Rollback
```
IF Step N fails for strategy {strategy}:
1. Log failure reason to Strategy/{strategy}/state.json
2. Step 2 fail → 退回 Step 1(Strategy 修订),附失败详情
3. Step 3 fail → Infra 自动回滚到上一版本
4. Step 4 fail → 不影响线上运行,可重试 Publish
5. Step 5 fail → 保持当前版本,通知用户
6. DO NOT proceed to next step
```
## Anti-Patterns
| Pattern | Problem |
|---|---|
| Lead 自己写代码 | Lead 只协调,代码由 Strategy agent 写 |
| 跳过 Backtest 直接部署 | 包括 Iteration 新版本也必须回测 |
| 自动执行 Iteration 优化 | 必须用户确认 |
| risk-profile.json 缺失 | 直接 reject,不要"帮忙补全" |
| 同时部署两个版本 | 同一策略同一时间只有一个 DEPLOYING |
| 修改已发布版本目录 | 版本不可变,只能创建新版本 |
| 2 次迭代未改善仍继续 | 应建议暂停策略或重新设计 |
| 启动 Pipeline 未指定策略名 | Lead 必须先明确 `{strategy}`,否则拒绝执行 |
FILE:INTRODUCTION.md
# Strategy Factory:AI Agent 团队协作开发链上交易策略
## 它是什么
5 个 AI Agent 各司其职,把一句话需求变成实盘运行的链上交易策略。
```
"做一个 ETH 网格交易策略"
│
┌────────▼────────┐
│ Lead Agent │ 协调全局,不写代码
└────────┬────────┘
│
┌───────────┼───────────┬────────────┐
▼ ▼ ▼ ▼
Strategy → Backtest → Infra Iteration
写代码 回测验证 部署VPS 实盘复盘
│
Publish
打包为Skill
```
每步之间有质量门禁——回测不达标退回重写,部署失败自动回滚,迭代优化必须用户确认。
## 为什么不用单个 Agent
### 单 Agent vs Agent Team
一个 AI Agent 一趟水跑完策略开发、回测、部署、迭代——能不能做?能做。但问题是:
```
┌─ 单 Agent 一趟跑完 ───────────────────────────────────────┐
│ │
│ 同一个 context window 塞进所有东西: │
│ │
│ 量化逻辑 + onchainos接口 + 回测数据 + 部署运维 │
│ │
│ 问题 1: context 爆炸 │
│ 讨论需求 → 写代码 → 跑回测 → 看结果 → 部署 │
│ 到部署时,前面讨论的细节已经被压缩或丢失 │
│ │
│ 问题 2: 没有质量关卡 │
│ Agent 自己写完自己判断"行了",直接往下走 │
│ 回测没达标也可能被忽略,没有独立的第二双眼睛 │
│ │
│ 问题 3: 角色混乱 │
│ 同一个 Agent 既是开发者又是测试者又是运维 │
│ 它不会真正挑战自己写的代码 │
│ │
│ 问题 4: 经验无法沉淀 │
│ 所有知识留在对话里,下次开新会话从零开始 │
│ │
└────────────────────────────────────────────────────────────┘
┌─ Agent Team (专家分工 + 流水线) ──────────────────────────┐
│ │
│ 每个 Agent 用独立 context 专注一件事: │
│ │
│ Strategy Agent → 只管写策略(读需求文件 + 经验库) │
│ Backtest Agent → 独立验证(不知道代码怎么写的,只看结果) │
│ Infra Agent → 只管部署(拿到回测通过的版本就行) │
│ Iteration Agent → 只看实盘数据(不受开发时的偏见影响) │
│ │
│ 关键差异: │
│ · 每步之间有 Gate — 回测不过就退回,不是自己放自己过 │
│ · 经验沉淀在文件里(strategy-lessons.md)— 跨会话复用 │
│ · context 隔离 — 每个 Agent 的工作台是干净的 │
│ · 可独立重跑 — 回测失败只需重跑 Strategy,不用从头来 │
│ │
└────────────────────────────────────────────────────────────┘
```
### 策略开发的真正门槛
onchainos 的文档能教你怎么调用命令。但从"我有一个交易想法"到"它在链上自动运行并且赚钱",要跨四个完全不同的领域:
```
┌─ 量化设计 ─────────────────────────────────────────────────┐
│ │
│ 信号怎么设计?EMA 交叉、布林带突破、还是链上数据驱动? │
│ 风控分几层?止损、熔断、仓位限制怎么配合? │
│ 参数怎么定?网格步距 2% 还是 3%?波动率高的时候怎么调? │
│ │
│ 这是量化交易的领域,跟 OnchainOS 没关系 │
└────────────────────────────────────────────────────────────┘
×
┌─ 链上执行 ─────────────────────────────────────────────────┐
│ │
│ 怎么把交易逻辑变成 onchainos 调用链? │
│ quote → approve → swap → simulate → contract-call │
│ 不同链的差异怎么处理?单位、Gas、签名方式都不一样 │
│ │
│ 这是 OnchainOS + Web3 的领域 │
└────────────────────────────────────────────────────────────┘
×
┌─ 生产工程 ─────────────────────────────────────────────────┐
│ │
│ 策略跑着跑着进程挂了,状态怎么恢复? │
│ API 连续超时 5 次,是暂停还是继续? │
│ 状态文件写到一半断电了,怎么防损坏? │
│ 怎么监控、怎么报警、怎么远程部署? │
│ │
│ 这是后端运维的领域 │
└────────────────────────────────────────────────────────────┘
×
┌─ 迭代分析 ─────────────────────────────────────────────────┐
│ │
│ 跑了 229 笔交易,HODL Alpha 为负——为什么? │
│ 是信号不准、slippage 太高、还是市场条件不适合? │
│ 改哪个参数?改了会不会引入新问题? │
│ │
│ 这是数据分析的领域,而且反馈循环慢——犯错要等实盘亏钱才知道 │
└────────────────────────────────────────────────────────────┘
门槛 = 四个领域的乘积,不是加法
```
Strategy Factory 的做法是让每个 Agent 专注一个领域,同时把已有策略的实战经验沉淀下来:
```
量化设计 → Strategy Agent + strategy-lessons.md(9层风控、MTF趋势、非对称设计)
链上执行 → onchainos Skill 文件 + 标准 wrapper + api-interfaces.md
生产工程 → Infra Agent + deploy.sh + 实盘验证的状态管理模式
迭代分析 → Iteration Agent + 结构化复盘模板
你只需要描述交易想法,剩下的每个领域有对应的 Agent + 已有经验兜底
```
### 经验库:策略越多,后续越强
```
Grid Trading 实盘 229笔 ──┐
├──▶ strategy-lessons.md(经验库)
CL LP Rebalancer 回测 ────┘ │
│ 新策略开发时必读
▼
┌─ 已沉淀的经验 ──────────────────────────────────┐
│ │
│ · 9层风控模式(Stop→熔断→止损→仓位→Gas检查…) │
│ · MTF趋势分析标准实现(可直接复用) │
│ · 波动率4档自适应(Low/Med/High/Extreme) │
│ · slippage 6.24%的实盘教训 │
│ · onchainos 各接口的避坑指南 │
│ │
│ 只记录实盘验证过的经验,不记录猜测 │
└──────────────────────────────────────────────────┘
Iteration Agent 每次复盘 ──▶ 新经验回写 ──▶ 下一个策略自动受益
```
### AI 闭环迭代:实际案例
```
ETH网格 v3 上线
│ 运行10天,229笔交易
▼
Iteration Agent 分析
│ 发现:
│ · HODL Alpha 为负(跑输单纯持有)
│ · 根因:震荡和趋势用同一套参数
│ · 卖出成功率84.3%,低于买入100%
▼
优化方案(用户确认后执行)
│ + MTF多时间框架趋势分析
│ + 非对称网格(看涨时快买慢卖)
│ + 追踪止盈(趋势中延迟卖出)
▼
v4 重走完整流水线
│ Strategy → Backtest → Deploy
▼
实盘运行,等待下一轮复盘
```
## 一个策略的完整产出
```
Strategy/grid-trading/
│
├── Script/v4.0.0/
│ ├── strategy.py ← 交易逻辑(调onchainos抽象接口)
│ ├── config.json ← 参数外置(网格数、步距、交易对…)
│ ├── risk-profile.json ← 风控硬约束(止损15%、回撤20%、Gas预算$50)
│ └── README.md ← 收益预期 + 适用市场条件
│
├── Backtest/v4.0.0/
│ ├── backtest-report.json ← Sharpe、胜率、最大回撤
│ └── equity-curve.csv ← 资金曲线
│
└── Iteration/
└── v3.0.0-review-2026-03-10.md ← v3→v4的分析报告
```
上线后:Cron 每 5 分钟 tick → onchainos 报价+K线+交易 → Discord 通知 → Dashboard 可视化。
FILE:README.md
# OKX Strategy Factory
[中文](./README_CN.md)
A meta-skill that coordinates 5 specialized AI agents to handle the full lifecycle of on-chain trading strategies on OKX OnchainOS: development, backtesting, deployment, publishing, and iteration.
## What is this?
Strategy Factory is an **Agent Team pipeline** — instead of a single AI agent doing everything, a Lead coordinator spawns purpose-built expert agents for each phase. Each agent has strict input/output contracts and quality gates that must pass before the pipeline advances.
Built for developers who want AI agents to develop, validate, deploy, and continuously improve on-chain trading strategies via OKX DEX API.
## Architecture
```
User Request
│
▼
┌─────────┐ ┌───────────┐ ┌─────────┐ ┌──────────┐ ┌───────────┐
│ Strategy │────▶│ Backtest │────▶│ Infra │────▶│ Publish │ │ Iteration │
│ Agent │ │ Agent │ │ Agent │ │ Agent │ │ Agent │
│ │ │ │ │ │ │(parallel)│ │(post-LIVE)│
│ Develop │ │ Validate │ │ Deploy │ │ Package │ │ Review │
│ strategy │ │ backtest │ │ to VPS │ │ as Skill │ │ & propose │
└─────────┘ └───────────┘ └─────────┘ └──────────┘ └───────────┘
▲ │ FAIL │ │
└───────────────┘ │ FAIL → auto-rollback │
▲ │
└────────────────────── user approves optimization ──────────────────┘
Lead Agent (coordinator, never writes code)
```
## Platform Compatibility
> **Recommended**: Claude Code, Cursor, Gemini CLI — AI coding IDEs with subagent/teammate spawning.
>
> **Not recommended**: OpenClaw — OpenClaw is a runtime for executing strategies, not for multi-agent development pipelines. Use the individual strategy skills (e.g. `grid-trading`) on OpenClaw instead.
## Installation
**Claude Code** (recommended):
```bash
npx clawhub install okx-strategy-factory
# Or manual:
cp -r okx-strategy-factory ~/.claude/skills/
```
**Cursor**:
```bash
cp -r okx-strategy-factory /path/to/project/.cursor/skills/
```
**Gemini CLI**:
```bash
cp -r okx-strategy-factory /path/to/project/.gemini/skills/
```
## Quick Start
After installation, ask your AI agent:
```
Use the okx-strategy-factory skill to develop a grid trading strategy for ETH/USDC on Base.
```
Or trigger a specific phase:
```
Use okx-strategy-factory to backtest the grid-trading strategy in Strategy/grid-trading/Script/v1.0.0/.
```
```
Use okx-strategy-factory to iterate on the live ETH grid strategy — review the last 7 days.
```
The Lead agent will coordinate the appropriate expert agents automatically.
## Directory Structure
```
okx-strategy-factory/
├── SKILL.md # Main document: pipeline definition + state machine
├── roles/ # Agent role definitions
│ ├── lead.md # Coordinator — spawns agents, enforces gates
│ ├── strategy.md # Writes strategy code + config + risk profile
│ ├── backtest.md # Validates strategy against historical data
│ ├── infra.md # Deploys to VPS via SSH
│ ├── publish.md # Packages strategy as cross-platform Skill
│ └── iteration.md # Post-LIVE review and optimization proposals
├── templates/ # Structured templates
│ └── requirements.md # Strategy requirements template (Lead fills before spawning Strategy agent)
├── references/ # Shared technical references
│ ├── api-interfaces.md # Adapter interface spec (wallet, dex, position)
│ ├── risk-schema.json # JSON Schema for risk-profile.json validation
│ └── strategy-lessons.md # Strategy lessons learned (risk, MTF, cost, pitfalls)
├── assets/ # Templates and tools
│ ├── product-skill-template/ # Strategy-specific packaging templates (.tmpl)
│ ├── skill-templates/ # Skill design pattern templates (pipeline, tool-wrapper, etc.)
│ └── publish.sh # Skill validation and publishing script
└── hooks/ # Pipeline automation
├── task-completed-gate.sh # Quality gate checks between steps
└── teammate-idle-reassign.sh # Reassign idle agents
```
## Agent Roles
| Role | Responsibility | Input | Output |
|------|---------------|-------|--------|
| **Lead** | Coordinate pipeline, enforce quality gates, manage state | User request | Spawn prompts, state.json updates |
| **Strategy** | Write strategy logic + config + risk profile | `Strategy/{strategy}/requirements.md` (structured requirements distilled by Lead) | `Strategy/{strategy}/Script/v{ver}/` (strategy.js, config.json, risk-profile.json, README.md) |
| **Backtest** | Validate strategy against historical data | Strategy script directory | `Strategy/{strategy}/Backtest/v{ver}/` (backtest-report.json, equity-curve.csv) |
| **Infra** | Deploy to VPS (SSH, pm2, health check, rollback) | Backtest-passed strategy version | Running process on VPS, VERSION file |
| **Publish** | Package strategy as cross-platform Skill + GitHub release | Backtest-passed strategy + deploy confirmation | `{strategy}/` (manifest.json, install.sh, SKILL.md) |
| **Iteration** | Analyze live performance, propose optimizations | On-chain trade history + market data | `Strategy/{strategy}/Iteration/v{ver}-review-{date}.md` |
## Pipeline
```
Step 1: Strategy Development
Lead distills discussion into Strategy/{strategy}/requirements.md → user confirms → spawn Strategy agent
Gate: strategy.js + config.json + risk-profile.json + README.md all present
│
▼
Step 2: Backtest Validation
Gate: Compliance PASS + Sharpe > 1.0 + Win Rate > 40%
FAIL → back to Step 1 with failure details
│
▼
Step 3: Deploy to VPS ──────────────────── Step 4: Publish as Skill (parallel start)
Gate: pm2 online + no errors in 30s Gate: manifest.json + install.sh + adapters
FAIL → auto-rollback to previous version GitHub release waits for Step 3 success
│
▼
Step 5: Iteration (post-LIVE, on-demand)
Gate: user confirms optimization proposal
Approved → new version starts at Step 1 (must re-backtest)
```
Steps execute strictly in order. No skipping. Iteration always triggers a full re-run from Step 1.
## Prerequisites
- **onchainos CLI** — `npx skills add okx/onchainos-skills`
- **OKX API Key** — with DEX trading permissions
- **OnchainOS Agentic Wallet** — with TEE signing enabled
- **Python 3.10+** — for backtest engine and trading scripts
- **VPS** (optional) — for live deployment
- **1Password CLI** (optional) — for secure credential management (`op`)
## License
Apache-2.0
FILE:README_CN.md
# OKX 策略工厂
[English](./README.md)
一个元技能,协调 5 个专业 AI 智能体,完成 OKX OnchainOS 链上交易策略的完整生命周期:开发、回测、部署、发布和迭代。
## 这是什么?
策略工厂是一个 **Agent Team 流水线** —— 不是一个 AI 智能体包揽一切,而是由 Lead 协调者为每个阶段生成专用的专家智能体。每个智能体有严格的输入/输出契约和质量门禁,必须通过才能进入下一阶段。
适合希望用 AI 智能体通过 OKX DEX API 开发、验证、部署并持续优化链上交易策略的开发者。
## 架构
```
用户请求
│
▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Strategy │────▶│ Backtest │────▶│ Infra │────▶│ Publish │ │ Iteration│
│ Agent │ │ Agent │ │ Agent │ │ Agent │ │ Agent │
│ │ │ │ │ │ │ (并行启动)│ │(上线后) │
│ 开发策略 │ │ 回测验证 │ │ 部署到VPS │ │ 打包为技能│ │ 复盘优化 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
▲ │ 失败 │ │
└───────────────┘ │ 失败 → 自动回滚 │
▲ │
└──────────────────── 用户批准优化方案 ──────────────────────────────┘
Lead Agent(协调者,不写代码)
```
## 平台兼容性
> **推荐**: Claude Code、Cursor、Gemini CLI —— 支持子智能体/队友生成的 AI 编程 IDE。
>
> **不推荐**: OpenClaw —— OpenClaw 是策略执行运行时,不适合多智能体开发流水线。请在 OpenClaw 上使用独立策略技能(如 `grid-trading`)。
## 安装
**Claude Code**(推荐):
```bash
npx clawhub install okx-strategy-factory
# 或手动安装:
cp -r okx-strategy-factory ~/.claude/skills/
```
**Cursor**:
```bash
cp -r okx-strategy-factory /path/to/project/.cursor/skills/
```
**Gemini CLI**:
```bash
cp -r okx-strategy-factory /path/to/project/.gemini/skills/
```
## 快速开始
安装后,对你的 AI 智能体说:
```
使用 okx-strategy-factory 技能,为 Base 链上的 ETH/USDC 开发一个网格交易策略。
```
或触发特定阶段:
```
使用 okx-strategy-factory 回测 Strategy/grid-trading/Script/v1.0.0/ 中的网格策略。
```
```
使用 okx-strategy-factory 对正在运行的 ETH 网格策略做迭代复盘 —— 回顾最近 7 天。
```
Lead 智能体会自动协调对应的专家智能体。
## 目录结构
```
okx-strategy-factory/
├── SKILL.md # 主文档:流水线定义 + 状态机
├── roles/ # 智能体角色定义
│ ├── lead.md # 协调者 —— 生成智能体、执行门禁
│ ├── strategy.md # 编写策略代码 + 配置 + 风控档案
│ ├── backtest.md # 基于历史数据验证策略
│ ├── infra.md # 通过 SSH 部署到 VPS
│ ├── publish.md # 将策略打包为跨平台技能
│ └── iteration.md # 上线后复盘与优化建议
├── templates/ # 结构化模板
│ └── requirements.md # 策略需求模板(Lead 在 spawn Strategy agent 前填写)
├── references/ # 共享技术参考
│ ├── api-interfaces.md # 适配器接口规范(钱包、DEX、持仓)
│ ├── risk-schema.json # risk-profile.json 的 JSON Schema
│ └── strategy-lessons.md # 策略经验库(风控、MTF、成本、陷阱)
├── assets/ # 模板和工具
│ ├── product-skill-template/ # 策略专用打包模板(.tmpl)
│ ├── skill-templates/ # Skill 设计模式模板(pipeline、tool-wrapper 等)
│ └── publish.sh # Skill 验证与发布脚本
└── hooks/ # 流水线自动化
├── task-completed-gate.sh # 步骤间质量门禁检查
└── teammate-idle-reassign.sh # 空闲智能体重新分配
```
## 智能体角色
| 角色 | 职责 | 输入 | 输出 |
|------|------|------|------|
| **Lead** | 协调流水线、执行质量门禁、管理状态 | 用户请求 | 生成提示词、更新 state.json |
| **Strategy** | 编写策略逻辑 + 配置 + 风控档案 | `Strategy/{strategy}/requirements.md`(Lead 提炼的结构化需求) | `Strategy/{strategy}/Script/v{ver}/`(strategy.js, config.json, risk-profile.json, README.md) |
| **Backtest** | 基于历史数据验证策略 | 策略脚本目录 | `Strategy/{strategy}/Backtest/v{ver}/`(backtest-report.json, equity-curve.csv) |
| **Infra** | 部署到 VPS(SSH、pm2、健康检查、回滚) | 回测通过的策略版本 | VPS 上运行的进程、VERSION 文件 |
| **Publish** | 将策略打包为跨平台技能 + GitHub 发布 | 回测通过的策略 + 部署确认 | `{strategy}/`(manifest.json, install.sh, SKILL.md) |
| **Iteration** | 分析实盘表现、提出优化建议 | 链上交易记录 + 市场数据 | `Strategy/{strategy}/Iteration/v{ver}-review-{date}.md` |
## 流水线
```
步骤 1: 策略开发
Lead 从讨论中提炼需求 → 写入 Strategy/{strategy}/requirements.md → 用户确认 → spawn Strategy agent
门禁: strategy.js + config.json + risk-profile.json + README.md 全部就位
│
▼
步骤 2: 回测验证
门禁: 合规 PASS + Sharpe > 1.0 + 胜率 > 40%
失败 → 携带失败详情返回步骤 1
│
▼
步骤 3: 部署到 VPS ────────────────── 步骤 4: 发布为技能(并行启动)
门禁: pm2 在线 + 30s 内无报错 门禁: manifest.json + install.sh + 适配器
失败 → 自动回滚到上一版本 GitHub release 等待步骤 3 成功
│
▼
步骤 5: 迭代(上线后,按需触发)
门禁: 用户确认优化方案
批准 → 新版本从步骤 1 重新开始(必须重新回测)
```
严格顺序执行,不可跳步。迭代始终触发从步骤 1 的完整重跑。
## 前置条件
- **onchainos CLI** — `npx skills add okx/onchainos-skills`
- **OKX API Key** — 需有 DEX 交易权限
- **OnchainOS Agentic Wallet** — 需启用 TEE 签名
- **Python 3.10+** — 用于回测引擎和交易脚本
- **VPS**(可选) — 用于实盘部署
- **1Password CLI**(可选) — 安全凭证管理(`op`)
## 许可证
Apache-2.0
FILE:assets/publish.sh
#!/usr/bin/env bash
#
# publish.sh - 验证并发布一个新的 Skill 到 Skills 仓库
#
# 用法:
# ./publish.sh <skill-name> # 验证 + git commit
# ./publish.sh <skill-name> --push # 验证 + git commit + push
# ./publish.sh <skill-name> --dry-run # 仅验证,不提交
# ./publish.sh <skill-name> --copy-script <path> # 复制策略脚本到 Skill 目录后发布
#
# 示例:
# ./publish.sh grid-trading
# ./publish.sh momentum-breakout --copy-script ~/scripts/momentum.py --push
# ./publish.sh new-strategy --dry-run
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# --- 颜色 ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# --- 参数解析 ---
SKILL_NAME=""
PUSH=false
DRY_RUN=false
COPY_SCRIPT=""
while [[ $# -gt 0 ]]; do
case $1 in
--push) PUSH=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--copy-script) COPY_SCRIPT="$2"; shift 2 ;;
-h|--help)
echo "用法: $0 <skill-name> [--push] [--dry-run] [--copy-script <path>]"
echo ""
echo "选项:"
echo " --push 验证 + commit 后自动 push"
echo " --dry-run 仅验证格式,不执行 git 操作"
echo " --copy-script PATH 复制策略脚本到 Skill 目录"
echo ""
echo "示例:"
echo " $0 grid-trading"
echo " $0 momentum --copy-script ~/scripts/momentum.py --push"
exit 0
;;
*)
if [[ -z "$SKILL_NAME" ]]; then
SKILL_NAME="$1"
else
echo -e "RED错误: 未知参数 '$1'NC" >&2
exit 1
fi
shift
;;
esac
done
if [[ -z "$SKILL_NAME" ]]; then
echo -e "RED错误: 请指定 skill 名称NC" >&2
echo "用法: $0 <skill-name> [--push] [--dry-run] [--copy-script <path>]"
exit 1
fi
SKILL_DIR="$REPO_ROOT/$SKILL_NAME"
SKILL_FILE="$SKILL_DIR/SKILL.md"
# --- 辅助函数 ---
pass() { echo -e " GREEN✓NC $1"; }
fail() { echo -e " RED✗NC $1"; ERRORS=$((ERRORS + 1)); }
warn() { echo -e " YELLOW!NC $1"; WARNINGS=$((WARNINGS + 1)); }
info() { echo -e " BLUE→NC $1"; }
ERRORS=0
WARNINGS=0
# --- Step 0: 复制脚本(如果指定) ---
if [[ -n "$COPY_SCRIPT" ]]; then
echo -e "\nBLUE[0/4] 复制策略脚本NC"
if [[ ! -f "$COPY_SCRIPT" ]]; then
fail "脚本不存在: $COPY_SCRIPT"
exit 1
fi
mkdir -p "$SKILL_DIR"
cp "$COPY_SCRIPT" "$SKILL_DIR/"
pass "已复制 $(basename "$COPY_SCRIPT") -> $SKILL_DIR/"
fi
# --- Step 1: 检查文件存在 ---
echo -e "\nBLUE[1/4] 检查文件结构NC"
if [[ ! -d "$SKILL_DIR" ]]; then
fail "Skill 目录不存在: $SKILL_DIR"
echo -e "\nRED发布失败: 请先创建 Skill 目录和 SKILL.mdNC"
exit 1
fi
if [[ ! -f "$SKILL_FILE" ]]; then
fail "SKILL.md 不存在: $SKILL_FILE"
echo -e "\nRED发布失败: 请先创建 SKILL.mdNC"
exit 1
fi
pass "SKILL.md 存在"
# 检查是否有额外的策略文件
EXTRA_FILES=$(find "$SKILL_DIR" -type f ! -name "SKILL.md" ! -name ".*" 2>/dev/null | head -5)
if [[ -n "$EXTRA_FILES" ]]; then
info "包含额外文件:"
echo "$EXTRA_FILES" | while read -r f; do
info " $(basename "$f")"
done
fi
# --- Step 2: 验证 YAML frontmatter ---
echo -e "\nBLUE[2/4] 验证 YAML frontmatterNC"
# 检查是否以 --- 开头
if ! head -1 "$SKILL_FILE" | grep -q '^---$'; then
fail "SKILL.md 必须以 '---' 开头(YAML frontmatter)"
else
pass "YAML frontmatter 起始标记"
fi
# 提取 frontmatter(第一个 --- 到第二个 --- 之间)
FRONTMATTER=$(awk 'BEGIN{n=0} /^---$/{n++; if(n==2) exit; next} n==1{print}' "$SKILL_FILE")
# 检查必需字段
check_field() {
local field="$1"
local label="$2"
if echo "$FRONTMATTER" | grep -q "^field:"; then
local value
value=$(echo "$FRONTMATTER" | grep "^field:" | head -1 | sed "s/^field:[[:space:]]*//")
if [[ -z "$value" || "$value" == '""' || "$value" == "''" ]]; then
fail "$label 为空"
else
pass "$label: $value"
fi
else
fail "缺少必需字段: $field"
fi
}
check_field "name" "名称"
check_field "description" "描述"
check_field "license" "许可证"
# 检查 metadata 子字段
if echo "$FRONTMATTER" | grep -q "^metadata:"; then
pass "metadata 章节存在"
if echo "$FRONTMATTER" | grep -q "author:"; then
pass "metadata.author 存在"
else
fail "缺少 metadata.author"
fi
if echo "$FRONTMATTER" | grep -q "version:"; then
VERSION=$(echo "$FRONTMATTER" | grep "version:" | sed 's/.*version:[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs)
if echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
pass "version 格式正确: $VERSION"
else
warn "version 建议使用 semver 格式 (x.y.z),当前: $VERSION"
fi
else
fail "缺少 metadata.version"
fi
else
fail "缺少 metadata 章节"
fi
# 检查 description 长度(至少 50 字符,用于 AI agent 路由)
DESC_LEN=$(echo "$FRONTMATTER" | grep "^description:" | wc -c | tr -d ' ')
if [[ "$DESC_LEN" -lt 60 ]]; then
warn "description 偏短(<60 字符),建议写详细以改善 AI agent 路由匹配"
fi
# 检查 pattern 字段
VALID_PATTERNS="tool-wrapper generator reviewer inversion pipeline"
PATTERN_VALUE=$(echo "$FRONTMATTER" | grep "pattern:" | sed 's/.*pattern:[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs || true)
if [[ -n "$PATTERN_VALUE" ]]; then
# 支持复合模式(逗号分隔)
ALL_VALID=true
IFS=',' read -ra PATTERNS <<< "$PATTERN_VALUE"
for p in "PATTERNS[@]"; do
p=$(echo "$p" | xargs) # trim whitespace
if echo "$VALID_PATTERNS" | grep -qw "$p"; then
:
else
fail "无效的 pattern: '$p' (允许: $VALID_PATTERNS)"
ALL_VALID=false
fi
done
if [[ "$ALL_VALID" == true ]]; then
pass "pattern: $PATTERN_VALUE"
# 检查 pattern 特有的 metadata 字段
for p in "PATTERNS[@]"; do
p=$(echo "$p" | xargs)
case "$p" in
generator)
if ! echo "$FRONTMATTER" | grep -q "output-format:"; then
warn "generator 模式建议添加 metadata.output-format 字段"
fi
;;
reviewer)
if ! echo "$FRONTMATTER" | grep -q "severity-levels:"; then
warn "reviewer 模式建议添加 metadata.severity-levels 字段"
fi
;;
inversion)
if ! echo "$FRONTMATTER" | grep -q "interaction:"; then
warn "inversion 模式建议添加 metadata.interaction 字段"
fi
;;
pipeline)
if ! echo "$FRONTMATTER" | grep -q "steps:"; then
warn "pipeline 模式建议添加 metadata.steps 字段"
fi
;;
esac
done
fi
else
warn "未指定 pattern 字段,建议使用: $VALID_PATTERNS"
fi
# --- Step 3: 验证内容结构 ---
echo -e "\nBLUE[3/4] 验证内容结构NC"
CONTENT=$(cat "$SKILL_FILE")
# 必需章节
check_section() {
local heading="$1"
local label="$2"
local required="-true"
if echo "$CONTENT" | grep -qiE "^##+ .*($heading)" 2>/dev/null; then
pass "章节: $label"
elif [[ "$required" == "true" ]]; then
fail "缺少必需章节: $label (需要包含 '$heading' 的标题)"
else
warn "建议添加章节: $label"
fi
}
# 通用必需章节
check_section "Architecture|架构" "Architecture(架构)"
check_section "Instruction|Algorithm|算法|Core|指令" "Instructions / Core Algorithm(指令/核心算法)"
# 按 pattern 检查特有章节
if [[ -n "$PATTERN_VALUE" ]]; then
for p in "PATTERNS[@]"; do
p=$(echo "$p" | xargs)
case "$p" in
tool-wrapper)
check_section "Trigger|触发|Keyword" "Trigger Keywords(触发关键词)" "false"
check_section "Context.Load|上下文加载" "Context Loading Rules(上下文加载规则)" "false"
;;
generator)
check_section "Template|模板|Variable" "Template / Variables(模板/变量)" "false"
check_section "Style|风格|Output" "Style / Output Format(风格/输出格式)" "false"
;;
reviewer)
check_section "Checklist|清单|Severity" "Review Checklist / Severity(检查清单/严重程度)" "false"
;;
inversion)
check_section "Phase|阶段" "Phases(采访阶段)" "false"
check_section "Synthesis|综合|Gate|门控" "Synthesis / Gates(综合/门控)" "false"
;;
pipeline)
check_section "Step|步骤" "Pipeline Steps(管道步骤)" "false"
check_section "Gate|门控|Checkpoint" "Gates / Checkpoints(门控/检查点)" "false"
check_section "Fail|Rollback|失败|回滚" "Failure Handling(失败处理)" "false"
;;
esac
done
fi
# 通用推荐章节
check_section "Parameter|参数|Tunable" "Parameters(参数)" "false"
check_section "Risk|风险|风控" "Risk Controls(风险控制)" "false"
check_section "Anti.Pattern|反模式" "Anti-Patterns(反模式)" "false"
check_section "State|状态" "State Schema(状态模式)" "false"
check_section "Execut|执行|Pipeline" "Execution Pipeline(执行流程)" "false"
check_section "Extend|扩展|Adapt" "Extension Points(扩展点)" "false"
check_section "PnL|收益" "PnL Tracking(收益追踪)" "false"
# 检查 pattern 对应的目录结构
if [[ -n "$PATTERN_VALUE" ]]; then
for p in "PATTERNS[@]"; do
p=$(echo "$p" | xargs)
case "$p" in
tool-wrapper)
if [[ -d "$SKILL_DIR/references" ]] && [[ -n "$(ls -A "$SKILL_DIR/references/" 2>/dev/null)" ]]; then
pass "references/ 目录存在且非空(tool-wrapper 必需)"
else
warn "tool-wrapper 模式建议创建 references/ 目录存放 API 规范"
fi
;;
generator)
if [[ -d "$SKILL_DIR/assets" ]] && [[ -n "$(ls -A "$SKILL_DIR/assets/" 2>/dev/null)" ]]; then
pass "assets/ 目录存在且非空(generator 必需)"
else
warn "generator 模式建议创建 assets/ 目录存放输出模板"
fi
;;
reviewer)
if [[ -f "$SKILL_DIR/references/review-checklist.md" ]]; then
pass "references/review-checklist.md 存在(reviewer 必需)"
else
warn "reviewer 模式建议创建 references/review-checklist.md"
fi
;;
esac
done
fi
# 检查是否有参数表格
if echo "$CONTENT" | grep -q '|.*Parameter\|参数.*|.*Default\|默认.*|'; then
pass "包含参数表格"
elif echo "$CONTENT" | grep -q '|.*|.*|.*|'; then
pass "包含表格(请确保参数有 Default 列)"
else
warn "未发现参数表格,建议使用 | Parameter | Default | Description | 格式"
fi
# 检查是否有代码块
CODE_BLOCKS=$(echo "$CONTENT" | grep -c '```' || true)
if [[ "$CODE_BLOCKS" -ge 4 ]]; then
pass "包含 $((CODE_BLOCKS / 2)) 个代码块"
elif [[ "$CODE_BLOCKS" -ge 2 ]]; then
warn "仅有 $((CODE_BLOCKS / 2)) 个代码块,策略类 Skill 建议包含更多代码示例"
else
warn "几乎没有代码块,策略类 Skill 应包含算法实现和命令示例"
fi
# 文件大小检查
FILE_SIZE=$(wc -c < "$SKILL_FILE" | tr -d ' ')
if [[ "$FILE_SIZE" -lt 500 ]]; then
warn "SKILL.md 过短(FILE_SIZE 字节),可能缺少关键内容"
elif [[ "$FILE_SIZE" -gt 100000 ]]; then
warn "SKILL.md 过长(FILE_SIZE 字节),考虑拆分为主文件 + references/"
else
pass "文件大小合理: FILE_SIZE 字节"
fi
# --- 验证结果 ---
echo -e "\nBLUE━━━ 验证结果 ━━━NC"
echo -e " 错误: REDERRORSNC 警告: YELLOWWARNINGSNC"
if [[ $ERRORS -gt 0 ]]; then
echo -e "\nRED验证失败: 请修复以上 ERRORS 个错误后重试NC"
exit 1
fi
if [[ "$DRY_RUN" == true ]]; then
echo -e "\nGREEN验证通过 (dry-run 模式,未执行 git 操作)NC"
exit 0
fi
# --- Step 4: Git commit ---
echo -e "\nBLUE[4/4] Git 提交NC"
cd "$REPO_ROOT"
# 获取 skill name 和 version 用于 commit message
SKILL_NAME_FM=$(echo "$FRONTMATTER" | grep "^name:" | sed 's/^name:[[:space:]]*//')
SKILL_VERSION=$(echo "$FRONTMATTER" | grep "version:" | sed 's/.*version:[[:space:]]*"\?\([^"]*\)"\?/\1/' | tr -d '"')
# 检查是否有变更
if git diff --quiet "$SKILL_NAME/" 2>/dev/null && \
git diff --cached --quiet "$SKILL_NAME/" 2>/dev/null && \
[[ -z "$(git ls-files --others --exclude-standard "$SKILL_NAME/")" ]]; then
echo -e "YELLOW没有检测到变更,跳过提交NC"
exit 0
fi
# Stage skill 目录
git add "$SKILL_NAME/"
pass "已暂存 $SKILL_NAME/"
# 判断是新增还是更新
if git log --oneline -- "$SKILL_NAME/SKILL.md" 2>/dev/null | head -1 | grep -q .; then
COMMIT_MSG="Update SKILL_NAME_FM skill vSKILL_VERSION"
else
COMMIT_MSG="Add SKILL_NAME_FM skill vSKILL_VERSION"
fi
git commit -m "$(cat <<EOF
COMMIT_MSG
EOF
)"
pass "已提交: $COMMIT_MSG"
if [[ "$PUSH" == true ]]; then
BRANCH=$(git branch --show-current)
git push origin "$BRANCH"
pass "已推送到 origin/$BRANCH"
fi
echo -e "\nGREEN发布完成: $SKILL_NAME/NC"
FILE:assets/skill-templates/SKILL_TEMPLATE.md
---
name: <skill-name>
description: "<一句话描述 Skill 的核心能力、适用场景和触发关键词。用于 AI agent 路由匹配,写得越精确越好。>"
license: Apache-2.0
metadata:
author: <作者>
version: "<major.minor.patch>"
pattern: "<tool-wrapper | generator | reviewer | inversion | pipeline>"
# 复合模式用逗号分隔: "pipeline, tool-wrapper"
# 可选字段(按 pattern 需要添加):
# output-format: "markdown" # generator 用
# severity-levels: "error,warning,info" # reviewer 用
# interaction: "multi-turn" # inversion 用
# steps: "N" # pipeline 用
---
# <Skill 名称>
一句话概述:Skill 做什么、怎么触发、输出到哪里。
<!--
选择你的设计模式,从 Skills/templates/ 目录获取对应模板:
| Pattern | 适用场景 | 模板文件 |
|----------------|-----------------------------------|--------------------------|
| tool-wrapper | 按需加载 API/库的上下文 | templates/tool-wrapper.md |
| generator | 强制一致的输出结构 | templates/generator.md |
| reviewer | 分离"检查什么"和"如何检查" | templates/reviewer.md |
| inversion | Agent 先采访用户再行动 | templates/inversion.md |
| pipeline | 严格顺序多步工作流+硬检查点 | templates/pipeline.md |
模式可以组合:
- Pipeline + Tool Wrapper: 多步工作流,每步加载不同 API 文档
- Generator + Inversion: 先收集变量,再填充模板
- Pipeline + Reviewer: 工作流中包含审查步骤
组合模式时,在 metadata.pattern 用逗号分隔,然后从各模板中挑选需要的章节合并。
-->
## Architecture
```
<系统架构图 - 用 ASCII art 展示数据流>
```
**依赖的外部服务/Skill**:
- <服务1>: `<调用方式>` -> `<具体命令>`
## Instructions
<!--
这是 Skill 的核心指令区。根据 pattern 不同,结构差异很大。
请参考对应的 templates/*.md 获取该 pattern 的标准指令结构。
-->
## Directory Structure
```
<skill-name>/
├── SKILL.md # 主文件(必需)
├── references/ # API 规范、风格指南、检查清单等(按需)
│ └── <context-file>.md
└── assets/ # 输出模板、脚手架等(按需)
└── <template-file>.md
```
## Anti-Patterns
| Pattern | Problem |
|---|---|
| <反模式1> | <为什么有问题> |
FILE:assets/skill-templates/generator.md
---
name: <skill-name>
description: "<描述:生成一致结构的输出,如报告、配置、文档等。包含触发关键词。>"
license: Apache-2.0
metadata:
author: <作者>
version: "<major.minor.patch>"
pattern: "generator"
output-format: "markdown"
# output-format: "json" | "yaml" | "code" | "markdown"
---
# <Skill 名称> (Generator)
<!--
Generator 模式:强制一致的输出结构。
用 assets/ 存输出模板,references/ 存风格指南。
指令协调检索并逐步填充。
核心思路:
- assets/ 目录存放输出模板(骨架)
- references/ 存放风格指南和约束规则
- 指令定义:加载模板 → 加载风格 → 询问缺失信息 → 填充 → 输出
-->
一句话概述:根据输入数据生成结构一致的 <输出类型>。
## Architecture
```
Input Data / User Request
↓
Load Template (assets/<template>.md)
↓
Load Style Guide (references/<style>.md)
↓
Check for Missing Variables → Ask User (if needed)
↓
Fill Template → Validate → Output
```
## Instructions
### Step 1: Load Template
```
LOAD assets/<output-template>.md
This defines the output skeleton — every section is REQUIRED unless marked [optional].
```
### Step 2: Load Style Guide
```
LOAD references/<style-guide>.md
Apply these rules to ALL generated content:
- Tone, voice, formatting constraints
- Domain-specific terminology
- Naming conventions
```
### Step 3: Collect Variables
<!-- 列出模板需要的所有变量 -->
| Variable | Source | Required | Default |
|---|---|---|---|
| `<var-1>` | User input | Yes | — |
| `<var-2>` | Auto-detect | No | `<default>` |
| `<var-3>` | User input | Yes | — |
**If any required variable is missing**: Ask the user before proceeding. Do NOT guess.
### Step 4: Fill Template
```
For each section in the template:
1. Substitute variables
2. Apply style guide rules
3. Validate against constraints (length limits, format rules, etc.)
```
### Step 5: Output
```
Output the filled template in <output-format> format.
If validation fails, report which sections need fixes.
```
## Template Variables Reference
<!-- 详细描述每个变量的格式要求 -->
### `<var-1>`
- Type: string
- Format: <格式描述>
- Constraints: <约束>
- Example: `<示例值>`
### `<var-2>`
- Type: string
- Format: <格式描述>
- Constraints: <约束>
- Example: `<示例值>`
## Directory Structure
```
<skill-name>/
├── SKILL.md # 主文件:生成流程指令
├── assets/ # 输出模板(骨架)
│ ├── <output-template>.md # 主输出模板
│ └── <variant-template>.md # 变体模板(如不同格式)
└── references/ # 风格指南和约束
├── <style-guide>.md # 语气、格式、术语
└── <constraints>.md # 字数限制、必填字段等
```
## Anti-Patterns
| Pattern | Problem |
|---|---|
| 不加载模板直接生成 | 输出结构不一致 |
| 猜测缺失变量 | 输出内容不准确,应该问用户 |
| 模板里写死内容 | 模板应该是骨架+占位符,不是完整文档 |
| 风格指南太模糊 | "写得好一点" 没有用,要具体到格式规则 |
| 跳过验证步骤 | 输出可能不符合约束 |
FILE:assets/skill-templates/inversion.md
---
name: <skill-name>
description: "<描述:通过多轮对话收集需求后再行动。包含触发关键词。>"
license: Apache-2.0
metadata:
author: <作者>
version: "<major.minor.patch>"
pattern: "inversion"
interaction: "multi-turn"
---
# <Skill 名称> (Inversion)
<!--
Inversion 模式:Agent 先采访用户再行动。
分阶段提问,显式门控("DO NOT start building until all phases are complete")。
核心思路:
- 分 Phase 提问,收集完整需求
- 每个 Phase 有明确的完成条件
- 所有 Phase 完成后才进入 Synthesis(生成)阶段
- 防止 agent 在信息不足时就开始行动
-->
一句话概述:通过结构化采访收集完整需求,然后生成 <输出类型>。
## Architecture
```
User Request
↓
Phase 1: <领域1> 提问 → 收集答案
↓ (gate: all required answers collected)
Phase 2: <领域2> 提问 → 收集答案
↓ (gate: all required answers collected)
Phase N: <领域N> 提问 → 收集答案
↓ (gate: ALL phases complete)
Synthesis: 生成输出
```
## Instructions
**CRITICAL RULE**: DO NOT start building/generating until ALL phases are complete.
If the user tries to skip ahead, remind them which phase is pending.
### Phase 1: <领域名称>
**Goal**: Understand <what this phase collects>
Ask the following questions (adapt wording to context):
1. <问题1>
2. <问题2>
3. <问题3>
**Completion gate**:
- [ ] <条件1 已满足>
- [ ] <条件2 已满足>
**If user is unsure**: Offer these defaults: <defaults>
### Phase 2: <领域名称>
**Goal**: Understand <what this phase collects>
Ask the following questions:
1. <问题1>
2. <问题2>
3. <问题3>
**Completion gate**:
- [ ] <条件1 已满足>
- [ ] <条件2 已满足>
<!-- 按需添加更多 Phase -->
### Phase N: Confirmation
**Goal**: Confirm all collected information before proceeding
```
Present a summary of all collected answers:
Phase 1 (<领域1>):
- <var>: <collected value>
Phase 2 (<领域2>):
- <var>: <collected value>
Ask: "Is this correct? Should I proceed with generation?"
```
**Completion gate**:
- [ ] User explicitly confirms
### Synthesis
**Precondition**: ALL phases above are complete and confirmed.
```
1. Compile all collected variables
2. [If combined with Generator] Load template from assets/
3. Generate output using collected context
4. Present output for review
```
## Phase Design Guidelines
<!-- 给 Skill 作者的指导 -->
- 每个 Phase 聚焦一个领域,不要混杂
- 问题按依赖关系排序(前面的答案可能影响后面的问题)
- 必须提供 "unsure" 的默认值选项
- Phase 数量建议 2-4 个,太多会让用户疲劳
- 最后一个 Phase 总是 Confirmation
## Directory Structure
```
<skill-name>/
├── SKILL.md # 主文件:Phase 定义 + 门控规则
├── references/ # 领域知识(辅助提问)
│ └── <domain-context>.md # 帮助 agent 提出更好问题的背景知识
└── assets/ # 输出模板(如果结合 Generator)
└── <output-template>.md
```
## Anti-Patterns
| Pattern | Problem |
|---|---|
| 信息不全就开始行动 | 输出质量差,需要反复修改 |
| 一次问太多问题(>5个) | 用户认知过载 |
| 没有完成门控 | Agent 可能跳过关键问题 |
| 不提供默认值 | 用户不确定时被卡住 |
| Phase 之间有循环依赖 | 无法线性推进 |
| 不做最终确认 | 误解需求后浪费大量工作 |
FILE:assets/skill-templates/pipeline.md
---
name: <skill-name>
description: "<描述:严格顺序的多步工作流,每步有门控条件。包含触发关键词。>"
license: Apache-2.0
metadata:
author: <作者>
version: "<major.minor.patch>"
pattern: "pipeline"
steps: "<N>"
---
# <Skill 名称> (Pipeline)
<!--
Pipeline 模式:严格顺序多步工作流 + 硬检查点。
显式菱形门控条件,需要满足条件才能进入下一步。
核心思路:
- 每步加载不同的 reference 文件
- 步骤间有门控条件(不能跳过)
- 门控可以是自动检查或用户确认
- 适合部署、交易执行、数据处理等多步流程
与 Inversion 的区别:
- Inversion 是"收集信息后一次性生成"
- Pipeline 是"每步都执行操作,步步推进"
-->
一句话概述:按严格顺序执行 N 步工作流,每步有门控检查。
## Architecture
```
Step 1: <步骤名>
↓ [Gate: <条件>]
Step 2: <步骤名>
↓ [Gate: <条件>]
Step N: <步骤名>
↓
Output / Completion
```
## Instructions
**CRITICAL RULE**: Steps MUST execute in order. Do NOT skip steps or proceed past a gate
that has not been satisfied. If a gate fails, STOP and report.
### Step 1: <步骤名称>
**Load**: `references/<step1-context>.md`
**Actions**:
1. <操作1>
2. <操作2>
3. <操作3>
**Output**: <本步骤的输出>
**Gate** (ALL must pass):
- [ ] <条件1> — 自动检查: `<检查命令或逻辑>`
- [ ] <条件2> — 用户确认: "Proceed to Step 2?"
### Step 2: <步骤名称>
**Load**: `references/<step2-context>.md`
**Input**: Step 1 output
**Actions**:
1. <操作1>
2. <操作2>
**Output**: <本步骤的输出>
**Gate**:
- [ ] <条件> — 自动检查: `<检查命令或逻辑>`
<!-- 按需添加更多步骤 -->
### Step N: <最终步骤>
**Load**: `references/<stepN-context>.md` (if needed)
**Input**: Step N-1 output
**Actions**:
1. <操作1>
2. <操作2>
**Output**: <最终输出>
**Completion criteria**:
- [ ] <最终验证条件>
## Gate Types
<!-- 门控类型参考 -->
| Type | Description | Example |
|---|---|---|
| `auto-check` | 自动验证,无需用户介入 | 文件存在、API 返回 200、测试通过 |
| `user-confirm` | 需要用户确认才能继续 | "Review the output. Proceed?" |
| `threshold` | 数值满足条件 | `success_rate > 95%` |
| `composite` | 多个条件 AND/OR 组合 | `auto-check AND user-confirm` |
## Failure & Rollback
<!-- 定义步骤失败时的处理策略 -->
```
IF Step N fails:
1. Log failure reason
2. [If rollback defined] Execute rollback for Step N
3. Report to user: which step failed, why, how to fix
4. DO NOT proceed to Step N+1
Rollback actions (optional, per step):
Step 1 rollback: <撤销操作>
Step 2 rollback: <撤销操作>
```
## State Tracking
<!-- Pipeline 可以用状态追踪执行进度 -->
```json
{
"pipeline": "<skill-name>",
"current_step": 2,
"steps": {
"1": {"status": "completed", "output": "...", "completed_at": "ISO"},
"2": {"status": "in_progress"},
"3": {"status": "pending"}
}
}
```
## Directory Structure
```
<skill-name>/
├── SKILL.md # 主文件:步骤定义 + 门控规则
└── references/ # 每步的上下文文档
├── step1-<context>.md # Step 1 需要的参考资料
├── step2-<context>.md # Step 2 需要的参考资料
└── step3-<context>.md # Step 3 需要的参考资料
```
## Combining with Other Patterns
<!-- Pipeline 经常与其他模式组合 -->
### Pipeline + Tool Wrapper
每步加载不同的 API reference,适合跨多个 API 的工作流:
```
Step 1: LOAD references/api-market-data.md → 获取价格
Step 2: LOAD references/api-swap.md → 执行交易
Step 3: LOAD references/api-portfolio.md → 验证结果
```
### Pipeline + Reviewer
某个步骤是审查步骤,加载 review-checklist:
```
Step 1: 生成代码
Step 2: LOAD references/review-checklist.md → 审查代码 [Gate: 0 errors]
Step 3: 部署
```
### Pipeline + Inversion
第一步是采访阶段,收集参数后执行后续步骤:
```
Step 1: Inversion phases → 收集需求 [Gate: user confirms]
Step 2: 生成 → 执行
Step 3: 验证
```
## Anti-Patterns
| Pattern | Problem |
|---|---|
| 跳过门控条件 | 后续步骤基于错误的前提执行 |
| 步骤间隐式依赖 | 应该显式声明每步的输入/输出 |
| 所有步骤加载相同 reference | 没必要用 Pipeline,用 Tool Wrapper 即可 |
| 无失败处理 | 步骤失败后状态不一致 |
| 步骤太多(>7) | 考虑合并相关步骤或拆分为子 Pipeline |
| 门控全是 user-confirm | 考虑哪些可以自动化 |
FILE:assets/skill-templates/reviewer.md
---
name: <skill-name>
description: "<描述:审查/评审特定领域的内容,按检查清单输出分级结果。包含触发关键词。>"
license: Apache-2.0
metadata:
author: <作者>
version: "<major.minor.patch>"
pattern: "reviewer"
severity-levels: "error,warning,info"
---
# <Skill 名称> (Reviewer)
<!--
Reviewer 模式:分离"检查什么"和"如何检查"。
references/review-checklist.md 存放模块化评分标准。
指令定义检查流程和输出格式。
核心思路:
- "检查什么" 定义在 references/review-checklist.md(可独立维护)
- "如何检查" 定义在 SKILL.md 的 Instructions 中
- 输出按严重程度分组:error → warning → info
-->
一句话概述:审查 <目标类型>,按检查清单输出分级发现。
## Architecture
```
Input (code / doc / strategy / config)
↓
Load Checklist (references/review-checklist.md)
↓
For each checklist item:
Check → Classify severity → Record finding
↓
Group by severity → Format output
↓
Summary + Recommendations
```
## Instructions
### Step 1: Load Review Checklist
```
LOAD references/review-checklist.md
Each checklist item has:
- ID: unique identifier (e.g., SEC-001)
- Category: grouping (e.g., Security, Performance)
- Check: what to verify
- Severity: default level if failed (error | warning | info)
- Fix: recommended remediation
```
### Step 2: Execute Review
```
For each item in checklist:
1. Apply the check to the input
2. Record result: PASS | FAIL
3. If FAIL: use the item's severity level
4. Collect evidence (line numbers, quotes, metrics)
```
### Step 3: Classify Findings
| Severity | Meaning | Action Required |
|---|---|---|
| `error` | 必须修复,阻塞发布/部署 | 立即处理 |
| `warning` | 应该修复,但不阻塞 | 计划处理 |
| `info` | 建议改进,可选 | 酌情处理 |
### Step 4: Output Report
```markdown
## Review Summary
- Errors: N
- Warnings: N
- Info: N
- Pass rate: X%
## Errors (must fix)
### [SEC-001] <检查标题>
- **Finding**: <具体发现>
- **Evidence**: <证据(行号、引用)>
- **Fix**: <修复建议>
## Warnings (should fix)
### [PERF-003] <检查标题>
...
## Info (nice to have)
### [STYLE-002] <检查标题>
...
```
### Severity Override Rules
<!-- 定义何时升级/降级严重程度 -->
```
- If <condition>: upgrade <ID> from warning → error
- If <condition>: downgrade <ID> from error → warning
- Context: <context that affects severity>
```
## Review Checklist Format
<!-- 这是 references/review-checklist.md 的格式规范 -->
```markdown
## <Category>
### <ID>: <Check Title>
- **Check**: <检查内容的具体描述>
- **Severity**: error | warning | info
- **Pass criteria**: <通过条件>
- **Fix**: <修复建议>
```
## Directory Structure
```
<skill-name>/
├── SKILL.md # 主文件:检查流程 + 输出格式
└── references/
├── review-checklist.md # 检查清单(模块化,可独立维护)
├── <domain-standards>.md # 领域标准(如安全规范)
└── <scoring-rubric>.md # 评分标准(如果需要打分)
```
## Anti-Patterns
| Pattern | Problem |
|---|---|
| 检查逻辑和检查清单混在一起 | 无法独立维护清单 |
| 不分级,所有发现同等对待 | 用户无法区分优先级 |
| 只列问题不给修复建议 | 审查没有可操作性 |
| 缺少证据/行号 | 用户找不到问题位置 |
| 检查清单太大不分类 | 难以导航和维护 |
FILE:assets/skill-templates/tool-wrapper.md
---
name: <skill-name>
description: "<描述:按需加载特定 API/库的上下文,作为绝对真理指导 agent 操作。包含触发关键词。>"
license: Apache-2.0
metadata:
author: <作者>
version: "<major.minor.patch>"
pattern: "tool-wrapper"
---
# <Skill 名称> (Tool Wrapper)
<!--
Tool Wrapper 模式:按需加载特定库/API 的上下文。
SKILL.md 监听关键词,从 references/ 目录动态加载文档,作为绝对真理应用。
核心思路:
- references/ 目录存放 API 规范、SDK 文档、类型定义
- 指令告诉 agent 何时加载哪个 reference
- 加载后的内容覆盖 agent 的先验知识
-->
一句话概述:封装 <API/库> 的操作接口,提供精确的上下文加载。
## Architecture
```
User Request
↓
Keyword Match → 加载 references/<relevant>.md
↓
Agent + Loaded Context → 执行操作
↓
Structured Output
```
**依赖的外部服务/Skill**:
- <API/库名>: `<调用方式>` -> `<具体命令>`
## Trigger Keywords
<!-- 列出触发此 Skill 的关键词,用于 agent 路由 -->
| Keyword / Pattern | Load Reference | Description |
|---|---|---|
| `<keyword-1>` | `references/<file1>.md` | <何时加载> |
| `<keyword-2>` | `references/<file2>.md` | <何时加载> |
| `<keyword-3>` | `references/<file1>.md, references/<file2>.md` | <组合加载> |
## Instructions
When the user mentions any trigger keyword:
1. **Load Context**: Read the corresponding reference file(s) from `references/` directory
2. **Apply as Ground Truth**: Treat loaded content as the authoritative specification — override any prior knowledge that conflicts
3. **Execute**: Follow the API/library conventions exactly as documented
4. **Validate**: Check output against the reference specification before returning
<!--
关键原则:
- Reference 文件是绝对真理,覆盖 agent 的训练知识
- 只加载需要的 reference,不要一次全加载(节省 context window)
- 如果用户请求与 reference 矛盾,以 reference 为准并告知用户
-->
### Context Loading Rules
```
IF user mentions <keyword-1>:
LOAD references/<file1>.md
APPLY as ground truth for <specific domain>
IF user mentions <keyword-2>:
LOAD references/<file2>.md
APPLY as ground truth for <specific domain>
IF task requires both:
LOAD both, <file1>.md takes precedence on conflicts
```
### Core Operations
<!-- 列出此 Skill 封装的核心操作 -->
| Operation | Command / API | Input | Output |
|---|---|---|---|
| `<op-1>` | `<command>` | <输入参数> | <返回格式> |
| `<op-2>` | `<command>` | <输入参数> | <返回格式> |
### Error Handling
```python
# 统一错误格式
failure_info = {
"reason": str, # 机器可读
"detail": str, # 人类可读上下文
"retriable": bool, # 是否可自动重试
"hint": str # 提示
}
```
## Directory Structure
```
<skill-name>/
├── SKILL.md # 主文件:触发规则 + 指令
└── references/ # API 规范和文档(绝对真理)
├── <api-spec>.md # API 端点、参数、返回值
├── <type-definitions>.md # 类型定义、枚举值
└── <error-codes>.md # 错误码和处理方式
```
## Anti-Patterns
| Pattern | Problem |
|---|---|
| 一次加载所有 references | 浪费 context window,降低精度 |
| 忽略 reference 与先验知识的冲突 | Reference 是真理,必须覆盖 |
| Reference 文件太大(>2000行) | 拆分为更细粒度的文件 |
| 把执行逻辑写在 reference 里 | Reference 只存事实,指令写在 SKILL.md |
FILE:hooks/task-completed-gate.sh
#!/usr/bin/env bash
# TaskCompleted hook — 质量门禁 (multi-strategy)
# Exit 0: 允许完成 | Exit 2: 拒绝,附反馈
set -euo pipefail
TEAMMATE="-"
STRATEGY="-"
# 如果未指定策略,尝试从最近修改的 Strategy/*/ 推断
if [[ -z "$STRATEGY" ]]; then
STRATEGY=$(ls -dt Strategy/*/ 2>/dev/null | head -1 | xargs basename 2>/dev/null || true)
fi
# 仍然为空则放行
if [[ -z "$STRATEGY" ]]; then
exit 0
fi
if [[ "$TEAMMATE" == "strategy" ]]; then
V=$(ls -d "Strategy/$STRATEGY/Script/v"*/ 2>/dev/null | sort -V | tail -1)
[ -z "$V" ] && { echo "FEEDBACK: Strategy/$STRATEGY/Script/ 下无版本目录"; exit 2; }
for f in config.json risk-profile.json README.md; do
[ ! -f "$V/$f" ] && { echo "FEEDBACK: 缺少 $V/$f"; exit 2; }
done
ls "$V"/strategy.{js,ts,py} &>/dev/null 2>&1 || { echo "FEEDBACK: 缺少策略主文件"; exit 2; }
for field in max_position_size_pct stop_loss_pct max_drawdown_pct gas_budget_usd slippage_tolerance_pct; do
grep -q "\"$field\"" "$V/risk-profile.json" || { echo "FEEDBACK: risk-profile.json 缺少字段: $field"; exit 2; }
done
fi
if [[ "$TEAMMATE" == "backtest" ]]; then
V=$(ls -d "Strategy/$STRATEGY/Backtest/v"*/ 2>/dev/null | sort -V | tail -1)
[ -z "$V" ] && { echo "FEEDBACK: Strategy/$STRATEGY/Backtest/ 下无回测输出目录"; exit 2; }
# 至少有报告文件
ls "$V"/*.{json,md} &>/dev/null 2>&1 || { echo "FEEDBACK: 回测目录缺少报告文件"; exit 2; }
fi
if [[ "$TEAMMATE" == "publish" ]]; then
# 检查策略根目录下的 manifest.json
[ ! -f "$STRATEGY/manifest.json" ] && { echo "FEEDBACK: $STRATEGY/ 缺少 manifest.json"; exit 2; }
fi
exit 0
FILE:hooks/teammate-idle-reassign.sh
#!/usr/bin/env bash
# TeammateIdle hook — 空闲时检查待办 (multi-strategy)
# Exit 0: 允许空闲 | Exit 2: 分配工作
set -euo pipefail
TEAMMATE="-"
case "$TEAMMATE" in
backtest)
# 遍历所有策略,检查未回测的版本
for strat_dir in Strategy/*/; do
[ ! -d "$strat_dir" ] && continue
strat=$(basename "$strat_dir")
for v in "Strategy/$strat/Script/v"*/; do
[ ! -d "$v" ] && continue
ver=$(basename "$v")
[ ! -d "Strategy/$strat/Backtest/$ver" ] && [ -f "$v/risk-profile.json" ] && {
echo "FEEDBACK: $strat/$ver 未回测。请验证 $v"; exit 2; }
done
done ;;
esac
exit 0
FILE:references/api-interfaces.md
# onchainos CLI 接口参考
策略代码通过 `subprocess` 调用 `onchainos` CLI 与链上交互。**这是唯一的执行方式**,不存在 SDK 或 Python 包。
## 运行时
| 环境 | 路径 | 架构 |
|------|------|------|
| 本地 Mac | `Agentic Wallet/onchainos` | arm64 |
| VPS | `/usr/local/bin/onchainos` | amd64 |
认证通过环境变量:`OKX_API_KEY` / `OKX_SECRET_KEY` / `OKX_PASSPHRASE`(从 1Password 获取)。
## 标准 Wrapper
所有策略必须使用统一的 CLI wrapper 模式(参考 `grid-trading/references/eth_grid_v1.py`):
```python
import subprocess, json, os
def onchainos_cmd(args: list[str], timeout: int = 30) -> dict | None:
"""Run onchainos CLI command, return parsed JSON."""
env = os.environ.copy()
env.setdefault("OKX_API_KEY", os.environ.get("OKX_API_KEY", ""))
env.setdefault("OKX_SECRET_KEY", os.environ.get("OKX_SECRET_KEY", ""))
env.setdefault("OKX_PASSPHRASE", os.environ.get("OKX_PASSPHRASE", ""))
try:
result = subprocess.run(
["onchainos"] + args,
capture_output=True, text=True, timeout=timeout, env=env,
)
output = result.stdout.strip() if result.stdout else ""
if output:
try:
data = json.loads(output)
if isinstance(data, dict) and "ok" in data:
return data
return {"ok": True, "data": data if isinstance(data, list) else [data]}
except json.JSONDecodeError:
pass
if result.returncode != 0:
stderr = result.stderr.strip() if result.stderr else ""
log(f"onchainos rc={result.returncode}: {' '.join(args[:3])} "
f"{'stderr=' + stderr[:150] if stderr else 'no output'}")
except subprocess.TimeoutExpired:
log(f"onchainos timeout: {' '.join(args[:3])}")
except Exception as e:
log(f"onchainos error: {e}")
return None
```
## 命令速查
### Wallet(钱包操作)
```bash
# 登录 & 状态
onchainos wallet login <email> --locale zh-CN
onchainos wallet verify <otp>
onchainos wallet status
onchainos wallet addresses # 所有地址,按链分组
onchainos wallet addresses --chain <chainId> # 指定链
# 余额(⚠️ 返回 UI 单位)
onchainos wallet balance # 所有链概览
onchainos wallet balance --chain <chainId> # 指定链
onchainos wallet balance --chain <chainId> --token-address <addr> # 指定 token
# 转账(⚠️ amount 用 UI 单位:"0.1" = 0.1 ETH)
onchainos wallet send --amount <amt> --receipt <addr> --chain <chainId>
onchainos wallet send --amount <amt> --receipt <addr> --chain <chainId> --contract-token <tokenAddr> # ERC-20
# 合约调用(TEE 签名)
onchainos wallet contract-call --to <addr> --chain <chainId> --input-data <hex>
onchainos wallet contract-call --to <addr> --chain <chainId> --input-data <hex> --value <amount> # payable
onchainos wallet contract-call --to <addr> --chain <chainId> --input-data <hex> --mev-protection # 防 MEV
# 交易历史
onchainos wallet history
onchainos wallet history --tx-hash <hash> --chain <chainId> --address <addr>
```
### Swap(DEX 聚合交易)
```bash
# 询价(⚠️ amount 用最小单位 wei/lamports)
onchainos swap quote --from <tokenAddr> --to <tokenAddr> --amount <minimalUnits> --chain <chainId>
# 授权(EVM 需要,Solana 不需要)
onchainos swap approve --token <tokenAddr> --amount <minimalUnits> --chain <chainId>
# 执行 swap(返回 tx calldata,需要通过 wallet contract-call 签名广播)
onchainos swap swap --from <tokenAddr> --to <tokenAddr> --amount <minimalUnits> --chain <chainId> --wallet <walletAddr>
# 支持的链 & 流动性源
onchainos swap chains
onchainos swap liquidity --chain <chainId>
```
**原生代币地址**:
- EVM: `0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee`
- Solana: `11111111111111111111111111111111`
### Market(行情数据)
```bash
# 价格
onchainos market price --address <tokenAddr> --chain <chainId>
onchainos market prices --tokens "<chainId>:<addr>,<chainId>:<addr>" # 批量
# K线(⚠️ Solana 用 wSOL SPL 地址)
onchainos market kline --address <tokenAddr> --chain <chainId> --bar <1m|5m|15m|1H|4H|1D> --limit <count>
# 指数价格(聚合多源)
onchainos market index --address <tokenAddr> --chain <chainId>
```
### Gateway(交易基础设施)
```bash
# Gas
onchainos gateway gas --chain <chainId> # 当前 gas price
onchainos gateway gas-limit --from <addr> --to <addr> --chain <chainId> # 估算 gas limit
onchainos gateway gas-limit --from <addr> --to <addr> --data <calldata> --chain <chainId>
# 模拟(不上链,验证交易可行性)
onchainos gateway simulate --from <addr> --to <contract> --data <calldata> --chain <chainId>
# 广播(需要已签名的 tx)
onchainos gateway broadcast --signed-tx <hex> --address <addr> --chain <chainId>
# 订单追踪
onchainos gateway orders --address <addr> --chain <chainId>
onchainos gateway orders --address <addr> --chain <chainId> --order-id <id>
```
### Security(安全扫描)
```bash
# Token 风险检测(蜜罐、rug pull)
onchainos security token-scan --tokens "<chainId>:<addr>,<chainId>:<addr>"
# DApp/URL 钓鱼检测
onchainos security dapp-scan --domain <domain>
# 交易预执行安全检查
onchainos security tx-scan --chain <chainId> --from <addr> --to <contract> --data <calldata>
# 授权查询
onchainos security approvals --address <addr> --chain <chainId>
```
### Token(代币数据)
```bash
# 搜索 & 信息
onchainos token search --query <term> --chains "<chainId1>,<chainId2>"
onchainos token info --address <addr> --chain <chainId>
onchainos token price-info --address <addr> --chain <chainId> # 价格 + 市值 + 流动性
# 持有者分析
onchainos token holders --address <addr> --chain <chainId>
onchainos token holders --address <addr> --chain <chainId> --tag-filter <whale|kol|smart_money|sniper>
# 流动性池
onchainos token liquidity --address <addr> --chain <chainId>
# 高级风险信息(创建者、持仓集中度)
onchainos token advanced-info --address <addr> --chain <chainId>
```
### Portfolio(公开地址查询)
```bash
onchainos portfolio total-value --address <addr> --chains "<chainId1>,<chainId2>"
onchainos portfolio all-balances --address <addr> --chains "<chainIds>"
onchainos portfolio token-balances --address <addr> --tokens "<chainId>:<addr>,<chainId>:<addr>" # 最多 20 个
```
## ⚠️ 关键陷阱
| 陷阱 | 说明 | 正确做法 |
|------|------|----------|
| **单位混用** | `wallet send` 用 UI 单位,`swap` 用最小单位 (wei) | 每个命令查本表确认单位 |
| **EVM approve** | EVM swap 前必须 approve,Solana 不需要 | 根据链判断是否 approve |
| **合约地址大小写** | onchainos 要求小写 | `.lower()` 处理 |
| **K线 Solana** | `market kline` 用 wSOL SPL 地址,不是原生地址 | 区分原生 vs SPL 地址 |
| **Gas 估算不可靠** | DEX API 返回的 gas 值不准 | 让 onchainos 内部估算,不要手动传 gas |
| **签名方式** | 用 `wallet contract-call` 签名,不是 gateway broadcast | 策略代码不直接签名 |
## 策略代码的标准执行流程
以 swap 为例(参考 grid-trading 实盘代码):
```
1. onchainos swap quote → 获取报价 + priceImpact
2. 检查 priceImpact < tolerance → 超出则放弃
3. onchainos swap approve → EVM 授权(Solana 跳过)
4. onchainos swap swap → 获取 tx calldata
5. onchainos gateway simulate → 预检交易(大额操作建议做)
6. onchainos wallet contract-call → TEE 签名 + 广播
7. 验证返回 txHash → 记录到 state.json
```
**完整示例见 `grid-trading/references/eth_grid_v1.py`** — 这是经过实盘验证的代码,新策略应复用其 wrapper 和执行模式。
FILE:references/risk-schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Risk Profile",
"description": "策略风控硬约束。所有字段必填。Backtest Agent 逐项校验。",
"type": "object",
"required": [
"max_position_size_pct", "stop_loss_pct", "take_profit_pct",
"max_drawdown_pct", "max_daily_loss_pct", "gas_budget_usd",
"slippage_tolerance_pct", "max_concurrent_positions", "market_conditions"
],
"properties": {
"max_position_size_pct": { "type": "number", "description": "单笔最大仓位占比 %", "minimum": 0.1, "maximum": 100 },
"stop_loss_pct": { "type": "number", "description": "止损触发 %", "minimum": 0.1 },
"take_profit_pct": { "type": "number", "description": "止盈触发 %", "minimum": 0.1 },
"max_drawdown_pct": { "type": "number", "description": "最大回撤 %", "minimum": 1 },
"max_daily_loss_pct": { "type": "number", "description": "单日最大亏损 %", "minimum": 0.5 },
"gas_budget_usd": { "type": "number", "description": "每日 Gas 预算 USD", "minimum": 1 },
"slippage_tolerance_pct": { "type": "number", "description": "滑点容忍度 %", "minimum": 0.01 },
"max_concurrent_positions": { "type": "integer", "description": "最大同时持仓数", "minimum": 1 },
"market_conditions": {
"type": "object",
"required": ["applicable", "not_applicable"],
"properties": {
"applicable": { "type": "array", "items": { "type": "string" } },
"not_applicable": { "type": "array", "items": { "type": "string" } }
}
}
}
}
FILE:references/strategy-lessons.md
# 策略经验库
从已有策略的开发、回测、实盘中提炼的可复用经验。新策略开发时 **必须** 先读本文件。
---
## 一、通用风控模式
所有策略已验证有效的风控层级(按优先级从高到低):
| 层级 | 机制 | 推荐值 | 来源 |
|------|------|--------|------|
| 1 | 全局 Stop 开关 | — | grid-trading, cl-lp |
| 2 | 熔断器(连续错误→冷却) | 5 错误 → 1h 冷却 | grid-trading, cl-lp |
| 3 | 数据验证(价格/余额/API 返回) | 每次操作前 | 通用 |
| 4 | 止损 / 追踪止损 | 15% / 10% | grid-trading, cl-lp |
| 5 | 仓位限制 | 按策略类型定义上限 | grid-trading |
| 6 | 最小操作间隔 | 30min (交易) / 2h (调仓) | grid-trading, cl-lp |
| 7 | 重复操作保护 | 同向最多 3 次后暂停 | grid-trading |
| 8 | Gas 成本检查 | Gas < 预期收益的 50% | cl-lp |
| 9 | 最小变化阈值 | > 3% (网格) / > 5% (范围) | grid-trading, cl-lp |
**原则**: 新策略不必照搬全部层级,但至少包含 1-4 层(Stop、熔断、数据验证、止损)。5-9 层根据策略类型选取。
---
## 二、多时间框架趋势分析 (MTF)
Grid Trading 首创,CL LP Rebalancer 已成功复用。**推荐所有需要趋势判断的策略复用此模块。**
```
短期 EMA: 5 bars (25min @ 5min K线) → 快速信号
中期 EMA: 12 bars (1h) → 方向确认
长期 EMA: 48 bars (4h) → 趋势过滤
8h 结构检测: 上升/下降/震荡 → 宏观背景
```
**输出**:
- `trend_direction`: bullish / bearish / neutral
- `trend_strength`: 0-1(> 0.3 时激活非对称逻辑)
**复用方式**: 独立函数,输入 K 线数组,输出趋势方向 + 强度。不依赖具体策略逻辑。
---
## 三、波动率自适应
两个策略都使用 K线 ATR + 价格标准差融合的波动率估计,效果优于单一指标。
**ATR 计算**: 24 根 1H K 线 → 24h 波动率窗口
**波动率分级**(CL LP 已验证):
| 级别 | ATR 范围 | 策略行为 |
|------|----------|----------|
| Low | < 1.5% | 收紧参数,追求资本效率 |
| Medium | 1.5-3% | 平衡配置 |
| High | 3-5% | 放宽参数,减少操作频率 |
| Extreme | > 5% | 防御模式,安全优先 |
**教训**: Grid Trading 的波动率乘数 (VOLATILITY_MULTIPLIER) 在不同趋势下需要差异化——看涨时放宽 (3.0x),看跌时收紧 (1.0x)。
---
## 四、非对称设计
两个策略都证明了**趋势方向应驱动买卖不对称**:
- **Grid Trading**: 看涨时买网格密、卖网格疏(快买慢卖);看跌反之
- **CL LP**: 看涨时上方范围宽、下方窄;看跌反之
**激活条件**: `trend_strength > 0.3` 时启用,否则保持对称。
**非对称系数**: Grid 用 0.4,CL LP 用 0.3。新策略建议从 0.3 起步,根据回测调整。
---
## 五、成本管理经验
### Slippage(最大痛点)
Grid Trading 回测暴露了 **6.24% 的 slippage**,直接抵消了交易利润。
**教训**:
- 单笔交易金额越大,slippage 越高 → 控制单笔大小
- DEX swap 天然存在 slippage,无法消除只能管理
- `slippage_tolerance_pct` 设为 1-1.5%,超出时放弃交易
### Gas
CL LP 在 Base 链上 Gas 极低($0.048/天),但其他链差异巨大。
**教训**:
- 始终检查 `gas_cost < expected_profit * gas_to_fee_ratio`
- Base/Arbitrum/Polygon 的 Gas 成本可忽略,Ethereum L1 需要特别注意
- 调仓/交易前先估算 Gas,不满足则跳过
---
## 六、状态管理模式
两个策略都使用 JSON 文件管理状态,已验证可靠:
```python
state = {
"positions": [], # 当前持仓
"price_history": [], # 288 根 5min K线 = 24h
"error_count": 0, # 熔断计数
"last_action_time": "", # 操作间隔控制
"equity_curve": [], # 资金曲线
"trade_log": [] # 交易记录(配对分析用)
}
```
**教训**:
- 状态文件必须原子写入(先写 tmp 再 rename),防止中断导致损坏
- 价格历史保留 24h 足够(288 根 5min),更长周期用 API 按需拉取
- 交易记录要保留完整的 round trip(买卖配对),便于 Iteration Agent 分析
---
## 七、回测指标基线
| 指标 | Grid Trading v4 | CL LP v1 | 建议门槛 |
|------|-----------------|----------|----------|
| Sharpe Ratio | — | 29.67 | > 1.0 |
| Max Drawdown | — | 2.72% | < 声明值 |
| Win Rate | — | 85.7% | > 40% |
| Profit Factor | — | 7.5 | > 1.5 |
| Gas/Revenue | 高 | 0.22% | < 50% |
| Slippage 实际 | 6.24% | 0.35% | < 声明值 |
| 在范围时间 (LP) | N/A | 99.3% | > 90% |
**注意**: CL LP 的指标异常优秀,部分因为回测期间市场条件理想(中等波动 + 温和趋势)。新策略不应以此为基准,而应以 Gate 最低要求(Sharpe>1.0, WR>40%)为目标。
---
## 八、适用市场条件
两个策略的共同发现:
| 市场状态 | 表现 | 应对 |
|----------|------|------|
| 震荡 / 低波动 | ✅ 最佳 | 正常运行 |
| 中等波动 + 温和趋势 | ✅ 良好 | 非对称模式激活 |
| 高波动 | ⚠️ 可接受 | 放宽参数,降低频率 |
| 极端波动 / 闪崩 | ❌ 风险高 | 建议暂停或进入防御模式 |
| 持续单边行情 | ❌ 不适合 | 网格策略天然逆势,LP 面临 IL |
**建议**: 新策略的 `risk-profile.json` 中 `market_conditions.not_applicable` 必须明确列出不适用场景。
---
## 九、执行架构模式
### onchainos CLI — 运行时依赖
所有策略的链上操作通过 `onchainos` 二进制执行。本地 `Agentic Wallet/onchainos` (arm64),VPS `/usr/local/bin/onchainos` (amd64)。onchainos 更新频繁,独立测试。
onchainos 的能力已作为 Claude Code Skill 安装(`okx-dex-swap`、`okx-agentic-wallet` 等),策略开发时按 skill 名称调用,接口细节以 skill 文档为准。
**与 onchainos 协作的经验教训**:
- `wallet send` 用 UI 单位("0.1" ETH),`swap` 系列用最小单位(wei)—— 混用必出错
- EVM swap 需要先 `approve` 再 `swap`,Solana 不需要
- `gateway simulate` 可在不上链的情况下预检交易,建议大额操作前必做
- `security token-scan` 可检测蜜罐,新策略接入未知 Token 前应先扫描
- onchainos 更新后,策略的 CLI 调用方式可能变化,部署前需验证兼容性
### 通用执行架构
```
Cron (5min) → Python 脚本 → onchainos CLI → OKX Web3 API → Chain
↓ ↓
state.json (本地) Wallet (TEE signing)
↓
Discord 通知 (via OpenClaw)
```
**标准 CLI 入口**(所有策略应统一):
```bash
python3 {strategy}.py tick # 主循环(cron 调度)
python3 {strategy}.py status # 当前状态
python3 {strategy}.py report # 日报
python3 {strategy}.py close # 完全退出(仅部分策略需要)
```
---
## 十、常见陷阱
| 陷阱 | 策略 | 后果 | 预防 |
|------|------|------|------|
| 硬编码 Token 地址 | 通用 | 换链时全部失效 | config.json 外置 |
| 不校验 API 返回 | 通用 | 空数据导致异常交易 | 数据验证层 |
| 止损和追踪止损冲突 | Grid | 两者同时触发导致重复平仓 | 互斥检查 |
| 调仓过于频繁 | CL LP | Gas + slippage 吃掉收益 | anti-churn 保护 |
| 价格历史不足就计算 ATR | 通用 | 波动率估计偏差大 | 最少 24 根 K 线才启用 |
| 忽略 decimal 差异 | 通用 | ETH(18) vs USDC(6) 计算错误 | 统一转换层 |
| 混用 UI 和最小单位 | 通用 | onchainos 不同命令单位不同,混用导致金额错误 | 查阅 cli-reference 确认每个命令的单位 |
| 回测用理想 slippage | Grid | 实盘 slippage 远高于预期 | 回测加入随机 slippage 模型 |
| onchainos 版本不兼容 | 通用 | 更新后 CLI 参数变化导致策略报错 | 部署前验证 onchainos 版本兼容性 |
---
## 更新记录
- 2026-03-20: 初始版本,从 grid-trading v4 和 cl-lp-rebalancer v1 提取
FILE:roles/backtest.md
# Backtest Agent
验证策略版本。不写策略。
## 参数
从 Lead 接收 `{strategy}` — 策略名称,决定所有输入/输出路径。
## 输入
`Strategy/{strategy}/Script/v{version}/` 下的完整策略文件
## 产出
写入 `Strategy/{strategy}/Backtest/v{version}/`(可复用已有的回测框架 `Strategy/{strategy}/Backtest/backtest_engine.py`):
1. **backtest-report.json**:
```json
{
"version": "", "strategy_name": "{strategy}",
"test_period": { "start": "", "end": "" },
"metrics": {
"sharpe_ratio": 0, "max_drawdown_pct": 0, "win_rate_pct": 0,
"profit_factor": 0, "total_return_pct": 0, "total_trades": 0,
"max_consecutive_losses": 0, "gas_cost_total_usd": 0, "slippage_impact_pct": 0
},
"compliance": {
"max_drawdown": { "declared": 0, "actual": 0, "pass": true },
"stop_loss": { "triggered_correctly": true, "pass": true },
"gas_budget": { "declared_daily": 0, "actual_daily_avg": 0, "pass": true },
"slippage": { "declared": 0, "actual_avg": 0, "pass": true },
"position_size": { "declared_max_pct": 0, "actual_max_pct": 0, "pass": true }
},
"verdict": "PASS | FAIL | CONDITIONAL",
"failures": []
}
```
2. **backtest-summary.md** — 人类可读报告
3. **equity-curve.csv** — `timestamp,equity,drawdown_pct,position_count`
## Verdict 规则
- Compliance 全 PASS + Sharpe > 1.0 + Win Rate > 40% → **PASS**
- 任一 Compliance FAIL → **FAIL**(列出每项失败的 declared vs actual)
- Compliance PASS 但指标 borderline → **CONDITIONAL**(说明哪项差多少)
向 Lead 报告 verdict + 完整 metrics。
FILE:roles/infra.md
# Infra Agent — 本地验证 + 生产部署
部署通过回测的策略。流程: **本地验证 → 生产部署**。不写策略、不做回测。
这是**开发者自用的部署流程**。消费者部署(OpenClaw/Docker)在产品 Skill 里定义。
## 参数
从 Lead 接收 `{strategy}` — 策略名称,决定所有源路径和 VPS 目标路径。
## 环境配置
策略脚本通过 `env_config.py` 加载环境配置,由 `ENV` 环境变量切换:
| 环境 | 配置文件 | onchainos | dry_run | 凭证 |
|------|---------|-----------|---------|------|
| `local` | `env.local.json` | `./Agentic Wallet/onchainos` (arm64) | `true` | 1Password |
| `production` | `env.production.json` | `/usr/local/bin/onchainos` (amd64) | `false` | 环境变量 |
## 部署脚本
所有操作通过 `deploy.sh` 统一执行:
```bash
./deploy.sh {strategy} validate # Step 1: 本地验证(3 tick dry-run)
./deploy.sh {strategy} local # 本地运行(持续 dry-run)
./deploy.sh {strategy} production # Step 2: 部署到 VPS
./deploy.sh {strategy} status # 查看 VPS 状态
./deploy.sh {strategy} stop # 停止 VPS 进程
```
## 流程
### Step 1: 本地验证(Gate)
**必须在部署 VPS 前通过。**
```bash
./deploy.sh {strategy} validate
```
验证内容:
- 策略脚本在本地 onchainos(arm64)环境下启动无报错
- RPC 连接正常,能获取价格和余额
- 钱包适配器响应正常
- 连续 3 个 tick dry-run 无异常
**Gate**: 3 tick 全部成功 → PASS,任一失败 → FAIL,退回 Strategy 修复。
### Step 2: 生产部署
```bash
./deploy.sh {strategy} production
```
自动完成:
1. **凭证获取** — 通过 1Password 获取 SSH 密钥(临时文件,用完删除)
2. **Pre-deploy Check** — SSH 连通、磁盘 > 1GB、onchainos 可用、备份当前版本
3. **上传 + 切换** — scp 上传 staging → pm2 stop → mv current → pm2 start
4. **健康检查(10s)** — pm2 status online + 无错误日志
5. **回滚** — 健康检查失败自动回滚到上一版本
### Step 3: 收尾
- 清理旧备份(保留最近 3 个)
- 向 Lead 报告结果 + 更新 state.json
## 回滚
部署脚本自动处理回滚。手动回滚:
```bash
./deploy.sh {strategy} stop
# SSH 到 VPS 手动恢复 backup
```
报告失败给 Lead。**不自动重试**。
## 部署窗口
优先 UTC 0:00–4:00。紧急修复需用户确认。
## 安全
- SSH 密钥: 1Password 临时取出 → mktemp → trap 清理。不存入环境变量/文件/日志
- OKX 凭证: 本地通过 `op` 获取,VPS 通过环境变量(pm2 ecosystem 管理)
FILE:roles/iteration.md
# Iteration Agent
复盘线上策略表现,提取因果关系,输出优化方案。不直接改代码。
## 参数
从 Lead 接收 `{strategy}` — 策略名称,决定所有输入/输出路径。
## 产出
写入 `Strategy/{strategy}/Iteration/v{ver}-review-{YYYY-MM-DD}.md`
## 分析框架(全部必填)
### 1. Performance Summary
期间收益 vs 预期区间。实际风控指标 vs 声明阈值。
### 2. Correct Decisions
盈利交易 + 市场背景。成功共性模式。可复用信号特征。
### 3. Wrong Decisions
亏损交易 + 市场背景。分类:信号误判 / 参数不当 / 市场突变 / 执行问题。标注可避免 vs 系统性风险。
### 4. Causal Extraction
```
[HIGH] IF {条件} THEN {结果} (N/M 次观察)
[LOW] IF {条件} THEN {结果} (数据不足)
```
强因果:5+ 次观察。弱因果:< 5 次。
### 5. Optimization Proposal
具体的 config.json 修改(字段、当前值 → 建议值、理由)。风控阈值调整。逻辑变更及预期效果。
### 6. Risk Flag
- 风控阈值触发 > 3 次 → **优先迭代项**
- 市场偏离适用条件 → **建议暂停**
- 连续亏损 > 回测最大值 → **升级给用户**
## 经验回写
每次复盘完成后,检查是否有**跨策略可复用**的新发现(风控模式、成本教训、市场条件洞察、新陷阱)。如有,追加到 `references/strategy-lessons.md` 对应章节,并更新底部的更新记录。
**回写标准**: 仅记录经过实盘验证的经验,不记录假设或未验证的推测。
## 规则
1. 产出是**提案** — 绝不自动执行
2. 新版本必须重新回测
3. 所有迭代记录永久保留
4. 连续 2 次未改善 → 建议暂停或重新设计
## 数据来源
参考 `grid-trading/SKILL.md` 的 AI Review & Optimization 部分:
- 从 `Strategy/{strategy}/state.json` 读取交易记录
- 配对 round trips(BUY-SELL 匹配)
- 分析 win rate、avg spread、loss/micro/good 分类
- 检查 MTF 数据在亏损时的趋势对齐
FILE:roles/lead.md
# Lead — 协调者
你是 Agent Team 的 Lead。**你绝不写代码**。你只做:协调、分发、质量门禁、状态管理。
## 参数
启动时接收 `{strategy}` 参数,标识当前操作的策略名称。所有 teammate spawn 时必须传递此参数。
## 第一跳:需求提炼(Lead → Strategy)
Spawn Strategy Agent **之前**,Lead 必须完成以下流程:
1. **提炼** — 从主窗口讨论中提取最终决策,丢弃犹豫、被否决方案、闲聊
2. **填模板** — 读取 `templates/requirements.md`,填写所有字段,写入 `Strategy/{strategy}/requirements.md`
3. **确认** — 将填好的需求展示给用户,等待确认。用户可修正后再继续
4. **Spawn** — 确认后 spawn Strategy Agent,prompt 中指向需求文件而非内联上下文
**原则**: 需求文件只记录"做什么",不记录"讨论过什么"。字段留空比填垃圾信息好。
## Spawn Teammates
| Teammate | 时机 | Spawn Prompt |
|----------|------|-------------|
| strategy | 新建/修订策略 | `Read roles/strategy.md and references/strategy-lessons.md. Strategy: {strategy}. Requirements: Strategy/{strategy}/requirements.md` |
| backtest | Strategy 产出完整 | `Read roles/backtest.md. Strategy: {strategy}. Validate Strategy/{strategy}/Script/v{ver}/` |
| infra | Backtest PASS | `Read roles/infra.md. Strategy: {strategy}. Deploy v{ver}.` |
| publish | Backtest PASS(可并行) | `Read roles/publish.md. Strategy: {strategy}. Package v{ver} as Skill.` |
| iteration | LIVE + 复盘请求 | `Read roles/iteration.md. Strategy: {strategy}. Review v{ver} for {period}.` |
## 质量门禁
**Strategy → Backtest 前**,验证 `Strategy/{strategy}/Script/v{version}/` 包含:
- `strategy.js` 或 `.ts`(无硬编码参数)
- `config.json`(参数外置)
- `risk-profile.json`(字段完整,校验 `references/risk-schema.json`)
- `README.md`(含收益预期 + 适用市场条件)
**缺任何文件 = reject**,附具体缺失项退回 strategy teammate。
**Backtest → Deploy 前**:
- Compliance 全 PASS + Sharpe > 1.0 + Win Rate > 40% → 自动通过
- Compliance PASS 但指标 borderline → CONDITIONAL,问用户
- 任一 Compliance FAIL → reject 附失败详情
## 版本管理
SemVer: `MAJOR.MINOR.PATCH`。每版本独立目录。已发布版本不可修改。
## 状态追踪
文件 `Strategy/{strategy}/state.json`:
```json
{ "strategy_name": "{strategy}", "state": "DRAFT", "version": "1.0.0", "live_version": "", "log": [] }
```
每次转换记录:`[STATE] {strategy} v{ver}: {OLD} → {NEW} | {reason}`
## 规则
1. 同时只有一个版本处于 DEPLOYING
2. Publish 在 Backtest 通过后开始抽象,GitHub release 等 Deploy 成功
3. **Iteration 新版本必须重新回测 — 无例外**
4. 任何 Agent 报错 → 暂停流水线 + 通知用户
5. 连续 2 次迭代未改善 → 建议暂停策略或重新设计
FILE:roles/publish.md
# Publish Agent
把策略抽象为独立的、跨平台可复用的产品 Skill。不写策略。
## 参数
从 Lead 接收 `{strategy}` — 策略名称,决定所有输入/输出路径。
## 职责
从 `Strategy/{strategy}/Script/v{version}/` 读取策略,用 `assets/product-skill-template/` 模板生成独立 Skill 包,输出到 `{strategy}/`。
## Skill 设计模式选择
生成 SKILL.md 前,先根据策略特征选择合适的设计模式。模式模板在 `assets/skill-templates/` 下:
| 模式 | 模板文件 | 适用场景 |
|------|----------|----------|
| pipeline | `pipeline.md` | 严格顺序多步工作流(如网格交易:取价→分析→决策→执行→通知) |
| tool-wrapper | `tool-wrapper.md` | 按需加载 API/CLI 上下文(如封装 onchainos CLI 操作) |
| generator | `generator.md` | 生成一致结构的输出(如报告、配置文件) |
| reviewer | `reviewer.md` | 审查/评估(如策略回测结果审查) |
| inversion | `inversion.md` | 先采访用户收集需求再行动 |
**选择流程**:
1. 读取策略代码,理解其执行模式
2. 从 `assets/skill-templates/` 加载匹配的模式模板
3. 用 `assets/skill-templates/SKILL_TEMPLATE.md` 作为 SKILL.md 的基础骨架
4. 按所选模式的结构组织 SKILL.md 的 Instructions 章节
5. 复合模式用逗号分隔写入 `metadata.pattern`(如 `"pipeline, tool-wrapper"`)
大多数交易策略是 **pipeline + tool-wrapper** 的组合:流水线定义执行步骤,tool-wrapper 加载每步需要的 API 参考文档。
## 发布验证
生成完成后,用 `assets/publish.sh` 验证 Skill 格式:
```bash
bash okx-strategy-factory/assets/publish.sh {strategy} --dry-run
```
验证通过后再执行 git commit。`publish.sh` 检查 YAML frontmatter、必需章节、模式特有字段等。
## 产出结构
```
{strategy}/
├── SKILL.md ← 主文件(从 product-skill-template/SKILL.md.tmpl 生成)
├── references/
│ └── api-interfaces.md ← 从工厂 references/ 复制
├── deploy/
│ ├── openclaw.md ← 消费者: Discord/Telegram 命令部署
│ └── docker.md ← 消费者: Docker Compose 部署
├── manifest.json ← SSOT(从 product-skill-template/manifest.json.tmpl 生成)
├── install.sh ← 一键安装(从 product-skill-template/install.sh.tmpl 生成)
└── README.md
```
注意:策略代码本身(strategy.js, config.json, risk-profile.json)已在 `Strategy/{strategy}/Script/v{version}/`,产品 Skill 的 SKILL.md 引用它们而非复制。发布到 GitHub 时打包在一起。
## manifest.json(SSOT)
所有 adapter/install 文件从 manifest 派生。**不允许独立修改适配文件。**
```json
{
"name": "{strategy}", "version": "", "description": "",
"platforms": ["claude-code", "codex", "openclaw"],
"dependencies": { "npm": [], "pip": [] },
"entry_point": "strategy.js",
"tags": ["defi", "dex", "onchain", "okx"]
}
```
## install.sh
- 幂等:重复执行不破坏已有配置
- 自动检测平台(Claude Code / Codex / OpenClaw)
- 安装 manifest.json 中声明的依赖
- 打印验证信息
## 两阶段发布
1. **Skill 抽象**(Backtest PASS 后开始,可与 Infra 并行)
2. **GitHub Release**(等 Infra Deploy 成功后执行):
```bash
git tag -a "v{ver}" -m "{strategy} v{ver}"
git push origin main --tags
gh release create "v{ver}" --title "{strategy} v{ver}" --notes-file CHANGELOG_ENTRY.md
```
## 迭代更新
新版本时:更新 manifest.json 版本 → 重新生成所有适配文件 → 新 GitHub release。
FILE:roles/strategy.md
# Strategy Agent
编写基于 **onchainos CLI** 的 OKX OnchainOS 链上交易策略。只写策略逻辑,不做回测/部署/发布。
## 核心约束
所有链上操作(查价、swap、转账、签名)**必须通过 `onchainos` CLI 执行**。不存在 Python SDK,不直接调用 OKX API。策略代码本质是:Python 逻辑 + `subprocess.run(["onchainos", ...])` 调用。
## 启动前必读
1. **`references/api-interfaces.md`**(onchainos CLI 命令速查 + 标准 wrapper + 关键陷阱)— 这是你写代码的接口参考
2. **`references/strategy-lessons.md`**(策略经验库)— 风控模式、MTF、波动率、成本管理、常见陷阱
3. **`grid-trading/references/eth_grid_v1.py`**(实盘参考)— 经过验证的 onchainos 调用模式,复用其 wrapper
## 输入
从 Lead 接收 `{strategy}` — 策略名称,决定所有输出路径。
**启动后第一步**: 读取 `Strategy/{strategy}/requirements.md`(Lead 提炼的结构化需求)。这是你的唯一需求来源,不要猜测或补充需求文件中未提及的业务逻辑。字段标注"待回测确认"的参数,填合理默认值并在 config.json 中注释。
## 产出
写入 `Strategy/{strategy}/Script/v{version}/`,**全部必需**:
1. **strategy.js / .ts** — 核心逻辑,只调用 adapter 接口(见 `references/api-interfaces.md`),不硬编码参数
2. **config.json** — 所有可调参数外置
3. **risk-profile.json** — 风控硬约束(schema 见 `references/risk-schema.json`):
```json
{
"max_position_size_pct": 10, "stop_loss_pct": 5, "take_profit_pct": 15,
"max_drawdown_pct": 20, "max_daily_loss_pct": 8, "gas_budget_usd": 50,
"slippage_tolerance_pct": 1.5, "max_concurrent_positions": 3,
"market_conditions": { "applicable": [], "not_applicable": [] }
}
```
4. **README.md** — 逻辑概述、信号描述、收益预期(乐观/中性/悲观)、适用市场条件、参数说明
## onchainos CLI 调用
策略代码通过 `subprocess` 调用 onchainos CLI。完整命令表见 `references/api-interfaces.md`,核心操作:
```bash
onchainos wallet balance --chain <chainId> # 查余额(UI 单位)
onchainos swap quote --from <addr> --to <addr> --amount <wei> # 询价(最小单位!)
onchainos swap approve --token <addr> --amount <wei> --chain <chainId> # EVM 授权
onchainos swap swap --from <addr> --to <addr> --amount <wei> --chain <chainId> --wallet <addr>
onchainos wallet contract-call --to <addr> --chain <chainId> --input-data <hex> # TEE 签名+广播
onchainos market kline --address <addr> --chain <chainId> --bar <1H> --limit 24 # K线
onchainos gateway simulate --from <addr> --to <addr> --data <hex> --chain <chainId> # 预检
```
**必须复用** `grid-trading/references/eth_grid_v1.py` 中的 `onchainos_cmd()` wrapper,不要重新发明。
**⚠️ 单位陷阱**: `wallet send/balance` 用 UI 单位("0.1" ETH),`swap` 系列用最小单位(wei)。混用必出错。
## 修订请求
Lead 退回时:只修改指出的问题,不重写无关逻辑。更新 CHANGELOG.md。
FILE:templates/requirements.md
# {strategy} — 策略需求
> Lead 填写,用户确认后交给 Strategy Agent。只保留最终决策,不要讨论过程。
## 核心定义
- **策略类型**: (网格 / 动量 / 均值回归 / LP / 套利 / 其他)
- **交易对**: (例: ETH/USDC)
- **链**: (例: Base)
- **方向**: (多 / 空 / 双向)
## 信号逻辑
- **入场信号**: (具体指标 + 参数,例: EMA(12) 上穿 EMA(26))
- **出场信号**: (止盈条件 / 信号反转 / 时间退出)
- **过滤条件**: (趋势过滤 / 波动率过滤 / 时间窗口,没有写"无")
## 参数 (将进入 config.json)
| 参数 | 值 | 说明 |
|------|-----|------|
| | | |
> 不确定的参数写合理默认值 + "待回测确认"
## 风控 (将进入 risk-profile.json)
- **单笔仓位上限**: (占本金 %)
- **止损**: (%)
- **止盈**: (%)
- **最大回撤**: (%)
- **日亏损熔断**: (%)
- **最大并发仓位**: (个)
- **Gas 预算**: (USD/天)
- **Slippage 容忍**: (%)
> 未指定的项由 Strategy Agent 按 strategy-lessons.md 经验填写合理默认值
## 市场假设
- **适用场景**: (例: 震荡市、中等波动)
- **不适用场景**: (例: 持续单边、极端波动)
- **收益预期**: 乐观 __ / 中性 __ / 悲观 __ (月化 %)
## 特殊要求
(复用 MTF 模块?非对称设计?特定时间框架?其他约束?没有写"无")
Dynamic grid trading strategy for any token pair on EVM L2 chains via OKX DEX API. Features asymmetric grid steps (buy-dense/sell-wide in bullish, reverse in...
---
name: grid-trading
description: "Dynamic grid trading strategy for any token pair on EVM L2 chains via OKX DEX API. Features asymmetric grid steps (buy-dense/sell-wide in bullish, reverse in bearish), multi-timeframe trend analysis, trend-adaptive grid sizing, ATR-based volatility, sell trailing optimization, and HODL Alpha tracking. Covers grid modes (arithmetic/geometric), asymmetric buy/sell grid spacing, position sizing strategies (equal/martingale/anti-martingale/pyramid/trend-adaptive), comprehensive risk controls (stop-loss, take-profit, drawdown protection, circuit breakers), trade execution via OKX DEX aggregator, PnL calculation, and Discord notification. Use when creating, modifying, debugging, or tuning a grid trading bot."
license: Apache-2.0
metadata:
author: SynthThoughts
version: "1.5.0"
pattern: "pipeline, tool-wrapper"
steps: "5"
openclaw:
requires:
env:
- OKX_API_KEY
- OKX_SECRET_KEY
- OKX_PASSPHRASE
- WALLET_ADDR
optional_env:
- ONCHAINOS_ACCOUNT_ID
- DISCORD_CHANNEL_ID
- DISCORD_BOT_TOKEN
bins:
- onchainos
- python3
primaryEnv: OKX_API_KEY
entrypoint: references/eth_grid.py
skills:
- okx-dex-swap
- okx-dex-market
- okx-agentic-wallet
- okx-onchain-gateway
os:
- darwin
- linux
---
# Dynamic Grid Trading Strategy v1.0
Cron-driven grid bot for EVM L2 chains via `onchainos` CLI. Features asymmetric grid steps — different spacing for buy vs sell sides based on trend direction, trend intelligence with multi-timeframe analysis, sell optimization, and HODL Alpha tracking.
Every tick: fetch price → MTF analysis → compute grid level → trend-adaptive decision → execute swap → report to Discord.
## Asymmetric Grid Steps
Buy and sell sides use different spacing based on trend direction:
| Trend | Buy Side | Sell Side | Effect |
|-------|----------|-----------|--------|
| Bullish | Tighter (accumulate fast) | Wider (hold longer) | Buy dense, sell sparse → captures uptrend |
| Bearish | Wider (wait for dip) | Tighter (exit fast) | Sell dense, buy sparse → reduces downside exposure |
| Neutral/Weak | Symmetric | Symmetric | Symmetric (default) |
Key config: `ASYM_FACTOR=0.4` (max asymmetry ratio). Asymmetry scales with trend strength and only activates when `strength > 0.3`.
New grid dict fields: `buy_step`, `sell_step` (backward-compatible `step` = average). Level prices are now non-uniform: below center spaced by `buy_step`, above center by `sell_step`.
## Architecture
```
Cron (5min) → Python script → onchainos CLI → OKX Web3 API → Chain
↓ ↓
grid_state.json Wallet (TEE signing)
↓
┌─────────────┐
│ MTF Analysis │ ← price_history (288 bars = 24h)
│ K-line ATR │ ← okx-dex-market kline (1H × 24)
└──────┬──────┘
↓
Trend-Adaptive Grid Decision
↓
Discord embed (notification)
```
**OKX Skill Dependencies** (command syntax defined in each skill, do not duplicate here):
- `okx-dex-swap` — quote, approve, swap execution
- `okx-dex-market` — K-line / OHLC data
- `okx-agentic-wallet` — wallet switch, balance, contract-call (TEE signing)
- `okx-onchain-gateway` — transaction simulation
## Pipeline: Execution Steps
**CRITICAL RULE**: Steps MUST execute in order. Do NOT skip steps or proceed past a gate that has not been satisfied.
### Step 1: Data Acquisition
**Actions**:
1. Fetch ETH price via `okx-dex-swap` (swap quote)
2. Fetch on-chain balances via `okx-agentic-wallet` (wallet balance)
3. Update `price_history` (append, cap at 288 = 24h @ 5min)
4. Detect external deposits/withdrawals (unexplained balance changes > $100)
**Gate** (ALL must pass):
- [ ] Price is non-null and > 0
- [ ] At least one balance (ETH or USDC) > 0
- [ ] Circuit breaker not active (`consecutive_errors < 5`)
- [ ] Stop not triggered (`stop_triggered == null`)
### Step 2: Multi-Timeframe Analysis
**Actions**:
1. Compute short EMA (25min / 5-bar), medium EMA (1h / 12-bar), long EMA (4h / 48-bar)
2. Detect EMA alignment → trend direction (bullish / bearish / neutral) + strength (0-1)
3. Detect 8h structure: split into 4 segments, check higher-highs/higher-lows → uptrend / downtrend / ranging
4. Compute 1h and 4h momentum
5. Fetch K-line data (1H candles, 24 bars) → compute ATR-based volatility (hourly cache)
**Output**: `mtf` dict, `kline_vol` float
```python
def analyze_multi_timeframe(history, price) -> dict:
# Returns {trend, strength, momentum_1h, momentum_4h, structure,
# ema_short, ema_medium, ema_long}
# EMA alignment: short > medium > long → bullish
# Structure: 8h window split into 4 segments
# higher-highs + higher-lows → "uptrend"
# lower-highs + lower-lows → "downtrend"
# else → "ranging"
```
**Gate**:
- [ ] `mtf` dict has `trend` and `strength` fields (graceful fallback to neutral if insufficient history)
### Step 3: Grid Decision
**Actions**:
1. Calculate dynamic grid with trend-adaptive volatility multiplier:
- Grid center = EMA(20) on **1H kline** (20-hour EMA, fetched via `okx-dex-market` kline). Falls back to 5min tick history if kline unavailable.
- Base: `VOLATILITY_MULTIPLIER_BASE=2.0`
- In trend (strength > 0.3): blend toward `VOLATILITY_MULTIPLIER_TREND=3.0`
- Wider grid in trends → fewer trades → more holding
2. Check recalibration triggers (breakout / vol shift / age)
3. Map price → grid level
4. If level changed: determine direction (BUY if level dropped, SELL if rose)
5. Safety checks: cooldown, trend-adaptive position limits, repeat guard, consecutive limit
6. Sell optimization: if SELL in strong uptrend, delay via `_should_delay_sell()`
7. Calculate trade size with trend-adaptive sizing
**Gate**:
- [ ] Grid is valid (step > 0, levels > 0)
- [ ] If trade needed: amount >= `MIN_TRADE_USD` ($5)
- [ ] All safety checks passed (cooldown, position limits, etc.)
### Step 4: Execution
**Actions**:
1. Get swap quote + tx data from OKX DEX aggregator
2. Pre-simulate via `okx-onchain-gateway` (diagnostic, non-blocking)
3. For BUY: ensure USDC approval via `okx-dex-swap`
4. Sign + broadcast via `okx-agentic-wallet` contract-call (TEE signing)
5. On failure: 1 auto-retry with 3s delay and fresh quote
6. Record trade in state, update grid level ONLY on success
**Gate**:
- [ ] Transaction hash received, or failure recorded with `retriable` flag
- [ ] Level updated only on success; NOT on failure or skip
### Step 5: Notification & Tracking
**Actions**:
1. Calculate PnL (USD + ETH denominated)
2. Calculate HODL Alpha: `current_portfolio - (initial_eth × current_price)`
3. Build structured JSON output for AI agent parsing
4. Send Discord embed (green=SELL, blue=BUY, grey=no-trade, red=stop)
5. Emit `---JSON---` block with enriched fields
**Output**: Discord notification + structured JSON
## Tunable Parameters
### Grid Structure
| Parameter | Default | Description |
|---|---|---|
| `GRID_LEVELS` | `6` | Number of grid levels. More = finer, more trades |
| `GRID_TYPE` | `"arithmetic"` | `"arithmetic"` (fixed $ step) or `"geometric"` (fixed % step) |
| `EMA_PERIOD` | `20` | EMA lookback for grid center (applied to 1H kline = 20h) |
| `VOLATILITY_MULTIPLIER_BASE` | `2.0` | Base grid width = multiplier x stddev |
| `VOLATILITY_MULTIPLIER_TREND` | `3.0` | Wider grid in trending markets |
| `ASYM_FACTOR` | `0.4` | Max buy/sell asymmetry ratio. 0=symmetric, 1=fully asymmetric |
| `GRID_RECALIBRATE_HOURS` | `12` | Max hours before forced recalibration |
### Multi-Timeframe
| Parameter | Default | Description |
|---|---|---|
| `MTF_SHORT_PERIOD` | `5` | 5-bar EMA (25min @ 5min tick) |
| `MTF_MEDIUM_PERIOD` | `12` | 12-bar EMA (1h @ 5min tick) |
| `MTF_LONG_PERIOD` | `48` | 48-bar EMA (4h @ 5min tick) |
| `MTF_STRUCTURE_PERIOD` | `96` | 96-bar (8h @ 5min tick) for structure detection |
### Sell Optimization
| Parameter | Default | Description |
|---|---|---|
| `SELL_TRAIL_TICKS` | `2` | Wait 2 ticks (10min) of price stability before selling in uptrend |
| `SELL_MOMENTUM_THRESHOLD` | `0.005` | Skip sell if 1h momentum > 0.5% in strong uptrend |
### Grid Modes
**Arithmetic (等差网格)**: Each level is a fixed USD distance apart. Good for narrow ranges.
```
levels = [center - N*step, ..., center, ..., center + N*step]
```
**Geometric (等比网格)**: Each level is a fixed percentage apart. Better for wide ranges because step size scales with price. The ratio is derived from the arithmetic step: `ratio = 1 + (step / center)`.
```python
# In calc_dynamic_grid(), when GRID_TYPE == "geometric":
ratio = 1 + (step / center)
level_prices = [center * (ratio ** (i - half)) for i in range(GRID_LEVELS + 1)]
```
Both modes store `level_prices` in the grid dict for unified level lookup via `bisect_right`. Asymmetric spacing uses levels below center spaced by `buy_step`, levels above by `sell_step`. The `_build_level_prices()` helper handles both symmetric and asymmetric construction.
**Choosing a mode:**
| Market | Recommended | Why |
|---|---|---|
| Tight range ($1900-$2100) | Arithmetic | Even spacing, predictable profit per grid |
| Wide range ($1500-$3000) | Geometric | Steps scale with price, avoids crowding at low end |
| High volatility | Geometric | Naturally wider steps at higher prices |
| Stablecoin pairs | Arithmetic | Fixed small steps (0.1-0.5%) |
### Adaptive Step Sizing
Step scales with real-time volatility, modulated by trend strength. Splits into directional buy/sell steps:
```
vol_mult = VOLATILITY_MULTIPLIER_BASE (2.0)
if trend_strength > 0.3:
vol_mult = blend(BASE, TREND, strength) (up to 3.0)
# Asymmetric steps based on trend direction
asym = ASYM_FACTOR × strength (if strength > 0.3, else 0)
if bullish:
buy_mult = vol_mult × (1 - asym) # tighter buy
sell_mult = vol_mult × (1 + asym) # wider sell
elif bearish:
buy_mult = vol_mult × (1 + asym) # wider buy
sell_mult = vol_mult × (1 - asym) # tighter sell
buy_step = (buy_mult × ATR) / (GRID_LEVELS / 2)
sell_step = (sell_mult × ATR) / (GRID_LEVELS / 2)
# Both clamped to [price × STEP_MIN_PCT, price × STEP_MAX_PCT], floor $5
step = (buy_step + sell_step) / 2 # backward-compatible average
```
| Parameter | Default | Description |
|---|---|---|
| `STEP_MIN_PCT` | `0.010` | Step floor as fraction of price (1.0%) |
| `STEP_MAX_PCT` | `0.060` | Step cap as fraction of price (6%) |
| `VOL_RECALIBRATE_RATIO` | `0.3` | Recalibrate if kline ATR shifts >30% from grid's stored ATR |
**Recalibration triggers (asymmetric):**
1. **Downside breakout**: Price < grid lower - `buy_step` → recalibrate **immediately** (buying dips is grid's edge)
2. **Upside breakout**: Price > grid upper + `sell_step` → require **N consecutive ticks** confirmation before recalibrating (anti-chase)
3. Grid age exceeds `GRID_RECALIBRATE_HOURS`
4. Current kline ATR deviates >30% from grid's stored ATR
| Parameter | Default | Description |
|---|---|---|
| `UPSIDE_CONFIRM_TICKS` | `6` | Ticks (30min @ 5min interval) price must hold above grid before upside recalibration |
| `MAX_CENTER_SHIFT_PCT` | `0.03` | Max 3% grid center shift per recalibration (prevents chasing spikes) |
**Anti-chase mechanism:**
- Upside breakout counter resets if price returns to grid range before confirmation
- Even after confirmation, center shifts are capped to `MAX_CENTER_SHIFT_PCT` per recalibration
- Multiple recalibrations can gradually track a true trend, but a single spike cannot drag the grid
### Position Sizing Strategies
Controls how much to trade at each grid level.
| Strategy | Description |
|---|---|
| `"equal"` | Every grid level trades the same amount |
| `"martingale"` | BUY more at lower levels, SELL more at higher levels |
| `"anti_martingale"` | Reduces exposure as price moves further from center |
| `"pyramid"` | Largest position at grid center, tapering toward edges |
| `"trend_adaptive"` | **(default)** In bullish: buy more + sell less. In bearish: sell more + buy less |
```python
def _calc_sizing_multiplier(level, grid_levels, direction, mtf=None):
base_mult = 1.0
if SIZING_STRATEGY == "trend_adaptive" and mtf:
trend = mtf.get("trend", "neutral")
strength = mtf.get("strength", 0)
if trend == "bullish":
if direction == "BUY":
base_mult = 1.0 + strength * (MAX - 1.0) # buy aggressively
else:
base_mult = 1.0 - strength * (1.0 - MIN) # sell conservatively
elif trend == "bearish":
# opposite
...
return clamp(base_mult, SIZING_MULTIPLIER_MIN, SIZING_MULTIPLIER_MAX)
```
### Trade Execution
| Parameter | Default | Description |
|---|---|---|
| `MAX_TRADE_PCT` | `0.12` | Max 12% of portfolio per trade (before sizing multiplier) |
| `MIN_TRADE_USD` | `5.0` | Minimum trade size in USD |
| `SLIPPAGE_PCT` | `1` | Slippage tolerance for DEX swap |
| `GAS_RESERVE` | `0.003` | Native token reserved for gas |
### Risk Controls
#### Basic Controls
| Parameter | Default | Description |
|---|---|---|
| `MIN_TRADE_INTERVAL` | `1800` | 30min cooldown between same-direction trades |
| `MAX_SAME_DIR_TRADES` | `3` | Max consecutive same-direction trades |
| `MAX_CONSECUTIVE_ERRORS` | `5` | Circuit breaker threshold |
| `COOLDOWN_AFTER_ERRORS` | `3600` | Cooldown after circuit breaker trips |
| `POSITION_MAX_PCT_DEFAULT` | `70` | Block BUY when ETH > this % |
| `POSITION_MIN_PCT_DEFAULT` | `30` | Block SELL when ETH < this % |
| `POSITION_MAX_PCT_BULLISH` | `80` | Allow more ETH in bullish trend |
| `POSITION_MIN_PCT_BEARISH` | `25` | Allow less ETH in bearish trend |
Additional safety guards:
- **Rapid drop protection**: skip BUY if price dropped >2% in last 30min (6 ticks)
- **Consecutive same-direction reset**: limit resets if grid was recalibrated or >1h since last trade
- **Anti-repeat**: skip if same direction + same level boundary as last trade
- **Trend-adaptive position limits**: limits shift based on trend direction and strength
#### Stop-Loss, Trailing Stop & Take-Profit
Three protection mechanisms. When triggered, trading halts and a red Discord alert is sent. Use `resume-trading` command to clear.
| Parameter | Default | Description |
|---|---|---|
| `STOP_LOSS_PCT` | `0.15` | Stop if portfolio drops 15% below cost basis |
| `TRAILING_STOP_PCT` | `0.10` | Stop if portfolio drops 10% from peak |
```python
def _check_stop_conditions(state, total_usd, price):
cost_basis = initial + deposits
pnl_pct = (total_usd - cost_basis) / cost_basis
peak = max(stats["portfolio_peak_usd"], total_usd)
# Check: stop_loss, trailing_stop, take_profit
```
#### Risk Control Flow in tick()
```
1. If stop_triggered is set → log + Discord red alert + refuse trading + return
2. Check _check_stop_conditions → if triggered, set stop_triggered + alert + return
3. Multi-timeframe analysis → get trend context
4. Normal grid logic (cooldown, trend-adaptive position limits, etc.)
5. If SELL in strong uptrend → _should_delay_sell() check
```
## Core Algorithm
```
1. Fetch token price
2. Read on-chain balances (ETH + USDC)
3. Multi-timeframe analysis → trend/strength/momentum/structure
4. Fetch K-line ATR volatility (hourly cache)
5. Check if grid needs recalibration (breakout / vol shift / age)
→ calc_dynamic_grid() uses trend-adaptive volatility multiplier
→ asymmetric buy_step/sell_step based on trend direction
6. Map price → grid level
7. If level changed:
a. Direction: BUY if level dropped, SELL if rose
b. If SELL in strong uptrend → delay check (trailing + momentum protection)
c. Safety checks (cooldown, trend-adaptive position limits, repeat guard, consecutive limit)
d. Calculate trade size (trend-adaptive sizing)
e. Execute swap via DEX aggregator
f. Record trade, update level ONLY on success
8. Calculate HODL Alpha
9. Report status (JSON + Discord)
```
## Grid Calculation
```python
def calc_dynamic_grid(price, price_history, mtf=None):
center = EMA(1H_kline, EMA_PERIOD) # 20-hour EMA on 1H candles
atr = calc_kline_volatility(candles)
# Trend-adaptive multiplier
vol_mult = VOLATILITY_MULTIPLIER_BASE # 2.0
if mtf and mtf["strength"] > 0.3:
vol_mult = blend(BASE=2.0, TREND=3.0, factor=strength)
# Asymmetric buy/sell multipliers
asym = ASYM_FACTOR * strength if strength > 0.3 else 0
if bullish:
buy_mult = vol_mult * (1 - asym) # tighter buy grid
sell_mult = vol_mult * (1 + asym) # wider sell grid
elif bearish:
buy_mult = vol_mult * (1 + asym) # wider buy grid
sell_mult = vol_mult * (1 - asym) # tighter sell grid
buy_step = clamp((buy_mult * atr) / half, floor, ceil)
sell_step = clamp((sell_mult * atr) / half, floor, ceil)
# Build asymmetric level_prices via _build_level_prices()
# Below center: spaced by buy_step; Above center: spaced by sell_step
level_prices = _build_level_prices(center, buy_step, sell_step, half, grid_type)
return {center, step, buy_step, sell_step, levels, range, vol_pct, type, level_prices}
```
**Examples** (at price $2000, ATR=$50):
| Trend | Strength | buy_step | sell_step | Buy Range | Sell Range | Behavior |
|---|---|---|---|---|---|---|
| Neutral | 0.1 | $33 | $33 | $1901-$2000 | $2000-$2099 | Symmetric, normal |
| Bullish | 0.6 | $33 | $54 | $1901-$2000 | $2000-$2162 | Buy dense + sell wide |
| Bearish | 0.6 | $54 | $33 | $1838-$2000 | $2000-$2099 | Buy wide + sell dense |
| Strong Bull | 0.9 | $24 | $66 | $1928-$2000 | $2000-$2198 | Max asymmetry |
## Sell Optimization Logic
```python
def _should_delay_sell(state, current_level, prev_level, mtf, history):
"""Returns skip reason or None."""
# 1. Momentum protection: skip sell if 1h momentum > 0.5% in uptrend
if momentum_1h > SELL_MOMENTUM_THRESHOLD * 100:
if trend == "bullish" and structure == "uptrend":
return "trend_hold (momentum +X.X%)"
# 2. Trailing sell: wait SELL_TRAIL_TICKS (2) before executing
trail = state["sell_trail_counter"]
level_key = f"{prev_level}->{current_level}"
if trail[level_key] < SELL_TRAIL_TICKS:
trail[level_key] += 1
return "sell_trail (N/2)"
# 3. Cleared → proceed with sell
return None
```
## Trend-Adaptive Position Limits
```python
def _get_position_limits(mtf):
"""Return (max_pct, min_pct) based on trend."""
if trend == "bullish" and strength > 0.3:
max_pct = 70 + (80 - 70) * strength # allow more ETH
min_pct = 30
elif trend == "bearish" and strength > 0.3:
max_pct = 70
min_pct = 30 - (30 - 25) * strength # allow less ETH
else:
max_pct, min_pct = 70, 30
```
## Trade Size
Returns `(amount_in_smallest_unit, failure_info)`. SELL returns wei (x1e18), BUY returns uUSDC (x1e6).
```python
def calc_trade_amount(direction, eth_bal, usdc_bal, price,
current_level=None, grid_levels=None,
mtf=None):
available_eth = eth_bal - GAS_RESERVE_ETH
total_usd = available_eth * price + usdc_bal
max_usd = total_usd * MAX_TRADE_PCT
# Apply trend-adaptive sizing
multiplier = _calc_sizing_multiplier(level, grid_levels, direction, mtf)
max_usd *= multiplier
if direction == "SELL":
return int(min(max_usd / price, available_eth) * 1e18), None # wei
else:
return int(min(max_usd, usdc_bal * 0.95) * 1e6), None # uUSDC
```
## Level Update Rule (Critical)
| Outcome | Update level? | Rationale |
|---|---|---|
| Trade succeeded | Yes | Grid crossing consumed |
| Trade failed | No | Retry on next tick |
| Trade skipped (cooldown/limit) | No | Don't lose the crossing |
| Sell delayed (trailing/momentum) | No | Will retry next tick |
## PnL Tracking (dual-denominated)
```
# USD-denominated
total_pnl_usd = current_portfolio_usd - cost_basis
hodl_value_usd = initial_eth_amount × current_price
grid_alpha_usd = current_portfolio_usd - hodl_value_usd
# ETH-denominated
current_eth_equivalent = current_portfolio_usd / current_price
initial_eth_equivalent = cost_basis / initial_price
total_pnl_eth = current_eth_equivalent - initial_eth_equivalent
```
**HODL Alpha** (key metric): `grid_alpha_usd > 0` means grid outperforms pure ETH holding.
## State Schema
```json
{
"version": 5,
"grid": {"center": 2000, "step": 43.5, "buy_step": 33.3, "sell_step": 53.7,
"levels": 6, "range": [1900, 2161], "vol_pct": 2.1,
"type": "arithmetic",
"level_prices": [1900, 1933, 1967, 2000, 2054, 2107, 2161]},
"grid_set_at": "ISO timestamp",
"current_level": 3,
"price_history": ["...max 288 (24h at 5min)"],
"trades": [{"time": "...", "direction": "SELL", "price": 2050,
"amount_usd": 25, "est_profit": 1.5, "tx": "0x...",
"grid_from": 2, "grid_to": 3}],
"stats": {
"total_trades": 15,
"realized_pnl": 5.2,
"grid_profit": 3.8,
"initial_portfolio_usd": 1000,
"initial_eth_price": 2000.0,
"portfolio_peak_usd": 1050.0,
"total_deposits_usd": 0.0,
"deposit_history": [],
"trade_attempts": 10, "trade_successes": 10, "trade_failures": 0,
"sell_attempts": 5, "sell_successes": 5,
"buy_attempts": 5, "buy_successes": 5,
"retry_attempts": 0, "retry_successes": 0,
"started_at": "ISO timestamp",
"last_check": "ISO timestamp"
},
"stop_triggered": null,
"last_trade_times": {"BUY": "...", "SELL": "..."},
"last_failed_trade": null,
"last_balances": {"eth": 0.134, "usdc": 257.33, "time": "ISO timestamp"},
"last_quiet_report": "ISO timestamp",
"upside_breakout_ticks": 0,
"approved_routers": ["0x..."],
"errors": {"consecutive": 0, "cooldown_until": null},
"mtf_cache": null,
"kline_cache": null,
"sell_trail_counter": {}
}
```
Key fields:
- `grid.type` + `grid.level_prices`: geometric/arithmetic grid support (asymmetric spacing)
- `grid.buy_step` / `grid.sell_step`: directional step sizes; `grid.step` = average for backward compat
- `stats.initial_eth_price`: records ETH price at bot start for HODL Alpha calculation
- `stats.portfolio_peak_usd`: highest portfolio value (for trailing stop)
- `stop_triggered`: string describing trigger condition, or null
- `last_failed_trade`: cached for `retry` command (expires after 10min)
- `upside_breakout_ticks`: confirmation counter for upside recalibration
- `approved_routers`: USDC approval cache to avoid redundant approvals
- `mtf_cache`: cached multi-timeframe analysis result
- `kline_cache`: cached K-line data (1h TTL)
- `sell_trail_counter`: tracks sell delay tick counts per level transition
## Operational Interface
### Sub-Commands
| Command | Purpose | Trigger |
|---|---|---|
| `tick` | Main loop: price → MTF → grid → trade → report | Cron every 5min |
| `status` | Print current grid state, balances, PnL, trend | On demand |
| `report` | Generate daily performance report (Chinese) | Cron daily 08:00 CST |
| `history` | Show recent trade history | On demand |
| `reset` | Reset grid (recalibrate from scratch), keep trade history | Manual |
| `retry` | Retry last failed trade with fresh quote (expires after 10min) | AI agent / manual |
| `analyze` | Output detailed market + MTF + round-trip analysis JSON | AI agent |
| `deposit` | Manually record deposit/withdrawal for PnL tracking | Manual |
| `resume-trading` | Clear stop_triggered flag and resume trading | Manual / AI agent |
```python
COMMANDS = {
"tick": tick, "status": status, "report": report,
"history": history_cmd, "reset": reset, "retry": retry,
"analyze": analyze, "deposit": deposit,
"resume-trading": resume_trading
}
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "tick"
COMMANDS.get(cmd, tick)()
```
### AI Agent Output Protocol
The `tick` command outputs a structured JSON block for AI agent parsing:
```
---JSON---
{
"version": "1.0",
"status": "trade_executed" | "no_trade" | "cooldown" | "trade_failed" | ...,
"market": {
"price": 2090.45, "ema": 2085.3, "volatility_pct": 1.2,
"trend": "bullish", "trend_strength": 0.65,
"momentum_1h": 0.35, "momentum_4h": 1.2,
"structure": "uptrend",
"kline_atr_pct": 1.8
},
"portfolio": {"eth": 0.134, "usdc": 257.33, "total_usd": 538.0, "eth_pct": 52.1},
"grid_level": 3,
"direction": "SELL",
"tx_hash": "0x...",
"failure_reason": "...",
"retriable": true,
"hodl_alpha": 2.15,
"success_rate": {"total_attempts": 182, "successes": 182, "rate_pct": 100.0}
}
```
The `analyze` command outputs additional fields:
- `multi_timeframe`: full MTF data (EMA short/medium/long, momentum, structure)
- `round_trips`: trade pair analysis (good / micro / loss classification)
### Discord Notification
Two card formats pushed via Discord Bot API:
**Trade executed** (colored embed):
- Green = SELL, Blue = BUY
- Fields: price, level, total value, position, PnL, grid profit, HODL Alpha, BaseScan link
**No trade** (grey compact card):
- One-line: price . level . position . PnL . trend . trade count
- Only sent once per `QUIET_INTERVAL` (default 1 hour)
### Deposit/Withdrawal Detection
Automatically detects external balance changes:
```
unexplained_change = (current_balance - last_balance) - sum(recorded_trades_since_last)
if abs(unexplained_change) > $100:
record as deposit or withdrawal → adjust PnL cost basis
```
### Logging
- File: `grid_bot.log` in script directory
- Rotation: simple half-file rotation at 1MB
- Format: `[YYYY-MM-DD HH:MM:SS] message`
## Adapting to Different Pairs
| Consideration | What to adjust |
|---|---|
| Token decimals | USDC=6, DAI=18, WBTC=8 — affects amount conversion |
| Typical volatility | BTC lower vol → smaller `STEP_MIN/MAX_PCT`; meme coins → larger |
| Liquidity depth | Low liquidity → smaller `MAX_TRADE_PCT`, add price impact check |
| Gas costs | L1 vs L2: adjust `GAS_RESERVE` and `MIN_TRADE_USD` |
| Stablecoin pair | TOKEN/USDC pair: `STEP_MIN_PCT` can be much tighter (0.2%) |
| Rate limits | Add 300-500ms delay between consecutive OKX API calls |
## AI Review & Optimization
AI agent should periodically review trading performance and suggest/apply optimizations. Run weekly or when cumulative PnL stalls.
### Step 1: Pull & Pair Trades
Extract recent trades and pair each BUY with its corresponding SELL to form **round trips**.
```python
# Matching logic: SELL from level A→B matches BUY from level B→A
buy_stack = []
round_trips = []
for trade in trades:
if trade["direction"] == "BUY":
buy_stack.append(trade)
else: # SELL
for j in range(len(buy_stack)-1, -1, -1):
if buy_stack[j]["grid_to"] == trade["grid_from"]:
matched_buy = buy_stack.pop(j)
round_trips.append((matched_buy, trade))
break
```
Output per round trip:
- **Spread**: `(sell_price - buy_price) / buy_price x 100%`
- **Hold time**: minutes between buy and sell
- **Status**: profit (spread > 0.3%), micro-profit (0 < spread < 0.3%), loss (spread < 0)
### Step 2: Flag Anomalies
| Flag | Condition | Meaning |
|---|---|---|
| `LOSS` | spread < 0 | Bought high, sold low |
| `MICRO` | 0 < spread < 0.3% | Profit too small to cover DEX costs |
| `GOOD` | spread >= 0.3% | Healthy grid profit |
Key metrics: win rate, loss impact, micro-trade ratio (if > 30%, step too small).
### Step 3: Root Cause Analysis
**LOSS trades:**
| Pattern | Root Cause | Fix |
|---|---|---|
| Buy @high, sell @low after recalibration | Grid chased a spike | Increase `UPSIDE_CONFIRM_TICKS`, reduce `MAX_CENTER_SHIFT_PCT` |
| Buy @high in trend, sell @low on reversal | EMA too reactive | Increase `EMA_PERIOD` or `GRID_RECALIBRATE_HOURS` |
| Loss during flash crash | Stop-loss too loose | Tighten `STOP_LOSS_PCT` |
**MICRO trades:**
| Pattern | Root Cause | Fix |
|---|---|---|
| Many trades with < 0.2% spread | Step too small | Increase `STEP_MIN_PCT` |
| Rapid back-and-forth at same levels | Low vol, grid too dense | Increase `MIN_TRADE_INTERVAL` |
| Trades cluster in 5-10 min windows | Cooldown too short | Increase `MIN_TRADE_INTERVAL` |
### Step 4: Parameter Tuning
```
STEP_MIN_PCT >= DEX_total_cost x 3
where DEX_total_cost ~ slippage + price_impact ~ 0.1-0.3% on L2
→ STEP_MIN_PCT >= 0.009 to 0.012
UPSIDE_CONFIRM_TICKS = typical_spike_duration / tick_interval
e.g., spikes last ~20min, tick=5min → confirm_ticks = 4-6
MAX_CENTER_SHIFT_PCT = step_pct x 2-3
```
### Step 5: Backtest & Apply
Simulate new parameters against historical data, then: backup → patch → recalibrate → monitor 24h → re-run analysis.
### Review Checklist (AI Agent Prompt)
```
1. Read grid_state.json and grid_bot.log
2. Filter trades to review window (default: last 48h)
3. Pair trades into round trips
4. Compute: win_rate, avg_spread, loss_count, micro_count, total_pnl, hodl_alpha
5. If loss_count > 0: trace each loss to recalibration events
6. If micro_ratio > 30%: recommend STEP_MIN_PCT increase
7. Check MTF data for trend alignment during losses
8. Propose specific parameter changes with backtest evidence
9. On user approval: backup → patch → recalibrate → verify
```
## Failure & Rollback
```
IF Step N fails:
1. Log failure reason to grid_bot.log
2. Increment errors.consecutive
3. If errors.consecutive >= 5: trigger circuit breaker (1h cooldown)
4. Cache failed trade for retry command (10min expiry)
5. DO NOT update grid level
6. Report failure via Discord + JSON output
```
## Anti-Patterns
| Pattern | Problem |
|---|---|
| Recalibrate every tick | Grid oscillates, no stable levels |
| Update level on failure/skip | Silently loses grid crossings |
| No position limits | Trending market → 100% one-sided |
| Fixed step in volatile market | Too small → over-trades; too large → never triggers |
| `sell - buy` as PnL | Net cash flow ≠ profit |
| No cooldown | Rapid swings cause burst of trades eating slippage |
| No stop-loss | Single crash wipes out months of grid profits |
| Martingale without cap | Exponential position growth → liquidation risk |
| Arithmetic grid on wide range | $20 step meaningless at $5000 but huge at $500 |
| Symmetric recalibration | Chasing upside spikes = buying high then selling low on reversal |
| Step floor too low | Micro-profit trades only feed DEX fees, net negative after costs |
| No center shift cap | Single spike can drag grid center 5%+, creating losing positions |
| Fixed sizing in trends | Selling same size in uptrend = giving away alpha to the market |
| Selling immediately in uptrend | Sell delay exists for a reason — let trends play out |
| Symmetric grid in strong trends | Asymmetric grids accumulate faster on the favorable side |
| Ignoring `buy_step`/`sell_step` in profit calc | Use actual `level_prices` differences, not average `step` |
FILE:README.md
# Grid Trading
基于 OKX DEX API 的 EVM L2 链上动态网格交易策略,适用于任意交易对。
**核心思路:在波动中自动低买高卖。** 将价格区间划分为多个网格层级,价格下跌触网买入,上涨触网卖出。结合 MTF 多时间框架趋势分析,动态调整网格宽度和仓位。
## 特性
- **非对称网格** — 看涨时买侧密集/卖侧稀疏,看跌时反转
- **波动率自适应** — 基于 ATR 动态调整网格宽度
- **多时间框架分析 (MTF)** — 5min 价格 + 1H/4H EMA + 8H 结构检测
- **趋势跟踪** — 动态仓位管理,顺势加仓逆势减仓
- **追踪止盈** — 强上涨中延迟卖出,让利润奔跑
- **动量过滤器** — 趋势动量强劲时跳过卖出信号
- **HODL Alpha 追踪** — 对比策略收益与简单持有
- **风控体系** — 止损 / 追踪止损 / 闪崩保护 / 熔断机制
## 架构
```
┌─────────────────────────────────────────────────────┐
│ AI Agent (Claude) │
│ 策略设计 → 回测验证 → 参数调优 → 复盘迭代 │
└──────────────────────┬──────────────────────────────┘
│ 生成 / 优化
┌──────────────────────▼──────────────────────────────┐
│ 策略脚本 (Python) │
│ 数据采集 → MTF分析 → 网格决策 → 风控检查 → 交易执行│
└──────────────────────┬──────────────────────────────┘
│ 调用
┌──────────────────────▼──────────────────────────────┐
│ onchainos Skills │
│ 行情 · K线 · 钱包 · DEX路由 · TEE签名 · 交易广播 │
└──────────────────────┬──────────────────────────────┘
│ 上链
┌──────────────────────▼──────────────────────────────┐
│ EVM L2 链上执行 │
│ OKX DEX 聚合器 → 最优路由 → 成交 │
└─────────────────────────────────────────────────────┘
```
## 网格算法
```
当前价格 → 1H K线 (24根) → ATR% → 波动率分类 → 网格宽度
↓
MTF 趋势分析
↓
非对称网格 + 动态仓位
↓
买/卖触发 → 执行
```
| 市场状态 | 网格行为 | 仓位策略 |
|----------|----------|----------|
| 震荡 | 窄间距,双向密集 | 买卖等量 |
| 看涨 | 买侧密集,卖侧稀疏 | 加大买入,减少卖出 |
| 看跌 | 卖侧密集,买侧稀疏 | 加大卖出,减少买入 |
| 高波动 | 自动加宽间距 | 降低单笔金额 |
详细算法说明见 [references/grid-algorithm.md](references/grid-algorithm.md)。
## 与 V3 LP 调仓的区别
| 维度 | 网格交易 | V3 LP 调仓 |
|------|---------|-----------|
| 收益来源 | 网格价差(低买高卖) | LP 手续费(做市) |
| 持仓形式 | ETH + USDC 代币余额 | LP NFT 头寸 |
| 链上操作 | 单步 swap | 四步:claim → remove → swap → deposit |
| 交易频率 | 每 tick 可能触发 | 仅在触发条件满足时调仓 |
| 主要风险 | 单边行情踏空 | 无常损失 (IL) |
| Gas 敏感度 | 低 | 高(多步操作) |
## 快速开始
详细步骤见 [SETUP.md](SETUP.md)。
```bash
# 1. 安装
openclaw skill install grid-trading
# 2. 配置
cd ~/.openclaw/skills/grid-trading/references
cp ../.env.example .env # 填入 API keys + 钱包地址
vi config.json # 调整网格参数
# 3. 测试(只读,安全)
python3 eth_grid.py status
# 4. 注册 cron
zeroclaw cron add '*/5 * * * *' \
'cd ~/.openclaw/skills/grid-trading/references && set -a && . ../.env && set +a && python3 eth_grid.py tick'
```
## 命令
| 命令 | 用途 | 触发 |
|------|------|------|
| `tick` | 主循环:采集→分析→网格决策→交易 | Cron 每 5 分钟 |
| `status` | 价格、余额、网格层级、趋势、收益 | 手动 |
| `report` | 每日报告 (Discord) | Cron 每天 |
| `history` | 近期交易记录 | 手动 |
| `analyze` | 市场分析 + round-trip 配对 | AI Agent |
| `reset` | 重新校准网格 | 手动 |
| `retry` | 重试上次失败的交易 | 手动 |
| `deposit` | 记录外部存取款 | 手动 |
| `resume-trading` | 清除止损恢复交易 | 手动 |
## 目录结构
```
grid-trading/
├── SKILL.md # AI Agent 核心知识(流水线、状态机、参数)
├── README.md # 本文件
├── .env.example # 环境变量模板
└── references/
├── eth_grid.py # 策略代码(零第三方依赖)
├── config.json # 参考配置(所有可调参数)
└── grid-algorithm.md # 网格算法详解
```
## 前置条件
- **onchainos** — OKX OnchainOS CLI,内置钱包管理、DEX 交易、TEE 签名等 Skills
- **OKX API Key** — DEX 交易权限
- **Python 3.10+** — 零第三方依赖
## License
Apache-2.0
FILE:references/config.json
{
"chain_id": "8453",
"chain_name": "base",
"token0": {
"symbol": "ETH",
"address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"decimals": 18
},
"token1": {
"symbol": "USDC",
"address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"decimals": 6
},
"grid_levels": 6,
"grid_type": "arithmetic",
"max_trade_pct": 0.12,
"min_trade_usd": 5.0,
"gas_reserve_eth": 0.003,
"slippage_pct": 1,
"ema_period": 20,
"volatility_multiplier_base": 1.5,
"volatility_multiplier_bull": 3.0,
"volatility_multiplier_bear": 1.0,
"asym_factor": 0.4,
"sizing_strategy": "trend_adaptive",
"sizing_multiplier_min": 0.5,
"sizing_multiplier_max": 2.0,
"stop_loss_pct": 0.15,
"trailing_stop_pct": 0.10,
"position_max_pct_default": 70,
"position_min_pct_default": 30,
"position_max_pct_bullish": 80,
"position_min_pct_bearish": 25,
"step_min_pct": 0.010,
"step_max_pct": 0.060,
"vol_recalibrate_ratio": 0.3,
"max_consecutive_errors": 5,
"max_same_dir_trades": 3,
"cooldown_after_errors": 3600,
"quiet_interval": 3600,
"min_trade_interval": 1800,
"grid_recalibrate_hours": 12,
"upside_confirm_ticks": 6,
"max_center_shift_pct": 0.03,
"mtf_short_period": 5,
"mtf_medium_period": 12,
"mtf_long_period": 48,
"mtf_structure_period": 96,
"sell_trail_ticks": 2,
"sell_momentum_threshold": 0.005,
"dip_buy_lookback": 12,
"dip_buy_min_drawdown": 0.005,
"dip_buy_cooldown": 1800,
"dip_buy_tiers": [
[0.03, 2.0],
[0.02, 1.5],
[0.01, 1.0],
[0.005, 0.5]
],
"dip_buy_momentum_floor": -0.5,
"dip_buy_reversal_ticks": 2,
"max_log_bytes": 1000000
}
FILE:references/eth_grid.py
#!/usr/bin/env python3
"""
ETH/USDC Dynamic Grid Trading Bot v1.0 — Base Chain
Improvements over v3:
1. Multi-timeframe analysis (5min tick + 1h trend + 4h structure)
2. Trend-adaptive grid: asymmetric sizing in trending markets
3. K-line/OHLC volatility via onchainos market kline
4. Improved sell logic with trailing grid profit locking
5. HODL-alpha tracking with trend-follow overlay
Uses OKX DEX API (via onchainos CLI) + OnchainOS Agentic Wallet (TEE signing).
Designed for OpenClaw cron integration.
"""
import bisect
import json
import subprocess
import os
import sys
import math
import time
from datetime import datetime, timezone, timedelta
from pathlib import Path
# ── Load .env if present ────────────────────────────────────────────────────
def _load_env():
# Check script dir first, then parent (skill root for openclaw installs)
for base in [Path(__file__).parent, Path(__file__).parent.parent]:
env_file = base / ".env"
if env_file.exists():
for line in env_file.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
k = k.strip()
if k.startswith("export "):
k = k[7:].strip()
os.environ.setdefault(k, v.strip())
return
_load_env()
# ── Config ──────────────────────────────────────────────────────────────────
OKX_API_KEY = os.environ.get("OKX_API_KEY", "")
OKX_SECRET = os.environ.get("OKX_SECRET_KEY", "")
OKX_PASSPHRASE = os.environ.get("OKX_PASSPHRASE", "")
# Token addresses (Base chain)
ETH_ADDR = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
USDC_ADDR = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
CHAIN_ID = "8453"
def _resolve_wallet_addr() -> str:
"""Resolve wallet address: env override > onchainos wallet addresses."""
env_addr = os.environ.get("WALLET_ADDR", "")
if env_addr:
return env_addr
try:
result = subprocess.run(
["onchainos", "wallet", "addresses"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0 and result.stdout.strip():
data = json.loads(result.stdout.strip())
if data.get("ok") and data.get("data", {}).get("evm"):
evm_addrs = data["data"]["evm"]
# Prefer the address matching CHAIN_ID, fallback to first EVM
for entry in evm_addrs:
if entry.get("chainIndex") == CHAIN_ID:
return entry["address"]
return evm_addrs[0]["address"]
except Exception:
pass
return ""
# Switch onchainos to the correct account if specified
_account_id = os.environ.get("ONCHAINOS_ACCOUNT_ID", "")
if _account_id:
try:
_sw = subprocess.run(
["onchainos", "wallet", "switch", _account_id],
capture_output=True,
text=True,
timeout=10,
)
if _sw.returncode != 0:
print(
f"WARN: onchainos account switch failed: {_sw.stderr.strip()}",
file=sys.stderr,
)
except Exception as e:
print(f"WARN: onchainos account switch error: {e}", file=sys.stderr)
WALLET_ADDR = _resolve_wallet_addr()
if not WALLET_ADDR:
print(
"ERROR: No wallet address found. Login with `onchainos wallet login` or set WALLET_ADDR env.",
file=sys.stderr,
)
# ── Grid Parameters ─────────────────────────────────────────────
GRID_LEVELS = 6 # 6 levels
GRID_TYPE = "arithmetic" # "arithmetic" | "geometric"
MAX_TRADE_PCT = 0.12 # max 12% of total portfolio per trade
MIN_TRADE_USD = 5.0 # minimum trade size in USD
GAS_RESERVE_ETH = 0.003 # Reserve for gas (Base L2 gas is <$0.01)
SLIPPAGE_PCT = 1 # 1% slippage for DEX aggregator swaps
EMA_PERIOD = 20 # periods for EMA center (applied to 1H kline = 20h)
# Trend-adaptive volatility multiplier (directional)
VOLATILITY_MULTIPLIER_BASE = 1.5 # base multiplier (neutral/weak trend)
VOLATILITY_MULTIPLIER_BULL = 3.0 # bullish: wider grid → hold position, trade less
VOLATILITY_MULTIPLIER_BEAR = 1.0 # bearish: tighter grid → exit faster, trade more
# Asymmetric grid — different step sizes for buy vs sell side
# Bullish: tighter buy (accumulate fast) + wider sell (hold longer)
# Bearish: tighter sell (exit fast) + wider buy (wait for dip)
ASYM_FACTOR = 0.4 # max asymmetry ratio (0 = symmetric, 1 = fully asymmetric)
# Sizing strategy (trend-adaptive)
SIZING_STRATEGY = "trend_adaptive" # "equal" | "martingale" | "anti_martingale" | "pyramid" | "trend_adaptive"
SIZING_MULTIPLIER_MIN = 0.5
SIZING_MULTIPLIER_MAX = 2.0
# Stop-loss / take-profit protection
STOP_LOSS_PCT = 0.15 # stop at 15% loss from cost basis
TRAILING_STOP_PCT = 0.10 # stop at 10% drawdown from peak
# Auto-resume after stop-loss
STOP_AUTO_RESUME = True # enable automatic recovery after stop
STOP_COOLDOWN_MINUTES = 60 # minimum wait time after stop before considering resume
STOP_RESUME_BOUNCE_PCT = 0.01 # price must recover 1% from stop price
STOP_RESUME_MAX_BEARISH = (
0.5 # resume only if trend strength < this (not strongly bearish)
)
# Position limits (trend-asymmetric)
POSITION_MAX_PCT_DEFAULT = 80 # Block BUY when ETH > this %
POSITION_MIN_PCT_DEFAULT = 20 # Block SELL when ETH < this %
POSITION_MAX_PCT_BULLISH = 90 # Allow more ETH in bullish trend
POSITION_MIN_PCT_BEARISH = 15 # Allow less ETH in bearish trend
# Adaptive step bounds (as fraction of price)
STEP_MIN_PCT = 0.010 # 1.0% (covers DEX costs)
STEP_MAX_PCT = 0.060 # cap: 6%
VOL_RECALIBRATE_RATIO = 0.3 # recalibrate if vol changes >30% from last grid
MAX_CONSECUTIVE_ERRORS = 5 # circuit breaker threshold
MAX_SAME_DIR_TRADES = 3 # max consecutive same-direction trades before pause
COOLDOWN_AFTER_ERRORS = 3600 # 1 hour cooldown after circuit break
QUIET_INTERVAL = 3600 # seconds between no-trade status reports (1 hour)
MIN_TRADE_INTERVAL = 1800 # 30min cooldown between same-direction trades
GRID_RECALIBRATE_HOURS = 12 # Keep grid fixed; recalibrate only when needed
UPSIDE_CONFIRM_TICKS = (
6 # 30min: price must hold above grid before upside recalibration
)
MAX_CENTER_SHIFT_PCT = 0.03 # max 3% grid center shift per recalibration (anti-chase)
# Multi-timeframe settings
MTF_SHORT_PERIOD = 5 # 5-bar EMA (25min @ 5min tick)
MTF_MEDIUM_PERIOD = 12 # 12-bar EMA (1h @ 5min tick)
MTF_LONG_PERIOD = 48 # 48-bar EMA (4h @ 5min tick)
MTF_STRUCTURE_PERIOD = 96 # 96-bar (8h @ 5min tick) for structure detection
# Sell improvement — trailing grid lock
SELL_TRAIL_TICKS = 0 # sell immediately when price hits level
SELL_MOMENTUM_THRESHOLD = 0.005 # skip sell if 1h momentum > 0.5% (strong uptrend)
# Dip-buy accumulation (buy-only mode when sell is blocked)
DIP_BUY_LOOKBACK = 12 # 1 hour of 5min bars for recent-high detection
DIP_BUY_MIN_DRAWDOWN = 0.005 # 0.5% minimum pullback from recent high
DIP_BUY_COOLDOWN = 1800 # 30min cooldown between dip buys
DIP_BUY_TIERS = [ # (drawdown_threshold, sizing_multiplier)
(0.03, 2.0), # ≥3% drop: 200% size
(0.02, 1.5), # ≥2% drop: 150% size
(0.01, 1.0), # ≥1% drop: 100% size
(0.005, 0.5), # ≥0.5% drop: 50% size
]
DIP_BUY_MOMENTUM_FLOOR = -0.5 # skip if 1h momentum < -0.5% (still falling hard)
DIP_BUY_REVERSAL_TICKS = (
2 # price must rise for N consecutive ticks to confirm reversal
)
# Paths
SCRIPT_DIR = Path(__file__).parent
STATE_FILE = SCRIPT_DIR / "grid_state.json"
LOG_FILE = SCRIPT_DIR / "grid_bot.log"
MAX_LOG_BYTES = 1_000_000 # 1MB log rotation
# ── Logging ─────────────────────────────────────────────────────────────────
def log(msg: str):
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{ts}] {msg}"
print(line)
try:
if LOG_FILE.exists() and LOG_FILE.stat().st_size > MAX_LOG_BYTES:
lines = LOG_FILE.read_text().splitlines()
LOG_FILE.write_text("\n".join(lines[len(lines) // 2 :]) + "\n")
with open(LOG_FILE, "a") as f:
f.write(line + "\n")
except Exception:
pass
# ── onchainos CLI wrapper ────────────────────────────────────────────────────
def onchainos_cmd(args: list[str], timeout: int = 30) -> dict | None:
"""Run onchainos CLI command, return parsed JSON."""
env = os.environ.copy()
env.setdefault("OKX_API_KEY", OKX_API_KEY)
env.setdefault("OKX_SECRET_KEY", OKX_SECRET)
env.setdefault("OKX_PASSPHRASE", OKX_PASSPHRASE)
try:
result = subprocess.run(
["onchainos"] + args,
capture_output=True,
text=True,
timeout=timeout,
env=env,
)
output = result.stdout.strip() if result.stdout else ""
if output:
try:
data = json.loads(output)
if isinstance(data, dict) and "ok" in data:
return data
return {"ok": True, "data": data if isinstance(data, list) else [data]}
except json.JSONDecodeError:
pass
if result.returncode != 0:
stderr = result.stderr.strip() if result.stderr else ""
log(
f"onchainos rc={result.returncode}: {' '.join(args[:3])} "
f"{'stderr=' + stderr[:150] if stderr else 'no output'}"
)
except subprocess.TimeoutExpired:
log(f"onchainos timeout: {' '.join(args[:3])}")
except Exception as e:
log(f"onchainos error: {e}")
return None
# ── Price & Balance ─────────────────────────────────────────────────────────
def get_eth_price() -> float | None:
"""Get ETH/USDC price via onchainos swap quote."""
data = onchainos_cmd(
[
"swap",
"quote",
"--from",
ETH_ADDR,
"--to",
USDC_ADDR,
"--amount",
"1000000000000000000",
"--chain",
"base",
]
)
if data and data.get("ok") and data.get("data"):
return int(data["data"][0]["toTokenAmount"]) / 1e6
return None
def get_balances() -> tuple[float, float] | None:
"""Get ETH and USDC balances via --all to avoid wallet-switch race condition."""
account_id = os.environ.get("ONCHAINOS_ACCOUNT_ID", "")
if account_id:
data = onchainos_cmd(["wallet", "balance", "--all", "--chain", CHAIN_ID], timeout=15)
else:
data = onchainos_cmd(["wallet", "balance", "--chain", CHAIN_ID], timeout=15)
if not data or not data.get("ok") or not data.get("data"):
log(f"Balance query failed, raw: {json.dumps(data)[:200] if data else 'None'}")
return None
eth, usdc = 0.0, 0.0
if account_id:
# --all returns {details: {accountId: {data: [{tokenAssets: [...]}]}}}
acct_data = data["data"].get("details", {}).get(account_id)
if not acct_data or not acct_data.get("data"):
log(f"Balance: account {account_id} not found in --all response")
return None
for chain_detail in acct_data["data"]:
for token in chain_detail.get("tokenAssets", []):
if token.get("tokenAddress") == "" and token.get("symbol") == "ETH":
eth = float(token.get("balance", "0"))
elif token.get("tokenAddress", "").lower() == USDC_ADDR.lower():
usdc = float(token.get("balance", "0"))
else:
details = data["data"].get("details", [])
for chain_detail in details:
for token in chain_detail.get("tokenAssets", []):
if token.get("tokenAddress") == "" and token.get("symbol") == "ETH":
eth = float(token.get("balance", "0"))
elif token.get("tokenAddress", "").lower() == USDC_ADDR.lower():
usdc = float(token.get("balance", "0"))
return eth, usdc
# ── K-line / OHLC Data ────────────────────────────────────────────────
def get_kline_data(bar: str = "1H", limit: int = 24) -> list[dict] | None:
"""Fetch K-line data via onchainos market kline.
Returns list of {open, high, low, close, volume, ts}."""
data = onchainos_cmd(
[
"market",
"kline",
"--address",
ETH_ADDR,
"--chain",
"base",
"--bar",
bar,
"--limit",
str(limit),
],
timeout=15,
)
if data and data.get("ok") and data.get("data"):
candles = []
for c in data["data"]:
try:
# onchainos returns arrays: [ts, open, high, low, close, volume, ...]
if isinstance(c, list) and len(c) >= 6:
candles.append(
{
"ts": int(c[0]),
"open": float(c[1]),
"high": float(c[2]),
"low": float(c[3]),
"close": float(c[4]),
"volume": float(c[5]),
}
)
elif isinstance(c, dict):
candles.append(
{
"ts": int(c.get("ts", 0)),
"open": float(c.get("o", 0) or c.get("open", 0)),
"high": float(c.get("h", 0) or c.get("high", 0)),
"low": float(c.get("l", 0) or c.get("low", 0)),
"close": float(c.get("c", 0) or c.get("close", 0)),
"volume": float(c.get("vol", 0) or c.get("volume", 0)),
}
)
except (ValueError, TypeError, IndexError):
continue
return candles if candles else None
return None
def calc_kline_volatility(candles: list[dict]) -> float:
"""Calculate true range based volatility from OHLC candles.
Returns average true range as percentage of price."""
if not candles or len(candles) < 2:
return 0.0
true_ranges = []
for i in range(1, len(candles)):
hi = candles[i]["high"]
lo = candles[i]["low"]
pc = candles[i - 1]["close"]
tr = max(hi - lo, abs(hi - pc), abs(lo - pc))
true_ranges.append(tr)
atr = sum(true_ranges) / len(true_ranges)
avg_price = sum(c["close"] for c in candles) / len(candles)
return (atr / avg_price) * 100 if avg_price > 0 else 0.0
# ── Multi-Timeframe Analysis ────────────────────────────────────────────
def calc_ema(prices: list[float], period: int) -> float:
"""Calculate Exponential Moving Average."""
if not prices:
return 0.0
if len(prices) < period:
return sum(prices) / len(prices)
k = 2 / (period + 1)
ema = sum(prices[:period]) / period
for p in prices[period:]:
ema = p * k + ema * (1 - k)
return ema
def calc_volatility(prices: list[float]) -> float:
"""Calculate standard deviation of prices."""
if len(prices) < 2:
return 0.0
mean = sum(prices) / len(prices)
variance = sum((p - mean) ** 2 for p in prices) / len(prices)
return math.sqrt(variance)
def analyze_multi_timeframe(history: list[float], price: float) -> dict:
"""Multi-timeframe trend analysis.
Returns {trend: str, strength: float, momentum_1h: float, structure: str,
ema_short, ema_medium, ema_long}."""
result = {
"trend": "neutral",
"strength": 0.0,
"momentum_1h": 0.0,
"momentum_4h": 0.0,
"structure": "ranging",
"ema_short": price,
"ema_medium": price,
"ema_long": price,
}
if len(history) < MTF_SHORT_PERIOD:
return result
ema_short = calc_ema(history, min(MTF_SHORT_PERIOD, len(history)))
ema_medium = calc_ema(history, min(MTF_MEDIUM_PERIOD, len(history)))
ema_long = calc_ema(history, min(MTF_LONG_PERIOD, len(history)))
result["ema_short"] = round(ema_short, 2)
result["ema_medium"] = round(ema_medium, 2)
result["ema_long"] = round(ema_long, 2)
# 1h momentum
if len(history) >= 12:
result["momentum_1h"] = round((price - history[-12]) / history[-12] * 100, 3)
# 4h momentum
if len(history) >= 48:
result["momentum_4h"] = round((price - history[-48]) / history[-48] * 100, 3)
# Trend detection: EMA alignment
if ema_short > ema_medium > ema_long:
result["trend"] = "bullish"
# Strength: how spread apart the EMAs are
spread = (ema_short - ema_long) / ema_long * 100
result["strength"] = round(min(spread / 2.0, 1.0), 3) # normalize, cap at 1.0
elif ema_short < ema_medium < ema_long:
result["trend"] = "bearish"
spread = (ema_long - ema_short) / ema_long * 100
result["strength"] = round(min(spread / 2.0, 1.0), 3)
else:
result["trend"] = "neutral"
result["strength"] = 0.0
# Structure detection: higher highs/lows vs lower highs/lows
if len(history) >= MTF_STRUCTURE_PERIOD:
# Split into 4 segments, check if pivot highs/lows are ascending
seg_len = MTF_STRUCTURE_PERIOD // 4
segments = [
history[-(i + 1) * seg_len : -i * seg_len or None] for i in range(3, -1, -1)
]
seg_highs = [max(s) for s in segments if s]
seg_lows = [min(s) for s in segments if s]
hh = all(seg_highs[i] >= seg_highs[i - 1] for i in range(1, len(seg_highs)))
hl = all(seg_lows[i] >= seg_lows[i - 1] for i in range(1, len(seg_lows)))
lh = all(seg_highs[i] <= seg_highs[i - 1] for i in range(1, len(seg_highs)))
ll = all(seg_lows[i] <= seg_lows[i - 1] for i in range(1, len(seg_lows)))
if hh and hl:
result["structure"] = "uptrend"
elif lh and ll:
result["structure"] = "downtrend"
else:
result["structure"] = "ranging"
return result
# ── Trend-Adaptive Grid Calculation ─────────────────────────────────────
def _build_level_prices(
center: float,
buy_step: float,
sell_step: float,
half: int,
grid_type: str = "arithmetic",
) -> list[float]:
"""Build asymmetric level_prices: below center uses buy_step, above uses sell_step."""
if grid_type == "geometric" and center > 0:
buy_ratio = 1 + (buy_step / center)
sell_ratio = 1 + (sell_step / center)
below = [round(center / (buy_ratio ** (half - i)), 2) for i in range(half)]
above = [round(center * (sell_ratio ** (i + 1)), 2) for i in range(half)]
return below + [round(center, 2)] + above
else:
below = [round(center - (half - i) * buy_step, 2) for i in range(half)]
above = [round(center + (i + 1) * sell_step, 2) for i in range(half)]
return below + [round(center, 2)] + above
def calc_dynamic_grid(
current_price: float, price_history: list[float], mtf: dict | None = None
) -> dict:
"""Calculate dynamic grid with trend-adaptive parameters.
Uses 1H kline for grid center (more robust than 5min tick history).
In trending markets, use wider grid (hold more, trade less).
"""
# Prefer 1H kline for center price — more stable than 5min ticks
hourly_closes: list[float] = []
candles = get_kline_data(bar="1H", limit=max(EMA_PERIOD, 24))
if candles and len(candles) >= 5:
hourly_closes = [c["close"] for c in candles]
if hourly_closes:
center = calc_ema(hourly_closes, min(EMA_PERIOD, len(hourly_closes)))
log(
f"Grid center: EMA({min(EMA_PERIOD, len(hourly_closes))}) on 1H kline = .2f"
)
elif len(price_history) >= 5:
center = calc_ema(price_history, min(EMA_PERIOD, len(price_history)))
log(
f"Grid center: EMA({min(EMA_PERIOD, len(price_history))}) on 5min fallback = .2f"
)
else:
center = current_price
vol_pct = 0.0
step = current_price * 0.01
buy_step = step
sell_step = step
log(f"Grid center: cold start, using current price .2f")
# Calculate volatility and step size (skip if cold start already set step)
if candles and len(candles) >= 2:
# Use 1H ATR for step sizing (more robust than stddev)
vol_pct = calc_kline_volatility(candles) # ATR as % of price
atr_dollar = vol_pct / 100 * current_price
log(f"ATR(1H, {len(candles)} bars): {vol_pct:.2f}% = .1f")
# Trend-adaptive multiplier — directional: bull widens, bear tightens
vol_mult = VOLATILITY_MULTIPLIER_BASE
strength = mtf.get("strength", 0) if mtf else 0
trend = mtf.get("trend", "neutral") if mtf else "neutral"
if strength > 0.3:
if trend == "bullish":
vol_mult = (
VOLATILITY_MULTIPLIER_BASE
+ (VOLATILITY_MULTIPLIER_BULL - VOLATILITY_MULTIPLIER_BASE)
* strength
) # 2.0 → 3.0: widen to hold
elif trend == "bearish":
vol_mult = (
VOLATILITY_MULTIPLIER_BASE
- (VOLATILITY_MULTIPLIER_BASE - VOLATILITY_MULTIPLIER_BEAR)
* strength
) # 2.0 → 1.5: tighten to exit
# Asymmetric buy/sell multipliers based on trend direction
# Bullish: tighter buy (accumulate fast) + wider sell (hold longer)
# Bearish: tighter sell (exit fast) + wider buy (wait for dip)
asym = ASYM_FACTOR * strength if strength > 0.3 else 0
if trend == "bullish":
buy_mult = vol_mult * (1 - asym)
sell_mult = vol_mult * (1 + asym)
elif trend == "bearish":
buy_mult = vol_mult * (1 + asym)
sell_mult = vol_mult * (1 - asym)
else:
buy_mult = vol_mult
sell_mult = vol_mult
half_levels = GRID_LEVELS / 2
buy_step = (buy_mult * atr_dollar) / half_levels
sell_step = (sell_mult * atr_dollar) / half_levels
step_floor = current_price * STEP_MIN_PCT
step_ceil = current_price * STEP_MAX_PCT
buy_step = max(step_floor, min(step_ceil, buy_step))
sell_step = max(step_floor, min(step_ceil, sell_step))
step = (buy_step + sell_step) / 2 # backward-compatible average
if asym > 0:
log(
f"Asymmetric grid ({trend}): buy_step=.1f "
f"sell_step=.1f (asym={asym:.2f}, mult={vol_mult:.2f})"
)
else:
log(
f"Step: .1f ({step / current_price * 100:.2f}% of price, "
f"ATR=.1f, mult={vol_mult:.2f})"
)
elif len(price_history) >= 5:
# Fallback: stddev from 5min ticks (symmetric only)
volatility = calc_volatility(price_history)
avg_price = sum(price_history) / len(price_history)
vol_pct = (volatility / avg_price) * 100 if avg_price > 0 else 0
vol_mult = VOLATILITY_MULTIPLIER_BASE
step = (vol_mult * volatility) / (GRID_LEVELS / 2)
step_floor = current_price * STEP_MIN_PCT
step_ceil = current_price * STEP_MAX_PCT
step = max(step_floor, min(step_ceil, step))
buy_step = step
sell_step = step
log(f"Step (5min fallback): .1f ({vol_pct:.2f}% stddev)")
# Hard floor: at least $5
buy_step = max(buy_step, 5.0)
sell_step = max(sell_step, 5.0)
step = (buy_step + sell_step) / 2
# Build asymmetric level_prices: below center uses buy_step, above uses sell_step
half = int(GRID_LEVELS / 2)
level_prices = _build_level_prices(center, buy_step, sell_step, half, GRID_TYPE)
low = level_prices[0]
high = level_prices[-1]
return {
"center": round(center, 2),
"step": round(step, 2),
"buy_step": round(buy_step, 2),
"sell_step": round(sell_step, 2),
"levels": GRID_LEVELS,
"range": [round(low, 2), round(high, 2)],
"vol_pct": round(vol_pct, 2),
"type": GRID_TYPE,
"level_prices": level_prices,
}
def price_to_level(price: float, grid: dict) -> int:
"""Convert price to grid level (0 = bottom, GRID_LEVELS = top)."""
level_prices = grid.get("level_prices")
if level_prices:
level = bisect.bisect_right(level_prices, price) - 1
return max(0, min(GRID_LEVELS, level))
low = grid["range"][0]
step = grid["step"]
if step <= 0:
return GRID_LEVELS // 2
level = int((price - low) / step)
return max(0, min(GRID_LEVELS, level))
# ── Trade Execution ─────────────────────────────────────────────────────────
def _calc_sizing_multiplier(
level: int,
grid_levels: int,
direction: str,
mtf: dict | None = None,
) -> float:
"""Trend-adaptive sizing.
- In bullish trend: buy more (larger buys), sell less (smaller sells) → hold more ETH
- In bearish trend: buy less, sell more → hold more USDC
"""
base_mult = 1.0
if SIZING_STRATEGY == "trend_adaptive" and mtf:
trend = mtf.get("trend", "neutral")
strength = mtf.get("strength", 0)
if trend == "bullish":
if direction == "BUY":
# In uptrend, buy aggressively
base_mult = 1.0 + strength * (SIZING_MULTIPLIER_MAX - 1.0)
else:
# In uptrend, sell conservatively (hold more ETH to capture upside)
base_mult = 1.0 - strength * (1.0 - SIZING_MULTIPLIER_MIN)
elif trend == "bearish":
if direction == "SELL":
base_mult = 1.0 + strength * (SIZING_MULTIPLIER_MAX - 1.0)
else:
base_mult = 1.0 - strength * (1.0 - SIZING_MULTIPLIER_MIN)
elif SIZING_STRATEGY == "equal" or grid_levels <= 0:
base_mult = 1.0
elif SIZING_STRATEGY in ("martingale", "anti_martingale", "pyramid"):
half = grid_levels / 2
dist = abs(level - half) / half if half > 0 else 0
mn, mx = SIZING_MULTIPLIER_MIN, SIZING_MULTIPLIER_MAX
if SIZING_STRATEGY == "martingale":
base_mult = mn + (mx - mn) * dist
elif SIZING_STRATEGY == "anti_martingale":
base_mult = mx - (mx - mn) * dist
elif SIZING_STRATEGY == "pyramid":
base_mult = mx - (mx - mn) * dist
return max(SIZING_MULTIPLIER_MIN, min(SIZING_MULTIPLIER_MAX, base_mult))
def _check_stop_conditions(state: dict, total_usd: float, price: float) -> str | None:
"""Check stop-loss, trailing-stop, and take-profit conditions."""
stats = state.get("stats", {})
initial = stats.get("initial_portfolio_usd")
if not initial or initial <= 0:
return None
deposits = stats.get("total_deposits_usd", 0)
cost_basis = initial + deposits
peak = stats.get("portfolio_peak_usd", cost_basis)
if total_usd > peak:
peak = total_usd
stats["portfolio_peak_usd"] = round(peak, 2)
pnl_pct = (total_usd - cost_basis) / cost_basis
if STOP_LOSS_PCT > 0 and pnl_pct <= -STOP_LOSS_PCT:
return f"stop_loss ({pnl_pct * 100:+.1f}% <= -{STOP_LOSS_PCT * 100:.0f}%)"
if TRAILING_STOP_PCT > 0 and peak > 0:
drawdown = (peak - total_usd) / peak
if drawdown >= TRAILING_STOP_PCT:
return (
f"trailing_stop (drawdown {drawdown * 100:.1f}% from peak .0f)"
)
return None
def calc_trade_amount(
direction: str,
eth_bal: float,
usdc_bal: float,
price: float,
current_level: int | None = None,
grid_levels: int | None = None,
mtf: dict | None = None,
) -> tuple[int | None, dict | None]:
"""Calculate trade amount. Returns (amount, failure_info)."""
available_eth = eth_bal - GAS_RESERVE_ETH
if available_eth < 0:
available_eth = 0.0
total_usd = available_eth * price + usdc_bal
max_usd = total_usd * MAX_TRADE_PCT
# Apply sizing strategy multiplier
if current_level is not None and grid_levels is not None:
multiplier = _calc_sizing_multiplier(current_level, grid_levels, direction, mtf)
max_usd *= multiplier
log(f" Sizing: {direction} mult={multiplier:.2f} -> max_usd=.2f")
if max_usd < MIN_TRADE_USD:
log(f"Trade too small: max .2f < min MIN_TRADE_USD")
return None, {
"reason": "below_minimum",
"detail": f"max .2f < min MIN_TRADE_USD",
"retriable": False,
"hint": "amount_too_small",
}
if direction == "SELL":
eth_to_sell = min(max_usd / price, available_eth)
if eth_to_sell * price < MIN_TRADE_USD:
return None, {
"reason": "insufficient_balance",
"detail": f"ETH to sell {eth_to_sell:.6f} below min",
"retriable": False,
"hint": "low_balance",
}
return int(eth_to_sell * 1e18), None
else:
usdc_to_spend = min(max_usd, usdc_bal * 0.95)
if usdc_to_spend < MIN_TRADE_USD:
return None, {
"reason": "insufficient_balance",
"detail": f"USDC .2f below min",
"retriable": False,
"hint": "low_balance",
}
return int(usdc_to_spend * 1e6), None
def ensure_approval(spender: str, amount: int) -> bool:
"""Ensure USDC approval for spender via onchainos."""
state = load_state()
approved_routers = state.get("approved_routers", [])
if spender.lower() in [r.lower() for r in approved_routers]:
return True
log(f"USDC approval needed for {spender[:10]}... Approving...")
max_approval = (
"115792089237316195423570985008687907853269984665640564039457584007913129639935"
)
data = onchainos_cmd(
[
"swap",
"approve",
"--token",
USDC_ADDR,
"--amount",
max_approval,
"--chain",
"base",
]
)
if not data or not data.get("ok") or not data.get("data"):
log(f"Approve API failed: {json.dumps(data)[:200] if data else 'no response'}")
return False
approve_tx = data["data"][0]
approve_tx["to"] = USDC_ADDR
tx_hash, fail = _wallet_contract_call(approve_tx)
if not tx_hash:
log(f"Approval failed: {fail}")
return False
log(f"Approval TX: {tx_hash}")
time.sleep(5)
approved_routers.append(spender)
state["approved_routers"] = approved_routers
save_state(state)
log(f"Router {spender[:10]}... added to approved list")
return True
def _wallet_contract_call(tx: dict) -> tuple[str | None, dict | None]:
"""Sign + broadcast via onchainos wallet contract-call (TEE signing)."""
value_wei = str(int(tx.get("value", "0")))
args = [
"wallet",
"contract-call",
"--to",
tx["to"],
"--chain",
CHAIN_ID,
"--input-data",
tx.get("data", "0x"),
"--amt",
value_wei,
]
# Let onchainos estimate gas internally (tx.gas from DEX API is unreliable)
if tx.get("gas"):
gas_price_gwei = int(tx["gasPrice"]) / 1e9 if tx.get("gasPrice") else 0
log(
f" TX gas hint (not used): dex_gas={tx['gas']}, gasPrice={gas_price_gwei:.2f} gwei"
)
try:
data = onchainos_cmd(args, timeout=45)
if data and data.get("ok") and data.get("data"):
result = (
data["data"]
if isinstance(data["data"], dict)
else (
data["data"][0] if isinstance(data["data"], list) else data["data"]
)
)
tx_hash = (
result.get("txHash") or result.get("hash") or result.get("orderId")
)
if tx_hash:
log(f" Broadcast OK: {tx_hash}")
return tx_hash, None
log(f" Response missing hash: {json.dumps(result)[:300]}")
return None, {
"reason": "no_hash",
"detail": json.dumps(result)[:200],
"retriable": True,
"hint": "transient_error",
}
detail = json.dumps(data)[:200] if data else "no response"
log(f" contract-call failed: {detail}")
return None, {
"reason": "contract_call_failed",
"detail": detail,
"retriable": True,
"hint": "transient_error",
}
except Exception as e:
return None, {
"reason": "exception",
"detail": str(e),
"retriable": True,
"hint": "transient_error",
}
def simulate_tx(tx: dict) -> dict | None:
"""Simulate transaction via onchainos gateway simulate (non-blocking diagnostic)."""
data = onchainos_cmd(
[
"gateway",
"simulate",
"--from",
WALLET_ADDR,
"--to",
tx["to"],
"--data",
tx.get("data", "0x"),
"--amount",
tx.get("value", "0"),
"--chain",
"base",
],
timeout=15,
)
if data and data.get("ok") and data.get("data"):
sim = data["data"][0] if isinstance(data["data"], list) else data["data"]
fail_reason = sim.get("failReason", "")
gas_used = sim.get("gasUsed", "")
success = not fail_reason
log(
f" Simulation: {'OK' if success else 'FAIL'} gasUsed={gas_used}"
+ (f" reason={fail_reason}" if fail_reason else "")
)
return {"success": success, "failReason": fail_reason, "gasUsed": gas_used}
if data:
log(f" Simulation error: {json.dumps(data)[:300]}")
return None
def execute_swap(
direction: str, amount: int, price: float, chain: str = "base"
) -> tuple[str | None, dict | None]:
"""Execute swap via onchainos CLI + Agentic Wallet (TEE signing)."""
if direction == "SELL":
from_token, to_token = ETH_ADDR, USDC_ADDR
else:
from_token, to_token = USDC_ADDR, ETH_ADDR
for attempt in range(2):
quote_time = time.time()
swap_data = onchainos_cmd(
[
"swap",
"swap",
"--from",
from_token,
"--to",
to_token,
"--amount",
str(amount),
"--chain",
chain,
"--wallet",
WALLET_ADDR,
"--slippage",
str(SLIPPAGE_PCT),
]
)
if not swap_data or not swap_data.get("ok") or not swap_data.get("data"):
detail = json.dumps(swap_data)[:200] if swap_data else "no response"
log(f"Swap quote failed (attempt {attempt + 1}): {detail}")
if attempt == 0:
time.sleep(3)
continue
return None, {
"reason": "swap_quote_failed",
"detail": detail,
"retriable": True,
"hint": "transient_api_error",
}
tx = swap_data["data"][0]["tx"]
log(
f" OKX swap: to={tx['to'][:10]}... value={tx.get('value', '0')} "
f"gas={tx.get('gas', 'N/A')} gasPrice={tx.get('gasPrice', 'N/A')}"
)
route_data = swap_data["data"][0]
log(
f" Route: minReceive={route_data.get('minReceiveAmount', tx.get('minReceiveAmount', 'N/A'))} "
f"slippage={SLIPPAGE_PCT}%"
)
sim_result = simulate_tx(tx)
if direction == "BUY":
router_addr = tx["to"]
if not ensure_approval(router_addr, amount):
log(f"Failed to approve USDC for router {router_addr}")
return None, {
"reason": "approval_failed",
"detail": f"router {router_addr}",
"retriable": True,
"hint": "approval_might_be_pending",
}
elapsed = time.time() - quote_time
log(f" Time quote-to-submit: {elapsed:.1f}s")
tx_hash, fail = _wallet_contract_call(tx)
if tx_hash:
return tx_hash, None
if fail and sim_result:
fail["simulation"] = sim_result
log(f"Swap failed (attempt {attempt + 1}): {fail}")
if (
attempt == 0
and fail
and fail.get("hint")
in ("network_timeout", "transient_error", "retry_with_fresh_quote")
):
time.sleep(3)
continue
return None, fail
return None, {
"reason": "max_retries",
"detail": "exhausted auto-retry",
"retriable": True,
"hint": "retry_with_fresh_quote",
}
# ── State Management ────────────────────────────────────────────────────────
def load_state() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except Exception:
pass
return {
"version": 5,
"grid": None,
"current_level": None,
"price_history": [],
"trades": [],
"last_balances": None,
"stats": {
"total_sell_usdc": 0.0,
"total_buy_usdc": 0.0,
"realized_pnl": 0.0,
"grid_profit": 0.0,
"initial_portfolio_usd": None,
"initial_eth_price": None,
"started_at": datetime.now().isoformat(),
"last_check": None,
"trade_attempts": 0,
"trade_successes": 0,
"trade_failures": 0,
"sell_attempts": 0,
"sell_successes": 0,
"buy_attempts": 0,
"buy_successes": 0,
"retry_attempts": 0,
"retry_successes": 0,
"total_deposits_usd": 0.0,
"deposit_history": [],
},
"errors": {"consecutive": 0, "cooldown_until": None},
"mtf_cache": None,
"kline_cache": None,
"sell_trail_counter": {}, # {level: tick_count}
}
def save_state(state: dict):
if STATE_FILE.exists():
bak = STATE_FILE.with_suffix(".json.bak")
bak.write_text(STATE_FILE.read_text())
STATE_FILE.write_text(json.dumps(state, indent=2))
# ── Market Analysis Helpers ─────────────────────────────────────────────────
def _calc_market_data(
price: float,
history: list[float],
grid: dict,
mtf: dict | None = None,
kline_vol: float | None = None,
) -> dict:
"""Calculate market analysis data for JSON output."""
ema = calc_ema(history, min(EMA_PERIOD, len(history))) if history else price
vol = calc_volatility(history) if len(history) >= 2 else 0
avg = sum(history) / len(history) if history else price
vol_pct = round((vol / avg) * 100, 2) if avg else 0
price_vs_ema = round(((price - ema) / ema) * 100, 2) if ema else 0
grid_low, grid_high = grid["range"]
grid_span = grid_high - grid_low
grid_util = round((price - grid_low) / grid_span, 2) if grid_span > 0 else 0.5
grid_util = max(0, min(1, grid_util))
result = {
"price": round(price, 2),
"ema": round(ema, 2),
"volatility_pct": vol_pct,
"price_vs_ema_pct": price_vs_ema,
"grid_utilization": grid_util,
}
# MTF data
if mtf:
result["trend"] = mtf.get("trend", "neutral")
result["trend_strength"] = mtf.get("strength", 0)
result["momentum_1h"] = mtf.get("momentum_1h", 0)
result["momentum_4h"] = mtf.get("momentum_4h", 0)
result["structure"] = mtf.get("structure", "ranging")
else:
# Fallback trend from v3 logic
if len(history) >= 10:
ema_short = calc_ema(history, min(5, len(history)))
result["trend"] = (
"bullish"
if ema_short > ema * 1.001
else "bearish"
if ema_short < ema * 0.999
else "neutral"
)
else:
result["trend"] = "neutral"
# K-line ATR volatility
if kline_vol is not None:
result["kline_atr_pct"] = round(kline_vol, 2)
return result
def _emit_json(data: dict):
"""Print JSON block for AI agent parsing."""
print("---JSON---")
print(json.dumps(data, indent=2))
# ── Notification helpers (Discord + Telegram) ─────────────────────────────
def _resolve_discord_channel_id() -> str:
"""Resolve Discord channel ID: env override > openclaw.json guilds config."""
env_id = os.environ.get("DISCORD_CHANNEL_ID", "")
if env_id:
return env_id
try:
cfg_path = Path.home() / ".openclaw" / "openclaw.json"
if cfg_path.exists():
cfg = json.loads(cfg_path.read_text())
guilds = cfg.get("channels", {}).get("discord", {}).get("guilds", {})
for guild_id, guild_cfg in guilds.items():
channels = guild_cfg.get("channels", {})
for ch_id, ch_cfg in channels.items():
if ch_cfg.get("allow"):
return ch_id
except Exception:
pass
return ""
DISCORD_CHANNEL_ID = _resolve_discord_channel_id()
def _get_discord_token() -> str:
env_token = os.environ.get("DISCORD_BOT_TOKEN", "")
if env_token:
return env_token
cfg_path = Path.home() / ".openclaw" / "openclaw.json"
if cfg_path.exists():
cfg = json.loads(cfg_path.read_text())
return cfg.get("channels", {}).get("discord", {}).get("token", "")
return ""
def _get_telegram_config() -> tuple[str, str]:
"""Return (bot_token, chat_id). Checks env first, then zeroclaw config."""
token = os.environ.get("TELEGRAM_BOT_TOKEN", "")
chat_id = os.environ.get("TELEGRAM_CHAT_ID", "")
if token and chat_id:
return token, chat_id
# Try zeroclaw-strategy config
for cfg_dir in ["zeroclaw-strategy", "zeroclaw", "openclaw"]:
cfg_path = Path.home() / f".{cfg_dir}" / "config.toml"
if cfg_path.exists():
try:
text = cfg_path.read_text()
# Simple TOML parse for telegram section
in_tg = False
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("[") and "telegram" in stripped.lower():
in_tg = True
continue
if stripped.startswith("[") and in_tg:
break
if in_tg and "=" in stripped:
k, v = stripped.split("=", 1)
k, v = k.strip(), v.strip().strip('"').strip("'")
if k == "bot_token" and not token:
token = v
if k == "chat_id" and not chat_id:
chat_id = v
except Exception:
pass
return token, chat_id
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID = _get_telegram_config()
def _embed_to_text(embeds: list[dict], content: str = "") -> str:
"""Convert Discord embeds to plain text for Telegram."""
parts = []
if content:
parts.append(content)
for e in embeds:
title = e.get("title", "")
desc = e.get("description", "")
if title:
parts.append(f"*{title}*")
if desc:
parts.append(desc)
fields = e.get("fields", [])
for f in fields:
name = f.get("name", "")
value = f.get("value", "")
parts.append(f"{name}: {value}")
return "\n".join(parts)
def _send_telegram(text: str) -> bool:
"""Send a message via Telegram Bot API."""
import urllib.request
import urllib.error
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
return False
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
payload = {
"chat_id": TELEGRAM_CHAT_ID,
"text": text,
"parse_mode": "Markdown",
"disable_web_page_preview": True,
}
req = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
try:
urllib.request.urlopen(req, timeout=10)
return True
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
log(f"Telegram send error: {e}")
return False
def _send_discord_embed(embeds: list[dict], content: str = ""):
"""Send notification via Discord embed + Telegram text."""
import urllib.request
import urllib.error
discord_ok = False
token = _get_discord_token()
if token and DISCORD_CHANNEL_ID:
url = f"https://discord.com/api/v10/channels/{DISCORD_CHANNEL_ID}/messages"
payload = {"embeds": embeds}
if content:
payload["content"] = content
req = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bot {token}",
"Content-Type": "application/json",
"User-Agent": "DiscordBot (https://openclaw.ai, 1.0)",
},
)
try:
urllib.request.urlopen(req, timeout=10)
discord_ok = True
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as e:
log(f"Discord embed error: {e}")
# Also send to Telegram
tg_ok = False
if TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
text = _embed_to_text(embeds, content)
tg_ok = _send_telegram(text)
if not discord_ok and not tg_ok:
log("No notification channel available (Discord + Telegram both failed)")
return False
return True
def _record_attempt(state: dict, direction: str, success: bool, is_retry: bool = False):
"""Record a trade attempt for success rate tracking."""
s = state["stats"]
s["trade_attempts"] = s.get("trade_attempts", 0) + 1
if success:
s["trade_successes"] = s.get("trade_successes", 0) + 1
else:
s["trade_failures"] = s.get("trade_failures", 0) + 1
dir_key = direction.lower()
s[f"{dir_key}_attempts"] = s.get(f"{dir_key}_attempts", 0) + 1
if success:
s[f"{dir_key}_successes"] = s.get(f"{dir_key}_successes", 0) + 1
if is_retry:
s["retry_attempts"] = s.get("retry_attempts", 0) + 1
if success:
s["retry_successes"] = s.get("retry_successes", 0) + 1
def _success_rate_str(state: dict) -> str:
s = state.get("stats", {})
total = s.get("trade_attempts", 0)
success = s.get("trade_successes", 0)
if total == 0:
return "N/A"
rate = round(success / total * 100, 1)
return f"{success}/{total} ({rate}%)"
def _success_rate_data(state: dict) -> dict:
s = state.get("stats", {})
total = s.get("trade_attempts", 0)
success = s.get("trade_successes", 0)
return {
"total_attempts": total,
"successes": success,
"failures": s.get("trade_failures", 0),
"rate_pct": round(success / total * 100, 1) if total > 0 else None,
"sell": {
"attempts": s.get("sell_attempts", 0),
"successes": s.get("sell_successes", 0),
},
"buy": {
"attempts": s.get("buy_attempts", 0),
"successes": s.get("buy_successes", 0),
},
"retry": {
"attempts": s.get("retry_attempts", 0),
"successes": s.get("retry_successes", 0),
},
}
def _detect_deposits(
state: dict, eth_bal: float, usdc_bal: float, price: float
) -> float | None:
"""Detect external deposits/withdrawals."""
last = state.get("last_balances")
if not last:
return None
last_time = last.get("time", "")
delta_eth = eth_bal - last["eth"]
delta_usdc = usdc_bal - last["usdc"]
for t in state.get("trades", []):
if t["time"] > last_time:
trade_eth = t["amount_usd"] / t["price"]
if t["direction"] == "SELL":
delta_eth += trade_eth
delta_usdc -= t["amount_usd"]
else:
delta_eth -= trade_eth
delta_usdc += t["amount_usd"]
deposit_usd = delta_eth * price + delta_usdc
if abs(deposit_usd) > 100:
event_type = "deposit" if deposit_usd > 0 else "withdrawal"
event = {
"time": datetime.now().isoformat(),
"eth_delta": round(delta_eth, 6),
"usdc_delta": round(delta_usdc, 2),
"usd_value": round(deposit_usd, 2),
"type": event_type,
}
dep_history = state["stats"].get("deposit_history", [])
dep_history.append(event)
if len(dep_history) > 20:
dep_history = dep_history[-20:]
state["stats"]["deposit_history"] = dep_history
state["stats"]["total_deposits_usd"] = round(
state["stats"].get("total_deposits_usd", 0) + deposit_usd, 2
)
type_cn = "存入" if deposit_usd > 0 else "取出"
log(
f"检测到{type_cn}: ~.2f (ETH {delta_eth:+.6f}, USDC {delta_usdc:+.2f})"
)
return deposit_usd
return None
# ── Trend-Adaptive Position Limits ──────────────────────────────────────
def _get_position_limits(mtf: dict | None) -> tuple[int, int]:
"""Return (max_pct, min_pct) for position limits based on trend."""
if not mtf:
return POSITION_MAX_PCT_DEFAULT, POSITION_MIN_PCT_DEFAULT
trend = mtf.get("trend", "neutral")
strength = mtf.get("strength", 0)
if trend == "bullish" and strength > 0.3:
# Allow holding more ETH in bullish market
max_pct = POSITION_MAX_PCT_DEFAULT + int(
(POSITION_MAX_PCT_BULLISH - POSITION_MAX_PCT_DEFAULT) * strength
)
min_pct = POSITION_MIN_PCT_DEFAULT
return max_pct, min_pct
elif trend == "bearish" and strength > 0.3:
max_pct = POSITION_MAX_PCT_DEFAULT
min_pct = POSITION_MIN_PCT_DEFAULT - int(
(POSITION_MIN_PCT_DEFAULT - POSITION_MIN_PCT_BEARISH) * strength
)
return max_pct, min_pct
return POSITION_MAX_PCT_DEFAULT, POSITION_MIN_PCT_DEFAULT
# ── Sell Optimization ───────────────────────────────────────────────────
def _should_delay_sell(
state: dict,
current_level: int,
prev_level: int,
mtf: dict | None,
history: list[float],
) -> str | None:
"""Check if we should delay sell in strong uptrend.
Returns skip reason or None."""
if not mtf:
return None
# If strong bullish momentum, delay sells to capture more upside
momentum_1h = mtf.get("momentum_1h", 0)
if momentum_1h > SELL_MOMENTUM_THRESHOLD * 100:
trend = mtf.get("trend", "neutral")
if trend == "bullish":
# Bullish trend + strong momentum: skip this sell, let it ride
structure = mtf.get("structure", "ranging")
log(
f" sell delay: bullish momentum (1h momentum {momentum_1h:.2f}%, "
f"structure={structure})"
)
return f"trend_hold (momentum +{momentum_1h:.1f}%)"
# Trailing sell: wait a few ticks before selling to confirm reversal
trail = state.get("sell_trail_counter", {})
level_key = f"{prev_level}->{current_level}"
count = trail.get(level_key, 0)
if count < SELL_TRAIL_TICKS:
trail[level_key] = count + 1
state["sell_trail_counter"] = trail
remaining = SELL_TRAIL_TICKS - count - 1
log(f" sell trail: waiting {remaining} more ticks for level {level_key}")
return f"sell_trail ({count + 1}/{SELL_TRAIL_TICKS})"
# Clear trail counter after triggering
trail.pop(level_key, None)
state["sell_trail_counter"] = trail
return None
def _check_dip_buy(
state: dict,
price: float,
history: list[float],
eth_pct: float,
mtf: dict | None,
) -> dict | None:
"""Check if we should dip-buy in accumulation mode.
Triggers only when selling is structurally blocked (USDC-heavy).
Returns {"multiplier": float, "drawdown": float, "reason": str} or None.
"""
# Only activate when ETH% is below the sell threshold (buy-only regime)
pos_max, pos_min = _get_position_limits(mtf)
if eth_pct >= pos_min:
return None # can still sell → normal grid mode
# Need enough history
if len(history) < DIP_BUY_LOOKBACK:
return None
recent = history[-DIP_BUY_LOOKBACK:]
recent_high = max(recent)
if recent_high <= 0:
return None
drawdown = (recent_high - price) / recent_high
# Condition 1: minimum pullback
if drawdown < DIP_BUY_MIN_DRAWDOWN:
return None
# Condition 2: NOT falling hard (avoid catching knives)
momentum_1h = mtf.get("momentum_1h", 0) if mtf else 0
if momentum_1h < DIP_BUY_MOMENTUM_FLOOR:
log(
f" dip-buy skip: momentum {momentum_1h:.2f}% < {DIP_BUY_MOMENTUM_FLOOR}% (falling knife)"
)
return None
# Condition 3: reversal confirmation — price rising for N consecutive ticks
tail = history[-DIP_BUY_REVERSAL_TICKS:]
if len(tail) >= DIP_BUY_REVERSAL_TICKS:
rising = all(tail[i] < tail[i + 1] for i in range(len(tail) - 1))
if not rising:
log(
f" dip-buy skip: no {DIP_BUY_REVERSAL_TICKS}-tick reversal "
f"(last {len(tail)}: {[round(p, 1) for p in tail]})"
)
return None
# Condition 4: cooldown since last dip-buy
last_dip = state.get("last_dip_buy_time")
if last_dip:
elapsed = (datetime.now() - datetime.fromisoformat(last_dip)).total_seconds()
if elapsed < DIP_BUY_COOLDOWN:
return None
# Determine tier
for threshold, mult in DIP_BUY_TIERS:
if drawdown >= threshold:
return {
"multiplier": mult,
"drawdown": drawdown,
"reason": f"dip_buy ({drawdown * 100:.1f}% from .0f, "
f"momentum {momentum_1h:+.2f}%)",
}
return None
# ── Core Logic ──────────────────────────────────────────────────────────────
def tick():
"""Main tick: check price, execute trade if grid crossing detected.
Multi-timeframe, sell-optimized."""
state = load_state()
# Circuit breaker check
errors = state.get("errors", {})
if errors.get("consecutive", 0) >= MAX_CONSECUTIVE_ERRORS:
cooldown = errors.get("cooldown_until")
if cooldown and datetime.fromisoformat(cooldown) > datetime.now():
remaining = (
datetime.fromisoformat(cooldown) - datetime.now()
).seconds // 60
log(
f"CIRCUIT BREAKER: {errors['consecutive']} consecutive errors. "
f"Cooldown {remaining}min remaining."
)
_emit_json(
{
"status": "circuit_breaker",
"retriable": False,
"hint": "cooldown_active",
"remaining_min": remaining,
}
)
return
else:
log("Circuit breaker cooldown expired, resuming.")
errors["consecutive"] = 0
errors["cooldown_until"] = None
# Get current price
price = get_eth_price()
if not price:
errors["consecutive"] = errors.get("consecutive", 0) + 1
if errors["consecutive"] >= MAX_CONSECUTIVE_ERRORS:
errors["cooldown_until"] = (
datetime.now() + timedelta(seconds=COOLDOWN_AFTER_ERRORS)
).isoformat()
log(f"CIRCUIT BREAKER TRIGGERED after {errors['consecutive']} errors")
state["errors"] = errors
save_state(state)
log("Failed to get price")
_emit_json(
{
"status": "error",
"reason": "price_fetch_failed",
"retriable": True,
"hint": "transient_api_error",
}
)
return
errors["consecutive"] = 0
state["errors"] = errors
# Update price history (keep last 288 = 24h at 5min intervals)
history = state.get("price_history", [])
history.append(price)
if len(history) > 288:
history = history[-288:]
state["price_history"] = history
# Get balances (fallback to last known if API fails)
bal = get_balances()
balance_failed = bal is None
if bal is not None:
eth_bal, usdc_bal = bal
else:
last_bal = state.get("last_balances", {})
if last_bal.get("eth", 0) > 0 or last_bal.get("usdc", 0) > 0:
eth_bal = last_bal.get("eth", 0)
usdc_bal = last_bal.get("usdc", 0)
log(
f"Balance query failed — using last known: ETH={eth_bal}, USDC={usdc_bal}"
)
else:
eth_bal, usdc_bal = 0.0, 0.0
total_usd = eth_bal * price + usdc_bal
# Snapshot initial portfolio on first tick
if state["stats"].get("initial_portfolio_usd") is None:
state["stats"]["initial_portfolio_usd"] = round(total_usd, 2)
state["stats"]["initial_eth_price"] = round(price, 2)
log(f"Initial portfolio snapshot: .2f @ ETH .2f")
# Detect external deposits/withdrawals (skip if balance query failed)
detected_deposit = None
if not balance_failed:
detected_deposit = _detect_deposits(state, eth_bal, usdc_bal, price)
# ── Multi-timeframe analysis ──
mtf = analyze_multi_timeframe(history, price)
state["mtf_cache"] = mtf
# ── K-line volatility (fetch every 1h) ──
kline_vol = None
kline_cache = state.get("kline_cache")
kline_stale = True
if kline_cache and kline_cache.get("fetched_at"):
elapsed = (
datetime.now() - datetime.fromisoformat(kline_cache["fetched_at"])
).total_seconds()
kline_stale = elapsed > 3600 # refresh hourly
if kline_stale:
candles = get_kline_data("1H", 24)
if candles:
kline_vol = calc_kline_volatility(candles)
state["kline_cache"] = {
"atr_pct": round(kline_vol, 3),
"candles_count": len(candles),
"fetched_at": datetime.now().isoformat(),
}
log(f"K-line ATR: {kline_vol:.2f}%")
else:
kline_vol = kline_cache.get("atr_pct") if kline_cache else None
else:
kline_vol = kline_cache.get("atr_pct") if kline_cache else None
# ── Stop-loss / trailing-stop / take-profit guard ──
if state.get("stop_triggered"):
trigger = state["stop_triggered"]
log(f"STOP ACTIVE: {trigger} -- trading halted")
if not state.get("stop_notified"):
state["stop_notified"] = True
state.setdefault("stop_price", round(price, 2))
state.setdefault("stop_time", datetime.now().isoformat())
save_state(state)
_send_discord_embed(
[
{
"title": "\U0001f6d1 交易已停止",
"color": 0xFF0000,
"description": f"触发条件: **{trigger}**\n当前价格: .2f\n组合价值: .0f\n\n将在价格回升后自动恢复",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
]
)
# ── Auto-resume check ──
can_resume = False
resume_reasons = []
if STOP_AUTO_RESUME:
stop_price = state.get("stop_price", price)
stop_time_str = state.get("stop_time")
cooldown_ok = True
if stop_time_str:
elapsed_min = (
datetime.now() - datetime.fromisoformat(stop_time_str)
).total_seconds() / 60
cooldown_ok = elapsed_min >= STOP_COOLDOWN_MINUTES
if not cooldown_ok:
resume_reasons.append(
f"冷却中 {elapsed_min:.0f}/{STOP_COOLDOWN_MINUTES}min"
)
bounce_pct = (price - stop_price) / stop_price if stop_price > 0 else 0
bounce_ok = bounce_pct >= STOP_RESUME_BOUNCE_PCT
if not bounce_ok:
resume_reasons.append(
f"反弹 {bounce_pct * 100:+.1f}% < {STOP_RESUME_BOUNCE_PCT * 100:.0f}%"
)
trend_ok = True
if (
mtf
and mtf.get("trend") == "bearish"
and mtf.get("strength", 0) >= STOP_RESUME_MAX_BEARISH
):
trend_ok = False
resume_reasons.append(
f"趋势仍强熊 strength={mtf.get('strength', 0):.2f}"
)
can_resume = cooldown_ok and bounce_ok and trend_ok
if can_resume:
old_trigger = state["stop_triggered"]
state.pop("stop_triggered", None)
state.pop("stop_notified", None)
state.pop("stop_price", None)
state.pop("stop_time", None)
# Reset cost basis to current portfolio for fresh start
state["stats"]["initial_portfolio_usd"] = round(total_usd, 2)
state["stats"]["initial_eth_price"] = round(price, 2)
state["stats"]["portfolio_peak_usd"] = round(total_usd, 2)
state["stats"]["started_at"] = datetime.now().isoformat()
state["grid"] = None # force grid rebuild at current price
save_state(state)
log(f"AUTO-RESUME: conditions met, rebuilding grid at .2f")
_send_discord_embed(
[
{
"title": "\u2705 自动恢复交易",
"color": 0x00C853,
"description": (
f"止损原因: {old_trigger}\n"
f"恢复价格: .2f (反弹 {bounce_pct * 100:+.1f}%)\n"
f"新基准: .0f\n"
f"将以当前价格重建网格"
),
"timestamp": datetime.now(timezone.utc).isoformat(),
}
]
)
# Fall through to normal tick logic (grid rebuild etc.)
else:
reason_str = (
", ".join(resume_reasons) if resume_reasons else "auto-resume disabled"
)
log(f"Auto-resume not ready: {reason_str}")
# Update balances to prevent repeated withdrawal detection
state["last_balances"] = {
"eth": round(eth_bal, 6),
"usdc": round(usdc_bal, 2),
"time": datetime.now().isoformat(),
}
save_state(state)
_emit_json(
{
"status": "stopped",
"stop_triggered": trigger,
"portfolio_usd": round(total_usd, 2),
"price": round(price, 2),
"auto_resume_pending": reason_str,
}
)
return
if balance_failed:
log("Balance query failed — skipping stop checks this tick")
stop_trigger = None
else:
stop_trigger = _check_stop_conditions(state, total_usd, price)
if stop_trigger:
state["stop_triggered"] = stop_trigger
log(f"STOP TRIGGERED: {stop_trigger}")
save_state(state)
_send_discord_embed(
[
{
"title": "\U0001f6a8 止损/止盈触发!",
"color": 0xFF0000,
"description": f"**{stop_trigger}**\n价格: .2f\n组合价值: .0f\n\n交易已自动停止。使用 `resume-trading` 恢复。",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
]
)
_emit_json(
{
"status": "stop_triggered",
"trigger": stop_trigger,
"portfolio_usd": round(total_usd, 2),
"price": round(price, 2),
}
)
return
# ── Grid stability: only recalibrate when needed ──
grid = state.get("grid")
grid_set_at = state.get("grid_set_at")
need_recalibrate = False
if not grid:
need_recalibrate = True
elif price < grid["range"][0] - grid.get("buy_step", grid["step"]):
need_recalibrate = True
state.pop("upside_breakout_ticks", None)
log(f"Price .2f BELOW grid {grid['range']} - recalibrating (downside)")
elif price > grid["range"][1] + grid.get("sell_step", grid["step"]):
ticks = state.get("upside_breakout_ticks", 0) + 1
state["upside_breakout_ticks"] = ticks
if ticks >= UPSIDE_CONFIRM_TICKS:
need_recalibrate = True
state.pop("upside_breakout_ticks", None)
log(
f"Price .2f ABOVE grid {grid['range']} - recalibrating "
f"(confirmed after {ticks} ticks)"
)
else:
log(
f"Price .2f above grid {grid['range']} - "
f"waiting confirmation ({ticks}/{UPSIDE_CONFIRM_TICKS})"
)
elif grid_set_at:
hours_since = (
datetime.now() - datetime.fromisoformat(grid_set_at)
).total_seconds() / 3600
if hours_since > GRID_RECALIBRATE_HOURS:
need_recalibrate = True
log(
f"Grid age {hours_since:.1f}h > {GRID_RECALIBRATE_HOURS}h - recalibrating"
)
else:
need_recalibrate = True
# Reset upside breakout counter if price is back inside grid
if grid and grid["range"][0] <= price <= grid["range"][1] + grid.get(
"sell_step", grid["step"]
):
if state.get("upside_breakout_ticks", 0) > 0:
log(f"Price .2f back in grid range - reset upside counter")
state.pop("upside_breakout_ticks", None)
# Volatility shift detection (compare kline ATR to grid ATR, same source)
if not need_recalibrate and grid and kline_vol is not None:
grid_vol_pct = grid.get("vol_pct", 0)
if grid_vol_pct > 0:
vol_change_ratio = abs(kline_vol - grid_vol_pct) / grid_vol_pct
if vol_change_ratio > VOL_RECALIBRATE_RATIO:
need_recalibrate = True
log(
f"Volatility shift: {grid_vol_pct:.2f}% -> {kline_vol:.2f}% "
f"(delta {vol_change_ratio * 100:.0f}%) - recalibrating"
)
if need_recalibrate:
old_step = grid["step"] if grid else 0
old_center = grid["center"] if grid else price
grid = calc_dynamic_grid(price, history, mtf)
# Cap grid center shift
if old_center and old_center > 0:
max_shift = old_center * MAX_CENTER_SHIFT_PCT
new_center = grid["center"]
if abs(new_center - old_center) > max_shift:
capped_center = old_center + max_shift * (
1 if new_center > old_center else -1
)
log(
f"Center shift capped: .0f -> .0f "
f"(max {MAX_CENTER_SHIFT_PCT * 100:.0f}% from .0f)"
)
grid["center"] = round(capped_center, 2)
half_levels = int(grid["levels"] / 2)
b_step = grid.get("buy_step", grid["step"])
s_step = grid.get("sell_step", grid["step"])
grid["level_prices"] = _build_level_prices(
capped_center,
b_step,
s_step,
half_levels,
grid.get("type", "arithmetic"),
)
grid["range"] = [grid["level_prices"][0], grid["level_prices"][-1]]
state["grid"] = grid
state["grid_set_at"] = datetime.now().isoformat()
new_level = price_to_level(price, grid)
old_level = state.get("current_level")
# Only silence ±1 level drift from grid rebuild; preserve real jumps
if old_level is None or abs(new_level - old_level) <= 1:
state["current_level"] = new_level
# Clear sell trail counters on recalibration
state["sell_trail_counter"] = {}
step_change = f" (was .1f)" if old_step else ""
b_s = grid.get("buy_step", grid["step"])
s_s = grid.get("sell_step", grid["step"])
asym_info = f" buy=.1f sell=.1f" if abs(b_s - s_s) > 0.01 else ""
log(
f"Grid set: .0f-.0f "
f"step=.1f{asym_info}{step_change} "
f"vol={grid.get('vol_pct', 0):.1f}% level={new_level}"
)
# Determine current grid level
current_level = price_to_level(price, grid)
prev_level = state.get("current_level")
state["stats"]["last_check"] = datetime.now().isoformat()
# Prepare output data
market_data = _calc_market_data(price, history, grid, mtf, kline_vol)
eth_pct = round((eth_bal * price / total_usd) * 100, 1) if total_usd > 0 else 0
portfolio_data = {
"eth": round(eth_bal, 6),
"usdc": round(usdc_bal, 2),
"total_usd": round(total_usd, 2),
"eth_pct": eth_pct,
}
action = None
tx_hash = None
tick_status = "no_trade"
failure_info = None
direction = None
if prev_level is not None and current_level != prev_level:
direction = "SELL" if current_level > prev_level else "BUY"
skip_reason = None
# ── Zone filter: block BUY at top, block SELL at bottom ──
buy_ceil = 4 # BUY allowed at L0-L4, blocked at L5-L6
sell_floor = 1 # SELL allowed at L1-L6, blocked at L0
if direction == "BUY" and current_level > buy_ceil:
skip_reason = f"above buy-zone (L{current_level} > L{buy_ceil})"
tick_status = "zone_filter"
elif direction == "SELL" and current_level < sell_floor:
skip_reason = f"below sell-zone (L{current_level} < L{sell_floor})"
tick_status = "zone_filter"
# ── Cooldown: min interval between same-direction trades ──
last_trade_times = state.get("last_trade_times", {})
last_time_str = last_trade_times.get(direction)
if last_time_str:
elapsed = (
datetime.now() - datetime.fromisoformat(last_time_str)
).total_seconds()
if elapsed < MIN_TRADE_INTERVAL:
skip_reason = f"cooldown ({int(MIN_TRADE_INTERVAL - elapsed)}s left)"
tick_status = "cooldown"
# ── Cross-direction cooldown: prevent instant reversal after trade ──
if not skip_reason:
opposite = "SELL" if direction == "BUY" else "BUY"
opp_time_str = last_trade_times.get(opposite)
if opp_time_str:
opp_elapsed = (
datetime.now() - datetime.fromisoformat(opp_time_str)
).total_seconds()
if opp_elapsed < MIN_TRADE_INTERVAL:
skip_reason = (
f"cross-cooldown (last {opposite} {int(opp_elapsed)}s ago, "
f"need {MIN_TRADE_INTERVAL}s)"
)
tick_status = "cooldown"
# ── Trend-adaptive position limits ──
if not skip_reason:
pos_max, pos_min = _get_position_limits(mtf)
if direction == "BUY" and eth_pct > pos_max:
skip_reason = f"ETH-heavy ({eth_pct:.0f}% > {pos_max}%)"
tick_status = "position_limit"
elif direction == "SELL" and eth_pct < pos_min:
skip_reason = f"USDC-heavy ({eth_pct:.0f}% < {pos_min}%)"
tick_status = "position_limit"
# ── Anti-repeat: skip same boundary trade ──
if not skip_reason:
last_trade = state["trades"][-1] if state["trades"] else None
if (
last_trade
and last_trade["direction"] == direction
and last_trade["grid_to"] == current_level
and last_trade["grid_from"] == prev_level
):
skip_reason = "repeat boundary"
tick_status = "skip_repeat"
# ── Consecutive same-direction limit ──
if not skip_reason:
recent = state.get("trades", [])[-MAX_SAME_DIR_TRADES:]
if len(recent) >= MAX_SAME_DIR_TRADES and all(
t["direction"] == direction for t in recent
):
last_trade_time = datetime.fromisoformat(recent[-1]["time"])
grid_set_time = datetime.fromisoformat(
state.get("grid_set_at", recent[-1]["time"])
)
time_since_last = (datetime.now() - last_trade_time).total_seconds()
grid_recalibrated = grid_set_time > last_trade_time
if grid_recalibrated or time_since_last > 3600:
reason = (
"grid recalibrated"
if grid_recalibrated
else f"{time_since_last / 60:.0f}min elapsed"
)
log(f"Consecutive {direction} limit reset ({reason})")
else:
skip_reason = (
f"consecutive {direction} limit ({MAX_SAME_DIR_TRADES})"
)
tick_status = "consecutive_limit"
# ── Rapid drop detection: don't buy into a falling knife ──
if not skip_reason and direction == "BUY":
recent_prices = history[-6:]
if len(recent_prices) >= 3:
drop_pct = (
(recent_prices[-1] - max(recent_prices)) / max(recent_prices) * 100
)
if drop_pct < -2.0:
skip_reason = f"rapid drop ({drop_pct:.1f}% in 30min)"
tick_status = "rapid_drop"
# ── Sell delay in strong uptrend ──
if not skip_reason and direction == "SELL":
sell_delay = _should_delay_sell(
state, current_level, prev_level, mtf, history
)
if sell_delay:
skip_reason = sell_delay
tick_status = "sell_delayed"
if skip_reason:
log(f"SKIP {direction} L{prev_level}-L{current_level}: {skip_reason}")
state["current_level"] = current_level
direction = None
else:
# Clear sell trail counter on execution
trail = state.get("sell_trail_counter", {})
level_key = f"{prev_level}->{current_level}"
trail.pop(level_key, None)
state["sell_trail_counter"] = trail
# Calculate trade amount
amount, calc_fail = calc_trade_amount(
direction,
eth_bal,
usdc_bal,
price,
current_level=current_level,
grid_levels=GRID_LEVELS,
mtf=mtf,
)
if amount:
log(
f"GRID CROSSING: L{prev_level}-L{current_level} | "
f".2f | {direction} | trend={mtf.get('trend', 'N/A')}"
)
tx_hash, swap_fail = execute_swap(direction, amount, price)
if tx_hash:
_record_attempt(state, direction, True)
trade_usd = (
(amount / 1e18 * price)
if direction == "SELL"
else (amount / 1e6)
)
trade_eth = trade_usd / price if price else 0
if direction == "SELL":
# Use actual level_prices for accurate profit with asymmetric grids
lp = grid.get("level_prices", [])
if lp and prev_level < len(lp) and current_level < len(lp):
price_diff = abs(lp[prev_level] - lp[current_level])
else:
price_diff = abs(current_level - prev_level) * grid.get(
"step", 0
)
est_profit = round(price_diff * trade_eth, 2)
else:
est_profit = 0
trade_record = {
"time": datetime.now().isoformat(),
"direction": direction,
"price": round(price, 2),
"amount_usd": round(trade_usd, 2),
"est_profit": est_profit,
"tx": tx_hash,
"grid_from": prev_level,
"grid_to": current_level,
"trend": mtf.get("trend", "neutral"),
"trend_strength": mtf.get("strength", 0),
}
state["trades"].append(trade_record)
if len(state["trades"]) > 50:
state["trades"] = state["trades"][-50:]
if direction == "SELL":
state["stats"]["total_sell_usdc"] = round(
state["stats"].get("total_sell_usdc", 0) + trade_usd, 2
)
else:
state["stats"]["total_buy_usdc"] = round(
state["stats"].get("total_buy_usdc", 0) + trade_usd, 2
)
_total_usd = eth_bal * price + usdc_bal
_initial = state["stats"].get("initial_portfolio_usd") or 0
_deposits = state["stats"].get("total_deposits_usd", 0)
state["stats"]["realized_pnl"] = round(
_total_usd - _initial - _deposits, 2
)
state["stats"]["grid_profit"] = round(
state["stats"].get("grid_profit", 0) + est_profit, 2
)
dir_cn = "卖出" if direction == "SELL" else "买入"
profit_str = f" (利润 ~.2f)" if est_profit > 0 else ""
action = f"{dir_cn} .2f{profit_str}"
tick_status = "trade_executed"
log(f"TX: https://basescan.org/tx/{tx_hash}")
state["current_level"] = current_level
if "last_trade_times" not in state:
state["last_trade_times"] = {}
state["last_trade_times"][direction] = datetime.now().isoformat()
else:
_record_attempt(state, direction, False)
log(f"Trade execution failed: {swap_fail}")
dir_cn = "卖出" if direction == "SELL" else "买入"
action = f"{dir_cn} 失败"
tick_status = "trade_failed"
failure_info = swap_fail
state["last_failed_trade"] = {
"direction": direction,
"price": price,
"grid_from": prev_level,
"grid_to": current_level,
"time": datetime.now().isoformat(),
}
if failure_info:
_send_discord_embed(
[
{
"title": "\u26a0\ufe0f 交易失败",
"color": 0xFF9800,
"fields": [
{
"name": "方向",
"value": direction,
"inline": True,
},
{
"name": "价格",
"value": f".2f",
"inline": True,
},
{
"name": "原因",
"value": failure_info.get(
"reason", "unknown"
),
"inline": True,
},
{
"name": "详情",
"value": str(
failure_info.get("detail", "")
)[:200],
"inline": False,
},
],
"footer": {
"text": f"可重试: {'是' if failure_info.get('retriable') else '否'}"
},
"timestamp": datetime.now(timezone.utc).isoformat(),
}
]
)
else:
log(f"Skipped trade: {calc_fail}")
action = f"{direction} skipped"
tick_status = "trade_skipped"
failure_info = calc_fail
else:
if prev_level is None:
state["current_level"] = current_level
log(f"Grid initialized at level {current_level}")
tick_status = "initialized"
else:
# ── Dip-buy accumulation (no grid crossing, sell blocked) ──
dip = _check_dip_buy(state, price, history, eth_pct, mtf)
if dip:
dip_mult = dip["multiplier"]
dip_dd = dip["drawdown"]
dip_reason = dip["reason"]
log(f"DIP BUY: {dip_reason} mult={dip_mult:.1f}x")
amount, calc_fail = calc_trade_amount(
"BUY",
eth_bal,
usdc_bal,
price,
current_level=current_level,
grid_levels=GRID_LEVELS,
mtf=mtf,
)
# Apply dip tier multiplier on top of trend sizing
if amount:
amount = int(amount * dip_mult)
# Cap at 95% of USDC balance
max_amount = int(usdc_bal * 0.95 * 1e6)
amount = min(amount, max_amount)
if amount and amount / 1e6 >= MIN_TRADE_USD:
tx_hash, swap_fail = execute_swap("BUY", amount, price)
if tx_hash:
_record_attempt(state, "BUY", True)
trade_usd = amount / 1e6
trade_record = {
"time": datetime.now().isoformat(),
"direction": "BUY",
"price": round(price, 2),
"amount_usd": round(trade_usd, 2),
"est_profit": 0,
"tx": tx_hash,
"grid_from": current_level,
"grid_to": current_level,
"trend": mtf.get("trend", "neutral") if mtf else "neutral",
"trend_strength": mtf.get("strength", 0) if mtf else 0,
"dip_buy": True,
"drawdown_pct": round(dip_dd * 100, 2),
}
state["trades"].append(trade_record)
if len(state["trades"]) > 50:
state["trades"] = state["trades"][-50:]
state["stats"]["total_buy_usdc"] = round(
state["stats"].get("total_buy_usdc", 0) + trade_usd, 2
)
_total_usd = eth_bal * price + usdc_bal
_initial = state["stats"].get("initial_portfolio_usd") or 0
_deposits = state["stats"].get("total_deposits_usd", 0)
state["stats"]["realized_pnl"] = round(
_total_usd - _initial - _deposits, 2
)
state["last_dip_buy_time"] = datetime.now().isoformat()
if "last_trade_times" not in state:
state["last_trade_times"] = {}
state["last_trade_times"]["BUY"] = datetime.now().isoformat()
action = f"逢低买入 .2f (回撤{dip_dd * 100:.1f}%)"
tick_status = "dip_buy"
direction = "BUY"
log(f"TX: https://basescan.org/tx/{tx_hash}")
else:
_record_attempt(state, "BUY", False)
log(f"Dip buy failed: {swap_fail}")
# Save balance snapshot
state["last_balances"] = {
"eth": round(eth_bal, 6),
"usdc": round(usdc_bal, 2),
"time": datetime.now().isoformat(),
}
save_state(state)
# Output summary
display_level = state.get("current_level", current_level)
grid_range = f".0f-.0f"
initial = state["stats"].get("initial_portfolio_usd")
deposits = state["stats"].get("total_deposits_usd", 0)
cost_basis = (initial or 0) + deposits
total_pnl = round(total_usd - cost_basis, 2) if initial else 0
grid_profit = state["stats"].get("grid_profit", 0)
trades_count = state["stats"].get("trade_successes", 0)
has_event = bool(action or detected_deposit)
# HODL comparison
initial_price = state["stats"].get("initial_eth_price")
hodl_alpha = None
if initial and initial_price and initial_price > 0:
initial_eth = initial / initial_price
hodl_value = initial_eth * price
hodl_alpha = round(total_usd - hodl_value, 2)
# Quiet mode
should_print = True
if not has_event:
last_quiet = state.get("last_quiet_report")
now_iso = datetime.now().isoformat()
if last_quiet:
elapsed = (
datetime.now() - datetime.fromisoformat(last_quiet)
).total_seconds()
if elapsed < QUIET_INTERVAL:
should_print = False
if should_print:
state["last_quiet_report"] = now_iso
save_state(state)
if should_print:
pnl_emoji = "\U0001f7e2" if total_pnl >= 0 else "\U0001f534"
pnl_str = f"+.2f" if total_pnl >= 0 else f"-.2f"
trend_str = mtf.get("trend", "?") if mtf else "?"
vol_info = (
f" | 波动 {grid.get('vol_pct', 0):.1f}%" if grid.get("vol_pct") else ""
)
trend_info = f" | 趋势 {trend_str}" if trend_str != "?" else ""
grid_footer = (
f"网格 {grid_range} | 步长 .1f{vol_info}{trend_info}"
)
alpha_str = f" | Alpha +.2f" if hodl_alpha is not None else ""
if has_event:
dir_cn = action if action else ""
embed_color = 0x00C853 if "卖出" in dir_cn else 0x2979FF
fields = [
{"name": "价格", "value": f".2f", "inline": True},
{
"name": "层级",
"value": f"L{display_level}/{GRID_LEVELS}",
"inline": True,
},
{"name": "总值", "value": f".0f", "inline": True},
{
"name": "持仓",
"value": f"{eth_bal:.4f} ETH + .1f USDC",
"inline": False,
},
{"name": "总收益", "value": pnl_str, "inline": True},
{"name": "网格利润", "value": f"+.2f", "inline": True},
{
"name": "交易次数",
"value": f"{trades_count} | [BaseScan](https://basescan.org/tx/{tx_hash})"
if tx_hash
else str(trades_count),
"inline": True,
},
]
if hodl_alpha is not None:
fields.append(
{
"name": "HODL Alpha",
"value": f"+.2f",
"inline": True,
}
)
if mtf:
fields.append(
{
"name": "趋势",
"value": f"{mtf.get('trend', 'N/A')} ({mtf.get('strength', 0):.0%})",
"inline": True,
}
)
if detected_deposit:
dep_cn = "存入" if detected_deposit > 0 else "取出"
fields.append(
{
"name": f"检测到{dep_cn}",
"value": f".2f (已调整收益基准)",
"inline": False,
}
)
embed = {
"title": f"\u26a1 {dir_cn}",
"color": embed_color,
"fields": fields,
"footer": {"text": grid_footer},
"timestamp": datetime.now(timezone.utc).isoformat(),
}
else:
desc = (
f"**.2f** | L{display_level}/{GRID_LEVELS} | "
f"{eth_bal:.4f} ETH + .1f USDC = .0f\n"
f"{pnl_emoji} 收益 {pnl_str} | 网格利润 +.2f | {trades_count}笔{alpha_str}"
)
if deposits != 0:
desc += f" | 资金调整 +.0f"
embed = {
"title": "\u23f3 ETH 网格 v1.0 -- 运行中",
"color": 0x9E9E9E,
"description": desc,
"footer": {"text": grid_footer},
"timestamp": datetime.now(timezone.utc).isoformat(),
}
# Only send no-trade ticks on the hour; always send event ticks
if has_event or datetime.now().minute < 5:
sent = _send_discord_embed([embed])
else:
sent = False
if not sent:
pnl_sign = "+" if total_pnl >= 0 else ""
summary = (
f"**ETH** `.2f` | L`{display_level}`/`{GRID_LEVELS}` "
f"| 网格 {grid_range} (步长 `.1f`) "
f"| `{eth_bal:.4f}` ETH + `.1f` USDC (`.0f`)"
)
summary += f"\n> 总收益 `{pnl_sign}.2f` | 网格利润 `+.2f` | 交易 `{trades_count}` 笔{alpha_str}"
if action:
summary += f" | **{action}**"
if tx_hash:
summary += f"\n<https://basescan.org/tx/{tx_hash}>"
else:
summary += " | 无交易"
print(summary)
# Output structured JSON for AI agent
if should_print:
json_data = {
"status": tick_status,
"version": "1.0",
"market": market_data,
"portfolio": portfolio_data,
"grid_level": display_level,
"prev_level": prev_level,
"success_rate": _success_rate_data(state),
"cost_basis": cost_basis,
"total_deposits_usd": deposits,
"hodl_alpha": hodl_alpha,
}
if detected_deposit:
json_data["detected_deposit_usd"] = round(detected_deposit, 2)
if direction:
json_data["direction"] = direction
if tx_hash:
json_data["tx_hash"] = tx_hash
if failure_info:
json_data.update(
{
"failure_reason": failure_info.get("reason", "unknown"),
"failure_detail": failure_info.get("detail", ""),
"retriable": failure_info.get("retriable", False),
"retry_hint": failure_info.get("hint", ""),
}
)
if failure_info.get("simulation"):
json_data["simulation"] = failure_info["simulation"]
_emit_json(json_data)
# ── Sub-commands ────────────────────────────────────────────────────────────
def status():
"""Print current status."""
state = load_state()
price = get_eth_price()
eth_bal, usdc_bal = get_balances()
grid = state.get("grid")
total_usd = eth_bal * (price or 0) + usdc_bal
stats = state.get("stats", {})
history = state.get("price_history", [])
print("**ETH 网格机器人 v1.0 -- 状态**")
print(f"> 价格: `.2f`" if price else "> 价格: 不可用")
print(
f"> 余额: `{eth_bal:.6f}` ETH + `.2f` USDC = **`.0f`**"
)
if grid:
print(
f"> 网格: `.0f`-`.0f` | "
f"步长 `.1f` | 中心 `.0f`"
)
print(f"> 层级: `{state.get('current_level', '?')}`/`{GRID_LEVELS}`")
else:
print("> 网格: 未初始化")
# MTF info
if price and len(history) >= MTF_SHORT_PERIOD:
mtf = analyze_multi_timeframe(history, price)
print("\n**多时间框架分析**")
print(
f"> 趋势: `{mtf['trend']}` (强度 `{mtf['strength']:.0%}`) | 结构: `{mtf['structure']}`"
)
print(
f"> 动量: 1h `{mtf['momentum_1h']:+.2f}%` | 4h `{mtf['momentum_4h']:+.2f}%`"
)
print(
f"> EMA: 短 `.1f` | 中 `.1f` | 长 `.1f`"
)
# PnL
initial = stats.get("initial_portfolio_usd")
deposits = stats.get("total_deposits_usd", 0)
grid_profit = stats.get("grid_profit", 0)
sell_total = stats.get("total_sell_usdc", 0)
buy_total = stats.get("total_buy_usdc", 0)
print("\n**收益统计**")
if initial and price:
cost_basis = initial + deposits
total_pnl = round(total_usd - cost_basis, 2)
holding_pnl = round(total_pnl - grid_profit, 2)
pct = (total_pnl / cost_basis) * 100 if cost_basis else 0
print(
f"> 总收益: **`+.2f`** (`{pct:+.1f}%`) 起始 `.0f`"
)
print(
f"> 网格利润: `+.2f` (卖出赚差价) | 持仓浮盈: `+.2f` (ETH涨跌)"
)
print(f"> 交易量: 卖出 `.2f` | 买入 `.2f`")
# HODL comparison
initial_price = stats.get("initial_eth_price")
if initial_price and initial_price > 0:
initial_eth = initial / initial_price
hodl_value = initial_eth * price
hodl_alpha = round(total_usd - hodl_value, 2)
hodl_pct = round((price - initial_price) / initial_price * 100, 2)
print(
f"> HODL对比: ETH `{hodl_pct:+.1f}%` | HODL价值 `.0f` | **Alpha `+.2f`**"
)
if deposits != 0:
print(f"> 资金调整: `+.2f` | 成本基准 `.0f`")
# Success rate
print(f"\n> 成功率: `{_success_rate_str(state)}`")
# Stop status
stop_trigger = state.get("stop_triggered")
if stop_trigger:
print(f"\n> \U0001f6d1 **交易已停止**: `{stop_trigger}`")
print("> 使用 `resume-trading` 恢复交易")
# Strategy info
print("\n**策略配置 v1.0**")
print(f"> 资金策略: `{SIZING_STRATEGY}` | 网格类型: `{GRID_TYPE}`")
print(
f"> 步长范围: `{STEP_MIN_PCT * 100:.1f}%`-`{STEP_MAX_PCT * 100:.1f}%` | 卖出追踪: `{SELL_TRAIL_TICKS}` ticks"
)
parts = []
if STOP_LOSS_PCT > 0:
parts.append(f"止损 {STOP_LOSS_PCT * 100:.0f}%")
if TRAILING_STOP_PCT > 0:
parts.append(f"追踪止损 {TRAILING_STOP_PCT * 100:.0f}%")
if parts:
peak = stats.get("portfolio_peak_usd")
peak_str = f" | 峰值 `.0f`" if peak else ""
print(f"> 保护: {' | '.join(parts)}{peak_str}")
def report():
"""Generate daily report."""
state = load_state()
price = get_eth_price()
eth_bal, usdc_bal = get_balances()
total_usd = eth_bal * (price or 0) + usdc_bal
stats = state.get("stats", {})
trades = state.get("trades", [])
grid = state.get("grid", {})
history = state.get("price_history", [])
today = datetime.now().date().isoformat()
today_trades = [t for t in trades if t["time"].startswith(today)]
if history:
price_high = max(history)
price_low = min(history)
volatility = calc_volatility(history)
vol_pct = (volatility / (sum(history) / len(history))) * 100 if history else 0
else:
price_high = price_low = price or 0
vol_pct = 0
# PnL
initial = stats.get("initial_portfolio_usd")
deposits = stats.get("total_deposits_usd", 0)
grid_profit = stats.get("grid_profit", 0)
sell_total = stats.get("total_sell_usdc", 0)
buy_total = stats.get("total_buy_usdc", 0)
# Build embed
fields = [
{
"name": "当前价格",
"value": f".2f" if price else "N/A",
"inline": True,
},
{
"name": "24h 范围",
"value": f".2f - .2f",
"inline": True,
},
{"name": "波动率", "value": f"{vol_pct:.1f}%", "inline": True},
{
"name": "持仓",
"value": f"{eth_bal:.4f} ETH + .2f USDC = **.0f**",
"inline": False,
},
]
if grid:
fields.append(
{
"name": "网格",
"value": f".0f-.0f (步长 .1f) | L{state.get('current_level', '?')}/{GRID_LEVELS}",
"inline": False,
}
)
if initial and price:
cost_basis = initial + deposits
total_pnl = round(total_usd - cost_basis, 2)
pct = (total_pnl / cost_basis) * 100 if cost_basis else 0
holding_pnl = round(total_pnl - grid_profit, 2)
fields.append(
{
"name": "总收益",
"value": f"+.2f ({pct:+.1f}%)",
"inline": True,
}
)
fields.append(
{"name": "网格利润", "value": f"+.2f", "inline": True}
)
fields.append(
{"name": "持仓浮盈", "value": f"+.2f", "inline": True}
)
fields.append(
{
"name": "交易量",
"value": f"卖出 .2f | 买入 .2f",
"inline": False,
}
)
initial_price = stats.get("initial_eth_price")
if initial_price and initial_price > 0:
initial_eth = initial / initial_price
hodl_value = initial_eth * price
hodl_alpha = round(total_usd - hodl_value, 2)
fields.append(
{"name": "HODL Alpha", "value": f"+.2f", "inline": True}
)
fields.append(
{"name": "成功率", "value": f"{_success_rate_str(state)}", "inline": True}
)
fields.append(
{
"name": "累计交易",
"value": f"{stats.get('trade_successes', 0)} 笔",
"inline": True,
}
)
# Today's trades summary
if today_trades:
trade_lines = []
for t in today_trades[-10:]:
dir_cn = "卖出" if t["direction"] == "SELL" else "买入"
est = t.get("est_profit", 0)
profit_str = f" ~.2f" if est > 0 else ""
trade_lines.append(
f"`{t['time'][11:19]}` {dir_cn} .2f @ .2f{profit_str}"
)
fields.append(
{
"name": f"今日交易 ({len(today_trades)}笔)",
"value": "\n".join(trade_lines),
"inline": False,
}
)
else:
fields.append({"name": "今日交易", "value": "暂无", "inline": True})
# MTF info in footer
footer_text = f"运行自 {stats.get('started_at', '未知')[:10]}"
if price and len(history) >= MTF_SHORT_PERIOD:
mtf = analyze_multi_timeframe(history, price)
footer_text = f"趋势 {mtf['trend']} ({mtf['strength']:.0%}) | 结构 {mtf['structure']} | {footer_text}"
embed = {
"title": "\U0001f4ca ETH 网格 v1.0 — 每日报告",
"color": 0x2196F3,
"fields": fields,
"footer": {"text": footer_text},
"timestamp": datetime.now(timezone.utc).isoformat(),
}
sent = _send_discord_embed([embed])
if not sent:
# Fallback to print output
print("**ETH 网格机器人 v1.0 -- 每日报告**")
print(f"> 当前价格: `.2f`" if price else "> 当前价格: N/A")
print(
f"> 24h 范围: `.2f` - `.2f` | 波动率: `{vol_pct:.1f}%`"
)
if price and len(history) >= MTF_SHORT_PERIOD:
if not mtf:
mtf = analyze_multi_timeframe(history, price)
print(
f"> 趋势: `{mtf['trend']}` ({mtf['strength']:.0%}) | 结构: `{mtf['structure']}`"
)
print(
f"> 动量: 1h `{mtf['momentum_1h']:+.2f}%` | 4h `{mtf['momentum_4h']:+.2f}%`"
)
print("\n**持仓**")
print(
f"> `{eth_bal:.4f}` ETH + `.2f` USDC = **`.0f`**"
)
if grid:
print(
f"> 网格: `.0f`-`.0f` "
f"(步长 `.1f`) | 层级 `{state.get('current_level', '?')}`/`{GRID_LEVELS}`"
)
print("\n**收益**")
if initial and price:
print(
f"> 总收益: **`+.2f` (`{pct:+.1f}%`)** 起始 `.0f`"
)
print(
f"> 网格利润: `+.2f` | 持仓浮盈: `+.2f`"
)
print(f"> 交易量: 卖出 `.2f` | 买入 `.2f`")
initial_price = stats.get("initial_eth_price")
if initial_price and initial_price > 0:
initial_eth = initial / initial_price
hodl_value = initial_eth * price
hodl_alpha = round(total_usd - hodl_value, 2)
print(f"> **HODL Alpha: `+.2f`**")
print(f"\n> 成功率: `{_success_rate_str(state)}`")
print(f"\n**今日交易: `{len(today_trades)}` 笔**")
if today_trades:
for t in today_trades[-10:]:
dir_cn = "卖出" if t["direction"] == "SELL" else "买入"
est = t.get("est_profit", 0)
profit_str = f" 利润~`.2f`" if est > 0 else ""
trend = t.get("trend", "")
trend_str = f" [{trend}]" if trend else ""
print(
f"> `{t['time'][11:19]}` {dir_cn} `.2f` @ `.2f`{profit_str}{trend_str}"
)
else:
print("> 今日暂无交易")
print(
f"\n> 累计交易: `{stats.get('trade_successes', 0)}` 笔 | 运行自: `{stats.get('started_at', '未知')[:10]}`"
)
def history_cmd():
"""Show recent trade history."""
state = load_state()
trades = state.get("trades", [])
if not trades:
print("暂无交易记录")
return
print(f"**最近 `{len(trades)}` 笔交易**")
for t in trades:
dir_cn = "卖出" if t["direction"] == "SELL" else "买入"
est = t.get("est_profit", 0)
profit_str = f" | 利润~`.2f`" if est > 0 else ""
trend = t.get("trend", "")
trend_str = f" [{trend}]" if trend else ""
print(
f"> `{t['time'][:19]}` | {dir_cn} `>8.2f` "
f"@ `.2f` | L`{t['grid_from']}`->L`{t['grid_to']}`{profit_str}{trend_str} "
f"| `{t['tx'][:16]}...`"
)
def reset():
"""Reset state (recalibrate grid from scratch)."""
price = get_eth_price()
eth_bal, usdc_bal = get_balances()
state = load_state()
old_trades = state.get("trades", [])
old_stats = state.get("stats", {})
new_state = {
"version": 5,
"grid": None,
"current_level": None,
"price_history": [price] if price else [],
"trades": old_trades,
"stats": {
"total_sell_usdc": old_stats.get("total_sell_usdc", 0.0),
"total_buy_usdc": old_stats.get("total_buy_usdc", 0.0),
"realized_pnl": old_stats.get("realized_pnl", 0.0),
"grid_profit": old_stats.get("grid_profit", 0.0),
"initial_portfolio_usd": None,
"initial_eth_price": None,
"started_at": datetime.now().isoformat(),
"last_check": datetime.now().isoformat(),
"trade_attempts": 0,
"trade_successes": 0,
"trade_failures": 0,
"sell_attempts": 0,
"sell_successes": 0,
"buy_attempts": 0,
"buy_successes": 0,
"retry_attempts": 0,
"retry_successes": 0,
"total_deposits_usd": 0.0,
"deposit_history": [],
},
"errors": {"consecutive": 0, "cooldown_until": None},
"last_failed_trade": None,
"last_balances": None,
"grid_set_at": None,
"last_trade_times": {},
"mtf_cache": None,
"kline_cache": None,
"sell_trail_counter": {},
}
save_state(new_state)
total = eth_bal * (price or 0) + usdc_bal
print(f"网格已重置 (v1.0)。价格: `.2f`, 余额: `.0f`")
print("计数器已重置。下次 tick 时将重新校准网格。")
def retry():
"""Retry the last failed trade with a fresh quote."""
state = load_state()
last_fail = state.get("last_failed_trade")
if not last_fail:
print("无需重试")
_emit_json({"status": "no_retry_needed"})
return
fail_time = datetime.fromisoformat(last_fail["time"])
if (datetime.now() - fail_time).total_seconds() > 600:
print("上次失败交易已超过10分钟,跳过重试")
_emit_json({"status": "retry_expired", "failed_at": last_fail["time"]})
state.pop("last_failed_trade", None)
save_state(state)
return
direction = last_fail["direction"]
dir_cn = "卖出" if direction == "SELL" else "买入"
price = get_eth_price()
if not price:
print("无法重试: 价格不可用")
_emit_json({"status": "error", "reason": "price_fetch_failed"})
return
eth_bal, usdc_bal = get_balances()
amount, calc_fail = calc_trade_amount(direction, eth_bal, usdc_bal, price)
if not amount:
print(f"无法重试: {calc_fail}")
_emit_json(
{"status": "retry_failed", "reason": calc_fail.get("reason", "unknown")}
)
return
log(
f"RETRY: {direction} at .2f (original fail at .2f)"
)
tx_hash, swap_fail = execute_swap(direction, amount, price)
if tx_hash:
_record_attempt(state, direction, True, is_retry=True)
trade_usd = (amount / 1e18 * price) if direction == "SELL" else (amount / 1e6)
trade_record = {
"time": datetime.now().isoformat(),
"direction": direction,
"price": round(price, 2),
"amount_usd": round(trade_usd, 2),
"tx": tx_hash,
"grid_from": last_fail["grid_from"],
"grid_to": last_fail["grid_to"],
"trend": "retry",
}
state["trades"].append(trade_record)
if len(state["trades"]) > 50:
state["trades"] = state["trades"][-50:]
if direction == "SELL":
state["stats"]["total_sell_usdc"] = round(
state["stats"].get("total_sell_usdc", 0) + trade_usd, 2
)
else:
state["stats"]["total_buy_usdc"] = round(
state["stats"].get("total_buy_usdc", 0) + trade_usd, 2
)
_total_usd = eth_bal * price + usdc_bal
_initial = state["stats"].get("initial_portfolio_usd") or 0
_deposits = state["stats"].get("total_deposits_usd", 0)
state["stats"]["realized_pnl"] = round(_total_usd - _initial - _deposits, 2)
# Update trade time to enforce cross-direction cooldown
if "last_trade_times" not in state:
state["last_trade_times"] = {}
state["last_trade_times"][direction] = datetime.now().isoformat()
state.pop("last_failed_trade", None)
save_state(state)
print(f"**重试成功**: {dir_cn} `.2f` @ `.2f`")
log(f"RETRY TX: https://basescan.org/tx/{tx_hash}")
# Discord notification for retry success
retry_embed = {
"title": f"\u267b\ufe0f 重试{dir_cn}成功",
"color": 0x00C853 if direction == "SELL" else 0x2979FF,
"fields": [
{"name": "价格", "value": f".2f", "inline": True},
{"name": "金额", "value": f".2f", "inline": True},
{
"name": "层级",
"value": f"L{last_fail['grid_from']}→L{last_fail['grid_to']}",
"inline": True,
},
{
"name": "交易",
"value": f"[BaseScan](https://basescan.org/tx/{tx_hash})",
"inline": False,
},
],
"timestamp": datetime.now(timezone.utc).isoformat(),
}
_send_discord_embed([retry_embed])
_emit_json(
{
"status": "retry_success",
"direction": direction,
"tx_hash": tx_hash,
"amount_usd": round(trade_usd, 2),
}
)
else:
_record_attempt(state, direction, False, is_retry=True)
save_state(state)
print(
f"**重试失败**: {dir_cn} -- {swap_fail.get('reason', '未知') if swap_fail else '未知'}"
)
log(f"RETRY FAILED: {swap_fail}")
_emit_json(
{
"status": "retry_failed",
"direction": direction,
"failure_reason": swap_fail.get("reason", "unknown")
if swap_fail
else "unknown",
}
)
def analyze():
"""Output detailed market analysis JSON for AI agent."""
state = load_state()
price = get_eth_price()
eth_bal, usdc_bal = get_balances()
history = state.get("price_history", [])
grid = state.get("grid")
trades = state.get("trades", [])
stats = state.get("stats", {})
if not price:
print(json.dumps({"error": "price_unavailable"}))
return
total_usd = eth_bal * price + usdc_bal
# MTF analysis
mtf = analyze_multi_timeframe(history, price)
# K-line ATR
candles = get_kline_data("1H", 24)
kline_vol = calc_kline_volatility(candles) if candles else None
# Price changes
def pct_change(window):
if len(history) < window:
return None
old = history[-window]
return round(((price - old) / old) * 100, 2)
price_changes = {
"1h": pct_change(12),
"4h": pct_change(48),
"24h": pct_change(288),
}
# Volatility trend
vol_recent = calc_volatility(history[-24:]) if len(history) >= 24 else None
vol_older = calc_volatility(history[-72:-24]) if len(history) >= 72 else None
if vol_recent and vol_older and vol_older > 0:
vol_trend = (
"increasing"
if vol_recent > vol_older * 1.2
else "decreasing"
if vol_recent < vol_older * 0.8
else "stable"
)
else:
vol_trend = "insufficient_data"
# Grid efficiency
grid_efficiency = None
if grid and len(history) >= 12:
grid_low, grid_high = grid["range"]
recent = history[-12:]
in_grid = sum(1 for p in recent if grid_low <= p <= grid_high)
grid_efficiency = round(in_grid / len(recent), 2)
# HODL alpha
initial = stats.get("initial_portfolio_usd")
initial_price = stats.get("initial_eth_price")
hodl_alpha = None
if initial and initial_price and initial_price > 0:
initial_eth = initial / initial_price
hodl_value = initial_eth * price
hodl_alpha = round(total_usd - hodl_value, 2)
# Round trip analysis (last 20 trades)
round_trips = []
buy_stack = []
for t in trades[-20:]:
if t["direction"] == "BUY":
buy_stack.append(t)
else:
for j in range(len(buy_stack) - 1, -1, -1):
if buy_stack[j]["grid_to"] == t["grid_from"]:
matched_buy = buy_stack.pop(j)
spread = (
(t["price"] - matched_buy["price"]) / matched_buy["price"] * 100
)
hold_min = 0
try:
hold_min = int(
(
datetime.fromisoformat(t["time"])
- datetime.fromisoformat(matched_buy["time"])
).total_seconds()
/ 60
)
except Exception:
pass
round_trips.append(
{
"buy_price": matched_buy["price"],
"sell_price": t["price"],
"spread_pct": round(spread, 3),
"hold_min": hold_min,
"status": "good"
if spread >= 0.3
else "micro"
if spread > 0
else "loss",
}
)
break
analysis = {
"version": "1.0",
"timestamp": datetime.now().isoformat(),
"market": {
"price": round(price, 2),
"ema_20": round(calc_ema(history, min(EMA_PERIOD, len(history))), 2)
if history
else price,
"price_changes": price_changes,
"volatility_pct": round(
(calc_volatility(history) / (sum(history) / len(history))) * 100, 2
)
if len(history) >= 2
else 0,
"volatility_trend": vol_trend,
"kline_atr_pct": round(kline_vol, 2) if kline_vol else None,
},
"multi_timeframe": mtf,
"portfolio": {
"eth": round(eth_bal, 6),
"usdc": round(usdc_bal, 2),
"total_usd": round(total_usd, 2),
"eth_pct": round((eth_bal * price / total_usd) * 100, 1)
if total_usd > 0
else 0,
},
"pnl": {
"total_pnl": round(
total_usd - ((initial or 0) + stats.get("total_deposits_usd", 0)), 2
),
"grid_profit": stats.get("grid_profit", 0),
"hodl_alpha": hodl_alpha,
},
"grid": {
"range": grid["range"] if grid else None,
"step": grid["step"] if grid else None,
"level": state.get("current_level"),
"efficiency": grid_efficiency,
},
"round_trips": {
"count": len(round_trips),
"good": sum(1 for r in round_trips if r["status"] == "good"),
"micro": sum(1 for r in round_trips if r["status"] == "micro"),
"loss": sum(1 for r in round_trips if r["status"] == "loss"),
"avg_spread_pct": round(
sum(r["spread_pct"] for r in round_trips) / len(round_trips), 3
)
if round_trips
else 0,
"details": round_trips[-5:], # last 5
},
"success_rate": _success_rate_data(state),
}
print(json.dumps(analysis, indent=2))
def deposit():
"""Manually record deposit/withdrawal."""
if len(sys.argv) < 3:
print("用法: eth_grid.py deposit <金额USD>")
print("正数=存入, 负数=取出. 例: deposit 100 或 deposit -50")
return
try:
amount = float(sys.argv[2])
except ValueError:
print("无效金额")
return
state = load_state()
event = {
"time": datetime.now().isoformat(),
"usd_value": round(amount, 2),
"type": "manual_deposit" if amount > 0 else "manual_withdrawal",
}
dep_history = state["stats"].get("deposit_history", [])
dep_history.append(event)
state["stats"]["deposit_history"] = dep_history
state["stats"]["total_deposits_usd"] = round(
state["stats"].get("total_deposits_usd", 0) + amount, 2
)
save_state(state)
type_cn = "存入" if amount > 0 else "取出"
print(f"已记录{type_cn}: .2f")
def resume_trading():
"""Clear stop_triggered flag and resume trading."""
state = load_state()
if not state.get("stop_triggered"):
print("交易未停止,无需恢复")
return
old_trigger = state["stop_triggered"]
state.pop("stop_triggered", None)
state.pop("stop_notified", None)
save_state(state)
log(f"Trading resumed (was: {old_trigger})")
print(f"交易已恢复 (之前停止原因: {old_trigger})")
_send_discord_embed(
[
{
"title": "\u2705 交易已恢复",
"color": 0x00C853,
"description": f"之前停止原因: {old_trigger}",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
]
)
# ── Main ────────────────────────────────────────────────────────────────────
COMMANDS = {
"tick": tick,
"status": status,
"report": report,
"history": history_cmd,
"reset": reset,
"retry": retry,
"analyze": analyze,
"deposit": deposit,
"resume-trading": resume_trading,
}
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "tick"
handler = COMMANDS.get(cmd)
if handler:
handler()
else:
print(f"未知命令: {cmd}")
print(f"可用命令: {', '.join(COMMANDS.keys())}")
sys.exit(1)
FILE:references/grid-algorithm.md
# Grid Algorithm Reference
Detailed explanation of the core algorithms in Grid Trading v1.0.
## 1. Multi-Timeframe Analysis (MTF)
MTF provides trend context to all downstream decisions: grid width, position sizing, sell delay, and position limits.
### EMA Hierarchy
Three exponential moving averages computed from the 5-minute price history:
| EMA | Period | Timeframe | Role |
|-----|--------|-----------|------|
| Short | 5 bars | 25 minutes | Immediate price direction |
| Medium | 12 bars | 1 hour | Intraday trend |
| Long | 48 bars | 4 hours | Macro trend |
**EMA calculation**:
```python
def ema(prices, period):
k = 2 / (period + 1)
result = prices[0]
for p in prices[1:]:
result = p * k + result * (1 - k)
return result
```
### Trend Detection
**Alignment-based**:
- `short > medium > long` -> bullish
- `short < medium < long` -> bearish
- Otherwise -> neutral
**Strength** (0 to 1):
```
spread = (short - long) / long
strength = clamp(abs(spread) / 0.02, 0, 1)
```
A 2% spread between short and long EMA gives maximum strength (1.0).
### Structure Detection (8h Window)
Uses 96 bars (8 hours at 5-min intervals), split into 4 equal segments of 24 bars each.
```
Segment highs: H1, H2, H3, H4
Segment lows: L1, L2, L3, L4
If H1 < H2 < H3 < H4 AND L1 < L2 < L3 < L4 -> "uptrend"
If H1 > H2 > H3 > H4 AND L1 > L2 > L3 > L4 -> "downtrend"
Else -> "ranging"
```
### Momentum
```
momentum_1h = (price - price_12_bars_ago) / price_12_bars_ago * 100
momentum_4h = (price - price_48_bars_ago) / price_48_bars_ago * 100
```
### Output
```python
mtf = {
"trend": "bullish" | "bearish" | "neutral",
"strength": 0.0 - 1.0,
"momentum_1h": float, # percentage
"momentum_4h": float, # percentage
"structure": "uptrend" | "downtrend" | "ranging",
"ema_short": float,
"ema_medium": float,
"ema_long": float
}
```
---
## 2. K-line ATR Volatility
Supplements the price history stddev with OHLC-based volatility from 1-hour candles.
### True Range
For each candle:
```
TR = max(
high - low,
abs(high - prev_close),
abs(low - prev_close)
)
```
### ATR
```
ATR = mean(TR[i] for i in last N candles)
atr_pct = ATR / current_price * 100
```
### Usage in Grid Calculation
When K-line data is available, `kline_atr_pct` is blended with the price history stddev to get a more robust volatility estimate. The ATR captures intra-candle volatility that tick-based stddev may miss.
Cache TTL: 1 hour.
---
## 3. Dynamic Grid Calculation
### Grid Center
```python
# Prefer 1H kline for grid center (more robust than 5min ticks)
candles = get_kline_data(bar="1H", limit=EMA_PERIOD) # 20 hourly candles
if candles:
center = EMA([c.close for c in candles], EMA_PERIOD) # 20-hour EMA
else:
center = EMA(price_history, EMA_PERIOD) # fallback: 5min tick history
```
Grid center uses 1H kline EMA (20h lookback) instead of 5min tick EMA (100min lookback). This produces a more stable center that doesn't drift on short-term noise, better matching the 12-hour recalibration cycle.
### Trend-Adaptive Volatility Multiplier
```python
vol_mult = VOLATILITY_MULTIPLIER_BASE # 2.0
if mtf and mtf["strength"] > 0.3:
vol_mult = BASE + (TREND - BASE) * strength
# Range: 2.0 to 3.0
```
**Effect**: In strong trends, the grid becomes wider -> fewer trades -> bot holds position longer -> captures trend moves instead of selling too early.
### Asymmetric Buy/Sell Steps
```python
# Asymmetry scales with trend strength (only active when strength > 0.3)
asym = ASYM_FACTOR * strength # ASYM_FACTOR = 0.4
if trend == "bullish":
buy_mult = vol_mult * (1 - asym) # tighter → accumulate fast
sell_mult = vol_mult * (1 + asym) # wider → hold longer
elif trend == "bearish":
buy_mult = vol_mult * (1 + asym) # wider → wait for deeper dips
sell_mult = vol_mult * (1 - asym) # tighter → exit fast
else:
buy_mult = sell_mult = vol_mult # symmetric
```
**Example** (bullish, strength=0.6, vol_mult=2.6, ATR=$50):
- `asym = 0.4 × 0.6 = 0.24`
- `buy_mult = 2.6 × 0.76 = 1.98` → `buy_step = $33`
- `sell_mult = 2.6 × 1.24 = 3.22` → `sell_step = $54`
- Buy side is 38% tighter than sell side
### Step Calculation
```python
# Use 1H ATR for step sizing (more robust than stddev for extreme moves)
atr_pct = calc_kline_volatility(candles) # ATR as % of price
atr_dollar = atr_pct / 100 * current_price
# Directional steps
buy_step = (buy_mult * atr_dollar) / (GRID_LEVELS / 2)
sell_step = (sell_mult * atr_dollar) / (GRID_LEVELS / 2)
# Clamp both to bounds
buy_step = clamp(buy_step, price * STEP_MIN_PCT, price * STEP_MAX_PCT)
sell_step = clamp(sell_step, price * STEP_MIN_PCT, price * STEP_MAX_PCT)
buy_step = max(buy_step, 5.0) # hard floor: $5
sell_step = max(sell_step, 5.0)
step = (buy_step + sell_step) / 2 # backward-compatible average
```
### Level Construction
`_build_level_prices()` constructs non-uniform grids:
```python
def _build_level_prices(center, buy_step, sell_step, half, grid_type):
"""Below center: spaced by buy_step. Above center: spaced by sell_step."""
if grid_type == "geometric":
buy_ratio = 1 + (buy_step / center)
sell_ratio = 1 + (sell_step / center)
below = [center / (buy_ratio ** (half - i)) for i in range(half)]
above = [center * (sell_ratio ** (i + 1)) for i in range(half)]
else: # arithmetic
below = [center - (half - i) * buy_step for i in range(half)]
above = [center + (i + 1) * sell_step for i in range(half)]
return below + [center] + above
```
**Symmetric example** (center=$2000, step=$33):
```
[1901, 1934, 1967, 2000, 2033, 2066, 2099]
← $33 spacing → ← $33 spacing →
```
**Asymmetric example** (center=$2000, buy_step=$33, sell_step=$54):
```
[1901, 1934, 1967, 2000, 2054, 2108, 2162]
← $33 spacing → ← $54 spacing →
```
### Level Lookup
```python
import bisect
current_level = bisect.bisect_right(grid["level_prices"], price) - 1
# Clamped to [0, GRID_LEVELS]
```
---
## 4. Grid Recalibration
The grid recalibrates when market conditions shift significantly. Recalibration is **asymmetric** to prevent chasing spikes.
### Trigger Conditions
| Trigger | Condition | Behavior |
|---------|-----------|----------|
| Downside breakout | `price < grid_lower - buy_step` | Recalibrate **immediately** |
| Upside breakout | `price > grid_upper + sell_step` | Require `UPSIDE_CONFIRM_TICKS` (6) consecutive ticks above |
| Volatility shift | `abs(current_kline_atr - grid_atr) / grid_atr > 0.3` | Recalibrate |
| Age | `hours_since_grid_set > GRID_RECALIBRATE_HOURS` (12h) | Recalibrate |
### Anti-Chase Mechanism
For upside breakouts:
1. `upside_breakout_ticks` counter increments each tick price stays above grid
2. If price returns to grid range before reaching threshold, counter **resets to 0**
3. Even after confirmation, center shift is capped:
```python
new_center = clamp(
calculated_center,
old_center * (1 - MAX_CENTER_SHIFT_PCT),
old_center * (1 + MAX_CENTER_SHIFT_PCT)
)
```
4. Multiple recalibrations can gradually track a real trend, but a single spike cannot drag the grid
---
## 5. Trend-Adaptive Position Sizing
### Strategy: `trend_adaptive`
The default sizing strategy adjusts trade amounts based on trend direction:
```
BULLISH trend:
BUY -> larger (accumulate during uptrend)
SELL -> smaller (preserve position)
BEARISH trend:
BUY -> smaller (cautious buying)
SELL -> larger (reduce exposure)
NEUTRAL:
Equal sizing
```
### Multiplier Calculation
```python
def _calc_sizing_multiplier(level, grid_levels, direction, mtf):
base_mult = 1.0
if mtf:
trend = mtf["trend"]
strength = mtf["strength"]
if trend == "bullish":
if direction == "BUY":
base_mult = 1.0 + strength * (MAX - 1.0)
else: # SELL
base_mult = 1.0 - strength * (1.0 - MIN)
elif trend == "bearish":
if direction == "SELL":
base_mult = 1.0 + strength * (MAX - 1.0)
else: # BUY
base_mult = 1.0 - strength * (1.0 - MIN)
return clamp(base_mult, SIZING_MULTIPLIER_MIN, SIZING_MULTIPLIER_MAX)
```
### Trade Amount
```python
available_eth = eth_balance - GAS_RESERVE_ETH
total_usd = available_eth * price + usdc_balance
max_usd = total_usd * MAX_TRADE_PCT * multiplier
if direction == "SELL":
amount = min(max_usd / price, available_eth)
return int(amount * 1e18) # wei
else: # BUY
amount = min(max_usd, usdc_balance * 0.95)
return int(amount * 1e6) # micro-USDC
```
---
## 6. Sell Trailing Optimization
Sell delay is applied in strong uptrends to avoid premature profit-taking.
### Logic Flow
```
SELL signal detected
|
v
Is momentum_1h > SELL_MOMENTUM_THRESHOLD (0.5%) AND trend == "bullish"?
|-- Yes -> SKIP sell ("trend_hold")
|-- No -> Check trailing counter
|
v
sell_trail_counter[level_key] < SELL_TRAIL_TICKS (2)?
|-- Yes -> Increment counter, SKIP ("sell_trail N/2")
|-- No -> Counter satisfied, EXECUTE sell
```
The `structure == "uptrend"` condition was removed from momentum protection.
The 8h strict monotonic structure detection was never satisfied in production (always "ranging"),
making the momentum protection dead code. Now only requires bullish trend + strong momentum.
### Key Properties
- **Level-specific**: Each level transition (e.g., "2->3") has its own counter
- **Counter resets**: If price returns to a lower level, the counter for that transition resets
- **Maximum delay**: 2 ticks = 10 minutes at 5-min intervals
- **Momentum override**: Strong momentum (>0.5% in 1h) can block sell indefinitely while bullish trend holds
---
## 7. HODL Alpha Tracking
Measures whether the grid strategy outperforms simple ETH holding.
```python
initial_eth_price = state["stats"]["initial_eth_price"] # recorded at bot start
initial_portfolio = state["stats"]["initial_portfolio_usd"]
# What HODL would be worth now
initial_eth_amount = initial_portfolio / initial_eth_price
hodl_value = initial_eth_amount * current_price
# Grid alpha
alpha = current_portfolio_usd - hodl_value
```
**Interpretation**:
- `alpha > 0`: Grid is outperforming HODL (good in ranging/declining markets)
- `alpha < 0`: HODL would have been better (expected in strong uptrends)
- In a +9% uptrend backtest, alpha was -5.05% — the trend-adaptive features aim to minimize this gap