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:
@@ -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
440
qmt/backtest_consumer.py
Normal 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
301
qmt/logger.py
Normal 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
533
qmt/message_processor.py
Normal 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: 消费者ID,None 则自动生成
|
||||
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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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}_backtest(TTL: 1 小时)
|
||||
└─ 实盘 → {strategy_name}_real(TTL: 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}_backtest(TTL: 1 小时)
|
||||
└─ 实盘 → {strategy_name}_real(TTL: 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` 中的配置
|
||||
|
||||
@@ -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
251
qmt/qmt_sender.py
Normal 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
|
||||
@@ -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}")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user