|
|
@@ -0,0 +1,486 @@
|
|
|
+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
|
|
|
+from config import rpc_url
|
|
|
+
|
|
|
+# logs
|
|
|
+from logger_config import get_logger
|
|
|
+logger = get_logger('as')
|
|
|
+
|
|
|
+# lite客户端
|
|
|
+from web3_py_client_lite import EthClient
|
|
|
+web3_client = EthClient(rpc_url)
|
|
|
+
|
|
|
+# 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 将在循环中动态确定
|
|
|
+BASE_TOKEN_TRADE_AMOUNT = decimal.Decimal(str(arb["BASE_TOKEN_TRADE_AMOUNT"])) # 确保是Decimal
|
|
|
+CEX_TRADE_AMOUNT = decimal.Decimal(str(arb["COIN_TOKEN_TRADE_AMOUNT"])) # 确保是Decimal
|
|
|
+OPEN_LIMIT = decimal.Decimal(str(arb["OPEN_LIMIT"])) # 确保是Decimal
|
|
|
+CLOSE_LIMIT = decimal.Decimal(str(arb["CLOSE_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"]
|
|
|
+CEX_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:7890', 'https': 'http://proxy_url:7890'}
|
|
|
+
|
|
|
+# 運行模式【trade、view】
|
|
|
+mode = None
|
|
|
+
|
|
|
+# 该api运行的端口
|
|
|
+api_endpoint_port = 5001
|
|
|
+
|
|
|
+# 配置請求的日志等級
|
|
|
+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 = CEX_TARGET_PAIR_USDT.split('_')[0] # e.g., RATO
|
|
|
+BASE_CURRENCY_SYMBOL = CEX_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, user_exchange_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, user_exchange_wallet_addr, '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"ERC20输出目标代币为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
|
|
|
+
|
|
|
+# CEX 现货 (获取 目标代币/USDT 的 bid 价格)
|
|
|
+# 返回: price_target_per_usdt (例如 RATO per USDT)
|
|
|
+def get_cex_spot_price_target_usdt_bid(pair_symbol):
|
|
|
+ host = "https://api.gateio.ws"
|
|
|
+ prefix = "/api/v4"
|
|
|
+ url = f"{host}{prefix}/spot/order_book"
|
|
|
+ params = {'currency_pair': pair_symbol, 'limit': 100}
|
|
|
+
|
|
|
+ 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 = CEX_TRADE_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"CEX无法以CEX_TRADE_AMOUNT={CEX_TRADE_AMOUNT}获取任何 efectiva 卖出价格,累积量为0")
|
|
|
+ return {"error": f"CEX订单簿深度不足以卖出{CEX_TRADE_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-18'), 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 # 返回的是实际能卖出 CEX_TRADE_AMOUNT (或更少,如果深度不足) 所得的 USDT 总额
|
|
|
+ else:
|
|
|
+ # logger.warning(f"CEX现货({pair_symbol}) bids 数据不存在或为空: {data}")
|
|
|
+ return {"error": f"CEX现货({pair_symbol}) bids 数据不存在或为空"}, decimal.Decimal('0')
|
|
|
+ except requests.exceptions.RequestException as e:
|
|
|
+ # logger.error(f"CEX现货({pair_symbol})请求错误: {e}")
|
|
|
+ return {"error": f"CEX现货({pair_symbol})请求错误: {e}"}, decimal.Decimal('0')
|
|
|
+ except Exception as e:
|
|
|
+ # logger.error(f"CEX现货({pair_symbol})处理错误: {e}", exc_info=True)
|
|
|
+ return {"error": f"CEX现货({pair_symbol})处理错误: {e}"}, decimal.Decimal('0')
|
|
|
+
|
|
|
+latest_values_for_table = {
|
|
|
+ f"dex_price": "N/A",
|
|
|
+ f"cex_price": "N/A",
|
|
|
+ f"diff_dex_vs_cex_percentage": "N/A",
|
|
|
+ "profit_value_for_table": "N/A", # 新增:用于表格的利润值
|
|
|
+ "dex_error": None, "cex_error": None,
|
|
|
+ "last_updated": "N/A",
|
|
|
+ "pair_usdt_for_display": CEX_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: CEX卖价 (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(pct, chain_swap_data, cex_price, dex_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_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)
|
|
|
+
|
|
|
+ '''
|
|
|
+ def get_public_ip_ipify():
|
|
|
+ """
|
|
|
+ 通过 api.ipify.org 获取公网 IP 地址。
|
|
|
+ 这个服务返回 JSON 格式数据。
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ response = requests.get('https://api.ipify.org?format=json')
|
|
|
+ response.raise_for_status()
|
|
|
+ data = response.json()
|
|
|
+ return data.get('ip')
|
|
|
+ except requests.exceptions.RequestException as e:
|
|
|
+ print(f"Error fetching IP from api.ipify.org: {e}")
|
|
|
+ return None
|
|
|
+ except ValueError:
|
|
|
+ print("Error decoding JSON from api.ipify.org")
|
|
|
+ return None
|
|
|
+ '''
|
|
|
+ ip = 'http://127.0.0.1'
|
|
|
+ url = '/table-data'
|
|
|
+ query_price_endpoint = f'{ip}:{api_endpoint_port}{url}'
|
|
|
+ arbitrage_data = {
|
|
|
+ "pct": str(pct),
|
|
|
+ "openLimit": str(OPEN_LIMIT.quantize(decimal.Decimal('0.00001'))),
|
|
|
+ "closeLimit": str(CLOSE_LIMIT.quantize(decimal.Decimal('0.00001'))),
|
|
|
+ "cexPrice": str(cex_price),
|
|
|
+ "dexPrice": str(dex_price),
|
|
|
+ "symbol": CEX_TARGET_PAIR_USDT,
|
|
|
+ "exchangeOutAmount": str(CEX_TRADE_AMOUNT.quantize(decimal.Decimal(f'1e-{out_dec}'))), # CEX上期望卖出的目标币数量
|
|
|
+ "strategy": STRATEGY,
|
|
|
+ "queryPriceUrl": query_price_endpoint,
|
|
|
+ }
|
|
|
+
|
|
|
+ # logger.info(f"正在提交套利数据到 {ARB_EXECUTOR_URL}, pct {arbitrage_data["pct"]}, openLimit {arbitrage_data["openLimit"]}, closeLimit {arbitrage_data["closeLimit"]}")
|
|
|
+
|
|
|
+ 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. CEX: 获取 price_target_per_usdt (例如 RATO/USDT) 和相应的 trade_value_usdt
|
|
|
+ # trade_value_usdt 是指如果以 CEX_TRADE_AMOUNT 的目标代币在CEX上砸盘卖出,能获得的USDT估值
|
|
|
+ cex_data, trade_value_usdt = get_cex_spot_price_target_usdt_bid(CEX_TARGET_PAIR_USDT)
|
|
|
+ cex_price = cex_data.get("price_target_per_usdt") # TARGET/USDT
|
|
|
+ cex_err = cex_data.get("error")
|
|
|
+
|
|
|
+ if cex_price is None:
|
|
|
+ cex_err = cex_err or "CEX价格为0或无效"
|
|
|
+
|
|
|
+ if cex_err or trade_value_usdt == decimal.Decimal('0'): # 如果CEX有问题或无法确定砸盘价值,则跳过本次循环
|
|
|
+ logger.warning(f"CEX数据获取问题: {cex_err}, trade_value_usdt: {trade_value_usdt}. 跳过本次循环。")
|
|
|
+ with data_lock: # 依然更新错误信息
|
|
|
+ latest_values_for_table["cex_error"] = cex_err
|
|
|
+ latest_values_for_table["dex_error"] = latest_values_for_table.get("dex_error") # 保持上次的dex_error
|
|
|
+ latest_values_for_table["last_updated"] = fetch_time_full
|
|
|
+ time.sleep(REFRESH_INTERVAL_SECONDS)
|
|
|
+ continue
|
|
|
+
|
|
|
+ # 2. 确定链上查询的输入金额 (USDT)
|
|
|
+ in_amount_to_query_human = BASE_TOKEN_TRADE_AMOUNT
|
|
|
+ in_amount_to_query_human = in_amount_to_query_human.quantize(decimal.Decimal('1e-2'), 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)
|
|
|
+ erc20_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,
|
|
|
+ USER_EXCHANGE_WALLET
|
|
|
+ )
|
|
|
+ dex_price = erc20_data.get("price_base_per_target") # USDT/TARGET
|
|
|
+ erc20_err = erc20_data.get("error")
|
|
|
+
|
|
|
+ # 4. 计算百分比差异
|
|
|
+ # diff = (CEX卖价 - 链上买价) / 链上买价
|
|
|
+ diff_erc20_vs_cex_pct = calculate_percentage_diff(
|
|
|
+ cex_price, # CEX卖价 (USDT/TARGET)
|
|
|
+ dex_price # 链上买价 (USDT/TARGET)
|
|
|
+ )
|
|
|
+
|
|
|
+ # 5. 计算实际利润额 (以USDT计价)
|
|
|
+ # 利润 = (CEX每目标币卖价 - 链上每目标币买价) * 链上买入的目标币数量
|
|
|
+ # 链上买入的目标币数量 = in_amount_to_query_human / dex_price
|
|
|
+ # 简化:利润百分比 * 投入的USDT金额
|
|
|
+ actual_profit_usdt = None
|
|
|
+ if diff_erc20_vs_cex_pct is not None and dex_price is not None and dex_price > 0:
|
|
|
+ # 基于百分比和投入金额
|
|
|
+ actual_profit_usdt = diff_erc20_vs_cex_pct * in_amount_to_query_human
|
|
|
+
|
|
|
+ # block_number = web3_client.w3.eth.block_number
|
|
|
+ block_number = 0
|
|
|
+ # 6. 满足利润条件,发送套利消息
|
|
|
+ global mode
|
|
|
+ global prev_profit_block_number
|
|
|
+
|
|
|
+ if diff_erc20_vs_cex_pct is not None and diff_erc20_vs_cex_pct > OPEN_LIMIT and mode == 'trade':
|
|
|
+ # 确保有完整的链上数据
|
|
|
+ if chain_swap_full_response:
|
|
|
+ send_arb_msg(diff_erc20_vs_cex_pct, chain_swap_full_response, cex_price, dex_price)
|
|
|
+ else:
|
|
|
+ logger.warning("利润满足但链上数据不完整,无法发送套利消息。")
|
|
|
+
|
|
|
+ prev_profit_block_number = block_number
|
|
|
+
|
|
|
+ current_point = {
|
|
|
+ "time": fetch_time_chart,
|
|
|
+ "dex_price": float(dex_price) if dex_price else None,
|
|
|
+ "cex_price": float(cex_price) if cex_price else None,
|
|
|
+ "diff_erc20_vs_cex": float(diff_erc20_vs_cex_pct) if diff_erc20_vs_cex_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["dex_price"] = f"{dex_price:.18f}" if dex_price else "N/A"
|
|
|
+ latest_values_for_table["cex_price"] = f"{cex_price:.18f}" if cex_price else "N/A"
|
|
|
+ latest_values_for_table["diff_dex_vs_cex_percentage"] = diff_erc20_vs_cex_pct
|
|
|
+ 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["dex_error"] = erc20_err
|
|
|
+ latest_values_for_table["cex_error"] = cex_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"
|
|
|
+
|
|
|
+ if erc20_err or cex_err :
|
|
|
+ logger.warning(f"{fetch_time_chart} Errors: erc20:{erc20_err}, CEX:{cex_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,
|
|
|
+ cex_pair_usdt=CEX_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['dex_price'] 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['cex_price'] for p in points], mode='lines',
|
|
|
+ name=f'CEX卖1价 ({display_base_asset}/{display_target_asset})',
|
|
|
+ line=dict(color='rgb(255, 99, 132)', dash='dash'),
|
|
|
+ hovertemplate=f'<b>CEX卖出价</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_erc20_vs_cex'] for p in points], mode='lines', name=f'价差百分比 (CEX卖价 vs Okx买价)',
|
|
|
+ line=dict(color='rgb(255, 159, 64)'),
|
|
|
+ hovertemplate=f'<b>(CEX卖价-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_gate'
|
|
|
+ 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 会动态变化,初始值从配置读取,但循环中会基于CEX的trade_value更新
|
|
|
+ # logger.info(f"链上查询初始金额: {arb['IN_AMOUNT_TO_QUERY']} {BASE_CURRENCY_SYMBOL} -> {TARGET_ASSET_SYMBOL}")
|
|
|
+ logger.info(f"CEX期望卖出量 (用于计算深度和价值): {CEX_TRADE_AMOUNT} {TARGET_ASSET_SYMBOL}")
|
|
|
+ logger.info(f"开仓阈值 > {OPEN_LIMIT} %")
|
|
|
+ logger.info(f"CEX现货交易对: {CEX_TARGET_PAIR_USDT}")
|
|
|
+
|
|
|
+ data_thread = threading.Thread(target=update_data_for_plotly_and_table, daemon=True)
|
|
|
+ data_thread.start()
|
|
|
+
|
|
|
+ api_endpoint_port = arb.get("PORT", 5001) # 从配置获取端口,如果没有则默认5001
|
|
|
+ logger.info(f"Flask 服务将在 http://0.0.0.0:{api_endpoint_port} 上运行 (刷新间隔: {REFRESH_INTERVAL_SECONDS}s)")
|
|
|
+ app.run(debug=False, host='0.0.0.0', port=api_endpoint_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)
|