feat: add convert order type

- Add `convert` orders for direct base/quote token exchange without order book
- Distinguish buy/sell validation: buy uses quoteOrderQty, sell uses quantity
- Support automatic amount estimation when only one of quantity/quoteOrderQty is provided
- Update README: replace old order type list, document convert usage with BASE/QUOTE symbols and new examples
- Lowercase order_type and side values throughout documentation
This commit is contained in:
2026-08-19 14:18:25 +08:00
parent 52fcccd178
commit 77d5f053f0
3 files changed
+240 -20

No files matched your search

+104 -13
View File
@@ -8,11 +8,12 @@ MEXC 定投交易机器人是一个自动化交易工具,用于在 MEXC 交易
1. **配置文件管理**:从 JSON 文件读取交易指令 1. **配置文件管理**:从 JSON 文件读取交易指令
2. **灵活调度**:支持按日期执行交易(`*`表示每天执行) 2. **灵活调度**:支持按日期执行交易(`*`表示每天执行)
3. **多种订单类型**:支持 LIMIT、MARKET、LIMIT_MAKER、IMMEDIATE_OR_CANCEL、FILL_OR_KILL 等订单类型 3. **多种订单类型**:支持 limit(限价)、market(市价)、convert(闪兑)等订单类型
4. **智能价格获取**:当无 price 参数时自动获取实时价格作为限价 4. **智能价格获取**:当无 price 参数时自动获取实时价格作为限价
5. **自动计算数量**LIMIT 订单支持只提供 quoteOrderQty 自动计算 quantity 5. **自动计算数量**limit 订单支持只提供 quoteOrderQty 自动计算 quantity
6. **证券代码映射**:支持不同代码格式间的转换 6. **闪兑交易**:convert 订单直接币币兑换,不走订单簿(buy 用 quoteOrderQtysell 用 quantity
7. **完整交易记录**:记录交易到 CSV 文件和日志 7. **证券代码映射**:支持不同代码格式间的转换
8. **完整交易记录**:记录交易到 CSV 文件和日志
## 安装与使用 ## 安装与使用
@@ -81,19 +82,17 @@ MEXC 定投交易机器人是一个自动化交易工具,用于在 MEXC 交易
- 类型:字符串 - 类型:字符串
- 必填:是 - 必填:是
- 可选值: - 可选值:
- `"LIMIT"`:限价单 - `"limit"`:限价单
- `"MARKET"`:市价单 - `"market"`:市价单
- `"LIMIT_MAKER"`:限价做市单 - `"convert"`:闪兑(币币兑换,不走订单簿,见下方说明)
- `"IMMEDIATE_OR_CANCEL"`:立即成交或取消
- `"FILL_OR_KILL"`:全部成交或取消
##### 3. 交易方向 (`side`) ##### 3. 交易方向 (`side`)
- 类型:字符串 - 类型:字符串
- 必填:是 - 必填:是
- 可选值: - 可选值:
- `"BUY"`:买入 - `"buy"`:买入
- `"SELL"`:卖出 - `"sell"`:卖出
##### 4. 执行日期 (`execute_dates`) ##### 4. 执行日期 (`execute_dates`)
@@ -118,6 +117,16 @@ MEXC 定投交易机器人是一个自动化交易工具,用于在 MEXC 交易
- `quoteOrderQty`:交易金额(字符串格式的数字) - `quoteOrderQty`:交易金额(字符串格式的数字)
- `price`:限价价格(字符串格式的数字),如果为空则自动获取最新价 - `price`:限价价格(字符串格式的数字),如果为空则自动获取最新价
> convert 闪兑订单的参数按方向区分,`quantity` / `quoteOrderQty` 至少提供一个:
> - `buy`:用计价币兑换基础币
> - `quoteOrderQty`:指定花费金额(如 `"100"` 表示用 100 USDT 兑换)
> - `quantity`:指定目标数量(如 `"0.01"` 表示兑换约 0.01 BTC,自动按实时价估算所需金额)
> - `sell`:用基础币兑换计价币
> - `quantity`:指定卖出数量(如 `"0.5"` 表示卖出 0.5 ETH
> - `quoteOrderQty`:指定目标金额(如 `"2000"` 表示兑换到约 2000 USDT,自动按实时价估算卖出数量)
>
> convert 订单`symbol`需使用 `BASE/QUOTE` 格式,如 `"BTC/USDT"`。
### 配置示例 ### 配置示例
#### 示例 1:每日限价买入 #### 示例 1:每日限价买入
@@ -157,7 +166,87 @@ MEXC 定投交易机器人是一个自动化交易工具,用于在 MEXC 交易
} }
``` ```
#### 示例 3多种订单组合 #### 示例 3每日闪兑买入
```json
{
"trades": [
{
"symbol": "BTC/USDT",
"order_type": "convert",
"side": "buy",
"execute_dates": ["*"],
"params": {
"quoteOrderQty": "100"
}
}
]
}
```
说明:每天用 100 USDT 通过闪兑兑换成 BTC(不走订单簿)。
#### 示例 4:闪兑卖出(按数量)
```json
{
"trades": [
{
"symbol": "ETH/USDT",
"order_type": "convert",
"side": "sell",
"execute_dates": ["2023-12-25"],
"params": {
"quantity": "0.5"
}
}
]
}
```
说明:在指定日期将 0.5 ETH 通过闪兑兑换成 USDT。
#### 示例 4b:闪兑买入(按目标数量自动估算金额)
```json
{
"trades": [
{
"symbol": "BTC/USDT",
"order_type": "convert",
"side": "buy",
"execute_dates": ["*"],
"params": {
"quantity": "0.01"
}
}
]
}
```
说明:每天按实时价估算约 0.01 BTC 所需 USDT,然后通过闪兑买入。
#### 示例 4c:闪兑卖出(按目标金额自动估算数量)
```json
{
"trades": [
{
"symbol": "ETH/USDT",
"order_type": "convert",
"side": "sell",
"execute_dates": ["2023-12-25"],
"params": {
"quoteOrderQty": "2000"
}
}
]
}
```
说明:在指定日期按实时价估算卖出 ETH,兑换到约 2000 USDT。
#### 示例 5:多种订单组合
```json ```json
{ {
@@ -188,10 +277,12 @@ MEXC 定投交易机器人是一个自动化交易工具,用于在 MEXC 交易
### 注意事项 ### 注意事项
1. 价格和数量都使用字符串格式而非数字,以避免浮点数精度问题 1. 价格和数量都使用字符串格式而非数字,以避免浮点数精度问题
2. 对于 LIMIT 订单,可以只提供`quoteOrderQty`,系统会自动计算`quantity` 2. 对于 `limit` 订单,可以只提供`quoteOrderQty`,系统会自动计算`quantity`
3. 当`execute_dates`包含`"*"`时,会忽略其他日期设置 3. 当`execute_dates`包含`"*"`时,会忽略其他日期设置
4. 系统会自动创建`output`目录存放日志和交易记录 4. 系统会自动创建`output`目录存放日志和交易记录
5. 所有时间均以系统时区为准 5. 所有时间均以系统时区为准
6. `order_type`、`side` 均为小写;`convert` 订单的`symbol`使用 `BASE/QUOTE` 格式
7. `convert` 闪兑产出的 CSV 备注为 `DCA Order ID (Convert): {id}``tool_csv_merge.py` 已兼容该格式
### 错误处理 ### 错误处理
+133 -6
View File
@@ -7,7 +7,7 @@
功能: 功能:
1. 从JSON配置文件读取交易指令 1. 从JSON配置文件读取交易指令
2. 支持按日期执行多个交易(*表示每天执行) 2. 支持按日期执行多个交易(*表示每天执行)
3. 支持多种订单类型 (limit, market) 3. 支持多种订单类型 (limit, market, convert)
4. 当无price时自动获取实时价格作为限价 4. 当无price时自动获取实时价格作为限价
5. limit订单支持只提供quoteOrderQty自动计算quantity 5. limit订单支持只提供quoteOrderQty自动计算quantity
6. 证券代码映射功能 6. 证券代码映射功能
@@ -100,7 +100,8 @@ class BotSpotTrade:
ORDER_TYPE_REQUIREMENTS = { ORDER_TYPE_REQUIREMENTS = {
"limit": ["quantity", "price", "quoteOrderQty"], "limit": ["quantity", "price", "quoteOrderQty"],
"market": ["quantity", "quoteOrderQty"] "market": ["quantity", "quoteOrderQty"],
"convert": ["quoteOrderQty", "quantity"]
} }
def __init__(self, exchange: ccxt.Exchange, symbol_mapping: Dict[str, str], config_file_name: str): def __init__(self, exchange: ccxt.Exchange, symbol_mapping: Dict[str, str], config_file_name: str):
@@ -138,7 +139,18 @@ class BotSpotTrade:
def _tool_map_symbol(self, symbol: str) -> str: def _tool_map_symbol(self, symbol: str) -> str:
return self.symbol_mapping.get(symbol, symbol) return self.symbol_mapping.get(symbol, symbol)
def _tool_validate_order_params(self, order_type: str, params: Dict[str, Any]) -> Tuple[bool, str]: def _tool_validate_order_params(
self, order_type: str, params: Dict[str, Any], side: Optional[str] = None
) -> Tuple[bool, str]:
if order_type == "convert":
if side == "buy":
if "quoteOrderQty" not in params and "quantity" not in params:
return False, "convert订单(买入)需要quoteOrderQty或quantity参数"
elif side == "sell":
if "quantity" not in params and "quoteOrderQty" not in params:
return False, "convert订单(卖出)需要quantity或quoteOrderQty参数"
return True, ""
required_params = self.ORDER_TYPE_REQUIREMENTS.get(order_type, []) required_params = self.ORDER_TYPE_REQUIREMENTS.get(order_type, [])
if not required_params: if not required_params:
return False, f"未知的订单类型: {order_type}" return False, f"未知的订单类型: {order_type}"
@@ -167,7 +179,7 @@ class BotSpotTrade:
result = result.rstrip('0').rstrip('.') result = result.rstrip('0').rstrip('.')
return result return result
def _tool_record_transaction(self, order_data: Dict[str, Any]) -> bool: def _tool_record_transaction(self, order_data: Dict[str, Any], is_convert: bool = False) -> bool:
try: try:
original_symbol = order_data["symbol"] original_symbol = order_data["symbol"]
mapped_symbol = self._tool_map_symbol(original_symbol) mapped_symbol = self._tool_map_symbol(original_symbol)
@@ -180,6 +192,11 @@ class BotSpotTrade:
trade_type = "买入" if side == "buy" else "卖出" trade_type = "买入" if side == "buy" else "卖出"
timestamp = datetime.fromtimestamp(order_data["timestamp"] / 1000).strftime("%Y-%m-%dT%H:%M") timestamp = datetime.fromtimestamp(order_data["timestamp"] / 1000).strftime("%Y-%m-%dT%H:%M")
if is_convert:
note = f"DCA Order ID (Convert): {order_id}"
else:
note = f"DCA Order ID: {order_id}"
row = [ row = [
timestamp, timestamp,
trade_type, trade_type,
@@ -188,7 +205,7 @@ class BotSpotTrade:
cummulative_quote_qty, cummulative_quote_qty,
"资金账户", "资金账户",
"CEX", "CEX",
f"DCA Order ID: {order_id}", note,
balances, balances,
] ]
@@ -214,7 +231,117 @@ class BotSpotTrade:
self.logger.error("记录交易失败: %s", str(e)) self.logger.error("记录交易失败: %s", str(e))
return False return False
def _trade_convert(self, symbol: str, side: str, **kwargs) -> Optional[Dict[str, Any]]:
"""闪兑交易:直接币币兑换,不走订单簿"""
if side not in ["buy", "sell"]:
self.logger.error("无效的交易方向: %s", side)
return None
quote_order_qty = kwargs.get("quoteOrderQty")
quantity = kwargs.get("quantity")
try:
base_currency, quote_currency = symbol.split("/")
except ValueError:
self.logger.error("无效的交易对格式: %s(应为 BASE/QUOTE 格式)", symbol)
return None
if side == "buy":
from_currency = quote_currency
to_currency = base_currency
if quote_order_qty is not None:
amount_str = quote_order_qty
else:
price = self.market.get_price(symbol)
if price is None:
self.logger.error("无法获取实时价格来估算花费金额")
return None
amount_str = str(float(quantity) * price)
else:
from_currency = base_currency
to_currency = quote_currency
if quantity is not None:
amount_str = quantity
else:
price = self.market.get_price(symbol)
if price is None:
self.logger.error("无法获取实时价格来估算卖出数量")
return None
amount_str = str(float(quote_order_qty) / price)
try:
amount = float(amount_str)
except (ValueError, TypeError):
self.logger.error("无效的金额: %s", amount_str)
return None
self.logger.info("开始闪兑: %s %s%s", amount, from_currency, to_currency)
try:
quote = self.exchange.fetchConvertQuote(from_currency, to_currency, amount)
self.logger.info(
"获取闪兑报价成功: %s %s%s %s, 报价ID: %s",
from_currency, amount, to_currency,
quote.get("toAmount"), quote.get("id"),
)
except Exception as e:
self.logger.error("获取闪兑报价失败: %s", str(e))
return None
if not quote or not quote.get("id"):
self.logger.error("闪兑报价无效: %s", quote)
return None
quote_id = quote["id"]
try:
convert_trade = self.exchange.createConvertTrade(
quote_id, from_currency, to_currency, amount
)
self.logger.info("创建闪兑交易成功: %s", convert_trade)
except Exception as e:
self.logger.error("创建闪兑交易失败: %s", str(e))
return None
if not convert_trade or not convert_trade.get("id"):
self.logger.error("闪兑交易未返回有效的ID: %s", convert_trade)
return None
trade_id = convert_trade["id"]
trade_detail = None
try:
trade_detail = self.exchange.fetchConvertTrade(trade_id, from_currency)
self.logger.info("查询闪兑结果成功: %s", trade_detail)
except Exception as e:
self.logger.warning("查询闪兑结果失败: %s(使用创建返回的数据)", str(e))
final_data = trade_detail or convert_trade
record_data = {
"symbol": symbol,
"id": trade_id,
"filled": final_data.get("toAmount", 0.0),
"cost": final_data.get("fromAmount", 0.0),
"side": side,
"timestamp": final_data.get("timestamp", int(time.time() * 1000)),
}
if not self._tool_record_transaction(record_data, is_convert=True):
self.logger.error("闪兑交易记录失败")
self.logger.info("闪兑交易完成: %s %s%s, 交易ID: %s", side, from_currency, to_currency, trade_id)
return final_data
def trade(self, symbol: str, order_type: str, side: str, **kwargs) -> Optional[Dict[str, Any]]: def trade(self, symbol: str, order_type: str, side: str, **kwargs) -> Optional[Dict[str, Any]]:
is_valid, error_msg = self._tool_validate_order_params(order_type, kwargs, side)
if not is_valid:
self.logger.error("订单参数验证失败: %s", error_msg)
return None
if order_type == "convert":
return self._trade_convert(symbol, side, **kwargs)
if side not in ["buy", "sell"]: if side not in ["buy", "sell"]:
self.logger.error("无效的交易方向: %s", side) self.logger.error("无效的交易方向: %s", side)
return None return None
@@ -273,7 +400,7 @@ class BotSpotTrade:
clean_price = current_price clean_price = current_price
amount = float(processed_kwargs["quoteOrderQty"]) / clean_price amount = float(processed_kwargs["quoteOrderQty"]) / clean_price
is_valid, error_msg = self._tool_validate_order_params(order_type, processed_kwargs) is_valid, error_msg = self._tool_validate_order_params(order_type, processed_kwargs, side)
if not is_valid: if not is_valid:
self.logger.error("订单参数验证失败: %s", error_msg) self.logger.error("订单参数验证失败: %s", error_msg)
return None return None
+3 -1
View File
@@ -5,6 +5,7 @@
import csv import csv
import os import os
import re
from collections import defaultdict from collections import defaultdict
@@ -33,7 +34,8 @@ def process_csv(input_file, output_file):
for row in reader: for row in reader:
datetime_str = row["日期"] datetime_str = row["日期"]
date_part, time_part = datetime_str.split("T") date_part, time_part = datetime_str.split("T")
order_id = row["备注"].split("Order ID: ")[-1].strip() match = re.search(r"Order ID[^:]*:\s*([^\s,]+)", row["备注"])
order_id = match.group(1) if match else row["备注"]
merge_key = (date_part, row["证券代码"]) merge_key = (date_part, row["证券代码"])
record = merged_records[merge_key] record = merged_records[merge_key]