efactor(qmt): 重构架构 - 统一信号发送和消息处理

- 修复 qmt_engine.py 中 initialize() 的重复代码
- 新增 message_processor.py: Redis Stream 消息处理器
- 新增 logger.py: 细粒度日志模块
- 新增 qmt_sender.py: 统一信号发送端(槽位+百分比模式)
- 新增 backtest_consumer.py: 回测消息消费者
- 删除旧模块: qmt_trader.py, qmt_signal_sender.py, qmt_percentage_sender.py
- 更新文档: qmt_functionality.md 反映新架构
This commit is contained in:
2026-02-25 20:49:56 +08:00
parent 5628fbb34c
commit 7b4112b70b
13 changed files with 6976 additions and 1380 deletions

View File

@@ -0,0 +1,20 @@
# coding: utf-8
"""
QMT (Quantitative Trading) Module
提供量化交易相关的功能:
- Redis Stream 消息处理
- 交易信号发送
- 回测消息消费
- 细粒度日志记录
"""
from .message_processor import StreamMessageProcessor, send_qmt_signal_to_stream
from .logger import QMTLogger, get_qmt_logger
__all__ = [
"StreamMessageProcessor",
"send_qmt_signal_to_stream",
"QMTLogger",
"get_qmt_logger",
]

440
qmt/backtest_consumer.py Normal file
View File

@@ -0,0 +1,440 @@
# coding: utf-8
"""
QMT 回测消息消费者
独立的回测消息消费脚本,用于:
- 消费回测消息流
- 记录完整的处理流程日志
- 模拟订单执行(不执行真实交易)
- ACK 确认消息
使用方法:
# 守护模式(持续运行,处理所有配置的策略)
python backtest_consumer.py
# 单次运行模式(处理一次后退出)
python backtest_consumer.py --once
# 指定策略运行
python backtest_consumer.py --strategy StrategyA
python backtest_consumer.py --once --strategy StrategyA,StrategyB
配置说明:
可通过环境变量或 .env.local 文件配置:
- REDIS_HOST: Redis 主机地址(默认: localhost
- REDIS_PORT: Redis 端口(默认: 6379
- REDIS_PASSWORD: Redis 密码(默认: None
- REDIS_DB: Redis 数据库(默认: 0
- BACKTEST_CONSUMER_ID: 消费者ID默认: backtest-consumer-1
- BACKTEST_STRATEGIES: 默认策略列表,逗号分隔
- LOG_LEVEL: 日志级别(默认: DEBUG
- LOG_FILE: 日志文件路径(默认: logs/backtest_consumer.log
消息格式:
消费的消息为 JSON 格式,包含以下字段:
- strategy_name: 策略名称
- stock_code: 股票代码(如 000001.SZ
- action: 交易动作BUY/SELL
- price: 价格
- total_slots: 目标持仓槽位数
- timestamp: 时间戳
- is_backtest: 是否为回测模式
"""
import os
import sys
import time
import json
import signal
import argparse
from datetime import datetime
from typing import List, Optional, Dict, Any
# 添加父目录到路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
from message_processor import StreamMessageProcessor
from logger import QMTLogger
except ImportError as e:
print(f"导入错误: {e}")
print("请确保在 qmt 目录下运行此脚本")
sys.exit(1)
def load_config() -> Dict[str, Any]:
"""加载配置文件
从 /qmt/config/.env.local 加载环境变量配置
Returns:
配置字典
"""
# 尝试加载 python-dotenv
dotenv_loaded = False
try:
from dotenv import load_dotenv
dotenv_available = True
except ImportError:
dotenv_available = False
print("[Config] 警告: 未安装 python-dotenv使用默认配置")
# 如果 dotenv 可用,尝试加载配置文件
if dotenv_available:
# 尝试多个路径
env_paths = [
os.path.join(
os.path.dirname(os.path.abspath(__file__)), "config", ".env.local"
),
os.path.join(os.path.dirname(__file__), "config", ".env.local"),
os.path.join(os.path.dirname(__file__), "..", "config", ".env.local"),
"/qmt/config/.env.local",
"config/.env.local",
]
for env_path in env_paths:
if os.path.exists(env_path):
load_dotenv(env_path)
print(f"[Config] 加载配置文件: {env_path}")
dotenv_loaded = True
break
if not dotenv_loaded:
print("[Config] 警告: 未找到 .env.local 文件,使用默认配置")
# 从环境变量读取配置
config = {
"redis": {
"host": os.getenv("REDIS_HOST", "localhost"),
"port": int(os.getenv("REDIS_PORT", "6379")),
"password": os.getenv("REDIS_PASSWORD") or None,
"db": int(os.getenv("REDIS_DB", "0")),
},
"consumer_id": os.getenv("BACKTEST_CONSUMER_ID", "backtest-consumer-1"),
"block_timeout": int(os.getenv("BACKTEST_BLOCK_TIMEOUT", "5000")),
"strategies": os.getenv("BACKTEST_STRATEGIES", ""),
"simulate_delay": float(os.getenv("BACKTEST_SIMULATE_DELAY", "0.1")),
"log_level": os.getenv("LOG_LEVEL", "DEBUG"),
"log_file": os.getenv("LOG_FILE", "logs/backtest_consumer.log"),
}
# 解析策略列表
if config["strategies"]:
config["strategy_list"] = [
s.strip() for s in config["strategies"].split(",") if s.strip()
]
else:
config["strategy_list"] = []
return config
class BacktestConsumer:
"""回测消息消费者"""
def __init__(self, config: Dict[str, Any]):
"""初始化回测消费者
Args:
config: 配置字典
"""
self.config = config
self.logger = QMTLogger(name="BacktestConsumer", log_file=config["log_file"])
# 初始化 Stream 处理器
self.processor = StreamMessageProcessor(redis_config=config["redis"])
# 运行状态
self.running = True
# 统计信息
self.stats = {
"total_received": 0,
"total_processed": 0,
"total_failed": 0,
"start_time": datetime.now(),
}
self.logger.info(f"[BacktestConsumer] 初始化完成")
self.logger.info(f"[BacktestConsumer] 消费者ID: {config['consumer_id']}")
self.logger.info(
f"[BacktestConsumer] 策略列表: {config['strategy_list'] or '所有策略'}"
)
def signal_handler(self, signum, frame):
"""信号处理函数"""
self.logger.info(f"[BacktestConsumer] 收到信号 {signum},准备退出...")
self.running = False
def simulate_order(self, strategy_name: str, data: Dict[str, Any]) -> bool:
"""模拟订单执行
Args:
strategy_name: 策略名称
data: 消息数据
Returns:
模拟成功返回 True
"""
action = data.get("action")
stock_code = data.get("stock_code")
price = data.get("price", 0)
total_slots = data.get("total_slots", 0)
self.logger.log_order_execution(
strategy_name=strategy_name,
stock_code=stock_code,
action=action,
volume=100, # 模拟数量
price=price,
)
# 模拟延迟
if self.config["simulate_delay"] > 0:
time.sleep(self.config["simulate_delay"])
# 记录模拟结果
if action == "BUY":
self.logger.info(
f"[模拟交易] {strategy_name} 买入 {stock_code} @ {price}, "
f"目标持仓: {total_slots}"
)
elif action == "SELL":
self.logger.info(
f"[模拟交易] {strategy_name} 卖出 {stock_code} @ {price}, 清仓释放槽位"
)
else:
self.logger.warning(f"[模拟交易] 未知动作: {action}")
return False
# 记录成功
self.logger.log_order_execution(
strategy_name=strategy_name,
stock_code=stock_code,
action=action,
volume=100,
price=price,
order_id=-1, # 模拟订单ID
)
return True
def process_messages(self, strategy_name: str) -> int:
"""处理单个策略的消息
Args:
strategy_name: 策略名称
Returns:
处理的消息数量
"""
processed = 0
try:
# 消费消息(回测模式,不使用消费者组)
messages = self.processor.consume_message(
strategy_name=strategy_name,
consumer_id=self.config["consumer_id"],
is_backtest=True,
block_ms=100, # 短阻塞
count=10, # 每次最多处理10条
)
if not messages:
return 0
for msg_id, data in messages:
self.stats["total_received"] += 1
try:
stream_key = f"qmt:{strategy_name}:backtest"
# 1. 记录消息接收
self.logger.log_message_receive(stream_key, msg_id, data)
# 2. 记录消息解析
self.logger.log_message_parse(
strategy_name=data.get("strategy_name", strategy_name),
stock_code=data.get("stock_code", ""),
action=data.get("action", ""),
price=data.get("price", 0),
extra_fields={
"total_slots": data.get("total_slots"),
"timestamp": data.get("timestamp"),
"is_backtest": data.get("is_backtest"),
},
)
# 3. 业务校验
self.logger.log_validation(
validation_type="field_check",
strategy_name=strategy_name,
details={"fields": list(data.keys())},
result=True,
)
# 4. 模拟订单执行
success = self.simulate_order(strategy_name, data)
if success:
self.stats["total_processed"] += 1
else:
self.stats["total_failed"] += 1
# 5. ACK 消息(回测模式不需要真正的 ACK但记录日志
self.logger.log_ack(stream_key, msg_id, True)
processed += 1
except Exception as e:
error_msg = f"消息处理异常: {str(e)}"
self.logger.error(error_msg, exc_info=True)
self.logger.log_failure(
stream_key=f"qmt:{strategy_name}:backtest",
message_id=msg_id,
error_reason=str(e),
to_failure_queue=False,
)
self.stats["total_failed"] += 1
except Exception as e:
self.logger.error(f"消费消息异常: {str(e)}", exc_info=True)
return processed
def run_once(self, specific_strategies: Optional[List[str]] = None) -> None:
"""单次运行
Args:
specific_strategies: 指定要处理的策略列表None 则处理所有
"""
strategies = specific_strategies or self.config["strategy_list"]
if not strategies:
self.logger.warning("[BacktestConsumer] 未指定策略,退出")
return
self.logger.info(f"[BacktestConsumer] 单次运行,策略: {strategies}")
total_processed = 0
for strategy in strategies:
count = self.process_messages(strategy)
total_processed += count
if count > 0:
self.logger.info(
f"[BacktestConsumer] 策略 {strategy} 处理 {count} 条消息"
)
self.logger.info(
f"[BacktestConsumer] 单次运行完成,共处理 {total_processed} 条消息"
)
self._log_stats()
def run_daemon(self, specific_strategies: Optional[List[str]] = None) -> None:
"""守护模式运行
Args:
specific_strategies: 指定要处理的策略列表None 则处理所有
"""
strategies = specific_strategies or self.config["strategy_list"]
if not strategies:
self.logger.warning("[BacktestConsumer] 未指定策略,退出")
return
self.logger.info(f"[BacktestConsumer] 守护模式启动,策略: {strategies}")
self.logger.info("按 Ctrl+C 停止")
# 设置信号处理
signal.signal(signal.SIGINT, self.signal_handler)
signal.signal(signal.SIGTERM, self.signal_handler)
last_heartbeat = time.time()
while self.running:
try:
# 处理所有策略
for strategy in strategies:
if not self.running:
break
self.process_messages(strategy)
# 心跳日志每60秒
if time.time() - last_heartbeat > 60:
self.logger.log_heartbeat(
component="BacktestConsumer",
status="running",
extra_info=self.stats,
)
last_heartbeat = time.time()
# 短暂休眠避免CPU占用过高
time.sleep(0.1)
except Exception as e:
self.logger.error(f"守护循环异常: {str(e)}", exc_info=True)
time.sleep(1)
self.logger.info("[BacktestConsumer] 守护模式停止")
self._log_stats()
def _log_stats(self) -> None:
"""记录统计信息"""
runtime = (datetime.now() - self.stats["start_time"]).total_seconds()
self.logger.info("=" * 50)
self.logger.info("[统计信息]")
self.logger.info(f"运行时间: {runtime:.1f}")
self.logger.info(f"接收消息: {self.stats['total_received']}")
self.logger.info(f"处理成功: {self.stats['total_processed']}")
self.logger.info(f"处理失败: {self.stats['total_failed']}")
self.logger.info("=" * 50)
def main():
"""主函数"""
parser = argparse.ArgumentParser(
description="QMT 回测消息消费者",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
python backtest_consumer.py # 守护模式,处理所有配置的策略
python backtest_consumer.py --once # 单次运行
python backtest_consumer.py --strategy A,B # 指定策略
python backtest_consumer.py --once --strategy A # 单次运行指定策略
""",
)
parser.add_argument(
"--once", action="store_true", help="单次运行模式(处理一次后退出)"
)
parser.add_argument(
"--strategy",
type=str,
default="",
help="指定策略名称,多个用逗号分隔(如: StrategyA,StrategyB",
)
args = parser.parse_args()
# 加载配置
config = load_config()
# 命令行参数覆盖配置
specific_strategies = None
if args.strategy:
specific_strategies = [s.strip() for s in args.strategy.split(",") if s.strip()]
# 创建消费者
consumer = BacktestConsumer(config)
# 运行
if args.once:
consumer.run_once(specific_strategies)
else:
consumer.run_daemon(specific_strategies)
if __name__ == "__main__":
main()

301
qmt/logger.py Normal file
View File

@@ -0,0 +1,301 @@
# coding: utf-8
"""
QMT Fine-grained Logger Module
提供细粒度的日志记录功能,用于追踪消息处理全流程。
所有方法默认使用 DEBUG 级别,确保详细的追踪信息。
"""
import logging
import sys
import os
from logging.handlers import RotatingFileHandler
from typing import Dict, Any, Optional
class QMTLogger:
"""QMT 细粒度日志记录器
提供消息处理全流程的日志记录,包括:
- 消息接收
- 消息解析
- 业务校验
- 订单执行
- 消息确认
- 失败处理
"""
def __init__(
self,
name: str = "QMT_Stream",
log_file: str = "logs/qmt_stream.log",
log_level: int = logging.DEBUG,
):
"""初始化日志记录器
Args:
name: 日志记录器名称
log_file: 日志文件路径
log_level: 日志级别
"""
self.logger = logging.getLogger(name)
self.logger.setLevel(log_level)
# 避免重复添加处理器
if self.logger.handlers:
return
# 创建日志目录
log_dir = os.path.dirname(log_file)
if log_dir and not os.path.exists(log_dir):
os.makedirs(log_dir)
# 日志格式
formatter = logging.Formatter(
"[%(asctime)s] [%(levelname)s] [%(filename)s:%(lineno)d] [%(threadName)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# 文件处理器 (轮转,最大 10MB保留 5 个备份)
file_handler = RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=5,
encoding="utf-8",
)
file_handler.setFormatter(formatter)
file_handler.setLevel(log_level)
# 控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
console_handler.setLevel(log_level)
self.logger.addHandler(file_handler)
self.logger.addHandler(console_handler)
self.logger.debug(f"[QMTLogger] 日志记录器初始化完成: {name}")
def log_message_receive(
self, stream_key: str, message_id: str, message_data: Dict[str, Any]
) -> None:
"""记录消息接收日志
Args:
stream_key: 流键名
message_id: 消息ID
message_data: 消息数据
"""
self.logger.debug(
f"[消息接收] stream={stream_key}, id={message_id}, "
f"data={str(message_data)[:500]}"
)
def log_message_parse(
self,
strategy_name: str,
stock_code: str,
action: str,
price: float,
extra_fields: Optional[Dict[str, Any]] = None,
) -> None:
"""记录消息解析日志
Args:
strategy_name: 策略名称
stock_code: 股票代码
action: 交易动作
price: 价格
extra_fields: 其他字段
"""
extra_str = f", extra={extra_fields}" if extra_fields else ""
self.logger.debug(
f"[消息解析] strategy={strategy_name}, code={stock_code}, "
f"action={action}, price={price}{extra_str}"
)
def log_validation(
self,
validation_type: str,
strategy_name: str,
details: Dict[str, Any],
result: bool,
) -> None:
"""记录业务校验日志
Args:
validation_type: 校验类型 (如: slot_check, date_check, field_check)
strategy_name: 策略名称
details: 校验详情
result: 校验结果 (True/False)
"""
result_str = "通过" if result else "拦截"
self.logger.debug(
f"[业务校验] type={validation_type}, strategy={strategy_name}, "
f"result={result_str}, details={details}"
)
def log_order_execution(
self,
strategy_name: str,
stock_code: str,
action: str,
volume: int,
price: float,
order_id: Optional[int] = None,
error: Optional[str] = None,
) -> None:
"""记录订单执行日志
Args:
strategy_name: 策略名称
stock_code: 股票代码
action: 交易动作
volume: 数量
price: 价格
order_id: 订单ID (成功时)
error: 错误信息 (失败时)
"""
if error:
self.logger.error(
f"[订单执行失败] strategy={strategy_name}, code={stock_code}, "
f"action={action}, vol={volume}, price={price}, error={error}"
)
elif order_id is not None:
self.logger.info(
f"[订单执行成功] strategy={strategy_name}, code={stock_code}, "
f"action={action}, vol={volume}, price={price}, order_id={order_id}"
)
else:
self.logger.debug(
f"[订单执行请求] strategy={strategy_name}, code={stock_code}, "
f"action={action}, vol={volume}, price={price}"
)
def log_ack(self, stream_key: str, message_id: str, success: bool) -> None:
"""记录消息确认日志
Args:
stream_key: 流键名
message_id: 消息ID
success: 确认是否成功
"""
result_str = "成功" if success else "失败"
if success:
self.logger.debug(
f"[消息确认] stream={stream_key}, id={message_id}, result={result_str}"
)
else:
self.logger.warning(
f"[消息确认] stream={stream_key}, id={message_id}, result={result_str}"
)
def log_failure(
self,
stream_key: str,
message_id: str,
error_reason: str,
to_failure_queue: bool = True,
) -> None:
"""记录失败处理日志
Args:
stream_key: 流键名
message_id: 消息ID
error_reason: 失败原因
to_failure_queue: 是否已进入失败队列
"""
queue_str = "已入失败队列" if to_failure_queue else "未入失败队列"
self.logger.warning(
f"[失败处理] stream={stream_key}, id={message_id}, "
f"reason={error_reason}, status={queue_str}"
)
def log_consumer_group(
self, stream_key: str, group_name: str, action: str, result: bool
) -> None:
"""记录消费者组操作日志
Args:
stream_key: 流键名
group_name: 消费者组名
action: 操作 (create, check)
result: 操作结果
"""
result_str = "成功" if result else "失败"
self.logger.debug(
f"[消费者组] stream={stream_key}, group={group_name}, "
f"action={action}, result={result_str}"
)
def log_pending_claim(
self, stream_key: str, consumer_id: str, message_ids: list, count: int
) -> None:
"""记录待处理消息认领日志
Args:
stream_key: 流键名
consumer_id: 消费者ID
message_ids: 认领的消息ID列表
count: 认领数量
"""
self.logger.info(
f"[消息认领] stream={stream_key}, consumer={consumer_id}, "
f"claimed_count={count}, ids={message_ids}"
)
def log_heartbeat(
self, component: str, status: str, extra_info: Optional[Dict[str, Any]] = None
) -> None:
"""记录心跳日志
Args:
component: 组件名称
status: 状态
extra_info: 额外信息
"""
extra_str = f", info={extra_info}" if extra_info else ""
self.logger.debug(f"[心跳] component={component}, status={status}{extra_str}")
def info(self, message: str) -> None:
"""记录 INFO 级别日志"""
self.logger.info(message)
def warning(self, message: str) -> None:
"""记录 WARNING 级别日志"""
self.logger.warning(message)
def error(self, message: str, exc_info: bool = False) -> None:
"""记录 ERROR 级别日志"""
self.logger.error(message, exc_info=exc_info)
def debug(self, message: str) -> None:
"""记录 DEBUG 级别日志"""
self.logger.debug(message)
# 全局日志记录器实例
_default_logger = None
def get_qmt_logger(
name: str = "QMT_Stream",
log_file: str = "logs/qmt_stream.log",
log_level: int = logging.DEBUG,
) -> QMTLogger:
"""获取 QMT 日志记录器实例
返回单例模式的日志记录器。
Args:
name: 日志记录器名称
log_file: 日志文件路径
log_level: 日志级别
Returns:
QMTLogger 实例
"""
global _default_logger
if _default_logger is None:
_default_logger = QMTLogger(name, log_file, log_level)
return _default_logger

533
qmt/message_processor.py Normal file
View File

@@ -0,0 +1,533 @@
# coding: utf-8
"""
QMT Redis Stream Message Processor
统一封装 Redis Stream 操作,提供消息发送、消费、确认、失败处理等功能。
"""
import json
import logging
import socket
import os
from typing import Dict, Any, Optional, List, Tuple
from datetime import datetime
import redis
class StreamMessageProcessor:
"""Redis Stream 消息处理器
封装所有 Redis Stream 操作,包括:
- 消息发送 (XADD)
- 消息消费 (XREADGROUP)
- 消息确认 (XACK)
- 失败队列 (XADD to failure stream)
- 消费者组管理
- 待处理消息认领
"""
# 消费者组名称
CONSUMER_GROUP = "qmt_consumers"
# 最大消息长度
MAXLEN = 1000
# 流键前缀
STREAM_PREFIX = "qmt"
def __init__(
self,
redis_client: Optional[redis.Redis] = None,
redis_config: Optional[Dict[str, Any]] = None,
):
"""初始化消息处理器
Args:
redis_client: 已有的 Redis 连接实例
redis_config: Redis 配置字典 (当 redis_client 为 None 时使用)
格式: {"host": "localhost", "port": 6379, "password": None, "db": 0}
"""
self.logger = logging.getLogger("QMT_Stream")
if redis_client:
self.r = redis_client
elif redis_config:
self.r = redis.Redis(
host=redis_config.get("host", "localhost"),
port=redis_config.get("port", 6379),
password=redis_config.get("password"),
db=redis_config.get("db", 0),
decode_responses=True,
)
else:
# 尝试从环境变量加载配置
self.r = self._load_redis_from_env()
self.logger.debug(f"[StreamMessageProcessor] Redis连接初始化完成")
def _load_redis_from_env(self) -> redis.Redis:
"""从环境变量加载 Redis 配置"""
import os
host = os.getenv("REDIS_HOST", "localhost")
port = int(os.getenv("REDIS_PORT", "6379"))
password = os.getenv("REDIS_PASSWORD") or None
db = int(os.getenv("REDIS_DB", "0"))
return redis.Redis(
host=host, port=port, password=password, db=db, decode_responses=True
)
def _get_stream_key(self, strategy_name: str, is_backtest: bool = False) -> str:
"""获取流键名
Args:
strategy_name: 策略名称
is_backtest: 是否为回测模式
Returns:
流键名,格式: qmt:{strategy_name}:real 或 qmt:{strategy_name}:backtest
"""
suffix = "backtest" if is_backtest else "real"
return f"{self.STREAM_PREFIX}:{strategy_name}:{suffix}"
def _get_failure_stream_key(self, strategy_name: str) -> str:
"""获取失败流键名
Args:
strategy_name: 策略名称
Returns:
失败流键名,格式: qmt:{strategy_name}:failed
"""
return f"{self.STREAM_PREFIX}:{strategy_name}:failed"
def send_message(
self,
strategy_name: str,
message_data: Dict[str, Any],
is_backtest: bool = False,
) -> Optional[str]:
"""发送消息到 Redis Stream
Args:
strategy_name: 策略名称
message_data: 消息数据字典
is_backtest: 是否为回测消息
Returns:
消息ID (格式: timestamp-sequence),失败返回 None
"""
stream_key = self._get_stream_key(strategy_name, is_backtest)
try:
# 确保消息数据是字符串格式
if isinstance(message_data, dict):
message_json = json.dumps(message_data, ensure_ascii=False)
else:
message_json = str(message_data)
# 使用 XADD 发送消息,设置最大长度
message_id = self.r.xadd(
stream_key,
{"data": message_json},
maxlen=self.MAXLEN,
approximate=True, # 使用近似裁剪,提高性能
)
self.logger.debug(
f"[消息发送] stream={stream_key}, id={message_id}, "
f"is_backtest={is_backtest}, data={message_json[:200]}"
)
return message_id
except redis.RedisError as e:
self.logger.error(f"[消息发送失败] stream={stream_key}, error={str(e)}")
return None
except Exception as e:
self.logger.error(
f"[消息发送异常] stream={stream_key}, error={str(e)}", exc_info=True
)
return None
def create_consumer_group(
self, strategy_name: str, is_backtest: bool = False
) -> bool:
"""创建消费者组
如果消费者组已存在,则忽略错误。
Args:
strategy_name: 策略名称
is_backtest: 是否为回测模式
Returns:
创建成功返回 True失败返回 False
"""
# 回测模式不使用消费者组
if is_backtest:
return True
stream_key = self._get_stream_key(strategy_name, is_backtest)
try:
# 创建消费者组,从最新消息开始消费 ($)
self.r.xgroup_create(stream_key, self.CONSUMER_GROUP, id="$", mkstream=True)
self.logger.info(
f"[消费者组创建] stream={stream_key}, group={self.CONSUMER_GROUP}"
)
return True
except redis.ResponseError as e:
if "BUSYGROUP" in str(e):
# 消费者组已存在,这是正常的
self.logger.debug(
f"[消费者组已存在] stream={stream_key}, group={self.CONSUMER_GROUP}"
)
return True
else:
self.logger.error(
f"[消费者组创建失败] stream={stream_key}, error={str(e)}"
)
return False
except Exception as e:
self.logger.error(
f"[消费者组创建异常] stream={stream_key}, error={str(e)}", exc_info=True
)
return False
def consume_message(
self,
strategy_name: str,
consumer_id: Optional[str] = None,
is_backtest: bool = False,
block_ms: int = 5000,
count: int = 1,
) -> Optional[List[Tuple[str, Dict[str, Any]]]]:
"""从 Redis Stream 消费消息
Args:
strategy_name: 策略名称
consumer_id: 消费者IDNone 则自动生成
is_backtest: 是否为回测模式
block_ms: 阻塞等待时间(毫秒)
count: 每次消费的消息数量
Returns:
消息列表,每个元素为 (message_id, message_data) 元组
无消息或失败返回 None
"""
stream_key = self._get_stream_key(strategy_name, is_backtest)
# 生成消费者ID
if consumer_id is None:
consumer_id = self._generate_consumer_id()
try:
if is_backtest:
# 回测模式使用 XREAD (不使用消费者组)
messages = self.r.xread(
{stream_key: "0"}, # 从最早未确认消息开始
count=count,
block=block_ms,
)
else:
# 实盘模式使用 XREADGROUP
# 确保消费者组存在
self.create_consumer_group(strategy_name, is_backtest)
messages = self.r.xreadgroup(
groupname=self.CONSUMER_GROUP,
consumername=consumer_id,
streams={stream_key: ">"}, # 读取新消息
count=count,
block=block_ms,
)
if not messages:
return None
# 解析消息
result = []
for stream_name, stream_messages in messages:
for msg_id, msg_fields in stream_messages:
# 解析消息数据
data = msg_fields.get("data", "{}")
try:
parsed_data = json.loads(data)
except json.JSONDecodeError:
parsed_data = {"raw_data": data}
result.append((msg_id, parsed_data))
self.logger.debug(
f"[消息接收] stream={stream_key}, id={msg_id}, "
f"consumer={consumer_id}, data={str(parsed_data)[:200]}"
)
return result if result else None
except redis.RedisError as e:
self.logger.error(f"[消息消费失败] stream={stream_key}, error={str(e)}")
return None
except Exception as e:
self.logger.error(
f"[消息消费异常] stream={stream_key}, error={str(e)}", exc_info=True
)
return None
def ack_message(
self, strategy_name: str, message_id: str, is_backtest: bool = False
) -> bool:
"""确认消息已消费
Args:
strategy_name: 策略名称
message_id: 消息ID
is_backtest: 是否为回测模式
Returns:
确认成功返回 True失败返回 False
"""
# 回测模式不需要确认
if is_backtest:
return True
stream_key = self._get_stream_key(strategy_name, is_backtest)
try:
result = self.r.xack(stream_key, self.CONSUMER_GROUP, message_id)
if result == 1:
self.logger.debug(
f"[消息确认] stream={stream_key}, id={message_id}, success=True"
)
return True
else:
self.logger.warning(
f"[消息确认] stream={stream_key}, id={message_id}, success=False, result={result}"
)
return False
except redis.RedisError as e:
self.logger.error(
f"[消息确认失败] stream={stream_key}, id={message_id}, error={str(e)}"
)
return False
except Exception as e:
self.logger.error(
f"[消息确认异常] stream={stream_key}, id={message_id}, error={str(e)}",
exc_info=True,
)
return False
def send_to_failure_queue(
self,
strategy_name: str,
original_message: Dict[str, Any],
error_reason: str,
retry_count: int = 0,
) -> Optional[str]:
"""将失败消息发送到失败队列
Args:
strategy_name: 策略名称
original_message: 原始消息数据
error_reason: 失败原因
retry_count: 重试次数
Returns:
失败消息ID失败返回 None
"""
failure_key = self._get_failure_stream_key(strategy_name)
try:
failure_data = {
"original_message": json.dumps(original_message, ensure_ascii=False),
"error_reason": error_reason,
"failed_at": datetime.now().isoformat(),
"retry_count": str(retry_count),
}
message_id = self.r.xadd(failure_key, failure_data)
self.logger.warning(
f"[失败队列] strategy={strategy_name}, id={message_id}, "
f"reason={error_reason}, retry_count={retry_count}"
)
return message_id
except redis.RedisError as e:
self.logger.error(
f"[失败队列发送失败] strategy={strategy_name}, error={str(e)}"
)
return None
except Exception as e:
self.logger.error(
f"[失败队列发送异常] strategy={strategy_name}, error={str(e)}",
exc_info=True,
)
return None
def claim_pending_messages(
self,
strategy_name: str,
consumer_id: Optional[str] = None,
is_backtest: bool = False,
min_idle_ms: int = 60000,
) -> Optional[List[Tuple[str, Dict[str, Any]]]]:
"""认领待处理消息(处理崩溃消费者的遗留消息)
Args:
strategy_name: 策略名称
consumer_id: 消费者ID
is_backtest: 是否为回测模式
min_idle_ms: 最小空闲时间(毫秒),超过此时间的消息才会被认领
Returns:
认领的消息列表,无消息或失败返回 None
"""
# 回测模式不需要认领
if is_backtest:
return None
stream_key = self._get_stream_key(strategy_name, is_backtest)
if consumer_id is None:
consumer_id = self._generate_consumer_id()
try:
# 获取待处理消息列表
pending_info = self.r.xpending_range(
stream_key, self.CONSUMER_GROUP, min=min_idle_ms, max="+", count=10
)
if not pending_info:
return None
# 认领消息
message_ids = [item["message_id"] for item in pending_info]
claimed = self.r.xclaim(
stream_key,
self.CONSUMER_GROUP,
consumer_id,
min_idle_time=min_idle_ms,
message_ids=message_ids,
)
if not claimed:
return None
# 解析消息
result = []
for msg_id, msg_fields in claimed:
data = msg_fields.get("data", "{}")
try:
parsed_data = json.loads(data)
except json.JSONDecodeError:
parsed_data = {"raw_data": data}
result.append((msg_id, parsed_data))
self.logger.info(
f"[消息认领] stream={stream_key}, id={msg_id}, "
f"consumer={consumer_id}, idle_ms>={min_idle_ms}"
)
return result if result else None
except redis.RedisError as e:
self.logger.error(f"[消息认领失败] stream={stream_key}, error={str(e)}")
return None
except Exception as e:
self.logger.error(
f"[消息认领异常] stream={stream_key}, error={str(e)}", exc_info=True
)
return None
def _generate_consumer_id(self) -> str:
"""生成消费者ID"""
hostname = socket.gethostname()
pid = os.getpid()
return f"{hostname}-{pid}"
def get_stream_info(
self, strategy_name: str, is_backtest: bool = False
) -> Dict[str, Any]:
"""获取流信息
Args:
strategy_name: 策略名称
is_backtest: 是否为回测模式
Returns:
流信息字典
"""
stream_key = self._get_stream_key(strategy_name, is_backtest)
try:
length = self.r.xlen(stream_key)
info = {"length": length, "stream_key": stream_key}
# 获取消费者组信息(仅实盘模式)
if not is_backtest:
try:
groups = self.r.xinfo_groups(stream_key)
info["groups"] = groups
except:
info["groups"] = []
return info
except redis.RedisError as e:
self.logger.error(f"[获取流信息失败] stream={stream_key}, error={str(e)}")
return {"length": 0, "stream_key": stream_key, "error": str(e)}
except Exception as e:
self.logger.error(
f"[获取流信息异常] stream={stream_key}, error={str(e)}", exc_info=True
)
return {"length": 0, "stream_key": stream_key, "error": str(e)}
# 便捷函数,用于快速发送消息
def send_qmt_signal_to_stream(
strategy_name: str,
stock_code: str,
action: str,
price: float,
total_slots: int,
timestamp: str,
is_backtest: bool = False,
redis_config: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""发送 QMT 交易信号到 Redis Stream
这是一个便捷函数,用于快速发送标准格式的交易信号。
Args:
strategy_name: 策略名称
stock_code: 股票代码
action: 交易动作 (BUY/SELL)
price: 价格
total_slots: 总槽位数
timestamp: 时间戳
is_backtest: 是否为回测
redis_config: Redis 配置
Returns:
消息ID失败返回 None
"""
processor = StreamMessageProcessor(redis_config=redis_config)
message = {
"strategy_name": strategy_name,
"stock_code": stock_code,
"action": action,
"price": price,
"total_slots": total_slots,
"timestamp": timestamp,
"is_backtest": is_backtest,
}
return processor.send_message(strategy_name, message, is_backtest)

View File

@@ -17,6 +17,15 @@ from xtquant.xttrader import XtQuantTrader, XtQuantTraderCallback
from xtquant.xttype import StockAccount
from xtquant import xtconstant
# 导入 Redis Stream 消息处理器和日志模块
try:
from .message_processor import StreamMessageProcessor
from .logger import QMTLogger
except ImportError:
# 当作为脚本直接运行时
from message_processor import StreamMessageProcessor
from logger import QMTLogger
# ================= 0. Windows 补丁 =================
try:
import ctypes
@@ -473,19 +482,66 @@ class MultiEngineManager:
self.start_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.is_scheduled_reconnecting = False # 定时重连调度器正在执行标志
self._initialized = True
# Stream 处理器和日志器将在 initialize 中创建
self.stream_processor: Optional[StreamMessageProcessor] = None
self.qmt_logger: Optional[QMTLogger] = None
def initialize(self, config_file="config.json"):
self._setup_logger()
self._setup_logger() # 先初始化 logger
with open(config_file, "r", encoding="utf-8") as f:
self.config = json.load(f)
self.r = redis.Redis(**self.config["redis"], decode_responses=True)
# 从 .env.local 加载 Redis 配置
redis_config = self._load_redis_config()
self.r = redis.Redis(**redis_config, decode_responses=True)
self.pos_manager = PositionManager(self.r)
# 初始化 Redis Stream 处理器
self.stream_processor = StreamMessageProcessor(redis_client=self.r)
self.qmt_logger = QMTLogger(name="QMT_Engine_Stream")
self.logger.info("Redis Stream 处理器初始化完成")
for t_cfg in self.config.get("qmt_terminals", []):
unit = TradingUnit(t_cfg)
unit.connect()
self.units[unit.qmt_id] = unit
def _load_redis_config(self) -> Dict[str, Any]:
"""从 .env.local 加载 Redis 配置"""
# 尝试加载 python-dotenv
try:
from dotenv import load_dotenv
# 尝试多个路径
env_paths = [
os.path.join(os.path.dirname(os.path.abspath(__file__)), "config", ".env.local"),
os.path.join(os.path.dirname(__file__), "config", ".env.local"),
os.path.join(os.path.dirname(__file__), "..", "config", ".env.local"),
"/qmt/config/.env.local",
"config/.env.local",
]
loaded = False
for env_path in env_paths:
if os.path.exists(env_path):
load_dotenv(env_path)
self.logger.info(f"[Config] 加载 Redis 配置: {env_path}")
loaded = True
break
if not loaded:
self.logger.warning("[Config] 警告: 未找到 .env.local 文件,使用默认配置")
except ImportError:
self.logger.warning("[Config] 警告: 未安装 python-dotenv使用默认配置")
# 从环境变量读取 Redis 配置
redis_config = {
"host": os.getenv("REDIS_HOST", "localhost"),
"port": int(os.getenv("REDIS_PORT", "6379")),
"password": os.getenv("REDIS_PASSWORD") or None,
"db": int(os.getenv("REDIS_DB", "0")),
}
return redis_config
def _setup_logger(self):
log_dir = "logs"
@@ -494,8 +550,8 @@ class MultiEngineManager:
log_file = os.path.join(
log_dir, f"{datetime.date.today().strftime('%Y-%m-%d')}.log"
)
logger = logging.getLogger("QMT_Engine")
logger.setLevel(logging.INFO)
self.logger = logging.getLogger("QMT_Engine")
self.logger.setLevel(logging.INFO)
# 确保日志流为 UTF-8
fmt = logging.Formatter(
"[%(asctime)s] [%(threadName)s] %(message)s", "%H:%M:%S"
@@ -504,9 +560,8 @@ class MultiEngineManager:
fh.setFormatter(fmt)
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(fmt)
logger.addHandler(fh)
logger.addHandler(sh)
self.logger.addHandler(fh)
self.logger.addHandler(sh)
def get_strategies_by_terminal(self, qmt_id):
return [
s
@@ -632,37 +687,147 @@ class MultiEngineManager:
time.sleep(10)
def process_route(self, strategy_name):
"""处理策略消息路由 - 使用 Redis Stream
从 Redis Stream 消费消息,处理成功后 ACK失败则进入失败队列。
"""
strat_cfg = self.config["strategies"].get(strategy_name)
unit = self.units.get(strat_cfg.get("qmt_id"))
if not unit or not unit.callback or not unit.callback.is_connected:
return
msg_json = self.r.lpop(f"{strategy_name}_real")
if not msg_json:
# 使用 StreamMessageProcessor 消费消息
if not self.stream_processor:
self.logger.error("[process_route] Stream处理器未初始化")
return
try:
data = json.loads(msg_json)
# 严格校验消息日期
if data.get("timestamp", "").split(" ")[
0
] != datetime.date.today().strftime("%Y-%m-%d"):
# 消费消息 (非阻塞,立即返回)
messages = self.stream_processor.consume_message(
strategy_name=strategy_name,
consumer_id=None, # 自动生成
is_backtest=False,
block_ms=100, # 短阻塞,快速返回
)
if not messages:
return
if data["action"] == "BUY":
self._execute_buy(unit, strategy_name, data)
elif data["action"] == "SELL":
self._execute_sell(unit, strategy_name, data)
except json.JSONDecodeError as e:
self.logger.error(
f"[{strategy_name}] JSON解析失败: {e}, 消息: {msg_json[:200]}"
)
except KeyError as e:
self.logger.error(f"[{strategy_name}] 消息缺少必要字段: {e}")
# 处理每条消息
for msg_id, data in messages:
try:
# 1. 记录消息接收日志
stream_key = f"qmt:{strategy_name}:real"
self.qmt_logger.log_message_receive(stream_key, msg_id, data)
# 2. 严格校验消息日期
msg_date = data.get("timestamp", "").split(" ")[0]
today_str = datetime.date.today().strftime("%Y-%m-%d")
if msg_date != today_str:
self.qmt_logger.log_validation(
validation_type="date_check",
strategy_name=strategy_name,
details={"msg_date": msg_date, "today": today_str},
result=False,
)
# 日期不符的消息也 ACK避免重复处理
self.stream_processor.ack_message(
strategy_name, msg_id, is_backtest=False
)
continue
self.qmt_logger.log_validation(
validation_type="date_check",
strategy_name=strategy_name,
details={"msg_date": msg_date},
result=True,
)
# 3. 执行交易动作
action = data.get("action")
# 获取策略配置,确定下单模式
strat_cfg = self.config["strategies"].get(strategy_name, {})
order_mode = strat_cfg.get("order_mode", "slots")
if action == "BUY":
self.qmt_logger.log_validation(
validation_type="action_check",
strategy_name=strategy_name,
details={"action": "BUY", "code": data.get("stock_code"), "order_mode": order_mode},
result=True,
)
# 根据下单模式执行相应逻辑
if order_mode == "percentage":
self._execute_percentage_buy(unit, strategy_name, data)
else:
self._execute_buy(unit, strategy_name, data)
elif action == "SELL":
self.qmt_logger.log_validation(
validation_type="action_check",
strategy_name=strategy_name,
details={"action": "SELL", "code": data.get("stock_code"), "order_mode": order_mode},
result=True,
)
# 根据下单模式执行相应逻辑
if order_mode == "percentage":
self._execute_percentage_sell(unit, strategy_name, data)
else:
self._execute_sell(unit, strategy_name, data)
else:
self.qmt_logger.log_validation(
validation_type="action_check",
strategy_name=strategy_name,
details={"action": action},
result=False,
)
self.logger.warning(f"[{strategy_name}] 未知动作: {action}")
# 4. 确认消息已处理
ack_success = self.stream_processor.ack_message(
strategy_name, msg_id, is_backtest=False
)
self.qmt_logger.log_ack(stream_key, msg_id, ack_success)
except json.JSONDecodeError as e:
error_msg = f"JSON解析失败: {e}"
self.logger.error(
f"[{strategy_name}] {error_msg}, 消息ID: {msg_id}"
)
self.qmt_logger.log_failure(stream_key, msg_id, error_msg)
# 解析失败的消息也发送 ACK避免死循环
self.stream_processor.ack_message(
strategy_name, msg_id, is_backtest=False
)
except KeyError as e:
error_msg = f"消息缺少必要字段: {e}"
self.logger.error(f"[{strategy_name}] {error_msg}")
self.qmt_logger.log_failure(stream_key, msg_id, error_msg)
self.stream_processor.send_to_failure_queue(
strategy_name, data, error_msg
)
# 发送失败队列后 ACK
self.stream_processor.ack_message(
strategy_name, msg_id, is_backtest=False
)
except Exception as e:
error_msg = f"消息处理异常: {str(e)}"
self.logger.error(f"[{strategy_name}] {error_msg}", exc_info=True)
self.qmt_logger.log_failure(stream_key, msg_id, error_msg)
# 异常消息进入失败队列
self.stream_processor.send_to_failure_queue(
strategy_name, data, error_msg
)
# 发送失败队列后 ACK
self.stream_processor.ack_message(
strategy_name, msg_id, is_backtest=False
)
except Exception as e:
self.logger.error(
f"[{strategy_name}] 消息处理异常: {str(e)}", exc_info=True
)
self.logger.error(f"[process_route] 消费消息异常: {str(e)}", exc_info=True)
def _execute_buy(self, unit, strategy_name, data):
strat_cfg = self.config["strategies"][strategy_name]
@@ -719,6 +884,15 @@ class MultiEngineManager:
if vol < 100:
return
# 记录订单执行请求
self.qmt_logger.log_order_execution(
strategy_name=strategy_name,
stock_code=data["stock_code"],
action="BUY",
volume=vol,
price=price,
)
oid = unit.xt_trader.order_stock(
unit.acc_obj,
data["stock_code"],
@@ -735,6 +909,26 @@ class MultiEngineManager:
self.logger.info(
f"√√√ [{unit.alias}] {strategy_name} 下单买入: {data['stock_code']} {vol}股 @ {price}"
)
# 记录订单执行成功
self.qmt_logger.log_order_execution(
strategy_name=strategy_name,
stock_code=data["stock_code"],
action="BUY",
volume=vol,
price=price,
order_id=oid,
)
else:
error_msg = "下单请求被拒绝 (Result=-1)"
self.logger.error(f"[{strategy_name}] {error_msg}")
self.qmt_logger.log_order_execution(
strategy_name=strategy_name,
stock_code=data["stock_code"],
action="BUY",
volume=vol,
price=price,
error=error_msg,
)
except:
self.logger.error(traceback.format_exc())
@@ -785,6 +979,15 @@ class MultiEngineManager:
)
price = round(float(data["price"]) + offset, 3)
# 记录订单执行请求
self.qmt_logger.log_order_execution(
strategy_name=strategy_name,
stock_code=data["stock_code"],
action="SELL",
volume=final_vol,
price=price,
)
oid = unit.xt_trader.order_stock(
unit.acc_obj,
data["stock_code"],
@@ -800,9 +1003,198 @@ class MultiEngineManager:
self.logger.info(
f"√√√ [{unit.alias}] {strategy_name} 下单卖出: {data['stock_code']} {final_vol}股 @ {price}"
)
# 记录订单执行成功
self.qmt_logger.log_order_execution(
strategy_name=strategy_name,
stock_code=data["stock_code"],
action="SELL",
volume=final_vol,
price=price,
order_id=oid,
)
else:
error_msg = "下单请求被拒绝 (Result=-1)"
self.logger.error(f"[{strategy_name}] {error_msg}")
self.qmt_logger.log_order_execution(
strategy_name=strategy_name,
stock_code=data["stock_code"],
action="SELL",
volume=final_vol,
price=price,
error=error_msg,
)
except:
self.logger.error(traceback.format_exc())
def _execute_percentage_buy(self, unit, strategy_name, data):
"""处理百分比模式的买入逻辑"""
strat_cfg = self.config["strategies"][strategy_name]
# 获取目标持仓百分比
position_pct = float(data.get("position_pct", 0))
if position_pct <= 0:
self.logger.warning(f"[{strategy_name}] 百分比模式买入: position_pct 无效 ({position_pct})")
return
self.logger.info(f"[{strategy_name}] [百分比模式] 处理买入: {data['stock_code']}, 目标占比: {position_pct}")
try:
asset = unit.xt_trader.query_stock_asset(unit.acc_obj)
if not asset:
self.logger.error(f"[{strategy_name}] API 错误: query_stock_asset 返回 None")
return
total_asset = asset.total_asset
available_cash = asset.cash
self.logger.info(f"[{strategy_name}] [百分比模式] 账户总资产: {total_asset:.2f}, 可用资金: {available_cash:.2f}")
# 计算目标金额
target_amount = total_asset * position_pct
actual_amount = min(target_amount, available_cash * 0.98) # 预留手续费滑点
self.logger.info(f"[{strategy_name}] [百分比模式] 目标金额: {target_amount:.2f}, 实际可用: {actual_amount:.2f}")
# 检查最小金额限制
if actual_amount < 2000:
self.logger.warning(f"[{strategy_name}] [百分比模式] 拦截买入: 金额过小 ({actual_amount:.2f} < 2000)")
return
# 价格校验
price = float(data.get("price", 0))
offset = strat_cfg.get("execution", {}).get("buy_price_offset", 0.0)
price = round(price + offset, 3)
if price <= 0:
self.logger.warning(f"[{strategy_name}] [百分比模式] 价格异常: {price}强制设为1.0")
price = 1.0
# 计算股数
vol = int(actual_amount / price / 100) * 100
self.logger.info(f"[{strategy_name}] [百分比模式] 计算股数: 资金{actual_amount:.2f} / 价格{price} -> {vol}")
if vol < 100:
self.logger.warning(f"[{strategy_name}] [百分比模式] 拦截买入: 股数不足 100 ({vol})")
return
# 记录订单执行请求
self.qmt_logger.log_order_execution(
strategy_name=strategy_name,
stock_code=data["stock_code"],
action="BUY",
volume=vol,
price=price,
)
oid = unit.xt_trader.order_stock(
unit.acc_obj,
data["stock_code"],
xtconstant.STOCK_BUY,
vol,
xtconstant.FIX_PRICE,
price,
strategy_name,
"PyBuyPct",
)
if oid != -1:
unit.order_cache[oid] = (strategy_name, data["stock_code"], "BUY")
self.logger.info(f"√√√ [{unit.alias}] [{strategy_name}] [百分比模式] 下单买入: {data['stock_code']} {vol}股 @ {price}")
# 记录订单执行成功
self.qmt_logger.log_order_execution(
strategy_name=strategy_name,
stock_code=data["stock_code"],
action="BUY",
volume=vol,
price=price,
order_id=oid,
)
else:
error_msg = "下单请求被拒绝 (Result=-1)"
self.logger.error(f"[{strategy_name}] [百分比模式] {error_msg}")
self.qmt_logger.log_order_execution(
strategy_name=strategy_name,
stock_code=data["stock_code"],
action="BUY",
volume=vol,
price=price,
error=error_msg,
)
except:
self.logger.error(traceback.format_exc())
def _execute_percentage_sell(self, unit, strategy_name, data):
"""处理百分比模式的卖出逻辑(清仓)"""
self.logger.info(f"[{strategy_name}] [百分比模式] 处理卖出: {data['stock_code']} (清仓)")
try:
# 查询实盘持仓
real_pos = unit.xt_trader.query_stock_positions(unit.acc_obj)
if real_pos is None:
self.logger.error(f"[{strategy_name}] [百分比模式] API 错误: query_stock_positions 返回 None")
return
rp = next((p for p in real_pos if p.stock_code == data["stock_code"]), None)
can_use = rp.can_use_volume if rp else 0
self.logger.info(f"[{strategy_name}] [百分比模式] 股票 {data['stock_code']} 实盘可用持仓: {can_use}")
if can_use <= 0:
self.logger.warning(f"[{strategy_name}] [百分比模式] 拦截卖出: 无可用持仓")
return
# 执行清仓
price = float(data.get("price", 0))
offset = self.config["strategies"][strategy_name].get("execution", {}).get("sell_price_offset", 0.0)
price = round(price + offset, 3)
self.logger.info(f"[{strategy_name}] [百分比模式] 执行清仓: {data['stock_code']} @ {price}, 数量: {can_use}")
# 记录订单执行请求
self.qmt_logger.log_order_execution(
strategy_name=strategy_name,
stock_code=data["stock_code"],
action="SELL",
volume=can_use,
price=price,
)
oid = unit.xt_trader.order_stock(
unit.acc_obj,
data["stock_code"],
xtconstant.STOCK_SELL,
can_use,
xtconstant.FIX_PRICE,
price,
strategy_name,
"PySellPct",
)
if oid != -1:
unit.order_cache[oid] = (strategy_name, data["stock_code"], "SELL")
self.logger.info(f"√√√ [{unit.alias}] [{strategy_name}] [百分比模式] 下单卖出: {data['stock_code']} {can_use}股 @ {price}")
# 记录订单执行成功
self.qmt_logger.log_order_execution(
strategy_name=strategy_name,
stock_code=data["stock_code"],
action="SELL",
volume=can_use,
price=price,
order_id=oid,
)
else:
error_msg = "下单请求被拒绝 (Result=-1)"
self.logger.error(f"[{strategy_name}] [百分比模式] {error_msg}")
self.qmt_logger.log_order_execution(
strategy_name=strategy_name,
stock_code=data["stock_code"],
action="SELL",
volume=can_use,
price=price,
error=error_msg,
)
except:
self.logger.error(traceback.format_exc())
def verify_connection(self, timeout=5):
"""验证物理连接是否有效"""
try:

View File

@@ -4,9 +4,9 @@
QMT 模块是 NewStock 量化交易系统的实盘交易执行模块,通过 `xtquant` 库连接 QMT 交易终端,实现自动化交易功能。该模块采用多终端架构设计,支持同时管理多个 QMT 终端实例,并提供 Web 仪表盘和 RESTful API 接口进行监控和操作。
系统核心特性包括:多终端并行管理、异步订单处理、断线自动重连、收盘自动清算、实时心跳检测等。所有交易信号通过 Redis 消息队列接收,确保交易指令的可靠传递和执行。
系统核心特性包括:多终端并行管理、异步订单处理、断线自动重连、收盘自动清算、实时心跳检测等。所有交易信号通过 Redis Stream 消息队列接收,确保交易指令的可靠传递和执行。
系统分为**信号发送端**和**交易执行端**两部分。信号发送端(`qmt_signal_sender.py`)运行在聚宽策略环境中,将策略产生的买卖信号推送至 Redis 队列;交易执行端(`qmt_engine.py` + `run.py`)运行在本地,从 Redis 消费信号并通过 QMT 终端执行实盘交易。
系统分为**信号发送端**和**交易执行端**两部分。信号发送端(`qmt_sender.py`)运行在聚宽策略环境中,将策略产生的买卖信号推送至 Redis Stream;交易执行端(`qmt_engine.py` + `run.py`)运行在本地,从 Redis Stream 消费信号并通过 QMT 终端执行实盘交易。
## 2. 核心组件
@@ -16,8 +16,10 @@ QMT 模块是 NewStock 量化交易系统的实盘交易执行模块,通过 `x
|------|------|
| [`run.py`](run.py) | 系统启动入口,负责初始化多终端管理器并启动 API 服务 |
| [`qmt_engine.py`](qmt_engine.py) | 核心引擎模块,包含多终端管理器和交易执行单元 |
| [`qmt_trader.py`](qmt_trader.py) | 旧版单终端交易引擎(保留兼容) |
| [`qmt_signal_sender.py`](qmt_signal_sender.py) | 信号发送端,运行于聚宽策略侧,将交易信号推送至 Redis 队列 |
| [`qmt_sender.py`](qmt_sender.py) | 统一信号发送端,支持槽位模式和百分比模式,运行于聚宽策略侧 |
| [`backtest_consumer.py`](backtest_consumer.py) | 回测消息消费者,模拟交易执行并记录完整日志 |
| [`message_processor.py`](message_processor.py) | Redis Stream 消息处理器,封装消息发送、消费、确认等功能 |
| [`logger.py`](logger.py) | 细粒度日志模块,追踪消息处理全流程 |
| [`api_server.py`](api_server.py) | FastAPI Web 服务,提供 RESTful API 接口 |
| [`dashboard.html`](dashboard.html) | Web 仪表盘前端页面 |
| [`start.bat`](start.bat) | Windows 启动脚本 |
@@ -37,14 +39,30 @@ QMT 模块是 NewStock 量化交易系统的实盘交易执行模块,通过 `x
| `UnitCallback` | 终端回调处理器,处理成交回报和错误通知 |
| `TerminalStatus` | 终端状态数据类,封装终端连接状态信息 |
#### qmt_trader.py
#### qmt_sender.py
| 函数 | 功能说明 |
|------|----------|
| `send_qmt_signal()` | 发送槽位模式信号(基于 total_slots |
| `send_qmt_percentage_signal()` | 发送百分比模式信号(基于 position_pct |
#### backtest_consumer.py
| 类名 | 功能说明 |
|------|----------|
| `SystemState` | 全局状态管理器,维护交易系统运行状态 |
| `PositionManager` | 虚拟持仓管理器,管理策略与股票的持仓映射 |
| `DailySettlement` | 日终清算处理器,处理收盘后的撤单和持仓修正 |
| `MyXtQuantTraderCallback` | QMT 交易回调,处理成交和错误事件 |
| `BacktestConsumer` | 回测消息消费者,支持守护模式和单次运行模式 |
#### message_processor.py
| 类名 | 功能说明 |
|------|----------|
| `StreamMessageProcessor` | Redis Stream 消息处理器,支持消息发送、消费、确认、失败处理 |
#### logger.py
| 类名 | 功能说明 |
|------|----------|
| `QMTLogger` | 细粒度日志记录器,追踪消息接收、解析、校验、执行、确认全流程 |
## 3. 功能详细说明
@@ -56,7 +74,7 @@ QMT 模块是 NewStock 量化交易系统的实盘交易执行模块,通过 `x
### 3.2 交易消息处理
交易信号通过 Redis 消息队列传递,每个策略对应一个独立的队列。消息格式为 JSON 对象,包含股票代码、操作类型、价格、时间戳等字段。系统对每条消息进行严格校验,包括日期校验、时间戳校验、必填字段校验等,确保只有当天的有效指令才会被执行。
交易信号通过 Redis Stream 消息队列传递,每个策略对应一个独立的 Stream。消息格式为 JSON 对象,包含股票代码、操作类型、价格、时间戳等字段。系统对每条消息进行严格校验,包括日期校验、时间戳校验、必填字段校验等,确保只有当天的有效指令才会被执行。
买入逻辑支持两种模式:
@@ -88,7 +106,13 @@ QMT 模块是 NewStock 量化交易系统的实盘交易执行模块,通过 `x
系统采用增强型日志系统,支持文件和控制台双路输出。日志格式包含时间戳、线程名、级别和消息内容,便于追踪问题。文件日志按日期命名,自动存放在 `logs/` 目录下。控制台输出强制刷新流,确保在 Windows 环境下日志实时显示。
日志级别分为 INFO、WARNING、ERROR 三级,重要操作和状态变化都会记录。交易相关日志特别标注策略名称和股票代码,方便后续分析和审计。
新增 `logger.py` 模块提供细粒度日志记录,追踪消息处理全流程:
- 消息接收(`log_message_receive`
- 消息解析(`log_message_parse`
- 业务校验(`log_validation`
- 订单执行(`log_order_execution`
- 消息确认(`log_message_ack`
- 失败处理(`log_failure`
## 4. API 接口列表
@@ -234,6 +258,21 @@ QMT 模块是 NewStock 量化交易系统的实盘交易执行模块,通过 `x
}
```
### 5.3 环境变量配置
系统支持通过 `.env.local` 文件或环境变量配置以下参数:
| 变量名 | 默认值 | 说明 |
|--------|--------|------|
| `REDIS_HOST` | localhost | Redis 主机地址 |
| `REDIS_PORT` | 6379 | Redis 端口 |
| `REDIS_PASSWORD` | None | Redis 密码 |
| `REDIS_DB` | 0 | Redis 数据库 |
| `BACKTEST_CONSUMER_ID` | backtest-consumer-1 | 回测消费者 ID |
| `BACKTEST_STRATEGIES` | "" | 默认策略列表,逗号分隔 |
| `LOG_LEVEL` | DEBUG | 日志级别 |
| `LOG_FILE` | logs/backtest_consumer.log | 日志文件路径 |
## 6. Web 仪表盘功能
### 6.1 功能概览
@@ -256,16 +295,20 @@ Web 仪表盘基于 Vue 3 和 Naive UI 组件库开发,提供可视化的系
仪表盘默认访问地址为 `http://localhost:8001`,该地址在系统启动时打印在控制台。首次访问时会自动加载所有终端状态、持仓信息和系统日志。
## 7. 信号发送端qmt_signal_sender.py
## 7. 信号发送端qmt_sender.py
### 7.1 模块定位
`qmt_signal_sender.py` 是 QMT 交易系统的**信号生产端**部署在聚宽JoinQuant策略运行环境中。它负责将策略产生的买卖信号序列化后推送到 Redis 队列,由本地 QMT 交易引擎消费并执行。该模块是连接"策略研究/回测平台"与"实盘交易执行"的桥梁。
`qmt_sender.py` 是 QMT 交易系统的**信号生产端**部署在聚宽JoinQuant策略运行环境中。它负责将策略产生的买卖信号序列化后推送到 Redis Stream,由本地 QMT 交易引擎消费并执行。该模块是连接"策略研究/回测平台"与"实盘交易执行"的桥梁。
该模块统一了槽位模式和百分比模式,通过不同函数发送不同类型的信号。
### 7.2 核心函数
#### `send_qmt_signal(code, target_total_slots, price, context, redis_config)`
发送槽位模式信号。
| 参数 | 类型 | 说明 |
|------|------|------|
| `code` | str | 股票代码,聚宽格式(如 `000001.XSHE``600519.XSHG` |
@@ -274,6 +317,20 @@ Web 仪表盘基于 Vue 3 和 Naive UI 组件库开发,提供可视化的系
| `context` | object | 聚宽上下文对象,提供 `run_params.type`(运行类型)和 `current_dt`(当前时间) |
| `redis_config` | dict | Redis 连接配置,包含 `host``port``password``db``strategy_name` 等字段 |
#### `send_qmt_percentage_signal(code, position_pct, action, price, is_backtest, timestamp, redis_config)`
发送百分比模式信号。
| 参数 | 类型 | 说明 |
|------|------|------|
| `code` | str | 股票代码,聚宽格式(如 `000001.XSHE``600519.XSHG` |
| `position_pct` | float | 目标持仓占总资产的比例,范围 0.0 ~ 1.0(如 0.2 表示 20% |
| `action` | str | 交易动作,固定为 `"BUY"``"SELL"` |
| `price` | float | 当前最新价格,用于实盘限价单参考 |
| `is_backtest` | bool | 是否为回测模式True/False |
| `timestamp` | str | 时间戳字符串,格式 `"YYYY-MM-DD HH:MM:SS"` |
| `redis_config` | dict | Redis 连接配置,包含 `host``port``password``db``strategy_name` 等字段 |
### 7.3 处理流程
```
@@ -285,20 +342,20 @@ Web 仪表盘基于 Vue 3 和 Naive UI 组件库开发,提供可视化的系
└─ 回测模式 → 限制最多发送 10 条(防止回测刷爆队列)
2. 建立 Redis 连接socket_timeout=1s
2. 建立 Redis Stream 连接
3. 数据转换与规范化
├─ 股票代码格式转换:.XSHE → .SZ.XSHG → .SH
└─ 动作判定target_total_slots > 0 → BUY= 0 → SELL
└─ 动作判定(槽位模式)target_total_slots > 0 → BUY= 0 → SELL
4. 构建 JSON 消息体
5. 队列路由
├─ 回测 → {strategy_name}_backtestTTL: 1 小时)
└─ 实盘 → {strategy_name}_realTTL: 7 天)
5. Stream 路由
├─ 回测 → qmt:{strategy_name}:backtest
└─ 实盘 → qmt:{strategy_name}:real
6. 控制台日志输出
@@ -306,8 +363,9 @@ Web 仪表盘基于 Vue 3 和 Naive UI 组件库开发,提供可视化的系
### 7.4 消息格式
发送到 Redis 队列的 JSON 消息结构:
发送到 Redis Stream 的 JSON 消息结构:
**槽位模式:**
```json
{
"strategy_name": "my_strategy",
@@ -320,94 +378,7 @@ Web 仪表盘基于 Vue 3 和 Naive UI 组件库开发,提供可视化的系
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `strategy_name` | str | 策略名称,来自 `redis_config['strategy_name']`,用于队列路由和持仓管理 |
| `stock_code` | str | QMT 格式的股票代码(`.SZ` / `.SH` |
| `action` | str | 交易动作,`BUY``SELL` |
| `price` | float | 信号触发时的最新价格 |
| `total_slots` | int | 策略的总槽位数BUY 时为策略设定值SELL 时为 0 |
| `timestamp` | str | 信号生成时间,格式 `YYYY-MM-DD HH:MM:SS` |
| `is_backtest` | bool | 是否为回测环境发出的信号 |
### 7.5 买卖意图判定逻辑
信号发送端不直接区分"买入函数"和"卖出函数",而是通过 `target_total_slots` 参数的值进行语义推断:
- **`target_total_slots > 0`**BUY策略意向持有该股票`total_slots` 传递策略的总持仓上限,供交易引擎计算单只股票的资金分配。
- **`target_total_slots = 0`**SELL策略意向清仓该股票释放所占槽位。
### 7.6 回测流量控制
模块级全局变量 `_BACKTEST_SEND_COUNT` 用于限制回测模式下的信号发送数量,上限为 10 条。这一机制防止长周期回测期间大量无效信号涌入 Redis 队列,回测队列的 TTL 也相应缩短为 1 小时(实盘为 7 天)。
### 7.7 队列命名规则
| 运行模式 | 队列名格式 | TTL |
|----------|-----------|-----|
| 实盘 | `{strategy_name}_real` | 604800 秒7 天) |
| 回测 | `{strategy_name}_backtest` | 3600 秒1 小时) |
### 7.8 股票代码格式转换
| 来源平台 | 格式 | 示例 |
|----------|------|------|
| 聚宽 | `.XSHE` / `.XSHG` | `000001.XSHE``600519.XSHG` |
| QMT | `.SZ` / `.SH` | `000001.SZ``600519.SH` |
## 8. 百分比下单信号发送端qmt_percentage_sender.py
### 8.1 模块定位
`qmt_percentage_sender.py` 是基于**仓位百分比**的 QMT 信号发送端,与槽位模式的 `qmt_signal_sender.py` 并行存在。该模块用于配置为 `order_mode: "percentage"` 的策略,通过指定目标持仓占账户总资产的比例来触发交易。
### 8.2 核心函数
#### `send_qmt_percentage_signal(code, position_pct, action, price, is_backtest, timestamp, redis_config)`
| 参数 | 类型 | 说明 |
|------|------|------|
| `code` | str | 股票代码,聚宽格式(如 `000001.XSHE``600519.XSHG` |
| `position_pct` | float | 目标持仓占总资产的比例,范围 0.0 ~ 1.0(如 0.2 表示 20% |
| `action` | str | 交易动作,固定为 `"BUY"``"SELL"` |
| `price` | float | 当前最新价格,用于实盘限价单参考 |
| `is_backtest` | bool | 是否为回测模式True/False |
| `timestamp` | str | 时间戳字符串,格式 `"YYYY-MM-DD HH:MM:SS"` |
| `redis_config` | dict | Redis 连接配置,包含 `host``port``password``db``strategy_name` 等字段 |
### 8.3 处理流程
```
策略触发信号
1. 环境判断与流量控制
├─ 实盘模式 → 直接通过
└─ 回测模式 → 限制最多发送 10 条(防止回测刷爆队列)
2. 建立 Redis 连接socket_timeout=1s
3. 数据转换与规范化
└─ 股票代码格式转换:.XSHE → .SZ.XSHG → .SH
4. 构建 JSON 消息体(包含 position_pct 字段)
5. 队列路由
├─ 回测 → {strategy_name}_backtestTTL: 1 小时)
└─ 实盘 → {strategy_name}_realTTL: 7 天)
6. 控制台日志输出
```
### 8.4 消息格式
发送到 Redis 队列的 JSON 消息结构:
**百分比模式:**
```json
{
"strategy_name": "my_strategy",
@@ -426,47 +397,43 @@ Web 仪表盘基于 Vue 3 和 Naive UI 组件库开发,提供可视化的系
| `stock_code` | str | QMT 格式的股票代码(`.SZ` / `.SH` |
| `action` | str | 交易动作,`BUY``SELL` |
| `price` | float | 信号触发时的最新价格 |
| `position_pct` | float | 目标持仓占账户总资产的比例,范围 0.0 ~ 1.0 |
| `total_slots` | int | 策略的总槽位数(槽位模式) |
| `position_pct` | float | 目标持仓占账户总资产的比例(百分比模式) |
| `timestamp` | str | 信号生成时间,格式 `YYYY-MM-DD HH:MM:SS` |
| `is_backtest` | bool | 是否为回测环境发出的信号 |
### 8.5 买卖意图判定逻辑
### 7.5 买卖意图判定逻辑
槽位模式不同,百分比模式需要**显式指定**交易动作
**槽位模式**:通过 `target_total_slots` 参数的值进行语义推断
- **`target_total_slots > 0`**BUY策略意向持有该股票`total_slots` 传递策略的总持仓上限,供交易引擎计算单只股票的资金分配。
- **`target_total_slots = 0`**SELL策略意向清仓该股票释放所占槽位。
**百分比模式**:需要**显式指定**交易动作:
- **`action = "BUY"`**:策略意向买入该股票,目标持仓占比为 `position_pct`。交易引擎根据账户总资产计算目标金额,然后转换为具体股数下单。
- **`action = "SELL"`**:策略意向清仓该股票。百分比模式下卖出采用简化逻辑,直接执行清仓操作。
### 8.6 买入计算公式
### 7.6 回测流量控制
当 QMT 端接收到百分比模式的买入信号时,按以下公式计算买入股数:
模块级全局变量 `_BACKTEST_SEND_COUNT` 用于限制回测模式下的信号发送数量,上限为 10 条。这一机制防止长周期回测期间大量无效信号涌入 Redis 队列。
```
目标金额 = 账户总资产 × position_pct
可用金额 = min(目标金额, 可用资金)
买入股数 = int(可用金额 / 价格 / 100) × 100
```
### 7.7 Stream 命名规则
**边界处理:**
- 单笔金额 < 2000 元 → 拦截不下单
- 计算股数 < 100 股 → 拦截不下单
- 价格 ≤ 0 → 强制设为 1.0(仅测试用)
| 运行模式 | Stream 键名格式 |
|----------|----------------|
| 实盘 | `qmt:{strategy_name}:real` |
| 回测 | `qmt:{strategy_name}:backtest` |
### 8.7 与槽位模式的对比
### 7.8 股票代码格式转换
| 特性 | 槽位模式 (slots) | 百分比模式 (percentage) |
|------|------------------|------------------------|
| 核心参数 | `total_slots` | `position_pct` |
| 持仓限制 | 有(同时持仓数量限制) | 无 |
| 资金分配 | 按剩余槽位均分资金 | 按总资产比例计算 |
| 卖出逻辑 | 按持仓数量计算 | 清仓 |
| 配置方式 | 配置文件设置 `order_mode: "slots"` | 配置文件设置 `order_mode: "percentage"` |
| 信号发送 | `send_qmt_signal()` | `send_qmt_percentage_signal()` |
| 来源平台 | 格式 | 示例 |
|----------|------|------|
| 聚宽 | `.XSHE` / `.XSHG` | `000001.XSHE``600519.XSHG` |
| QMT | `.SZ` / `.SH` | `000001.SZ``600519.SH` |
### 8.8 使用示例
### 7.9 使用示例
```python
from qmt_percentage_sender import send_qmt_percentage_signal
from qmt_sender import send_qmt_signal, send_qmt_percentage_signal
# Redis 配置
redis_config = {
@@ -474,10 +441,19 @@ redis_config = {
"port": 6379,
"password": None,
"db": 0,
"strategy_name": "MyPercentageStrategy"
"strategy_name": "MyStrategy"
}
# 买入信号:目标持仓占账户总资产的 20%
# 槽位模式买入信号
send_qmt_signal(
code="000001.XSHE",
target_total_slots=5,
price=15.5,
context=context, # 聚宽上下文
redis_config=redis_config
)
# 百分比模式买入信号(目标持仓 20%
send_qmt_percentage_signal(
code="000001.XSHE",
position_pct=0.2,
@@ -487,22 +463,43 @@ send_qmt_percentage_signal(
timestamp="2026-02-17 14:30:00",
redis_config=redis_config
)
# 卖出信号:清仓
send_qmt_percentage_signal(
code="000001.XSHE",
position_pct=0,
action="SELL",
price=15.8,
is_backtest=False,
timestamp="2026-02-17 14:35:00",
redis_config=redis_config
)
```
## 8. 回测消息消费者backtest_consumer.py
### 8.1 模块定位
`backtest_consumer.py` 是独立的回测消息消费脚本,用于消费回测消息流、记录完整的处理流程日志、模拟订单执行(不执行真实交易),并支持 ACK 确认消息。
### 8.2 使用方式
```bash
# 守护模式(持续运行,处理所有配置的策略)
python backtest_consumer.py
# 单次运行模式(处理一次后退出)
python backtest_consumer.py --once
# 指定策略运行
python backtest_consumer.py --strategy StrategyA
python backtest_consumer.py --once --strategy StrategyA,StrategyB
```
### 8.3 配置说明
可通过环境变量或 `.env.local` 文件配置:
- `REDIS_HOST`: Redis 主机地址(默认: localhost
- `REDIS_PORT`: Redis 端口(默认: 6379
- `REDIS_PASSWORD`: Redis 密码(默认: None
- `REDIS_DB`: Redis 数据库(默认: 0
- `BACKTEST_CONSUMER_ID`: 消费者ID默认: backtest-consumer-1
- `BACKTEST_STRATEGIES`: 默认策略列表,逗号分隔
- `LOG_LEVEL`: 日志级别(默认: DEBUG
- `LOG_FILE`: 日志文件路径(默认: logs/backtest_consumer.log
## 9. 系统架构
### 8.1 组件关系图
### 9.1 组件关系图
```
┌─────────────────────────────────────────────────────────────────────────────┐
@@ -520,7 +517,7 @@ send_qmt_percentage_signal(
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ 日志系统 │ │ 终端执行单元 │ │ Web 仪表盘 │ │
│ │ logs/ │ │ TradingUnit │ │ dashboard.html │ │
│ │ logger.py │ │ TradingUnit │ │ dashboard.html │ │
│ └─────────────┘ │ × N │ │ │ │
│ └────────┬────────┘ └─────────────────────────┘ │
│ │ │
@@ -534,22 +531,22 @@ send_qmt_percentage_signal(
│ 外部依赖 │
│ ┌─────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ Redis │ │ QMT 终端 │ │ 浏览器客户端 │ │
│ │ 消息队列 │ │ 实盘交易 │ │ HTTP 请求 │ │
│ │ Stream │ │ 实盘交易 │ │ HTTP 请求 │ │
│ └─────────────┘ └─────────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 8.2 数据流向图
### 9.2 数据流向图
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 数据流向 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 聚宽策略 ──> qmt_signal_sender ──> Redis 队列 ──> 消息处理循环 │
│ (信号发送端) {strategy}_real │ │
│ 聚宽策略 ──> qmt_sender ──> Redis Stream ──> 消息处理循环
│ (信号发送端) qmt:{strategy}:real │ │
│ ▼ │
│ 槽位检查 ──> 资金检查
│ 槽位/百分比检查
│ │ │
│ ▼ │
│ 订单执行 (QMT API) │
@@ -572,20 +569,21 @@ send_qmt_percentage_signal(
└─────────────────────────────────────────────────────────────────────────────┘
```
### 8.3 消息处理流程
### 9.3 消息处理流程
1. **消息接收**:系统从 Redis 队列 `{strategy_name}_real` 中取消息
1. **消息接收**:系统从 Redis Stream `qmt:{strategy_name}:real`取消息
2. **消息解析**:将 JSON 消息解析为结构化数据,验证必填字段
3. **日期校验**:检查消息日期是否为当天,过期消息丢弃
4. **槽位检查**查询策略已占用槽位,判断是否允许新买入
5. **资金检查**:查询账户可用资金,计算单只股票可买入金额
4. **模式检查**根据策略配置确定使用槽位模式或百分比模式
5. **槽位/资金检查**:查询策略已占用槽位或账户总资产,判断是否允许新买入
6. **数量计算**:根据资金和价格计算买入股数(向下取整为百股)
7. **订单执行**:调用 QMT API 下单,成功则缓存订单信息
8. **状态更新**:标记虚拟持仓,异步等待成交回调
9. **消息确认**:发送 ACK 确认消息已处理
## 10. 启动与停止
### 9.1 Windows 启动
### 10.1 Windows 启动
使用提供的 `start.bat` 脚本启动系统:
@@ -600,11 +598,11 @@ cd qmt
python run.py
```
### 9.2 日志文件位置
### 10.2 日志文件位置
系统日志保存在 `qmt/logs/{日期}.log` 目录下,文件名格式为 `2026-01-27.log`。日志按日期自动切分,当日期变化时创建新的日志文件。
### 9.3 端口说明
### 10.3 端口说明
| 服务 | 默认端口 | 说明 |
|------|----------|------|
@@ -617,3 +615,4 @@ python run.py
3. **交易日时间**:交易逻辑仅在 09:15-11:30 和 13:00-15:00 期间执行
4. **维护时段**:每日 21:32-21:50 为 QMT 维护时段,此时段不执行重连
5. **权限要求**:确保程序有权限写入 `logs/` 目录
6. **配置文件**Redis 配置优先从 `.env.local` 文件加载,如不存在则使用 `config.json` 中的配置

View File

@@ -1,102 +0,0 @@
import redis
import json
# --- 模块级全局变量 ---
_BACKTEST_SEND_COUNT = 0
def send_qmt_percentage_signal(
code, position_pct, action, price, is_backtest, timestamp, redis_config
):
"""
发送基于仓位百分比的信号到 Redis
参数:
- code: 股票代码 (聚宽格式: 000001.XSHE)
- position_pct: 目标持仓占总资产的比例 (0.0 ~ 1.0,如 0.2 表示 20%)
- action: 交易动作,"BUY""SELL"
- price: 当前最新价格 (用于实盘限价单参考)
- is_backtest: 是否为回测模式 (True/False)
- timestamp: 时间戳字符串,格式 "YYYY-MM-DD HH:MM:SS"
- redis_config: Redis配置字典包含 host, port, password, db, strategy_name
"""
global _BACKTEST_SEND_COUNT
try:
# ---------------------------------------------------------
# 1. 环境判断与流量控制
# ---------------------------------------------------------
if is_backtest:
if _BACKTEST_SEND_COUNT >= 10:
return
_BACKTEST_SEND_COUNT += 1
# ---------------------------------------------------------
# 2. 建立 Redis 连接
# ---------------------------------------------------------
r = redis.Redis(
host=redis_config["host"],
port=redis_config["port"],
password=redis_config.get("password"),
db=redis_config.get("db", 0),
decode_responses=True,
socket_timeout=1,
)
# ---------------------------------------------------------
# 3. 数据转换与规范化
# ---------------------------------------------------------
# 股票代码格式转换: 聚宽(.XSHE/.XSHG) -> QMT(.SZ/.SH)
qmt_code = code
if code.endswith(".XSHE"):
qmt_code = code.replace(".XSHE", ".SZ")
elif code.endswith(".XSHG"):
qmt_code = code.replace(".XSHG", ".SH")
# 校验 action 参数
if action not in ["BUY", "SELL"]:
print(f"[Error] 无效的 action 参数: {action},必须是 'BUY''SELL'")
return
# ---------------------------------------------------------
# 4. 构建消息体
# ---------------------------------------------------------
base_strategy_name = redis_config.get("strategy_name", "default_strategy")
msg = {
"strategy_name": base_strategy_name,
"stock_code": qmt_code,
"action": action,
"price": price,
"position_pct": float(position_pct),
"timestamp": timestamp,
"is_backtest": is_backtest,
}
json_payload = json.dumps(msg)
# ---------------------------------------------------------
# 5. 队列路由
# ---------------------------------------------------------
queue_key = (
f"{base_strategy_name}_backtest"
if is_backtest
else f"{base_strategy_name}_real"
)
expire_seconds = 3600 if is_backtest else 604800
r.rpush(queue_key, json_payload)
r.expire(queue_key, expire_seconds)
# ---------------------------------------------------------
# 6. 控制台输出
# ---------------------------------------------------------
log_prefix = "【回测】" if is_backtest else "【实盘】"
pct_display = f"{position_pct * 100:.1f}%"
desc = f"目标仓位:{pct_display}" if action == "BUY" else "清仓"
print(
f"{log_prefix} 百分比信号 -> {qmt_code} | 动作:{action} | {desc} | 价格:{price} | 时间:{timestamp}"
)
except Exception as e:
print(f"[Error] 发送QMT百分比信号失败: {e}")

251
qmt/qmt_sender.py Normal file
View File

@@ -0,0 +1,251 @@
import redis
import json
import socket
import os
from typing import Dict, Any, Optional
# --- 模块级全局变量 ---
_BACKTEST_SEND_COUNT = 0
# --- Stream 配置常量 ---
STREAM_PREFIX = "qmt"
MAXLEN = 1000 # Stream 最大长度
def _get_stream_key(strategy_name: str, is_backtest: bool = False) -> str:
"""获取流键名
Args:
strategy_name: 策略名称
is_backtest: 是否为回测模式
Returns:
流键名,格式: qmt:{strategy_name}:real 或 qmt:{strategy_name}:backtest
"""
suffix = "backtest" if is_backtest else "real"
return f"{STREAM_PREFIX}:{strategy_name}:{suffix}"
def _create_redis_client(redis_config: Dict[str, Any]) -> redis.Redis:
"""创建 Redis 客户端"""
return redis.Redis(
host=redis_config.get("host", "localhost"),
port=redis_config.get("port", 6379),
password=redis_config.get("password"),
db=redis_config.get("db", 0),
decode_responses=True,
socket_timeout=1,
)
def _send_to_stream(
r: redis.Redis,
strategy_name: str,
message_data: Dict[str, Any],
is_backtest: bool = False,
) -> Optional[str]:
"""发送消息到 Redis Stream
Args:
r: Redis 客户端
strategy_name: 策略名称
message_data: 消息数据字典
is_backtest: 是否为回测消息
Returns:
消息ID (格式: timestamp-sequence),失败返回 None
"""
stream_key = _get_stream_key(strategy_name, is_backtest)
try:
# 确保消息数据是字符串格式
message_json = json.dumps(message_data, ensure_ascii=False)
# 使用 XADD 发送消息,设置最大长度
message_id = r.xadd(
stream_key,
{"data": message_json},
maxlen=MAXLEN,
approximate=True, # 使用近似裁剪,提高性能
)
return message_id
except Exception as e:
print(f"[Error] Stream 发送失败: {e}")
return None
def _convert_code_to_qmt(code: str) -> str:
"""股票代码格式转换: 聚宽(.XSHE/.XSHG) -> QMT(.SZ/.SH)"""
if code.endswith(".XSHE"):
return code.replace(".XSHE", ".SZ")
elif code.endswith(".XSHG"):
return code.replace(".XSHG", ".SH")
return code
def send_qmt_signal(code, target_total_slots, price, context, redis_config):
"""
发送信号到 Redis Stream (基于槽位状态判断买卖意图)
参数:
- code: 股票代码 (聚宽格式: 000001.XSHE)
- target_total_slots:
* 意向持仓时: 传入策略设定的总槽位数 (例如 5)。此时 action 判定为 BUY。
* 意向清仓时: 传入 0。此时 action 判定为 SELL。
- price: 当前最新价格 (用于实盘限价单参考)
- context: 聚宽上下文对象
- redis_config: Redis配置字典包含 host, port, password, db, strategy_name
"""
global _BACKTEST_SEND_COUNT
try:
# ---------------------------------------------------------
# 1. 环境判断与流量控制
# ---------------------------------------------------------
run_type = context.run_params.type
is_backtest = run_type in ["simple_backtest", "full_backtest"]
if is_backtest:
if _BACKTEST_SEND_COUNT >= 10:
print(f"[流量控制] 回测消息已达上限 (10条),跳过发送")
return
_BACKTEST_SEND_COUNT += 1
# ---------------------------------------------------------
# 2. 建立 Redis 连接
# ---------------------------------------------------------
r = _create_redis_client(redis_config)
# ---------------------------------------------------------
# 3. 数据转换与规范化
# ---------------------------------------------------------
qmt_code = _convert_code_to_qmt(code)
# 【核心逻辑】:根据 target_total_slots 判断动作
if target_total_slots > 0:
action = "BUY"
slots_val = int(target_total_slots)
else:
action = "SELL"
slots_val = 0
# ---------------------------------------------------------
# 4. 构建消息体
# ---------------------------------------------------------
base_strategy_name = redis_config.get("strategy_name", "default_strategy")
ts_str = context.current_dt.strftime("%Y-%m-%d %H:%M:%S")
msg = {
"strategy_name": base_strategy_name,
"stock_code": qmt_code,
"action": action,
"price": price,
"total_slots": slots_val,
"timestamp": ts_str,
"is_backtest": is_backtest,
}
# ---------------------------------------------------------
# 5. 使用 Stream 发送消息
# ---------------------------------------------------------
message_id = _send_to_stream(r, base_strategy_name, msg, is_backtest)
if message_id:
# ---------------------------------------------------------
# 6. 控制台输出
# ---------------------------------------------------------
log_prefix = "【回测】" if is_backtest else "【实盘】"
desc = f"目标总持仓:{slots_val}" if action == "BUY" else "清仓释放槽位"
print(
f"{log_prefix} 信号同步 -> {qmt_code} | 动作:{action} | {desc} | 时间:{ts_str} | msg_id:{message_id}"
)
else:
print(f"[Error] 发送QMT信号失败: Stream 返回 None")
except Exception as e:
print(f"[Error] 发送QMT信号失败: {e}")
def send_qmt_percentage_signal(
code, position_pct, action, price, is_backtest, timestamp, redis_config
):
"""
发送基于仓位百分比的信号到 Redis Stream
参数:
- code: 股票代码 (聚宽格式: 000001.XSHE)
- position_pct: 目标持仓占总资产的比例 (0.0 ~ 1.0,如 0.2 表示 20%)
- action: 交易动作,"BUY""SELL"
- price: 当前最新价格 (用于实盘限价单参考)
- is_backtest: 是否为回测模式 (True/False)
- timestamp: 时间戳字符串,格式 "YYYY-MM-DD HH:MM:SS"
- redis_config: Redis配置字典包含 host, port, password, db, strategy_name
"""
global _BACKTEST_SEND_COUNT
try:
# ---------------------------------------------------------
# 1. 环境判断与流量控制
# ---------------------------------------------------------
if is_backtest:
if _BACKTEST_SEND_COUNT >= 10:
return
_BACKTEST_SEND_COUNT += 1
# ---------------------------------------------------------
# 2. 建立 Redis 连接
# ---------------------------------------------------------
r = _create_redis_client(redis_config)
# ---------------------------------------------------------
# 3. 数据转换与规范化
# ---------------------------------------------------------
qmt_code = _convert_code_to_qmt(code)
# 校验 action 参数
if action not in ["BUY", "SELL"]:
print(f"[Error] 无效的 action 参数: {action},必须是 'BUY''SELL'")
return
# ---------------------------------------------------------
# 4. 构建消息体
# ---------------------------------------------------------
base_strategy_name = redis_config.get("strategy_name", "default_strategy")
msg = {
"strategy_name": base_strategy_name,
"stock_code": qmt_code,
"action": action,
"price": price,
"position_pct": float(position_pct),
"timestamp": timestamp,
"is_backtest": is_backtest,
}
# ---------------------------------------------------------
# 5. 使用 Stream 发送消息
# ---------------------------------------------------------
message_id = _send_to_stream(r, base_strategy_name, msg, is_backtest)
if message_id:
# ---------------------------------------------------------
# 6. 控制台输出
# ---------------------------------------------------------
log_prefix = "【回测】" if is_backtest else "【实盘】"
pct_display = f"{position_pct * 100:.1f}%"
desc = f"目标仓位:{pct_display}" if action == "BUY" else "清仓"
print(
f"{log_prefix} 百分比信号 -> {qmt_code} | 动作:{action} | {desc} | 价格:{price} | 时间:{timestamp} | msg_id:{message_id}"
)
else:
print(f"[Error] 发送QMT百分比信号失败: Stream 返回 None")
except Exception as e:
print(f"[Error] 发送QMT百分比信号失败: {e}")
# 便捷函数别名,保持向后兼容
send_signal = send_qmt_signal
send_percentage_signal = send_qmt_percentage_signal

View File

@@ -1,101 +0,0 @@
import redis
import json
import datetime
# --- 模块级全局变量 ---
_BACKTEST_SEND_COUNT = 0
def send_qmt_signal(code, target_total_slots, price, context, redis_config):
"""
发送信号到 Redis (基于槽位状态判断买卖意图)
参数:
- code: 股票代码 (聚宽格式: 000001.XSHE)
- target_total_slots:
* 意向持仓时: 传入策略设定的总槽位数 (例如 5)。此时 action 判定为 BUY。
* 意向清仓时: 传入 0。此时 action 判定为 SELL。
- price: 当前最新价格 (用于实盘限价单参考)
- context: 聚宽上下文对象
- redis_config: Redis配置字典
"""
global _BACKTEST_SEND_COUNT
try:
# ---------------------------------------------------------
# 1. 环境判断与流量控制
# ---------------------------------------------------------
run_type = context.run_params.type
is_backtest = run_type in ['simple_backtest', 'full_backtest']
if is_backtest:
if _BACKTEST_SEND_COUNT >= 10:
return
_BACKTEST_SEND_COUNT += 1
# ---------------------------------------------------------
# 2. 建立 Redis 连接
# ---------------------------------------------------------
r = redis.Redis(
host=redis_config['host'],
port=redis_config['port'],
password=redis_config.get('password'),
db=redis_config.get('db', 0),
decode_responses=True,
socket_timeout=1
)
# ---------------------------------------------------------
# 3. 数据转换与规范化
# ---------------------------------------------------------
# 股票代码格式转换: 聚宽(.XSHE/.XSHG) -> QMT(.SZ/.SH)
qmt_code = code
if code.endswith('.XSHE'):
qmt_code = code.replace('.XSHE', '.SZ')
elif code.endswith('.XSHG'):
qmt_code = code.replace('.XSHG', '.SH')
# 【核心逻辑修改】:根据 target_total_slots 判断动作
# 不再通过函数名判断,而是看目标状态
if target_total_slots > 0:
action = 'BUY'
slots_val = int(target_total_slots) # 告知后端我是基于“N只模型”中的一只
else:
action = 'SELL'
slots_val = 0 # 清仓
# ---------------------------------------------------------
# 4. 构建消息体
# ---------------------------------------------------------
base_strategy_name = redis_config.get('strategy_name', 'default_strategy')
ts_str = context.current_dt.strftime('%Y-%m-%d %H:%M:%S')
msg = {
'strategy_name': base_strategy_name,
'stock_code': qmt_code,
'action': action,
'price': price,
'total_slots': slots_val,
'timestamp': ts_str,
'is_backtest': is_backtest
}
json_payload = json.dumps(msg)
# ---------------------------------------------------------
# 5. 队列路由
# ---------------------------------------------------------
queue_key = f"{base_strategy_name}_backtest" if is_backtest else f"{base_strategy_name}_real"
expire_seconds = 3600 if is_backtest else 604800
r.rpush(queue_key, json_payload)
r.expire(queue_key, expire_seconds)
# ---------------------------------------------------------
# 6. 控制台输出
# ---------------------------------------------------------
log_prefix = "【回测】" if is_backtest else "【实盘】"
desc = f"目标总持仓:{slots_val}" if action == 'BUY' else "清仓释放槽位"
print(f"{log_prefix} 信号同步 -> {qmt_code} | 动作:{action} | {desc} | 时间:{ts_str}")
except Exception as e:
print(f"[Error] 发送QMT信号失败: {e}")

View File

@@ -1,951 +0,0 @@
# coding:utf-8
import time, datetime, traceback, sys, json, os, threading
import logging
from typing import Optional
import redis
from xtquant import xtdata
from xtquant.xttrader import XtQuantTrader, XtQuantTraderCallback
from xtquant.xttype import StockAccount
from xtquant import xtconstant
# FastAPI 相关
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
import uvicorn
# ================= 0. Windows 防卡死补丁 =================
try:
import ctypes
kernel32 = ctypes.windll.kernel32
# 禁用快速编辑模式 (0x0040)
kernel32.SetConsoleMode(kernel32.GetStdHandle(-10), 128)
except:
pass
# ================= 1. 全局状态管理 =================
class SystemState:
def __init__(self):
self.xt_trader = None
self.acc = None
self.pos_manager = None
self.callback = None
self.is_running = True
self.start_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.last_heartbeat = "Initializing..."
self.config = {}
# 重连控制
self.reconnect_attempts: int = 0 # 累计重连次数
self.max_reconnect_attempts: int = 3 # 最大重连次数
self.last_reconnect_fail_time: Optional[float] = None # 上次重连失败时间
GLOBAL_STATE = SystemState()
CURRENT_LOG_DATE = None
ORDER_CACHE = {} # 内存缓存: OrderID -> (Strategy, Code, Action)
# ================= 2. 增强型日志系统 =================
def setup_logger():
global CURRENT_LOG_DATE
log_dir = "logs"
if not os.path.exists(log_dir):
os.makedirs(log_dir)
today_str = datetime.date.today().strftime("%Y-%m-%d")
CURRENT_LOG_DATE = today_str
log_file = os.path.join(log_dir, f"{today_str}.log")
logger = logging.getLogger("QMT_Trader")
logger.setLevel(logging.INFO)
# 清除旧 handler
if logger.handlers:
for handler in logger.handlers[:]:
try:
handler.close()
logger.removeHandler(handler)
except:
pass
# 格式中增加 线程名,方便排查是 API 线程还是 交易线程
formatter = logging.Formatter(
"[%(asctime)s] [%(levelname)s] [%(threadName)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# 文件输出
file_handler = logging.FileHandler(log_file, mode="a", encoding="utf-8")
file_handler.setFormatter(formatter)
# 控制台输出 (强制刷新流,防止命令行卡住不显示)
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)
stream_handler.flush = sys.stdout.flush
logger.addHandler(file_handler)
logger.addHandler(stream_handler)
return logger
logger = setup_logger()
# ================= 3. 配置加载 =================
def load_config(config_file="config.json"):
if getattr(sys, "frozen", False):
base_path = os.path.dirname(sys.executable)
else:
base_path = os.path.dirname(os.path.abspath(__file__))
full_path = os.path.join(base_path, config_file)
if not os.path.exists(full_path):
if os.path.exists(config_file):
full_path = config_file
else:
logger.error(f"找不到配置文件: {full_path}")
sys.exit(1)
try:
with open(full_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"配置文件错误: {e}")
sys.exit(1)
# ================= 4. 业务逻辑类 =================
class PositionManager:
def __init__(self, r_client):
self.r = r_client
def _get_key(self, strategy_name):
return f"POS:{strategy_name}"
def mark_holding(self, strategy_name, code):
self.r.hsetnx(self._get_key(strategy_name), code, 0)
def rollback_holding(self, strategy_name, code):
key = self._get_key(strategy_name)
val = self.r.hget(key, code)
if val is not None and int(val) == 0:
self.r.hdel(key, code)
logger.warning(f"[{strategy_name}] 回滚释放槽位: {code}")
def update_actual_volume(self, strategy_name, code, delta_vol):
key = self._get_key(strategy_name)
new_vol = self.r.hincrby(key, code, int(delta_vol))
if new_vol <= 0:
self.r.hdel(key, code)
new_vol = 0
return new_vol
def get_position(self, strategy_name, code):
vol = self.r.hget(self._get_key(strategy_name), code)
return int(vol) if vol else 0
def get_holding_count(self, strategy_name):
return self.r.hlen(self._get_key(strategy_name))
def get_all_virtual_positions(self, strategy_name):
return self.r.hgetall(self._get_key(strategy_name))
def force_delete(self, strategy_name, code):
self.r.hdel(self._get_key(strategy_name), code)
def clean_stale_placeholders(self, strategy_name, xt_trader, acc):
try:
key = self._get_key(strategy_name)
all_pos = self.r.hgetall(key)
if not all_pos:
return
active_orders = xt_trader.query_stock_orders(acc, cancelable_only=True)
active_codes = (
[o.stock_code for o in active_orders] if active_orders else []
)
real_positions = xt_trader.query_stock_positions(acc)
real_holdings = (
[p.stock_code for p in real_positions if p.volume > 0]
if real_positions
else []
)
for code, vol_str in all_pos.items():
if int(vol_str) == 0:
if (code not in real_holdings) and (code not in active_codes):
self.r.hdel(key, code)
logger.warning(f"[{strategy_name}] 自动清理僵尸占位: {code}")
except Exception as e:
logger.error(f"清理僵尸占位异常: {e}")
class DailySettlement:
def __init__(self, xt_trader, acc, pos_mgr, strategies):
self.trader = xt_trader
self.acc = acc
self.pos_mgr = pos_mgr
self.strategies = strategies
self.has_settled = False
def run_settlement(self):
logger.info("=" * 40)
logger.info("执行收盘清算流程...")
try:
orders = self.trader.query_stock_orders(self.acc, cancelable_only=True)
logger.info(
f"收盘清算 - 查询可撤单订单: 获取到 {len(orders) if orders else 0} 个订单"
)
if orders:
for o in orders:
logger.info(
f"收盘清算 - 撤单: OrderID={o.order_id}, Stock={o.stock_code}"
)
self.trader.cancel_order_stock(self.acc, o.order_id)
time.sleep(2)
logger.info(f"收盘清算 - 完成撤单操作,共处理 {len(orders)} 个订单")
else:
logger.info("收盘清算 - 无待撤单订单")
except Exception as e:
logger.error(f"收盘清算 - 查询/撤单失败: {str(e)}", exc_info=True)
real_positions = self.trader.query_stock_positions(self.acc)
real_pos_map = (
{p.stock_code: p.volume for p in real_positions if p.volume > 0}
if real_positions
else {}
)
for strategy in self.strategies:
virtual = self.pos_mgr.get_all_virtual_positions(strategy)
for code, v_str in virtual.items():
v = int(v_str)
if code not in real_pos_map:
logger.warning(
f" [修正] {strategy} 幽灵持仓 {code} (Redis={v}) -> 强制释放"
)
self.pos_mgr.force_delete(strategy, code)
elif v == 0 and code in real_pos_map:
real_vol = real_pos_map[code]
self.pos_mgr.update_actual_volume(strategy, code, real_vol)
logger.info(
f" [修正] {strategy} 修正占位符 {code} 0 -> {real_vol}"
)
logger.info("清算完成")
self.has_settled = True
def reset_flag(self):
self.has_settled = False
class MyXtQuantTraderCallback(XtQuantTraderCallback):
def __init__(self, pos_mgr):
self.pos_mgr = pos_mgr
self.is_connected = False
def on_disconnected(self):
logger.warning(">> 回调通知: 交易端连接断开")
self.is_connected = False
def on_stock_trade(self, trade):
try:
cache_info = ORDER_CACHE.get(trade.order_id)
if not cache_info:
return
strategy, _, action = cache_info
logger.info(
f">>> [成交] {strategy} {trade.stock_code} {trade.traded_volume}"
)
if action == "BUY":
self.pos_mgr.update_actual_volume(
strategy, trade.stock_code, trade.traded_volume
)
elif action == "SELL":
self.pos_mgr.update_actual_volume(
strategy, trade.stock_code, -trade.traded_volume
)
except Exception as e:
logger.error(f"on_stock_trade 成交回调处理失败: {str(e)}", exc_info=True)
def on_order_error(self, err):
try:
logger.error(
f"下单失败回调: OrderID={err.order_id}, 错误信息={err.error_msg}"
)
cache = ORDER_CACHE.get(err.order_id)
if cache and cache[2] == "BUY":
logger.info(f"回滚持仓: Strategy={cache[0]}, Stock={cache[1]}")
self.pos_mgr.rollback_holding(cache[0], cache[1])
del ORDER_CACHE[err.order_id]
except Exception as e:
logger.error(f"on_order_error 错误回调处理失败: {str(e)}", exc_info=True)
# ================= 5. 核心消息处理 (重写版:拒绝静默失败) =================
def get_strategy_config(strategy_name, config):
"""获取策略配置,支持新旧配置格式兼容"""
strategies = config.get("strategies", {})
# 如果 strategies 是列表(旧格式),转换为默认配置
if isinstance(strategies, list):
return {"order_mode": "slots", "total_slots": 5}
# 获取策略配置
strategy_config = strategies.get(strategy_name, {})
# 设置默认值
result = {
"order_mode": strategy_config.get("order_mode", "slots"),
"total_slots": strategy_config.get("total_slots", 5),
}
return result
def process_percentage_buy(
strategy_name, stock_code, price, position_pct, xt_trader, acc
):
"""处理百分比模式的买入逻辑"""
logger.info(f"[百分比模式] 处理买入: {stock_code}, 目标占比: {position_pct}")
# 查询资产
asset = xt_trader.query_stock_asset(acc)
if not asset:
logger.error("API 错误: query_stock_asset 返回 None可能是 QMT 断连或未同步")
return
total_asset = asset.total_asset
available_cash = asset.cash
logger.info(
f"[百分比模式] 账户总资产: {total_asset:.2f}, 可用资金: {available_cash:.2f}"
)
# 计算目标金额
target_amount = total_asset * position_pct
actual_amount = min(target_amount, available_cash)
logger.info(
f"[百分比模式] 目标金额: {target_amount:.2f}, 实际可用: {actual_amount:.2f}"
)
# 检查最小金额限制
if actual_amount < 2000:
logger.warning(f"[百分比模式] 拦截买入: 金额过小 ({actual_amount:.2f} < 2000)")
return
# 价格校验
if price <= 0:
logger.warning(
f"[百分比模式] 价格异常: {price}强制设为1.0以计算股数(仅测试用)"
)
price = 1.0
# 计算股数
vol = int(actual_amount / price / 100) * 100
logger.info(
f"[百分比模式] 计算股数: 资金{actual_amount:.2f} / 价格{price} -> {vol}"
)
if vol < 100:
logger.warning(f"[百分比模式] 拦截买入: 股数不足 100 ({vol})")
return
# 执行下单
oid = xt_trader.order_stock(
acc,
stock_code,
xtconstant.STOCK_BUY,
vol,
xtconstant.FIX_PRICE,
price,
strategy_name,
"PyBuyPct",
)
if oid != -1:
logger.info(f"[百分比模式] √√√ 下单成功: ID={oid} {stock_code} 买入 {vol}")
ORDER_CACHE[oid] = (strategy_name, stock_code, "BUY")
else:
logger.error(
f"[百分比模式] XXX 下单请求被拒绝 (Result=-1),请检查 QMT 终端报错"
)
def process_percentage_sell(strategy_name, stock_code, price, xt_trader, acc):
"""处理百分比模式的卖出逻辑(清仓)"""
logger.info(f"[百分比模式] 处理卖出: {stock_code} (清仓)")
# 查询实盘持仓
real_pos = xt_trader.query_stock_positions(acc)
if real_pos is None:
logger.error("[百分比模式] API 错误: query_stock_positions 返回 None")
return
rp = next((p for p in real_pos if p.stock_code == stock_code), None)
can_use = rp.can_use_volume if rp else 0
logger.info(f"[百分比模式] 股票 {stock_code} 实盘可用持仓: {can_use}")
if can_use <= 0:
logger.warning(f"[百分比模式] 拦截卖出: 无可用持仓")
return
# 执行清仓
logger.info(f"[百分比模式] 执行清仓: {stock_code} @ {price}, 数量: {can_use}")
oid = xt_trader.order_stock(
acc,
stock_code,
xtconstant.STOCK_SELL,
can_use,
xtconstant.FIX_PRICE,
price,
strategy_name,
"PySellPct",
)
if oid != -1:
logger.info(f"[百分比模式] √√√ 下单成功: ID={oid} {stock_code} 卖出 {can_use}")
ORDER_CACHE[oid] = (strategy_name, stock_code, "SELL")
else:
logger.error(f"[百分比模式] XXX 下单请求被拒绝 (Result=-1)")
def process_slots_buy(
strategy_name, stock_code, price, total_slots, xt_trader, acc, pos_manager
):
"""处理槽位模式的买入逻辑(原有逻辑保持不变)"""
holding = pos_manager.get_holding_count(strategy_name)
empty = total_slots - holding
logger.info(
f"[槽位模式] 检查持仓: 当前占用 {holding} / 总槽位 {total_slots} -> 剩余 {empty}"
)
if empty <= 0:
logger.warning(f"[槽位模式] 拦截买入: 槽位已满,不执行下单")
return
# 查询资金
asset = xt_trader.query_stock_asset(acc)
if not asset:
logger.error(
"[槽位模式] API 错误: query_stock_asset 返回 None可能是 QMT 断连或未同步"
)
return
logger.info(f"[槽位模式] 当前可用资金: {asset.cash:.2f}")
amt = asset.cash / empty
if amt < 2000:
logger.warning(f"[槽位模式] 拦截买入: 单笔金额过小 ({amt:.2f} < 2000)")
return
if price <= 0:
logger.warning(f"[槽位模式] 价格异常: {price}强制设为1.0以计算股数(仅测试用)")
price = 1.0
vol = int(amt / price / 100) * 100
logger.info(f"[槽位模式] 计算股数: 资金{amt:.2f} / 价格{price} -> {vol}")
if vol < 100:
logger.warning(f"[槽位模式] 拦截买入: 股数不足 100 ({vol})")
return
# 执行下单
oid = xt_trader.order_stock(
acc,
stock_code,
xtconstant.STOCK_BUY,
vol,
xtconstant.FIX_PRICE,
price,
strategy_name,
"PyBuy",
)
if oid != -1:
logger.info(f"[槽位模式] √√√ 下单成功: ID={oid} {stock_code} 买入 {vol}")
ORDER_CACHE[oid] = (strategy_name, stock_code, "BUY")
pos_manager.mark_holding(strategy_name, stock_code)
else:
logger.error(f"[槽位模式] XXX 下单请求被拒绝 (Result=-1),请检查 QMT 终端报错")
def process_slots_sell(strategy_name, stock_code, price, xt_trader, acc, pos_manager):
"""处理槽位模式的卖出逻辑(原有逻辑保持不变)"""
v_vol = pos_manager.get_position(strategy_name, stock_code)
logger.info(f"[槽位模式] 卖出 - Redis 记录虚拟持仓: {v_vol}")
if v_vol > 0:
logger.info(f"[槽位模式] 卖出 - 正在查询实盘持仓: {stock_code}")
real_pos = xt_trader.query_stock_positions(acc)
logger.info(
f"[槽位模式] 卖出 - 实盘持仓查询完成,获取到 {len(real_pos) if real_pos else 0} 条记录"
)
if real_pos is None:
logger.error("[槽位模式] API 错误: query_stock_positions 返回 None")
return
rp = next((p for p in real_pos if p.stock_code == stock_code), None)
can_use = rp.can_use_volume if rp else 0
logger.info(f"[槽位模式] 卖出 - 股票 {stock_code} 实盘可用持仓: {can_use}")
final = min(v_vol, can_use)
logger.info(f"[槽位模式] 卖出 - 计算卖出量: min({v_vol}, {can_use}) = {final}")
if final > 0:
logger.info(
f"[槽位模式] 卖出 - 执行卖出订单: {stock_code} @ {price}, 数量: {final}"
)
oid = xt_trader.order_stock(
acc,
stock_code,
xtconstant.STOCK_SELL,
final,
xtconstant.FIX_PRICE,
price,
strategy_name,
"PySell",
)
if oid != -1:
logger.info(
f"[槽位模式] √√√ 下单成功: ID={oid} {stock_code} 卖出 {final}"
)
ORDER_CACHE[oid] = (strategy_name, stock_code, "SELL")
else:
logger.error(f"[槽位模式] XXX 下单请求被拒绝 (Result=-1)")
else:
logger.warning(
f"[槽位模式] 拦截卖出: 最终计算卖出量为 0 (虚拟:{v_vol}, 实盘:{can_use})"
)
else:
logger.warning(f"[槽位模式] 拦截卖出: Redis 中无此持仓记录,忽略")
def process_strategy_queue(
strategy_name, r_client, xt_trader, acc, pos_manager, config
):
queue_key = f"{strategy_name}_real"
# 1. 获取消息
msg_json = r_client.lpop(queue_key)
if not msg_json:
return
# 2. 存入历史并解析 (打印原始消息,确保知道收到了什么)
logger.info(f"-------- 处理消息 [{strategy_name}] --------")
logger.info(f"收到原始消息: {msg_json}")
try:
r_client.rpush(f"{queue_key}:history", msg_json)
try:
data = json.loads(msg_json)
except json.JSONDecodeError:
logger.error("JSON 解析失败,跳过消息")
return
# 3. 基础校验 (每一步失败都必须打印 Log)
if data.get("is_backtest"):
logger.warning(f"检测到回测标记 is_backtest=True忽略此消息")
return
msg_ts = data.get("timestamp")
if not msg_ts:
logger.warning(f"消息缺失时间戳 timestamp忽略")
return
today_str = datetime.date.today().strftime("%Y-%m-%d")
msg_date = msg_ts.split(" ")[0]
if msg_date != today_str:
logger.warning(f"消息日期过期: {msg_date} != 今日 {today_str},忽略")
return
# 4. 提取关键字段
stock_code = data.get("stock_code")
action = data.get("action")
price = float(data.get("price", 0))
if not stock_code or not action:
logger.error(f"缺少关键字段: Code={stock_code}, Action={action}")
return
# 5. 获取策略配置,确定下单模式
strategy_config = get_strategy_config(strategy_name, config)
order_mode = strategy_config.get("order_mode", "slots")
logger.info(
f"解析成功: {action} {stock_code} @ {price}, 下单模式: {order_mode}"
)
# 6. QMT 存活检查
if xt_trader is None or acc is None:
logger.error("严重错误: QMT 对象未初始化 (xt_trader is None)")
return
# 7. 根据下单模式执行相应逻辑
if order_mode == "percentage":
# 百分比模式
position_pct = float(data.get("position_pct", 0))
if action == "BUY":
process_percentage_buy(
strategy_name, stock_code, price, position_pct, xt_trader, acc
)
elif action == "SELL":
process_percentage_sell(
strategy_name, stock_code, price, xt_trader, acc
)
else:
logger.error(f"未知的 Action: {action}")
else:
# 槽位模式(默认)
total_slots = int(
data.get("total_slots", strategy_config.get("total_slots", 5))
)
if action == "BUY":
process_slots_buy(
strategy_name,
stock_code,
price,
total_slots,
xt_trader,
acc,
pos_manager,
)
elif action == "SELL":
process_slots_sell(
strategy_name, stock_code, price, xt_trader, acc, pos_manager
)
else:
logger.error(f"未知的 Action: {action}")
except Exception as e:
logger.error(f"消息处理发生未捕获异常: {str(e)}", exc_info=True)
# ================= 6. QMT初始化 =================
def init_qmt_trader(qmt_path, account_id, account_type, pos_manager):
try:
session_id = int(time.time())
logger.info(f"正在连接 QMT (Path: {qmt_path})...")
trader = XtQuantTrader(qmt_path, session_id)
acc = StockAccount(account_id, account_type)
callback = MyXtQuantTraderCallback(pos_manager)
trader.register_callback(callback)
trader.start()
res = trader.connect()
if res == 0:
logger.info(f"QMT 连接成功 [Session:{session_id}]")
trader.subscribe(acc)
callback.is_connected = True
return trader, acc, callback
else:
logger.error(f"QMT 连接失败 Code:{res} (请检查 QMT 是否登录且路径正确)")
return None, None, None
except Exception as e:
logger.error(f"初始化异常: {e}", exc_info=True)
return None, None, None
# ================= 7. 交易逻辑主循环 =================
def trading_loop():
global logger
threading.current_thread().name = "TradeThread"
logger.info(">>> 交易逻辑子线程启动 <<<")
GLOBAL_STATE.config = load_config("config.json")
CONFIG = GLOBAL_STATE.config
redis_cfg = CONFIG["redis"]
qmt_cfg = CONFIG["qmt"]
watch_list = CONFIG["strategies"]
try:
r = redis.Redis(**redis_cfg, decode_responses=True)
r.ping()
pos_manager = PositionManager(r)
GLOBAL_STATE.pos_manager = pos_manager
logger.info("Redis 连接成功")
except Exception as e:
logger.critical(f"Redis 连接失败: {e}")
return
# 初始化
xt_trader, acc, callback = init_qmt_trader(
qmt_cfg["path"], qmt_cfg["account_id"], qmt_cfg["account_type"], pos_manager
)
GLOBAL_STATE.xt_trader = xt_trader
GLOBAL_STATE.acc = acc
GLOBAL_STATE.callback = callback
settler = None
if xt_trader:
settler = DailySettlement(xt_trader, acc, pos_manager, watch_list)
for s in watch_list:
pos_manager.clean_stale_placeholders(s, xt_trader, acc)
logger.info(">>> 进入主轮询循环 <<<")
last_health_check = 0 # 上次深度检查时间
while GLOBAL_STATE.is_running:
try:
# 1. 基础心跳更新
GLOBAL_STATE.last_heartbeat = datetime.datetime.now().strftime("%H:%M:%S")
# 2. 状态诊断与自动修复 (关键修改!!!)
# 每 15 秒执行一次“深度探测”,而不是每一轮都看 callback
if time.time() - last_health_check > 15:
last_health_check = time.time()
is_alive_physically = False
# 尝试通过“查资产”来验证连接是否真的活着
if GLOBAL_STATE.xt_trader and GLOBAL_STATE.acc:
try:
asset = GLOBAL_STATE.xt_trader.query_stock_asset(
GLOBAL_STATE.acc
)
if asset:
is_alive_physically = True
# 【核心修复】:如果物理探测成功,强行修正 callback 状态
if (
GLOBAL_STATE.callback
and not GLOBAL_STATE.callback.is_connected
):
GLOBAL_STATE.callback.is_connected = True
logger.info(
"✅ [自愈] 检测到资产查询正常,修正伪造的断开状态 (False -> True)"
)
except Exception as e:
logger.warning(f"[健康检查] 资产查询失败: {str(e)}")
# 只有当 逻辑断开(callback) AND 物理断开(无法查资产) 时,才判定为断线
current_status = (
GLOBAL_STATE.callback.is_connected
if GLOBAL_STATE.callback
else False
)
# 减少日志刷屏:只有状态真的异常时才打印
if not current_status and not is_alive_physically:
logger.warning(
f"⚠️ 线程存活检查 | 逻辑状态:{current_status} | 物理探测:失败"
)
# 3. 断线重连逻辑
# 只有"物理探测"彻底失败了,才执行重连
if not is_alive_physically:
# 避让 QMT 夜间重启高峰期 (23:20 - 23:35)
# 避免在这段时间疯狂重连打印日志
now_hm = datetime.datetime.now().strftime("%H%M")
if "2320" <= now_hm <= "2335":
logger.info("⏳ QMT维护时段暂停重连休眠60秒...")
time.sleep(60)
continue
if datetime.date.today().weekday() >= 5: # 周末
time.sleep(3600)
continue
# 检查重连次数是否超过限制
if (
GLOBAL_STATE.reconnect_attempts
>= GLOBAL_STATE.max_reconnect_attempts
):
logger.warning(
f"⚠️ 重连失败次数已达上限 ({GLOBAL_STATE.reconnect_attempts}/{GLOBAL_STATE.max_reconnect_attempts}),停止自动重连"
)
# 如果距离上次失败超过5分钟重置计数器
if GLOBAL_STATE.last_reconnect_fail_time:
elapsed = (
time.time() - GLOBAL_STATE.last_reconnect_fail_time
)
if elapsed > 300: # 5分钟
GLOBAL_STATE.reconnect_attempts = 0
logger.info(
f"⏰ 重连计数器已重置 (距离上次失败 {elapsed / 60:.1f} 分钟)"
)
else:
logger.info(f"⏳ 需要等待 {300 - elapsed:.0f} 秒后重试")
# 在重连次数超限时,仍然等待一段时间再继续循环
time.sleep(60)
continue
logger.warning(
f"🚫 确认连接丢失,执行重连 ({GLOBAL_STATE.reconnect_attempts + 1}/{GLOBAL_STATE.max_reconnect_attempts})..."
)
if GLOBAL_STATE.xt_trader:
try:
GLOBAL_STATE.xt_trader.stop()
logger.info("已停止旧交易实例")
except Exception as e:
logger.error(f"停止旧交易实例失败: {str(e)}", exc_info=True)
new_trader, new_acc, new_cb = init_qmt_trader(
qmt_cfg["path"],
qmt_cfg["account_id"],
qmt_cfg["account_type"],
pos_manager,
)
if new_trader:
GLOBAL_STATE.xt_trader = new_trader
GLOBAL_STATE.acc = new_acc
GLOBAL_STATE.callback = new_cb
GLOBAL_STATE.reconnect_attempts = 0 # 重连成功后重置计数
GLOBAL_STATE.last_reconnect_fail_time = None
settler = DailySettlement(
new_trader, new_acc, pos_manager, watch_list
)
logger.info("✅ 重连成功")
else:
GLOBAL_STATE.reconnect_attempts += 1
GLOBAL_STATE.last_reconnect_fail_time = time.time()
logger.error(
f"❌ 重连失败,已尝试 {GLOBAL_STATE.reconnect_attempts}/{GLOBAL_STATE.max_reconnect_attempts}60秒后重试"
)
time.sleep(60)
continue
# 4. 日志轮转与心跳文件
today_str = datetime.date.today().strftime("%Y-%m-%d")
if today_str != CURRENT_LOG_DATE:
logger = setup_logger()
try:
with open("heartbeat.txt", "w") as f:
f.write(datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
except Exception as e:
logger.warning(f"[心跳] 写入心跳文件失败: {str(e)}")
# 5. 交易逻辑处理
current_time_str = datetime.datetime.now().strftime("%H%M%S")
is_trading_time = ("091500" <= current_time_str <= "113000") or (
"130000" <= current_time_str <= "150000"
)
# 如果连接正常(无论 callback 怎么说只要上面探测过了xt_trader 就是可用的)
if is_trading_time and GLOBAL_STATE.xt_trader:
if settler and settler.has_settled:
settler.reset_flag()
for s in watch_list:
process_strategy_queue(
s,
r,
GLOBAL_STATE.xt_trader,
GLOBAL_STATE.acc,
pos_manager,
CONFIG,
)
elif "150500" <= current_time_str <= "151000":
if settler and not settler.has_settled:
settler.run_settlement()
time.sleep(1 if is_trading_time else 5)
except Exception as e:
logger.critical("交易循环异常", exc_info=True)
time.sleep(10)
# ================= 8. FastAPI 接口 =================
app = FastAPI(title="QMT Monitor")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
async def read_root():
if os.path.exists("dashboard.html"):
return FileResponse("dashboard.html")
return {"error": "Dashboard not found"}
@app.get("/api/status")
def get_status():
connected = False
if GLOBAL_STATE.callback:
connected = GLOBAL_STATE.callback.is_connected
return {
"running": True,
"qmt_connected": connected,
"start_time": GLOBAL_STATE.start_time,
"last_loop_update": GLOBAL_STATE.last_heartbeat,
"account_id": GLOBAL_STATE.acc.account_id if GLOBAL_STATE.acc else "Unknown",
}
@app.get("/api/positions")
def get_positions():
real_pos_list = []
virtual_pos_map = {}
if (
GLOBAL_STATE.xt_trader
and GLOBAL_STATE.acc
and GLOBAL_STATE.callback
and GLOBAL_STATE.callback.is_connected
):
try:
positions = GLOBAL_STATE.xt_trader.query_stock_positions(GLOBAL_STATE.acc)
if positions:
for p in positions:
if p.volume > 0:
real_pos_list.append(
{
"code": p.stock_code,
"volume": p.volume,
"can_use": p.can_use_volume,
"market_value": p.market_value,
}
)
except Exception as e:
logger.warning(f"[API] 查询持仓失败: {str(e)}")
if GLOBAL_STATE.config and GLOBAL_STATE.pos_manager:
for s in GLOBAL_STATE.config.get("strategies", []):
v_data = GLOBAL_STATE.pos_manager.get_all_virtual_positions(s)
virtual_pos_map[s] = v_data
return {"real_positions": real_pos_list, "virtual_positions": virtual_pos_map}
@app.get("/api/logs")
def get_logs(lines: int = 50):
today_str = datetime.date.today().strftime("%Y-%m-%d")
log_path = os.path.join("logs", f"{today_str}.log")
if not os.path.exists(log_path):
return {"logs": ["暂无今日日志"]}
try:
with open(log_path, "r", encoding="utf-8") as f:
all_lines = f.readlines()
return {"logs": [line.strip() for line in all_lines[-lines:]]}
except Exception as e:
return {"logs": [f"读取失败: {str(e)}"]}
# ================= 9. 启动入口 =================
if __name__ == "__main__":
# 使用 -u 参数运行是最佳实践: python -u main.py
# 但这里也在代码里强制 flush 了
print(">>> 系统正在启动...")
t = threading.Thread(target=trading_loop, daemon=True)
t.start()
print("Web服务启动: http://localhost:8001")
uvicorn.run(app, host="0.0.0.0", port=8001, log_level="warning")