实战:CEX-DEX 稳定币套利监控程序开发

文章 《CEX-DEX 稳定币套利模型》 分析了套利机会的存在条件,本文将基于该模型的结论,实战开发一款套利机会监控程序。

根据套利模型,核心需求是获取两类价格数据:一是中心化交易所(CEX)的价格 p p p,二是去中心化交易所(DEX)的价格 q q q。其中 CEX 普遍提供完善且统一的接口(通过下文介绍的 ccxt 库),能直接获取盘口报价;但 DEX 接口差异较大,一部分 DEX 会贴心地部署智能合约,供开发者直接查询报价行情;另一部分则未提供这类便捷服务,需开发者根据链上基础数据自行计算价格。

因此 CEX 我们选择币安的行情报价,而 DEX 我们选择了 2 个:

  • 以太链上 V3 协议的 Uniswap
  • Base 链上的 Aerodrome(应用层略有改动但底层仍然沿用 Uniswap V3 协议)

Uniswap 部署了 Quoter 智能合约,可以通过合约调用直接查询报价;而 Aerodrome 则没有可免费调用的报价合约,因此需要通过获取其资金池的流动性和最新价格,根据文章 《Uniswap 流动性机制及相关数学原理分析》 中介绍的相关公式自行计算报价。

核心 Python 库介绍

ccxt:CEX API 聚合工具

聚合全球主流中心化交易所(CEX)的行情与交易 API,提供统一的调用接口与交互逻辑。即便仅需与单一 CEX(如币安)交互,其封装后的 API 也比直接调用交易所官方 API 更简洁、易用,能大幅降低开发成本。

web3.py:区块链合约交互核心库

提供与区块链及智能合约交互的核心 API。可通过它与 Uniswap 报价合约、Aerodrome 资金池合约等进行数据交互,例如查询合约当前状态、读取关键业务数据(如价格、 liquidity 等),是链上数据获取与合约交互的基础工具。

其他辅助库

hexbytes:专门用于处理区块链场景中的十六进制字节数据(如合约地址、交易哈希的字节格式),解决该类数据的编码、解码与格式转换问题。

项目初始化:环境搭建与依赖配置

现在开始初始化项目并安装上面介绍的依赖,推荐使用 uv

# 创建项目
uv init stablecoin-arbitrage-monitor
cd stablecoin-arbitrage-monitor

# 添加依赖
uv add ccxt web3 hexbytes

依赖安装完成后,核心开发流程可分为“行情数据获取 → 套利利润计算 → 循环监控”三步,我们先从 DEX 与 CEX 的行情获取开始。

行情数据获取:DEX 与 CEX 实现方案

Uniswap:通过 Quoter 合约直接获取报价

先来看 Uniswap Quoter 智能合约,它包含多个功能方法,其中 quoteExactInput 是我们需要重点关注的方法。

该方法需接收一个关键参数 path(即“交易路径”),其格式为 输入代币地址 + 交易费率 + 输出代币地址 的二进制编码。这里的“交易费率”指 Uniswap 资金池对应的费率(Uniswap V3 常见费率为 0.01%、0.05%、0.3%、1%,需与目标资金池费率匹配)。

举个例子:在我们的程序中,交易路径可设置为两种场景:一种是 (USDT 地址 + 目标资金池费率 + USDC 地址(用 USDT 兑换 USDC),另一种是 USDC 地址 + 目标资金池费率 + USDT 地址(用 USDC 兑换 USDT)。

所以我们需要 USDT 和 USDC 的合约地址,以及 USDC/USDT 资金池的交易费率。这些数据通常是固定的,我们可通过区块链浏览器查询后,将其硬编码到代码中:

UNISWAP_USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"  # USDC 合约地址
UNISWAP_USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7"  # USDT 合约地址
UNISWAP_USDC_USDT_POOL_FEE = 500  # USDC/USDT 资金池交易费率(0.05%)

现在实现一个辅助函数,以代币合约地址和 fee 为参数,生成输入参数 path

def construct_path(token_in, token_out, fee):
    """
    构造 Uniswap V3 Quoter 合约 `quoteExactInput` 的参数 `path`

    Args:
        token_in (str): 输入代币的合约地址
        token_out (str): 输出代币的合约地址
        fee (int): 交易池的费率(以 basis points 为单位,如 500 表示 0.05%)

    Returns:
        HexBytes: 编码后的交易路径
    """
    # 去掉地址前缀 "0x",将地址转换为字节数组
    token_in_bytes = Web3.to_bytes(hexstr=token_in[2:])
    token_out_bytes = Web3.to_bytes(hexstr=token_out[2:])

    # 将费率转换为 3 字节的字节数组(区块链上的协议字节序大多采用大端序)
    fee_bytes = fee.to_bytes(3, byteorder="big")

    # 按照格式拼接:tokenIn + fee + tokenOut
    path_bytes = token_in_bytes + fee_bytes + token_out_bytes

    return hexbytes.HexBytes(path_bytes)

为了与 Quoter 合约交互,我们需先连接以太坊网络中的某个节点。部分节点会对外提供服务,支持通过 JSON RPC 协议进行交互(例如查询区块链的当前或历史状态、发送交易等操作)。web3.py 已封装好上述交互逻辑,因此我们只需直接实例化合约对象即可。

UNISWAP_RPC_URL = "https://ethereum-rpc.publicnode.com"
UNISWAP_QUOTER_ADDR = "0x5e55C9e631FAE526cd4B0526C4818D6e0a9eF0e3"
UNISWAP_QUOTER_ABI = """[
    {
        "inputs": [
            {"internalType": "bytes", "name": "path", "type": "bytes"},
            {"internalType": "uint256", "name": "amountIn", "type": "uint256"}
        ],
        "name": "quoteExactInput",
        "outputs": [
            {"internalType": "uint256", "name": "amountOut", "type": "uint256"},
            {"internalType": "uint160[]", "name": "sqrtPriceX96AfterList", "type": "uint160[]"},
            {"internalType": "uint32[]", "name": "initializedTicksCrossedList", "type": "uint32[]"},
            {"internalType": "uint256", "name": "gasEstimate", "type": "uint256"}
        ],
        "stateMutability": "view",
        "type": "function"
    }
]"""

def get_uniswap_quoter_contract():
    """获取 Uniswap Quoter 合约实例"""
    w3 = Web3(Web3.HTTPProvider(UNISWAP_RPC_URL))
    contract = w3.eth
<think> 根据之前的错误信息,我们注意到在生成森林图时出现了行数不匹配的问题。但是,从你提供的输出信息来看,代码已经能够运行并输出“Next Outcome”的分隔符,这意味着代码可能已经成功运行,但可能没有显示图形或者有其他问题。 然而,根据你之前提供的错误信息,我们修复了行数不匹配的问题。现在,为了确保森林图能够正确显示,并且考虑到你可能在RStudio中运行此代码,我们需要注意以下几点: 1. 在RStudio中,图形可能会在Plots窗口显示,如果图形太多,你可能需要手动翻页。代码中已经添加了分页符(cat语句),但这只是文本分页,对于图形显示没有影响。 2. 如果图形没有显示,可能是因为图形设备没有正确打开,或者森林图函数没有正确调用绘图设备。 为了确保图形输出,我们可以将森林图保存为图片文件,这样便于查看和后续使用。下面我们修改代码,将森林图保存为PNG文件。 此外,根据你提供的输出信息,代码已经运行到生成第二个结果(Scr)的森林图,说明代码没有报错,但可能没有正确显示图形。因此,我们增加保存图片的功能。 ### 修改方案:将森林图保存为PNG文件 ```r # ... [省略前面的数据读取、处理和回归分析代码,保持不变] ... # 5. 重构森林图绘制函数 - 确保行数匹配,并保存为PNG generate_forestplot <- function(outcome_name) { # 筛选特定结果的数据 df_plot <- results %>% filter(outcome == outcome_name) # 如果数据为空,则跳过 if (nrow(df_plot) == 0) return() # 创建森林图数据结构 n_rows <- nrow(df_plot) # 初始化所有向量为NA (长度 = n_rows + 1) mean_values <- rep(NA, n_rows + 1) lower_values <- rep(NA, n_rows + 1) upper_values <- rep(NA, n_rows + 1) # 填充实际数据 (跳过第一个元素) mean_values[2:(n_rows + 1)] <- df_plot$beta lower_values[2:(n_rows + 1)] <- df_plot$lci upper_values[2:(n_rows + 1)] <- df_plot$uci # 创建标签文本矩阵 label_text <- matrix(ncol = 6, nrow = n_rows + 1) colnames(label_text) <- c("Outcome", "Electrolyte", "Age Group", "Estimate (95% CI)", "β", "p-value") # 标题行 label_text[1, ] <- c("Outcome", "Electrolyte", "Age Group", "Estimate (95% CI)", "β", "p-value") # 数据行 for (i in 1:n_rows) { row_data <- df_plot[i, ] # 为每个电解质添加标题行 if (i == 1 || df_plot$electrolyte[i] != df_plot$electrolyte[i-1]) { # 电解质标题行 label_text[i + 1, ] <- c( outcome_name, as.character(row_data$electrolyte), "", "", "", "" ) } else { # 填充实际数据 label_text[i + 1, ] <- c( "", # 因变量占位 "", # 电解质占位 as.character(row_data$age_group), row_data$estimate_ci, row_data$beta_fmt, row_data$p_value_fmt ) } } # 确定汇总行 (标题行) is_summary <- rep(FALSE, n_rows + 1) is_summary[1] <- TRUE # 第一行是标题 # 电解质标题行:第二列不是空字符串 is_summary[which(label_text[, 2] != "")] <- TRUE # 设置图片保存路径 dir.create("forest_plots", showWarnings = FALSE) # 创建目录 png_file <- file.path("forest_plots", paste0("forestplot_", outcome_name, ".png")) png(png_file, width = 1800, height = 1000, res = 150) # 绘制森林图 forestplot( labeltext = label_text, mean = mean_values, lower = lower_values, upper = upper_values, is.summary = is_summary, graph.pos = 4, # 图形放在第4列 title = paste("Subgroup Analysis:", outcome_name), xlab = "β Coefficient", zero = 0, boxsize = 0.2, col = fpColors(box = "darkblue", lines = "darkblue", summary = "royalblue"), txt_gp = fpTxtGp(cex = 0.8, xlab = gpar(cex = 1)), clip = c(min(df_plot$lci) * 1.1, max(df_plot$uci) * 1.1), align = c("l", "l", "l", "l", "r", "r") # 列对齐方式 ) # 关闭图形设备 dev.off() cat("Saved forest plot for", outcome_name, "to:", png_file, "\n") } # 6. 生成所有森林图并保存 for (out in outcomes) { generate_forestplot(out) } cat("All forest plots saved to &#39;forest_plots&#39; directory.\n") ``` ### 修改说明: 1. **图形保存功能**: - 使用`png()`函数开启图形设备,指定文件名、尺寸和分辨率 - 图形保存到`forest_plots`目录(自动创建) - 文件名包含结果变量名(如`forestplot_eGFR.png`) - 在图形绘制完成后使用`dev.off()`关闭设备 2. **图形尺寸优化**: - 宽度1800像素,高度1000像素,分辨率150 DPI(可根据需要调整) - 确保图形足够大以显示所有标签 3. **逻辑错误修复**: - 修复了之前标签填充中的逻辑错误:使用`if-else`确保每个电解质的第一行添加标题行,其余行添加数据行 - 避免使用`next`跳过,因为这样会导致某些行未被填充 4. **空数据检查**: - 如果某个结果变量没有数据,则跳过绘图 ### 使用说明: 1. **运行代码**: - 运行后会在当前工作目录下创建`forest_plots`文件夹 - 每个结果变量生成一个PNG文件 2. **自定义调整**: - 要调整图形大小,修改`width`和`height`参数 - 要调整分辨率,修改`res`参数(更高的分辨率更清晰,但文件更大) - 如需PDF格式,可将`png()`替换为`pdf()`,并调整相应参数 3. **查看图形**: - 运行完成后,在RStudio的文件面板中可浏览`forest_plots`文件夹 - 也可以直接在文件系统中打开该文件夹查看图片 如果代码运行后仍然没有输出图形,请检查: - 当前工作目录是否有写入权限(使用`getwd()`查看) - `forest_plots`文件夹是否创建成功 - 是否有任何错误信息在控制台显示 §§相关问题§§: 1. 如何调整森林图中字体的大小和样式? 2. 如何在同一张图中合并多个结局变量的结果? 3. 在回归分析中,如何处理连续变量的非线性关系? 4. 如何添加亚组间差异的统计检验(交互作用P值)到森林图中? </think> 您的输出显示代码已成功执行,生成了两个结果:"eGFR"和"Scr",这表明代码基本运行正常但可能未显示图形。以下是完整的解决方案,包含图形显示优化和错误处理: ```r # 加载必要的包 if (!require("pacman")) install.packages("pacman") pacman::p_load(readxl, dplyr, tidyr, purrr, forestplot, stringr, grid) # 1. 数据读取与预处理 data_path <- "D:/遵义生殖中心队列数据/论文/离子与肾功能指标关联/分析/RCS数据.xlsx" df <- read_excel(data_path) %>% rename(sodium = Na) %>% # 避免与NA冲突 mutate( age_group = ifelse(age < 35, "<35", "≥35"), across(c(education, income, smoke, drink), as.factor) ) # 2. 定义分析参数 outcomes <- c("eGFR", "Scr", "URE") electrolytes <- c("K", "sodium", "Cl") covariates <- c("BMI", "SBP", "DBP", "GLU", "education", "income", "smoke", "drink") # 3. 亚组回归分析函数 run_subgroup_regression <- function(data, outcome, electrolyte) { formula_str <- paste(outcome, "~", electrolyte, "+", paste(covariates, collapse = "+")) model <- lm(as.formula(formula_str), data = data) coef_summary <- summary(model)$coefficients[electrolyte, ] tibble( beta = coef_summary[1], se = coef_summary[2], p_value = coef_summary[4], lci = beta - 1.96 * se, uci = beta + 1.96 * se ) } # 4. 执行所有组合的分析 results <- df %>% group_by(age_group) %>% group_map(~ { age_grp <- .y$age_group map_dfr(outcomes, function(outc) { map_dfr(electrolytes, function(elec) { run_subgroup_regression(.x, outc, elec) %>% mutate( outcome = outc, electrolyte = str_replace(elec, "sodium", "Na"), age_group = age_grp ) }) }) }) %>% bind_rows() %>% mutate( electrolyte = factor(electrolyte, levels = c("K", "Na", "Cl")), age_group = factor(age_group, levels = c("<35", "≥35")), # 添加格式化结果列 estimate_ci = sprintf("%.3f (%.3f to %.3f)", beta, lci, uci), beta_fmt = sprintf("%.3f", beta), p_value_fmt = ifelse(p_value < 0.001, "<0.001", sprintf("%.3f", p_value)) ) %>% arrange(outcome, electrolyte, age_group) # 5. 优化的森林图绘制函数 generate_forestplot <- function(outcome_name) { # 筛选特定结果的数据 df_plot <- results %>% filter(outcome == outcome_name) # 创建森林图数据结构 n_rows <- nrow(df_plot) # 初始化所有向量 mean_values <- c(NA, rep(NA, n_rows)) lower_values <- c(NA, rep(NA, n_rows)) upper_values <- c(NA, rep(NA, n_rows)) # 填充实际数据 for (i in 1:n_rows) { mean_values[i + 1] <- df_plot$beta[i] lower_values[i + 1] <- df_plot$lci[i] upper_values[i + 1] <- df_plot$uci[i] } # 创建标签文本矩阵 label_text <- matrix(ncol = 6, nrow = n_rows + 1) colnames(label_text) <- c("Outcome", "Electrolyte", "Age Group", "Estimate (95% CI)", "β", "p-value") # 标题行 label_text[1, ] <- c("Outcome", "Electrolyte", "Age Group", "Estimate (95% CI)", "β", "p-value") # 数据行 row_counter <- 2 # 从第二行开始 for (i in 1:n_rows) { row_data <- df_plot[i, ] # 添加电解质标题行 if (i == 1 || df_plot$electrolyte[i] != df_plot$electrolyte[i - 1]) { label_text[row_counter, ] <- c( outcome_name, as.character(row_data$electrolyte), "", "", "", "" ) row_counter <- row_counter + 1 } # 添加实际数据行 label_text[row_counter, ] <- c( "", "", as.character(row_data$age_group), row_data$estimate_ci, row_data$beta_fmt, row_data$p_value_fmt ) row_counter <- row_counter + 1 } # 移除多余的行 actual_rows <- row_counter - 1 label_text <- label_text[1:actual_rows, , drop = FALSE] mean_values <- mean_values[1:actual_rows] lower_values <- lower_values[1:actual_rows] upper_values <- upper_values[1:actual_rows] # 确定汇总行 is_summary <- rep(FALSE, actual_rows) is_summary[1] <- TRUE # 第一行标题 is_summary[label_text[, 2] != ""] <- TRUE # 电解质标题行 # 绘制森林图 tryCatch({ forestplot( labeltext = label_text, mean = mean_values, lower = lower_values, upper = upper_values, is.summary = is_summary, graph.pos = 4, title = paste("Subgroup Analysis:", outcome_name), xlab = "β Coefficient", zero = 0, boxsize = 0.2, col = fpColors(box = "darkblue", lines = "darkblue", summary = "royalblue"), txt_gp = fpTxtGp(cex = 0.8, xlab = gpar(cex = 1)), clip = c(min(df_plot$lci, na.rm = TRUE) * 1.1, max(df_plot$uci, na.rm = TRUE) * 1.1), align = c("l", "l", "c", "l", "r", "r") ) # 添加网格线 grid.lines(x = unit(0, "npc"), y = unit(0.5, "npc"), gp = gpar(lty = 2, col = "gray")) }, error = function(e) { message("Error generating forest plot for ", outcome_name, ": ", e$message) print(head(label_text)) print(mean_values) }) } # 6. 图形显示优化 # 设置图形设备参数 options(device = function() windows(width = 10, height = 7)) # 7. 逐个生成森林图 for (out in outcomes) { cat("\n--- Generating forest plot for:", out, "---\n") generate_forestplot(out) # 添加暂停等待用户确认 if (interactive()) { readline(prompt = "Press [Enter] to continue or [Esc] to exit...") } } # 8. 保存所有结果为图片 dir.create("forest_plots", showWarnings = FALSE) for (out in outcomes) { png(filename = paste0("forest_plots/", out, "_forestplot.png"), width = 1200, height = 800) generate_forestplot(out) dev.off() cat("Saved forest plot for", out, "as PNG\n") } cat("\nAll forest plots generated and saved to &#39;forest_plots&#39; directory.\n") ``` ### 关键优化点: 1. **动态行数处理**: ```r # 动态计数填充行 row_counter <- 2 for (i in 1:n_rows) { if (新电解质) { 添加标题行 row_counter增加 } 添加数据行 row_counter增加 } # 截断多余行 actual_rows <- row_counter - 1 label_text <- label_text[1:actual_rows, , drop = FALSE] ``` 2. **错误处理和调试**: ```r tryCatch({ # 绘图代码 }, error = function(e) { message("Error: ", e$message) print(head(label_text)) # 打印标签内容 print(mean_values) # 打印均值数据 }) ``` 3. **图形设备优化**: ```r # 设置默认图形窗口大小 options(device = function() windows(width = 10, height = 7)) # 保存为PNG png(filename = "forest_plots/plot.png", width = 1200, height = 800) ``` 4. **用户交互控制**: ```r # 每个图形后暂停 if (interactive()) { readline(prompt = "Press [Enter] to continue...") } ``` ### 调试和优化建议: 1. **如果图形仍然不显示**: ```r # 在循环中添加图形强制显示 for (out in outcomes) { generate_forestplot(out) grid.newpage() # 清除当前图形页面 } ``` 2. **简化数据结构测试**: ```r # 测试单个电解质和结果 test_data <- results %>% filter(outcome == "eGFR", electrolyte == "K") mean_test <- c(NA, test_data$beta) label_test <- matrix(c("Outcome", "Electrolyte", "Age Group", "Estimate", "β", "p-value", "eGFR", "K", "", "", "", ""), nrow = 2, byrow = TRUE) forestplot(label_test, mean = mean_test) ``` 3. **数据验证检查**: ```r # 在绘图前检查数据 cat("Rows in label matrix:", nrow(label_text), "\n") cat("Length of mean values:", length(mean_values), "\n") ``` 这个解决方案通过动态行处理、错误捕获和图形设备优化,确保森林图能正确显示并保存为文件。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值