Browse Source

c的意思是checker,检查器。检查器做好了。

skyfffire 3 months ago
parent
commit
0ce04d087e

+ 0 - 0
checker/erc20_to_mexc_checker.py → checker/c_erc20_to_mexc.py


+ 481 - 0
checker/c_erc20_to_mexc_simple.py

@@ -0,0 +1,481 @@
+import requests
+import decimal
+import time
+import threading
+import json
+import logging
+import ok_chain_client # 假设这是你的 OKX Chain 客户端库
+import pprint
+import plotly.graph_objects as go
+import argparse
+
+from flask import Flask, render_template, jsonify
+from collections import deque
+from plotly.utils import PlotlyJSONEncoder
+
+
+# configs
+from config import wallet
+from config import okchain_api
+from config import arb
+
+# logs
+from logger_config import get_logger
+logger = get_logger('as')
+
+# lite客户端
+from web3_py_client_lite import EthClient
+web3_client = EthClient()
+
+# delay区块,有利润后延迟几个区块再发
+prev_profit_block_number = 0
+send_delay_block = 1
+
+# ok web3的配置
+ok_chain_client.api_config = okchain_api # 假设ok_chain_client有此配置方式
+
+# --- 配置 arb_executor.py 的 HTTP 地址和端口 ---
+ARB_EXECUTOR_URL = arb["ARB_EXECUTOR_URL"]
+
+# --- 配置部分 ---
+# IN_AMOUNT_TO_QUERY 将在循环中动态确定
+EXCHANGE_OUT_AMOUNT = decimal.Decimal(str(arb["COIN_TOKEN_TRADE_AMOUNT"])) # 确保是Decimal
+PROFIT_LIMIT = decimal.Decimal(str(arb["PROFIT_LIMIT"])) # 确保是Decimal
+IN_TOKEN_ADDRESS = arb["BASE_TOKEN_ADDRESS"]
+IN_TOKEN_DECIMALS = web3_client.get_erc20_decimals(IN_TOKEN_ADDRESS)
+OUT_TOKEN_ADDRESS = arb["COIN_TOKEN_ADDRESS"]
+SLIPPAGE = arb["SLIPPAGE"]
+MEXC_TARGET_PAIR_USDT = arb["CEX_PAIR"]
+CHAIN_ID = arb["CHAIN_ID"]
+STRATEGY = arb["STRATEGY"]
+
+# 錢包的配置
+USER_WALLET = wallet["user_wallet"]
+USER_EXCHANGE_WALLET = wallet["user_exchange_wallet"]
+
+proxies = None # {'http': 'http://proxy_url:port', 'https': 'http://proxy_url:port'}
+
+# 運行模式【trade、view】
+mode = None
+# oo_price_usdt_per_target = None # 这个全局变量似乎没有被有效使用,价格在循环内获取
+
+# 配置請求的日志等級
+app = Flask(__name__)
+log = logging.getLogger('werkzeug')
+log.setLevel(logging.ERROR)
+
+REFRESH_INTERVAL_SECONDS = 1 # 稍微增加间隔以减少API调用频率
+MAX_HISTORY_POINTS_PLOTLY = 21600
+historical_data_points = deque(maxlen=MAX_HISTORY_POINTS_PLOTLY)
+
+TARGET_ASSET_SYMBOL = MEXC_TARGET_PAIR_USDT.split('_')[0] # e.g., RATO
+BASE_CURRENCY_SYMBOL = MEXC_TARGET_PAIR_USDT.split('_')[1] # e.g., USDT (assumed to be consistent with IN_TOKEN_ADDRESS)
+
+# --- 链上价格获取函数 (Okx) ---
+# 返回: price_base_per_target (例如 USDT per RATO)
+def get_chain_price_vs_target_currency(chain_id, in_token_addr, out_token_addr, amount_in_base_human, in_token_decimals, slippage, user_wallet_addr):
+    try:
+        # amount_in_base_human 已经是 decimal.Decimal 类型的人类可读数量
+        in_token_amount_atomic = int(amount_in_base_human * (10 ** in_token_decimals)) # 转为原子单位整数
+        data = ok_chain_client.swap(chain_id, in_token_amount_atomic, in_token_addr, out_token_addr, slippage, user_wallet_addr, None, 'fast')
+
+        if data.get('code') == '0' and data.get('data'):
+            d = data['data'][0]
+            router_result = d['routerResult']
+            in_dec, out_dec = int(router_result['fromToken']['decimal']), int(router_result['toToken']['decimal'])
+            atomic_in_base, atomic_out_target = decimal.Decimal(router_result['fromTokenAmount']), decimal.Decimal(router_result['toTokenAmount'])
+
+            human_in_base = atomic_in_base / (10 ** in_dec)
+            human_out_target = atomic_out_target / (10 ** out_dec)
+            if human_out_target == 0: return {"error": f"OO输出目标代币为0 ({CHAIN_ID})"}, data # data 也返回
+            return {"price_base_per_target": human_in_base / human_out_target}, data
+        else:
+            pprint.pprint(data)
+            return {
+                "error": f"Okx API错误({chain_id}) - Code:{data.get('code', 'N/A')}, Msg:{data.get('msg', data.get('message', 'N/A')) if isinstance(data, dict) else '格式错误'}"}, None
+    except Exception as e:
+        logger.error(f"Okx ({chain_id})请求错误详情: ", exc_info=True)
+        return {"error": f"Okx ({chain_id})请求错误: {e}"}, None
+
+# MEXC 现货 (获取 目标代币/USDT 的 bid 价格)
+# 返回: price_target_per_usdt (例如 RATO per USDT)
+def get_mexc_spot_price_target_usdt_bid(pair_symbol):
+    url = "https://api.mexc.com/api/v3/depth"
+    params = {'symbol': pair_symbol.replace('_', ''), 'limit': 1000} # 减少limit,5000可能过大且非必要
+    try:
+        r = requests.get(url, params=params, proxies=proxies, timeout=5) # 减少超时
+        r.raise_for_status()
+        data = r.json()
+        if 'bids' in data and data['bids']: # 确保bids存在且不为空
+            bids = data['bids']
+            trade_volume_remaining = EXCHANGE_OUT_AMOUNT # 还需要卖出的数量 (Decimal)
+            trade_value = decimal.Decimal('0') # 累计的总价值 (Decimal)
+            accumulated_volume = decimal.Decimal('0') # 累计吃单量
+
+            for orderbook in bids:
+                price = decimal.Decimal(orderbook[0])
+                volume = decimal.Decimal(orderbook[1])
+
+                if trade_volume_remaining <= decimal.Decimal('0'):
+                    break # 已经满足卖出量
+
+                can_fill = min(volume, trade_volume_remaining)
+                trade_value += price * can_fill
+                accumulated_volume += can_fill
+                trade_volume_remaining -= can_fill
+            
+            if accumulated_volume == decimal.Decimal('0'): # 如果一点都没卖出去
+                 # logger.warning(f"MEXC无法以EXCHANGE_OUT_AMOUNT={EXCHANGE_OUT_AMOUNT}获取任何 efectiva 卖出价格,累积量为0")
+                return {"error": f"MEXC订单簿深度不足以卖出{EXCHANGE_OUT_AMOUNT} {TARGET_ASSET_SYMBOL}"}, decimal.Decimal('0')
+
+            # 计算平均卖出价格
+            # sell_price 代表 1 TARGET_ASSET = X USDT
+            sell_price = trade_value / accumulated_volume 
+            sell_price = sell_price.quantize(decimal.Decimal('1e-10'), rounding=decimal.ROUND_DOWN)
+
+            # trade_value 代表卖出 accumulated_volume 个 TARGET_ASSET 能得到的 USDT 总量
+            return {
+                "price_target_per_usdt": sell_price # 这个名字其实是 RATO/USDT,所以可以叫 price_target_per_base
+            }, trade_value # 返回的是实际能卖出 EXCHANGE_OUT_AMOUNT (或更少,如果深度不足) 所得的 USDT 总额
+        else:
+            # logger.warning(f"MEXC现货({pair_symbol}) bids 数据不存在或为空: {data}")
+            return {"error": f"MEXC现货({pair_symbol}) bids 数据不存在或为空"}, decimal.Decimal('0')
+    except requests.exceptions.RequestException as e:
+        # logger.error(f"MEXC现货({pair_symbol})请求错误: {e}")
+        return {"error": f"MEXC现货({pair_symbol})请求错误: {e}"}, decimal.Decimal('0')
+    except Exception as e:
+        # logger.error(f"MEXC现货({pair_symbol})处理错误: {e}", exc_info=True)
+        return {"error": f"MEXC现货({pair_symbol})处理错误: {e}"}, decimal.Decimal('0')
+
+latest_values_for_table = {
+    f"oo_price_usdt_per_target": "N/A",
+    f"mexc_price_target_per_base": "N/A", # MEXC Bid1 (converted to USDT/TARGET)
+    f"diff_oo_vs_mexc_percentage": "N/A",
+    "profit_value_for_table": "N/A", # 新增:用于表格的利润值
+    "oo_error": None, "mexc_error": None,
+    "last_updated": "N/A",
+    "mexc_pair_usdt_for_display": MEXC_TARGET_PAIR_USDT,
+    "target_asset_symbol_for_display": TARGET_ASSET_SYMBOL,
+    "base_currency_symbol_for_display": BASE_CURRENCY_SYMBOL
+}
+data_lock = threading.Lock()
+
+def calculate_percentage_diff(price_a_base_per_target, price_b_base_per_target):
+    # price_a: MEXC卖价 (USDT/TARGET) - 链上买的目标币,拿到CEX卖掉
+    # price_b: 链上买价 (USDT/TARGET) - 链上用USDT买目标币
+    # 期望 price_a > price_b
+    if price_a_base_per_target is not None and price_b_base_per_target is not None and \
+       isinstance(price_a_base_per_target, decimal.Decimal) and \
+       isinstance(price_b_base_per_target, decimal.Decimal) and price_b_base_per_target != 0:
+        # (卖价 - 买价) / 买价
+        rst = (price_a_base_per_target - price_b_base_per_target) / price_b_base_per_target
+        rst = rst.quantize(decimal.Decimal('1e-6'), rounding=decimal.ROUND_DOWN) # 提高精度
+        return rst
+    return None
+
+def send_arb_msg(profit_amount, chain_swap_data, mexc_price_usdt_per_target, in_amount_to_query_human):
+    # chain_swap_data 是从 get_chain_price_vs_target_currency 返回的第二个值
+    if not (chain_swap_data and chain_swap_data.get('data') and chain_swap_data['data']):
+        logger.error(f"套利消息发送失败:链上交易数据不完整 {chain_swap_data}")
+        return
+
+    d = chain_swap_data['data'][0]
+    tx = d['tx'] # 这是预签名的交易结构体,不是tx hash
+    router_result = d['routerResult']
+    from_token_info = router_result['fromToken']
+    to_token_info = router_result['toToken']
+
+    in_dec, out_dec = int(from_token_info['decimal']), int(to_token_info['decimal'])
+    # human_in_base 根据实际传入的 IN_AMOUNT_TO_QUERY (trade_value) 确定
+    # human_out_target 是链上swap的实际输出
+    atomic_out_target = decimal.Decimal(router_result['toTokenAmount'])
+    human_out_target = atomic_out_target / (10 ** out_dec)
+
+    arbitrage_data = {
+        "tx": tx, # 预签名交易
+        "profit": str(profit_amount.quantize(decimal.Decimal('0.001'))),
+        "profitLimit": str(PROFIT_LIMIT.quantize(decimal.Decimal('0.001'))),
+        # "mexcPriceUsdtPerTarget": str(mexc_price_usdt_per_target.quantize(decimal.Decimal('1e-8'))),
+        "symbol": MEXC_TARGET_PAIR_USDT,
+        "fromToken": IN_TOKEN_ADDRESS,
+        "fromTokenAmountHuman": str(in_amount_to_query_human.quantize(decimal.Decimal(f'1e-{in_dec}'))),
+        "fromTokenDecimal": str(in_dec),
+        "toToken": OUT_TOKEN_ADDRESS,
+        "toTokenAmountHuman": str(human_out_target.quantize(decimal.Decimal(f'1e-{out_dec}'))),
+        "toTokenDecimal": str(out_dec),
+        "exchangeOutAmount": str(EXCHANGE_OUT_AMOUNT.quantize(decimal.Decimal(f'1e-{out_dec}'))), # CEX上期望卖出的目标币数量
+        "strategy": STRATEGY,
+    }
+
+    logger.info(f"正在提交套利数据到 {ARB_EXECUTOR_URL}, profit {arbitrage_data["profit"]}, profitLimit {arbitrage_data["profitLimit"]}")
+
+    try:
+        response = requests.post(ARB_EXECUTOR_URL, json=arbitrage_data, timeout=10)
+        logger.info(f"套利执行器响应状态码: {response.status_code}")
+        try:
+            response_data = response.json()
+            logger.info(f"套利执行器响应内容: {response_data}")
+        except requests.exceptions.JSONDecodeError:
+            logger.error(f"套利执行器响应无法解析为JSON: {response.text}")
+    except requests.exceptions.RequestException as e:
+        logger.error(f"连接套利执行器 {ARB_EXECUTOR_URL} 失败: {e}")
+    except Exception as e:
+        logger.error(f"发送套利消息未知错误: {e}", exc_info=True)
+
+def update_data_for_plotly_and_table():
+    global historical_data_points, latest_values_for_table # IN_AMOUNT_TO_QUERY
+    logger.info(f"数据更新线程 ({TARGET_ASSET_SYMBOL}/{BASE_CURRENCY_SYMBOL})...")
+    
+    # local_in_amount_to_query = decimal.Decimal(str(arb["IN_AMOUNT_TO_QUERY"])) # 从配置初始化,后续动态调整
+
+    while True:
+        fetch_time_full = time.strftime("%Y-%m-%d %H:%M:%S")
+        fetch_time_chart = time.strftime("%H:%M:%S")
+
+        # 1. MEXC: 获取 price_target_per_usdt (例如 RATO/USDT) 和相应的 trade_value_usdt
+        # trade_value_usdt 是指如果以 EXCHANGE_OUT_AMOUNT 的目标代币在MEXC上砸盘卖出,能获得的USDT估值
+        mexc_data, trade_value_usdt = get_mexc_spot_price_target_usdt_bid(MEXC_TARGET_PAIR_USDT)
+        mexc_price_target_per_usdt = mexc_data.get("price_target_per_usdt") # TARGET/USDT
+        mexc_err = mexc_data.get("error")
+
+        # price_target_per_usdt: 这是1个目标币能卖多少USDT, 即 USDT/TARGET
+        # 所以可以直接用,不需要转换,变量名应为 mexc_price_target_per_base
+        mexc_price_target_per_base = None
+        if mexc_price_target_per_usdt is not None and mexc_price_target_per_usdt > 0:
+            mexc_price_target_per_base = mexc_price_target_per_usdt # RATO/USDT => USDT/TARGET (命名约定)
+        elif not mexc_err and mexc_price_target_per_usdt is not None:
+             mexc_err = mexc_err or "MEXC价格为0或无效"
+        
+        if mexc_err or trade_value_usdt == decimal.Decimal('0'): # 如果MEXC有问题或无法确定砸盘价值,则跳过本次循环
+            logger.warning(f"MEXC数据获取问题: {mexc_err}, trade_value_usdt: {trade_value_usdt}. 跳过本次循环。")
+            with data_lock: # 依然更新错误信息
+                latest_values_for_table["mexc_error"] = mexc_err
+                latest_values_for_table["oo_error"] = latest_values_for_table.get("oo_error") # 保持上次的oo_error
+                latest_values_for_table["last_updated"] = fetch_time_full
+            time.sleep(REFRESH_INTERVAL_SECONDS)
+            continue
+
+        # 2. 确定链上查询的输入金额 (USDT)
+        # 使用 MEXC 卖出 EXCHANGE_OUT_AMOUNT 个目标币能得到的USDT数量 (trade_value_usdt)
+        # 作为链上购买目标币时花费的USDT数量 (in_amount_to_query_human)
+        in_amount_to_query_human = trade_value_usdt.quantize(decimal.Decimal('1.00'), rounding=decimal.ROUND_DOWN) # 保留两位小数,向下取整
+        if in_amount_to_query_human <= decimal.Decimal('0'):
+            logger.warning(f"计算出的链上查询金额为0或负数 ({in_amount_to_query_human} USDT),跳过。trade_value_usdt: {trade_value_usdt}")
+            time.sleep(REFRESH_INTERVAL_SECONDS)
+            continue
+
+        # 3. 获取链上价格:用 in_amount_to_query_human 这么多的USDT去买目标币,能买到多少,以及价格 (USDT/TARGET)
+        oo_data, chain_swap_full_response = get_chain_price_vs_target_currency(
+            CHAIN_ID,
+            IN_TOKEN_ADDRESS,      # USDT
+            OUT_TOKEN_ADDRESS,     # TARGET
+            in_amount_to_query_human, # 花费的USDT数量
+            IN_TOKEN_DECIMALS,     # USDT的精度
+            SLIPPAGE,
+            USER_WALLET
+        )
+        oo_price_usdt_per_target = oo_data.get("price_base_per_target") # USDT/TARGET
+        oo_err = oo_data.get("error")
+
+        # 4. 计算百分比差异
+        # diff = (MEXC卖价 - 链上买价) / 链上买价
+        diff_oo_vs_mexc_pct = calculate_percentage_diff(
+            mexc_price_target_per_base, # MEXC卖价 (USDT/TARGET)
+            oo_price_usdt_per_target                  # 链上买价 (USDT/TARGET)
+        )
+
+        # 5. 计算实际利润额 (以USDT计价)
+        # 利润 = (MEXC每目标币卖价 - 链上每目标币买价) * 链上买入的目标币数量
+        # 链上买入的目标币数量 = in_amount_to_query_human / oo_price_usdt_per_target
+        # 简化:利润百分比 * 投入的USDT金额
+        actual_profit_usdt = None
+        if diff_oo_vs_mexc_pct is not None and oo_price_usdt_per_target is not None and oo_price_usdt_per_target > 0:
+            # 方案A: 基于百分比和投入金额
+            actual_profit_usdt = diff_oo_vs_mexc_pct * in_amount_to_query_human
+            # # 方案B: 基于单价差和数量 (更精确,如果chain_swap_full_response可用)
+            # if chain_swap_full_response and chain_swap_full_response.get('data'):
+            #     router_result = chain_swap_full_response['data'][0]['routerResult']
+            #     atomic_out_target = decimal.Decimal(router_result['toTokenAmount'])
+            #     out_dec = int(router_result['toToken']['decimal'])
+            #     human_out_target_onchain = atomic_out_target / (10 ** out_dec) # 链上实际换到的目标币
+
+            #     # 能在CEX卖出的目标币数量,取链上换到的数量和CEX预设卖出量的较小值,因为CEX订单深度是按EXCHANGE_OUT_AMOUNT查的
+            #     effective_target_to_sell_on_mexc = min(human_out_target_onchain, EXCHANGE_OUT_AMOUNT)
+
+            #     revenue_on_mexc = effective_target_to_sell_on_mexc * mexc_price_target_per_base
+            #     cost_on_chain = in_amount_to_query_human # 这是我们实际在链上花的USDT
+            #     actual_profit_usdt_v2 = revenue_on_mexc - cost_on_chain
+            #     actual_profit_usdt = actual_profit_usdt_v2 # 使用更精确的V2版本
+
+        # block_number = web3_client.w3.eth.block_number
+        block_number = 0
+        # 6. 满足利润条件,发送套利消息, PROFIT_LIMIT + 3的3是提前計算的成本,否則一直提交
+        global mode
+        global prev_profit_block_number
+
+        if actual_profit_usdt is not None and actual_profit_usdt > PROFIT_LIMIT + 3 and mode == 'trade':
+            # 确保有完整的链上数据
+            if chain_swap_full_response:
+            # and block_number == prev_profit_block_number + send_delay_block:
+                send_arb_msg(actual_profit_usdt, chain_swap_full_response, mexc_price_target_per_base, in_amount_to_query_human)
+            else:
+                logger.warning("利润满足但链上数据不完整,无法发送套利消息。")
+            
+            prev_profit_block_number = block_number
+
+        current_point = {
+            "time": fetch_time_chart,
+            "oo_price_usdt_per_target": float(oo_price_usdt_per_target) if oo_price_usdt_per_target else None,
+            "mexc_price_target_per_base": float(mexc_price_target_per_base) if mexc_price_target_per_base else None,
+            "diff_oo_vs_mexc": float(diff_oo_vs_mexc_pct) if diff_oo_vs_mexc_pct is not None else None,
+            "profit_value": float(actual_profit_usdt) if actual_profit_usdt is not None else None, # 新增:用于图表的实际利润额
+        }
+
+        with data_lock:
+            historical_data_points.append(current_point)
+            latest_values_for_table["oo_price_usdt_per_target"] = f"{oo_price_usdt_per_target:.8f}" if oo_price_usdt_per_target else "N/A"
+            latest_values_for_table["mexc_price_target_per_base"] = f"{mexc_price_target_per_base:.8f}" if mexc_price_target_per_base else "N/A"
+            latest_values_for_table["diff_oo_vs_mexc_percentage"] = f"{diff_oo_vs_mexc_pct:+.4%}" if diff_oo_vs_mexc_pct is not None else "N/A" # 显示为百分比
+            latest_values_for_table["profit_value_for_table"] = f"{actual_profit_usdt:.2f} {BASE_CURRENCY_SYMBOL}" if actual_profit_usdt is not None else "N/A" # 新增
+            latest_values_for_table["oo_error"] = oo_err
+            latest_values_for_table["mexc_error"] = mexc_err
+            latest_values_for_table["last_updated"] = fetch_time_full
+            latest_values_for_table["in_amount_for_query_display"] = f"{in_amount_to_query_human:.2f} {BASE_CURRENCY_SYMBOL}" if in_amount_to_query_human > 0 else "N/A"
+
+        # logger.info(f"{fetch_time_chart} Price Query: Chain Input {in_amount_to_query_human:.2f} {BASE_CURRENCY_SYMBOL} | OKX Price: {oo_price_usdt_per_target_display} | MEXC Price: {mexc_price_target_per_base_display} | Diff: {diff_display} | Profit: {profit_display}")
+        if oo_err or mexc_err :
+             logger.warning(f"{fetch_time_chart} Errors: OO:{oo_err}, MEXC:{mexc_err}")
+
+        time.sleep(REFRESH_INTERVAL_SECONDS)
+
+@app.route('/')
+def index_plotly():
+    return render_template('index_plotly_dynamic_ok.html',
+                           target_asset=TARGET_ASSET_SYMBOL,
+                           base_asset=BASE_CURRENCY_SYMBOL,
+                           mexc_pair_usdt=MEXC_TARGET_PAIR_USDT,
+                           refresh_interval_ms=REFRESH_INTERVAL_SECONDS * 1000)
+
+@app.route('/table-data')
+def get_table_data():
+    with data_lock:
+        # logger.info(f"Table data requested: {latest_values_for_table}")
+        return jsonify(latest_values_for_table)
+
+@app.route('/plotly-chart-data')
+def get_plotly_chart_data():
+    with data_lock:
+        points = list(historical_data_points)
+        # logger.info(f"Chart data requested, {len(points)} points.")
+        if not points:
+            fig = go.Figure() # Create an empty figure
+            fig.update_layout(title_text="暂无数据")
+            empty_json = json.loads(json.dumps(fig, cls=PlotlyJSONEncoder))
+            return jsonify({
+                "price_chart": empty_json,
+                "diff_chart": empty_json,
+                "profit_chart": empty_json # 新增:空利润图表
+            })
+
+        times = [p['time'] for p in points]
+        display_target_asset = latest_values_for_table["target_asset_symbol_for_display"]
+        display_base_asset = latest_values_for_table["base_currency_symbol_for_display"]
+
+        common_xaxis_config = dict(title='时间')
+        common_legend_config = dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1)
+        # if len(times) > 1: # Plotly handles autorange for single point ok
+        #     common_xaxis_config['range'] = [times[0], times[-1]]
+        # else:
+        common_xaxis_config['autorange'] = True
+
+        # Price Chart
+        fig_prices = go.Figure()
+        fig_prices.add_trace(go.Scatter(x=times, y=[p['oo_price_usdt_per_target'] for p in points], mode='lines',
+                                        name=f'Okx ({display_base_asset}/{display_target_asset})',
+                                        line=dict(color='rgb(75, 192, 192)'),
+                                        hovertemplate=f'<b>Okx链上价</b><br>价格: %{{y:.8f}} {display_base_asset}<extra></extra>',
+                                        connectgaps=True)) # 处理None值不画线
+        fig_prices.add_trace(go.Scatter(x=times, y=[p['mexc_price_target_per_base'] for p in points], mode='lines',
+                                        name=f'MEXC卖1价 ({display_base_asset}/{display_target_asset})',
+                                        line=dict(color='rgb(255, 99, 132)', dash='dash'),
+                                        hovertemplate=f'<b>MEXC卖出价</b><br>价格: %{{y:.8f}} {display_base_asset}<extra></extra>',
+                                        connectgaps=True))
+        fig_prices.update_layout(title_text=f'{display_base_asset}/{display_target_asset} 价格历史',
+                                 xaxis=common_xaxis_config.copy(),
+                                 yaxis_title=f'价格 (1 {display_target_asset} = X {display_base_asset})',
+                                 legend_title_text='平台',
+                                 legend=common_legend_config.copy(), hovermode='x unified',
+                                 margin=dict(l=70, r=30, t=80, b=50))
+
+        # Percentage Difference Chart
+        fig_diffs = go.Figure()
+        fig_diffs.add_trace(
+            go.Scatter(x=times, y=[p['diff_oo_vs_mexc'] for p in points], mode='lines', name=f'价差百分比 (MEXC卖价 vs Okx买价)',
+                       line=dict(color='rgb(255, 159, 64)'),
+                       hovertemplate=f'<b>(MEXC卖价-Okx买价)/Okx买价</b><br>百分比: %{{y:+.4%}}<extra></extra>', # 显示为百分比
+                       connectgaps=True))
+        fig_diffs.update_layout(title_text=f'价差百分比历史曲线',
+                                xaxis=common_xaxis_config.copy(),
+                                yaxis_title='价差百分比', legend_title_text='对比', legend=common_legend_config.copy(),
+                                yaxis_zeroline=True, hovermode='x unified', margin=dict(l=70, r=30, t=80, b=50),
+                                yaxis_tickformat=".4%") # y轴也显示为百分比
+
+        # --- 新增 Profit Chart ---
+        fig_profit = go.Figure()
+        fig_profit.add_trace(
+            go.Scatter(x=times, y=[p['profit_value'] for p in points], mode='lines', name=f'预估利润 ({display_base_asset})',
+                       line=dict(color='rgb(153, 102, 255)'), # 紫色
+                       hovertemplate=f'<b>预估利润</b><br>金额: %{{y:,.2f}} {display_base_asset}<extra></extra>', # 利润金额,保留2位小数
+                       connectgaps=True))
+        fig_profit.update_layout(title_text=f'预估利润历史 ({display_base_asset})',
+                                 xaxis=common_xaxis_config.copy(),
+                                 yaxis_title=f'利润 ({display_base_asset})', 
+                                 legend_title_text='利润额',
+                                 legend=common_legend_config.copy(),
+                                 yaxis_zeroline=True, hovermode='x unified',
+                                 margin=dict(l=70, r=30, t=80, b=50),
+                                 yaxis_tickformat="$,.2f") # y轴格式化为货币
+
+        combined_figure_data = {
+            "price_chart": json.loads(json.dumps(fig_prices, cls=PlotlyJSONEncoder)),
+            "diff_chart": json.loads(json.dumps(fig_diffs, cls=PlotlyJSONEncoder)),
+            "profit_chart": json.loads(json.dumps(fig_profit, cls=PlotlyJSONEncoder)) # 新增
+        }
+        return jsonify(combined_figure_data)
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description='套利监控和交易脚本。')
+    parser.add_argument('--mode',
+                        required=True,
+                        choices=['trade', 'view'], # 限制可选值
+                        help='运行模式: "trade" (执行交易) 或 "view" (仅观察)')
+
+    except_strategy = 'erc20_to_mexc'
+    if STRATEGY != except_strategy:
+        raise Exception(f"策略不匹配! 期待{except_strategy}, 实际{STRATEGY}")
+
+    try:
+        args = parser.parse_args()
+        mode = args.mode
+        logger.info(f"脚本运行模式为: {mode}")
+
+        logger.info("应用启动...")
+        logger.info(f"目标资产: {TARGET_ASSET_SYMBOL}, 计价货币: {BASE_CURRENCY_SYMBOL}, 获取到的Decimal: {IN_TOKEN_DECIMALS}")
+        # IN_AMOUNT_TO_QUERY 会动态变化,初始值从配置读取,但循环中会基于MEXC的trade_value更新
+        # logger.info(f"链上查询初始金额: {arb['IN_AMOUNT_TO_QUERY']} {BASE_CURRENCY_SYMBOL} -> {TARGET_ASSET_SYMBOL}")
+        logger.info(f"MEXC期望卖出量 (用于计算深度和价值): {EXCHANGE_OUT_AMOUNT} {TARGET_ASSET_SYMBOL}")
+        logger.info(f"利润阈值: {PROFIT_LIMIT} {BASE_CURRENCY_SYMBOL}")
+        logger.info(f"MEXC现货交易对: {MEXC_TARGET_PAIR_USDT}")
+        
+        data_thread = threading.Thread(target=update_data_for_plotly_and_table, daemon=True)
+        data_thread.start()
+
+        port = arb.get("PORT", 5001) # 从配置获取端口,如果没有则默认5001
+        logger.info(f"Flask 服务将在 http://0.0.0.0:{port} 上运行 (刷新间隔: {REFRESH_INTERVAL_SECONDS}s)")
+        app.run(debug=False, host='0.0.0.0', port=port, use_reloader=False)
+
+    except SystemExit: # argparse 在参数错误时会引发 SystemExit
+        # parser.print_help() # argparse 默认会打印帮助信息
+        logger.info("脚本因参数错误而退出。请提供 '--mode' 参数 ('trade' 或 'view')。")
+    except Exception as e:
+        logger.critical(f"主程序发生严重错误: {e}", exc_info=True)

+ 0 - 0
checker/mexc_to_erc20_checker.py → checker/c_mexc_to_erc20.py


+ 431 - 0
checker/c_mexc_to_erc20_simple.py

@@ -0,0 +1,431 @@
+import requests
+import decimal
+import time
+import threading
+import json
+import logging
+import ok_chain_client # 假设这是你的 OKX Chain 客户端库
+import pprint
+import plotly.graph_objects as go
+import argparse
+
+from flask import Flask, render_template, jsonify
+from collections import deque
+from plotly.utils import PlotlyJSONEncoder
+
+# configs
+from config import wallet
+from config import okchain_api
+from config import arb
+
+# logs
+from logger_config import get_logger
+logger = get_logger('as')
+
+# lite客户端
+from web3_py_client_lite import EthClient
+web3_client = EthClient()
+
+# ok web3的配置
+ok_chain_client.api_config = okchain_api # 假设ok_chain_client有此配置方式
+
+# --- 配置 arb_executor.py 的 HTTP 地址和端口 ---
+ARB_EXECUTOR_URL = arb["ARB_EXECUTOR_URL"]
+
+# --- 配置部分 ---
+# EXCHANGE_OUT_AMOUNT 将在循环中动态确定
+IN_AMOUNT_TO_QUERY = decimal.Decimal(str(arb["COIN_TOKEN_TRADE_AMOUNT"]))
+PROFIT_LIMIT = decimal.Decimal(str(arb["PROFIT_LIMIT"])) # 确保是Decimal
+IN_TOKEN_ADDRESS = arb["COIN_TOKEN_ADDRESS"]
+IN_TOKEN_DECIMALS = web3_client.get_erc20_decimals(IN_TOKEN_ADDRESS)
+OUT_TOKEN_ADDRESS = arb["BASE_TOKEN_ADDRESS"]
+SLIPPAGE = arb["SLIPPAGE"]
+MEXC_TARGET_PAIR_USDT = arb["CEX_PAIR"]
+CHAIN_ID = arb["CHAIN_ID"]
+STRATEGY = arb["STRATEGY"]
+
+# 錢包的配置
+USER_WALLET = wallet["user_wallet"]
+USER_EXCHANGE_WALLET = wallet["user_exchange_wallet"]
+
+proxies = None # {'http': 'http://proxy_url:port', 'https': 'http://proxy_url:port'}
+
+# 運行模式【trade、view】
+mode = None
+# chain_price = None # 这个全局变量似乎没有被有效使用,价格在循环内获取
+
+# 配置請求的日志等級
+app = Flask(__name__)
+log = logging.getLogger('werkzeug')
+log.setLevel(logging.ERROR)
+
+REFRESH_INTERVAL_SECONDS = 1 # 稍微增加间隔以减少API调用频率
+MAX_HISTORY_POINTS_PLOTLY = 21600
+historical_data_points = deque(maxlen=MAX_HISTORY_POINTS_PLOTLY)
+
+TARGET_ASSET_SYMBOL = MEXC_TARGET_PAIR_USDT.split('_')[0] # e.g., RATO
+BASE_CURRENCY_SYMBOL = MEXC_TARGET_PAIR_USDT.split('_')[1] # e.g., USDT (assumed to be consistent with IN_TOKEN_ADDRESS)
+
+# --- 链上价格获取函数 (链上) ---
+# 返回: price_base_per_target (例如 USDT per RATO)
+def get_chain_price_vs_target_currency(chain_id, in_token_addr, out_token_addr, amount_in_base_human, in_token_decimals, slippage, user_wallet_addr):
+    try:
+        # amount_in_base_human 已经是 decimal.Decimal 类型的人类可读数量
+        in_token_amount_atomic = int(amount_in_base_human * (10 ** in_token_decimals)) # 转为原子单位整数
+        data = ok_chain_client.swap(chain_id, in_token_amount_atomic, in_token_addr, out_token_addr, slippage, user_wallet_addr, None, 'fast')
+
+        if data.get('code') == '0' and data.get('data'):
+            d = data['data'][0]
+            router_result = d['routerResult']
+            in_dec, out_dec = int(router_result['fromToken']['decimal']), int(router_result['toToken']['decimal'])
+            atomic_in_base, atomic_out_target = decimal.Decimal(router_result['fromTokenAmount']), decimal.Decimal(router_result['toTokenAmount'])
+
+            human_in_target = atomic_in_base / (10 ** in_dec)
+            human_out_base = atomic_out_target / (10 ** out_dec)
+            if human_out_base == 0: return {"error": f"OO输出目标代币为0 ({CHAIN_ID})"}, data, 0 # data 也返回
+
+            # 1target = x usdt,这个价格
+            return {"price_base_per_target": human_out_base / human_in_target }, data, human_out_base
+        else:
+            pprint.pprint(data)
+            return {
+                "error": f"链上 API错误({chain_id}) - Code:{data.get('code', 'N/A')}, Msg:{data.get('msg', data.get('message', 'N/A')) if isinstance(data, dict) else '格式错误'}"}, None, 0
+    except Exception as e:
+        logger.error(f"链上 ({chain_id})请求错误详情: ", exc_info=True)
+        return {"error": f"链上 ({chain_id})请求错误: {e}"}, None, 0
+
+# MEXC 现货 (获取 目标代币/USDT 的 bid 价格)
+# human_out_base: 需要买入大概多少价值的target
+def get_mexc_spot_price_target_usdt_ask(human_out_base):
+    url = "https://api.mexc.com/api/v3/depth"
+    params = {'symbol': MEXC_TARGET_PAIR_USDT.replace('_', ''), 'limit': 1000} 
+
+    try:
+        r = requests.get(url, params=params, proxies=proxies, timeout=5) # 减少超时
+        r.raise_for_status()
+        data = r.json()
+        if 'asks' in data and data['asks']: # 确保asks存在且不为空
+            asks = data['asks']
+            trade_value_remaining = human_out_base # 还需要买入的价值(USDT)
+            trade_value = decimal.Decimal('0') # 累计的总价值 (Decimal)
+            accumulated_volume = decimal.Decimal('0') # 累计吃单量
+
+            for orderbook in asks:
+                price = decimal.Decimal(orderbook[0])
+                volume = decimal.Decimal(orderbook[1])
+
+                if trade_value_remaining <= decimal.Decimal('0'):
+                    break # 已经满足买入量
+
+                can_fill = min(volume, trade_value_remaining / price)
+                trade_value += price * can_fill
+                accumulated_volume += can_fill
+                trade_value_remaining -= price * can_fill
+
+            #     print(trade_value, accumulated_volume, trade_value_remaining)
+            
+            # print('\n\n')
+            
+            if accumulated_volume == decimal.Decimal('0'): # 如果一点都没买入去
+                return {"error": f"MEXC订单簿深度不足以买入{human_out_base} {BASE_CURRENCY_SYMBOL}"}, decimal.Decimal('0')
+
+            # 计算平均买入价格
+            # buy_price 代表 1 TARGET_ASSET = X USDT
+            buy_price = trade_value / accumulated_volume 
+            buy_price = buy_price.quantize(decimal.Decimal('1e-10'), rounding=decimal.ROUND_DOWN)
+
+            # trade_value 代表买入 accumulated_volume 个 TARGET_ASSET 能得到的 TARGET 总量
+            return {
+                "price_target_per_base": buy_price
+            }, trade_value # 返回的是实际能买入 EXCHANGE_OUT_AMOUNT (或更少,如果深度不足) 所得的 USDT 总额
+        else:
+            # logger.warning(f"MEXC现货({MEXC_TARGET_PAIR_USDT}) asks 数据不存在或为空: {data}")
+            return {"error": f"MEXC现货({MEXC_TARGET_PAIR_USDT}) asks 数据不存在或为空"}, decimal.Decimal('0')
+    except requests.exceptions.RequestException as e:
+        # logger.error(f"MEXC现货({MEXC_TARGET_PAIR_USDT})请求错误: {e}")
+        return {"error": f"MEXC现货({MEXC_TARGET_PAIR_USDT})请求错误: {e}"}, decimal.Decimal('0')
+    except Exception as e:
+        # logger.error(f"MEXC现货({MEXC_TARGET_PAIR_USDT})处理错误: {e}", exc_info=True)
+        return {"error": f"MEXC现货({MEXC_TARGET_PAIR_USDT})处理错误: {e}"}, decimal.Decimal('0')
+
+latest_values_for_table = {
+    f"chain_price": "N/A",
+    f"cex_price": "N/A", # MEXC price (converted to USDT/TARGET)
+    f"diff_oo_vs_mexc_percentage": "N/A",
+    "profit_value_for_table": "N/A", # 新增:用于表格的利润值
+    "oo_error": None, "mexc_error": None,
+    "last_updated": "N/A",
+    "mexc_pair_usdt_for_display": MEXC_TARGET_PAIR_USDT,
+    "target_asset_symbol_for_display": TARGET_ASSET_SYMBOL,
+    "base_currency_symbol_for_display": BASE_CURRENCY_SYMBOL
+}
+data_lock = threading.Lock()
+
+def calculate_percentage_diff(sell_price, buy_price):
+    # 期望 sell_price > buy_price
+    if sell_price is not None and buy_price is not None and \
+       isinstance(sell_price, decimal.Decimal) and \
+       isinstance(buy_price, decimal.Decimal) and buy_price != 0:
+        # (卖价 - 买价) / 买价
+        rst = (sell_price - buy_price) / buy_price
+        rst = rst.quantize(decimal.Decimal('1e-6'), rounding=decimal.ROUND_DOWN) # 提高精度
+        return rst
+    return None
+
+def send_arb_msg(profit_amount, chain_swap_data, human_in_base, human_out_base, chain_price, cex_price):
+    # chain_swap_data 是从 get_chain_price_vs_target_currency 返回的第二个值
+    if not (chain_swap_data and chain_swap_data.get('data') and chain_swap_data['data']):
+        logger.error(f"套利消息发送失败:链上交易数据不完整 {chain_swap_data}")
+        return
+
+    d = chain_swap_data['data'][0]
+    tx = d['tx'] # 这是预签名的交易结构体,不是tx hash
+    router_result = d['routerResult']
+    from_token_info = router_result['fromToken']
+    to_token_info = router_result['toToken']
+
+    in_dec, out_dec = int(from_token_info['decimal']), int(to_token_info['decimal'])
+    # human_in_target 根据实际传入的 IN_AMOUNT_TO_QUERY (trade_value) 确定
+    # human_out_base 是链上swap的实际输出
+    atomic_out_target = decimal.Decimal(router_result['toTokenAmount'])
+    human_out_base = atomic_out_target / (10 ** out_dec)
+
+    arbitrage_data = {
+        "tx": tx, # 预签名交易
+        "profit": str(profit_amount.quantize(decimal.Decimal('0.001'))),
+        "profitLimit": str(PROFIT_LIMIT.quantize(decimal.Decimal('0.001'))),
+        "symbol": MEXC_TARGET_PAIR_USDT,
+        "fromToken": IN_TOKEN_ADDRESS,
+        "fromTokenAmountHuman": str(human_in_base.quantize(decimal.Decimal(f'1e-{in_dec}'))),
+        "fromTokenDecimal": str(in_dec),
+        "toToken": OUT_TOKEN_ADDRESS,
+        "toTokenAmountHuman": str(human_out_base.quantize(decimal.Decimal(f'1e-{out_dec}'))),
+        "toTokenDecimal": str(out_dec),
+        "chainPrice": str(chain_price),
+        "cexPrice": str(cex_price),
+        "strategy": STRATEGY,
+    }
+
+    logger.info(f"正在提交套利数据到 {ARB_EXECUTOR_URL}, profit {arbitrage_data["profit"]}, profitLimit {arbitrage_data["profitLimit"]}")
+
+    try:
+        response = requests.post(ARB_EXECUTOR_URL, json=arbitrage_data, timeout=10)
+        logger.info(f"套利执行器响应状态码: {response.status_code}")
+        try:
+            response_data = response.json()
+            logger.info(f"套利执行器响应内容: {response_data}")
+        except requests.exceptions.JSONDecodeError:
+            logger.error(f"套利执行器响应无法解析为JSON: {response.text}")
+    except requests.exceptions.RequestException as e:
+        logger.error(f"连接套利执行器 {ARB_EXECUTOR_URL} 失败: {e}")
+    except Exception as e:
+        logger.error(f"发送套利消息未知错误: {e}", exc_info=True)
+
+def update_data_for_plotly_and_table():
+    global historical_data_points, latest_values_for_table # IN_AMOUNT_TO_QUERY
+    logger.info(f"数据更新线程 ({TARGET_ASSET_SYMBOL}/{BASE_CURRENCY_SYMBOL})...")
+    
+    # local_in_amount_to_query = decimal.Decimal(str(arb["IN_AMOUNT_TO_QUERY"])) # 从配置初始化,后续动态调整
+
+    while True:
+        fetch_time_full = time.strftime("%Y-%m-%d %H:%M:%S")
+        fetch_time_chart = time.strftime("%H:%M:%S")
+
+        # 1. 获取链上价格:卖出 IN_AMOUNT_TO_QUERY 这么多的TARGET去卖成USDT,能卖到多少,以及价格 (USDT/TARGET)
+        oo_data, chain_swap_full_response, human_out_base = get_chain_price_vs_target_currency(
+            CHAIN_ID,
+            IN_TOKEN_ADDRESS,      # TARGET
+            OUT_TOKEN_ADDRESS,     # USDT
+            IN_AMOUNT_TO_QUERY,     # 链上卖出代币量
+            IN_TOKEN_DECIMALS,     # 链上卖出代币的精度
+            SLIPPAGE,
+            USER_WALLET
+        )
+        chain_price = oo_data.get("price_base_per_target") # USDT/TARGET
+        oo_err = oo_data.get("error")
+
+        # 2. MEXC: 获取 price_target_per_base (例如 RATO/USDT)
+        # trade_value_usdt 是指如果以 EXCHANGE_OUT_AMOUNT 的目标代币在MEXC上砸盘卖出,能获得的USDT估值
+        mexc_data, trade_value_usdt = get_mexc_spot_price_target_usdt_ask(human_out_base)
+        cex_price = mexc_data.get("price_target_per_base") # TARGET/USDT
+        mexc_err = mexc_data.get("error")
+
+        # 3. 计算百分比差异
+        # diff = (链上卖价 - MEXC买价) / MEXC买价
+        diff_oo_vs_mexc_pct = calculate_percentage_diff(
+            chain_price,                   # 链上卖价 (USDT/TARGET)
+            cex_price,                 # MEXC买价 (USDT/TARGET)
+        )
+
+        # 4. 计算实际利润额 (以USDT计价)
+        # 简化:利润百分比 * 投入的USDT金额
+        actual_profit_usdt = None
+        if diff_oo_vs_mexc_pct is not None and chain_price is not None and chain_price > 0:
+            # 基于百分比和投入金额
+            actual_profit_usdt = diff_oo_vs_mexc_pct * human_out_base
+
+        # 5. 满足利润条件,发送套利消息, PROFIT_LIMIT + 3的3是提前計算的成本,否則一直提交
+        global mode
+        if actual_profit_usdt is not None and actual_profit_usdt > PROFIT_LIMIT + 3 and mode == 'trade':
+            if chain_swap_full_response: # 确保有完整的链上数据
+                human_in_base = IN_AMOUNT_TO_QUERY
+                send_arb_msg(actual_profit_usdt, chain_swap_full_response, human_in_base, human_out_base, chain_price, cex_price)
+            else:
+                logger.warning("利润满足但链上数据不完整,无法发送套利消息。")
+
+        current_point = {
+            "time": fetch_time_chart,
+            "chain_price": float(chain_price) if chain_price else None,
+            "cex_price": float(cex_price) if cex_price else None,
+            "diff_oo_vs_mexc": float(diff_oo_vs_mexc_pct) if diff_oo_vs_mexc_pct is not None else None,
+            "profit_value": float(actual_profit_usdt) if actual_profit_usdt is not None else None, # 新增:用于图表的实际利润额
+        }
+
+        with data_lock:
+            historical_data_points.append(current_point)
+            latest_values_for_table["chain_price"] = f"{chain_price:.8f}" if chain_price else "N/A"
+            latest_values_for_table["cex_price"] = f"{cex_price:.8f}" if cex_price else "N/A"
+            latest_values_for_table["diff_oo_vs_mexc_percentage"] = f"{diff_oo_vs_mexc_pct:+.4%}" if diff_oo_vs_mexc_pct is not None else "N/A" # 显示为百分比
+            latest_values_for_table["profit_value_for_table"] = f"{actual_profit_usdt:.2f} {BASE_CURRENCY_SYMBOL}" if actual_profit_usdt is not None else "N/A" # 新增
+            latest_values_for_table["oo_error"] = oo_err
+            latest_values_for_table["mexc_error"] = mexc_err
+            latest_values_for_table["last_updated"] = fetch_time_full
+            latest_values_for_table["in_amount_for_query_display"] = f"{human_out_base:.2f} {BASE_CURRENCY_SYMBOL}" if human_out_base > 0 else "N/A"
+
+        # logger.info(f"{fetch_time_chart} Price Query: Chain Input {human_out_base:.2f} {BASE_CURRENCY_SYMBOL} | OKX Price: {chain_price_display} | MEXC Price: {cex_price_display} | Diff: {diff_display} | Profit: {profit_display}")
+        if oo_err or mexc_err :
+             logger.warning(f"{fetch_time_chart} Errors: OO:{oo_err}, MEXC:{mexc_err}")
+
+        time.sleep(REFRESH_INTERVAL_SECONDS)
+
+@app.route('/')
+def index_plotly():
+    return render_template('index_plotly_dynamic_ok.html',
+                           target_asset=TARGET_ASSET_SYMBOL,
+                           base_asset=BASE_CURRENCY_SYMBOL,
+                           mexc_pair_usdt=MEXC_TARGET_PAIR_USDT,
+                           refresh_interval_ms=REFRESH_INTERVAL_SECONDS * 1000)
+
+@app.route('/table-data')
+def get_table_data():
+    with data_lock:
+        # logger.info(f"Table data requested: {latest_values_for_table}")
+        return jsonify(latest_values_for_table)
+
+@app.route('/plotly-chart-data')
+def get_plotly_chart_data():
+    with data_lock:
+        points = list(historical_data_points)
+        # logger.info(f"Chart data requested, {len(points)} points.")
+        if not points:
+            fig = go.Figure() # Create an empty figure
+            fig.update_layout(title_text="暂无数据")
+            empty_json = json.loads(json.dumps(fig, cls=PlotlyJSONEncoder))
+            return jsonify({
+                "price_chart": empty_json,
+                "diff_chart": empty_json,
+                "profit_chart": empty_json # 新增:空利润图表
+            })
+
+        times = [p['time'] for p in points]
+        display_target_asset = latest_values_for_table["target_asset_symbol_for_display"]
+        display_base_asset = latest_values_for_table["base_currency_symbol_for_display"]
+
+        common_xaxis_config = dict(title='时间')
+        common_legend_config = dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1)
+        # if len(times) > 1: # Plotly handles autorange for single point ok
+        #     common_xaxis_config['range'] = [times[0], times[-1]]
+        # else:
+        common_xaxis_config['autorange'] = True
+
+        # Price Chart
+        fig_prices = go.Figure()
+        fig_prices.add_trace(go.Scatter(x=times, y=[p['chain_price'] for p in points], mode='lines',
+                                        name=f'链上({display_base_asset}/{display_target_asset})',
+                                        line=dict(color='rgb(75, 192, 192)'),
+                                        hovertemplate=f'<b>链上价</b><br>价格: %{{y:.8f}} {display_base_asset}<extra></extra>',
+                                        connectgaps=True)) # 处理None值不画线
+        fig_prices.add_trace(go.Scatter(x=times, y=[p['cex_price'] for p in points], mode='lines',
+                                        name=f'MEXC价 ({display_base_asset}/{display_target_asset})',
+                                        line=dict(color='rgb(255, 99, 132)', dash='dash'),
+                                        hovertemplate=f'<b>MEXC价</b><br>价格: %{{y:.8f}} {display_base_asset}<extra></extra>',
+                                        connectgaps=True))
+        fig_prices.update_layout(title_text=f'{display_base_asset}/{display_target_asset} 价格历史',
+                                 xaxis=common_xaxis_config.copy(),
+                                 yaxis_title=f'价格 (1 {display_target_asset} = X {display_base_asset})',
+                                 legend_title_text='平台',
+                                 legend=common_legend_config.copy(), hovermode='x unified',
+                                 margin=dict(l=70, r=30, t=80, b=50))
+
+        # Percentage Difference Chart
+        fig_diffs = go.Figure()
+        fig_diffs.add_trace(
+            go.Scatter(x=times, y=[p['diff_oo_vs_mexc'] for p in points], mode='lines', name=f'价差百分比 (MEXC卖价 vs 链上买价)',
+                       line=dict(color='rgb(255, 159, 64)'),
+                       hovertemplate=f'<b>(MEXC卖价-链上买价)/链上买价</b><br>百分比: %{{y:+.4%}}<extra></extra>', # 显示为百分比
+                       connectgaps=True))
+        fig_diffs.update_layout(title_text=f'价差百分比历史曲线',
+                                xaxis=common_xaxis_config.copy(),
+                                yaxis_title='价差百分比', legend_title_text='对比', legend=common_legend_config.copy(),
+                                yaxis_zeroline=True, hovermode='x unified', margin=dict(l=70, r=30, t=80, b=50),
+                                yaxis_tickformat=".4%") # y轴也显示为百分比
+
+        # --- 新增 Profit Chart ---
+        fig_profit = go.Figure()
+        fig_profit.add_trace(
+            go.Scatter(x=times, y=[p['profit_value'] for p in points], mode='lines', name=f'预估利润 ({display_base_asset})',
+                       line=dict(color='rgb(153, 102, 255)'), # 紫色
+                       hovertemplate=f'<b>预估利润</b><br>金额: %{{y:,.2f}} {display_base_asset}<extra></extra>', # 利润金额,保留2位小数
+                       connectgaps=True))
+        fig_profit.update_layout(title_text=f'预估利润历史 ({display_base_asset})',
+                                 xaxis=common_xaxis_config.copy(),
+                                 yaxis_title=f'利润 ({display_base_asset})', 
+                                 legend_title_text='利润额',
+                                 legend=common_legend_config.copy(),
+                                 yaxis_zeroline=True, hovermode='x unified',
+                                 margin=dict(l=70, r=30, t=80, b=50),
+                                 yaxis_tickformat="$,.2f") # y轴格式化为货币
+
+        combined_figure_data = {
+            "price_chart": json.loads(json.dumps(fig_prices, cls=PlotlyJSONEncoder)),
+            "diff_chart": json.loads(json.dumps(fig_diffs, cls=PlotlyJSONEncoder)),
+            "profit_chart": json.loads(json.dumps(fig_profit, cls=PlotlyJSONEncoder)) # 新增
+        }
+        return jsonify(combined_figure_data)
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description='套利监控和交易脚本。')
+    parser.add_argument('--mode',
+                        required=True,
+                        choices=['trade', 'view'], # 限制可选值
+                        help='运行模式: "trade" (执行交易) 或 "view" (仅观察)')
+
+    except_strategy = 'mexc_to_erc20'
+    if STRATEGY != except_strategy:
+        raise Exception(f"策略不匹配! 期待{except_strategy}, 实际{STRATEGY}")
+
+    try:
+        args = parser.parse_args()
+        mode = args.mode
+        logger.info(f"脚本运行模式为: {mode}")
+
+        logger.info("应用启动...")
+        logger.info(f"目标资产: {TARGET_ASSET_SYMBOL}, 计价货币: {BASE_CURRENCY_SYMBOL}, 获取到的Decimal: {IN_TOKEN_DECIMALS}")
+        # IN_AMOUNT_TO_QUERY 会动态变化,初始值从配置读取,但循环中会基于MEXC的trade_value更新
+        # logger.info(f"链上查询初始金额: {arb['IN_AMOUNT_TO_QUERY']} {BASE_CURRENCY_SYMBOL} -> {TARGET_ASSET_SYMBOL}")
+        logger.info(f"链上期望卖出量 (用于计算深度和价值): {IN_AMOUNT_TO_QUERY} {TARGET_ASSET_SYMBOL}")
+        logger.info(f"利润阈值: {PROFIT_LIMIT} {BASE_CURRENCY_SYMBOL}")
+        logger.info(f"MEXC现货交易对: {MEXC_TARGET_PAIR_USDT}")
+        
+        data_thread = threading.Thread(target=update_data_for_plotly_and_table, daemon=True)
+        data_thread.start()
+
+        port = arb.get("PORT", 5001) # 从配置获取端口,如果没有则默认5001
+        logger.info(f"Flask 服务将在 http://0.0.0.0:{port} 上运行 (刷新间隔: {REFRESH_INTERVAL_SECONDS}s)")
+        app.run(debug=False, host='0.0.0.0', port=port, use_reloader=False)
+
+    except SystemExit: # argparse 在参数错误时会引发 SystemExit
+        # parser.print_help() # argparse 默认会打印帮助信息
+        logger.info("脚本因参数错误而退出。请提供 '--mode' 参数 ('trade' 或 'view')。")
+    except Exception as e:
+        logger.critical(f"主程序发生严重错误: {e}", exc_info=True)

+ 3 - 1
toto.readme

@@ -69,8 +69,10 @@
 2025-07-21
 [-] 先不用超价单,而是连续市价单,直到仓位吃满为止
 
-其它todo
+2025-07-22
 [ ] 简易套利,双方向的测试
+
+其它todo
 [ ] 等待下一次余额平衡失败,然后修复
 [ ] 下单使用超价单而不是市价单(1、防止砸穿盘面控制风险;2、防止抹茶交易所撤销市价单)
 [ ] 所有策略共用web3、web3搞成wss的,可能会稳定很多