1377 lines
63 KiB
Python
1377 lines
63 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
雪球跟单交易系统 - 优化版
|
||
严格按照业务逻辑文档规范实现
|
||
|
||
主要优化:
|
||
1. 规范化变量命名,符合业务逻辑文档术语标准
|
||
2. 重构业务流程,按照文档规范的流程组织
|
||
3. 优化数据处理逻辑,完善字段扩展和计算公式
|
||
4. 增强错误处理机制和日志输出规范
|
||
"""
|
||
|
||
import pandas as pd
|
||
import yaml
|
||
import os
|
||
import time
|
||
import datetime
|
||
import logging
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
from pyqmt_secure import pyqmt as qmt
|
||
import warnings
|
||
import subprocess
|
||
from typing import Tuple, Optional, Dict, Any
|
||
|
||
warnings.filterwarnings(action='ignore')
|
||
|
||
class XueqiuTradingSystem:
|
||
"""
|
||
雪球跟单交易系统
|
||
严格按照业务逻辑文档规范实现
|
||
"""
|
||
|
||
def __init__(self, config_file: str = None):
|
||
"""
|
||
系统初始化方法
|
||
|
||
Args:
|
||
config_file: 配置文件路径,如果为None则自动确定路径
|
||
"""
|
||
# 自动确定配置文件路径逻辑
|
||
if config_file is None:
|
||
# 获取当前脚本文件的绝对路径所在目录
|
||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||
# 配置文件位于源代码目录的上级目录,拼接完整路径
|
||
config_file = os.path.join(os.path.dirname(current_dir), '参数设置.yaml')
|
||
|
||
# 第一步:设置日志系统,为后续操作提供日志记录功能
|
||
self._setup_logging()
|
||
# 记录系统初始化开始的日志信息
|
||
self.logger.info('[系统初始化] 开始初始化雪球跟单交易系统...')
|
||
|
||
# 第二步:加载YAML配置文件,获取系统运行所需的各项参数
|
||
self.config = self._load_config(config_file)
|
||
|
||
# 第三步:根据配置文件初始化系统核心参数(如跟单比例、交易时间等)
|
||
self._init_system_parameters()
|
||
|
||
# 第四步:初始化QMT量化交易接口,建立与券商交易系统的连接
|
||
self._init_qmt_interface()
|
||
|
||
# 第五步:初始化数据管理模块,设置数据存储和处理相关参数
|
||
self._init_data_management()
|
||
|
||
# 记录系统初始化完成的日志信息
|
||
self.logger.info('[系统初始化] 雪球跟单交易系统初始化完成')
|
||
|
||
def _setup_logging(self):
|
||
"""
|
||
设置日志系统
|
||
按照业务逻辑文档规范设置日志级别和输出格式
|
||
"""
|
||
# 创建日志目录
|
||
log_dir = os.path.dirname(os.path.abspath(__file__))
|
||
log_file = os.path.join(log_dir, 'trading_system.log')
|
||
|
||
# 设置日志格式
|
||
log_format = '%(asctime)s - %(levelname)s - %(message)s'
|
||
date_format = '%Y-%m-%d %H:%M:%S'
|
||
|
||
# 配置日志处理器
|
||
handlers = [
|
||
logging.StreamHandler(), # 控制台输出
|
||
logging.FileHandler(log_file, encoding='utf-8') # 文件输出
|
||
]
|
||
|
||
# 设置日志级别
|
||
log_level = self.config.get('日志级别', 'INFO') if hasattr(self, 'config') else 'INFO'
|
||
level = getattr(logging, log_level.upper(), logging.INFO)
|
||
|
||
logging.basicConfig(
|
||
level=level,
|
||
format=log_format,
|
||
datefmt=date_format,
|
||
handlers=handlers,
|
||
force=True # 强制重新配置
|
||
)
|
||
|
||
self.logger = logging.getLogger(__name__)
|
||
self.logger.info(f'[日志系统] 日志级别设置为: {log_level}')
|
||
self.logger.info(f'[日志系统] 日志文件路径: {log_file}')
|
||
|
||
def _load_config(self, config_file: str) -> Dict[str, Any]:
|
||
"""
|
||
加载配置文件
|
||
|
||
Args:
|
||
config_file: 配置文件路径
|
||
|
||
Returns:
|
||
配置字典
|
||
"""
|
||
try:
|
||
if os.path.exists(config_file):
|
||
with open(config_file, 'r', encoding='utf-8') as f:
|
||
config = yaml.safe_load(f)
|
||
self.logger.info(f'[配置加载] 成功加载配置文件: {config_file}')
|
||
return config
|
||
else:
|
||
self.logger.warning(f'[配置加载] 配置文件不存在: {config_file},使用默认配置')
|
||
return {}
|
||
except Exception as e:
|
||
self.logger.error(f'[配置加载] 读取配置文件失败: {str(e)}')
|
||
return {}
|
||
|
||
def _init_system_parameters(self):
|
||
"""
|
||
初始化系统参数
|
||
"""
|
||
# 从配置文件读取参数
|
||
self.account_follow_ratio = self.config.get('账户跟单比例(%)', 0.0)
|
||
self.portfolio_configs = self.config.get('组合配置', [])
|
||
|
||
# 确保组合配置中的跟单比例字段名称一致
|
||
for config in self.portfolio_configs:
|
||
if '组合跟单比例(%)' not in config and '跟单比例' in config:
|
||
config['组合跟单比例(%)'] = config['跟单比例'] * 100
|
||
|
||
# 参数值范围验证
|
||
if not (0 <= self.account_follow_ratio <= 100):
|
||
self.logger.warning(f'[参数验证] 账户跟单比例(%)值 {self.account_follow_ratio} 超出范围0-100,将使用默认值0')
|
||
self.account_follow_ratio = 0.0
|
||
|
||
for config in self.portfolio_configs:
|
||
ratio = config.get('组合跟单比例(%)', 0.0)
|
||
if not (0 <= ratio <= 100):
|
||
self.logger.warning(f'[参数验证] 组合 {config.get("组合名字", "未知")} 的跟单比例(%)值 {ratio} 超出范围0-100,将使用默认值0')
|
||
config['组合跟单比例(%)'] = 0
|
||
|
||
# 验证所有组合跟单比例总和不超过100%
|
||
total_ratio = sum(config.get('组合跟单比例(%)', 0.0) for config in self.portfolio_configs)
|
||
if total_ratio > 100:
|
||
self.logger.warning(f'[参数验证] 所有组合跟单比例总和 {total_ratio}% 超过100%,将按比例缩放到100%以内')
|
||
# 按比例缩放所有组合的跟单比例
|
||
scale_factor = 100.0 / total_ratio
|
||
for config in self.portfolio_configs:
|
||
original_ratio = config.get('组合跟单比例(%)', 0.0)
|
||
scaled_ratio = round(original_ratio * scale_factor, 2)
|
||
config['组合跟单比例(%)'] = scaled_ratio
|
||
self.logger.info(f'[参数调整] 组合 {config.get("组合名字", "未知")} 跟单比例从 {original_ratio}% 调整为 {scaled_ratio}%')
|
||
|
||
# 重新计算总和并记录
|
||
new_total = sum(config.get('组合跟单比例(%)', 0.0) for config in self.portfolio_configs)
|
||
self.logger.info(f'[参数调整] 调整后所有组合跟单比例总和: {new_total}%')
|
||
else:
|
||
self.logger.info(f'[参数验证] 所有组合跟单比例总和: {total_ratio}%,符合要求')
|
||
|
||
self.loop_interval = 3 # 循环间隔(秒)
|
||
|
||
# 数据文件路径配置 - 使用绝对路径
|
||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||
project_root = os.path.dirname(current_dir)
|
||
auxiliary_dir = os.path.join(project_root, '辅助文件')
|
||
|
||
self.data_file_path = os.path.join(auxiliary_dir, '数据.csv') # 最终处理后的数据文件
|
||
self.raw_data_file_path = os.path.join(auxiliary_dir, '雪球原始数据.csv') # 雪球原始数据文件
|
||
|
||
# 已处理记录ID集合
|
||
self.processed_record_ids = set()
|
||
|
||
# 统计信息
|
||
self.execution_count = 0
|
||
self.total_new_records = 0
|
||
|
||
self.logger.info(f'[系统参数] 账户跟单比例(%): {self.account_follow_ratio}%')
|
||
self.logger.info(f'[系统参数] 组合配置数量: {len(self.portfolio_configs)}')
|
||
|
||
def _init_qmt_interface(self):
|
||
"""
|
||
初始化QMT交易接口
|
||
"""
|
||
try:
|
||
qmt_path = self.config.get('QMT路径', 'C:\\国金QMT交易端模拟\\userdata_mini')
|
||
account_id = self.config.get('资金账号', '39972702')
|
||
|
||
self.logger.info(f'[QMT初始化] QMT路径: {qmt_path}')
|
||
self.logger.info(f'[QMT初始化] 资金账号: {account_id}')
|
||
|
||
self.qmt = qmt(path=qmt_path, acc=account_id)
|
||
self.qmt_available = True
|
||
self.logger.info('[QMT初始化] QMT交易接口初始化成功')
|
||
|
||
except Exception as e:
|
||
self.logger.warning(f'[QMT初始化] QMT交易接口初始化失败: {str(e)}')
|
||
self.logger.warning('[QMT初始化] QMT连接失败不影响数据处理和计算逻辑,系统将继续运行(仅交易功能不可用)')
|
||
self.qmt = None
|
||
self.qmt_available = False
|
||
|
||
def _init_data_management(self):
|
||
"""
|
||
初始化数据管理
|
||
"""
|
||
try:
|
||
if os.path.exists(self.data_file_path):
|
||
df = pd.read_csv(self.data_file_path, encoding='utf-8-sig')
|
||
# 检查文件是否只有表头(空数据)
|
||
if len(df) == 0:
|
||
self.logger.info('[数据管理] 数据文件为空,重置已处理记录ID集合')
|
||
self.processed_record_ids = set()
|
||
elif '记录ID' in df.columns:
|
||
self.processed_record_ids = set(df['记录ID'].astype(str))
|
||
self.logger.info(f'[数据管理] 已加载 {len(self.processed_record_ids)} 个已处理记录ID')
|
||
else:
|
||
self.logger.warning('[数据管理] 数据文件中未找到记录ID列')
|
||
self.processed_record_ids = set()
|
||
else:
|
||
self.logger.info('[数据管理] 数据文件不存在,将从空数据开始')
|
||
self.processed_record_ids = set()
|
||
|
||
except Exception as e:
|
||
self.logger.error(f'[数据管理] 初始化数据管理失败: {str(e)}')
|
||
self.processed_record_ids = set()
|
||
|
||
def is_trading_time(self) -> Tuple[bool, str]:
|
||
"""
|
||
判断当前是否为交易时间
|
||
|
||
Returns:
|
||
(是否为交易时间, 时间状态信息)
|
||
"""
|
||
now = datetime.datetime.now()
|
||
current_time = now.time()
|
||
|
||
# 交易时间:周一至周五 9:15-15:00(测试模式)
|
||
trading_start = datetime.time(9, 15)
|
||
trading_end = datetime.time(15, 00)
|
||
|
||
# 检查是否为工作日
|
||
if now.weekday() >= 5: # 周六=5, 周日=6
|
||
return False, "当前为周末,非交易时间"
|
||
|
||
# 检查时间范围
|
||
if trading_start <= current_time <= trading_end:
|
||
return True, "当前为交易时间"
|
||
else:
|
||
return False, f"当前时间 {current_time.strftime('%H:%M:%S')} 不在交易时间范围内(00:00-23:59)"
|
||
|
||
def execute_xueqiu_script(self) -> Tuple[bool, Optional[str]]:
|
||
"""
|
||
执行雪球数据获取脚本
|
||
|
||
Returns:
|
||
(是否成功, 错误信息)
|
||
"""
|
||
try:
|
||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||
xueqiu_path = os.path.join(current_dir, 'xueqiu.py')
|
||
project_root = os.path.dirname(current_dir)
|
||
|
||
result = subprocess.run(
|
||
['python', xueqiu_path],
|
||
capture_output=True,
|
||
text=True,
|
||
encoding='gbk',
|
||
cwd=project_root
|
||
)
|
||
|
||
if result.returncode == 0:
|
||
self.logger.info('[雪球数据] 雪球数据获取成功')
|
||
return True, None
|
||
else:
|
||
self.logger.error(f'[雪球数据] 雪球数据获取失败: {result.stderr}')
|
||
return False, result.stderr
|
||
|
||
except Exception as e:
|
||
self.logger.error(f'[雪球数据] 执行雪球脚本异常: {str(e)}')
|
||
return False, str(e)
|
||
|
||
def get_account_total_assets(self) -> Optional[float]:
|
||
"""
|
||
获取账户总资产
|
||
|
||
Returns:
|
||
账户总资产,获取失败返回None
|
||
"""
|
||
try:
|
||
if not self.qmt_available or self.qmt is None:
|
||
self.logger.warning('[账户资产] QMT接口不可用,返回None')
|
||
return None
|
||
|
||
asset_df = self.qmt.query_stock_asset()
|
||
if asset_df is None or asset_df.empty:
|
||
self.logger.warning('[账户资产] 查询账户资产返回空数据,返回None')
|
||
return None
|
||
|
||
total_assets = asset_df['总资产'].iloc[0]
|
||
self.logger.info(f'[账户资产] 账户总资产: {total_assets:.2f}元')
|
||
return total_assets
|
||
|
||
except Exception as e:
|
||
self.logger.warning(f'[账户资产] 获取账户总资产失败: {str(e)},返回None')
|
||
return None
|
||
|
||
def convert_stock_code_format(self, original_code: str) -> str:
|
||
"""
|
||
转换股票代码格式
|
||
从 SH600109 格式转换为 600109.SH 格式
|
||
|
||
Args:
|
||
original_code: 原始股票代码
|
||
|
||
Returns:
|
||
转换后的股票代码
|
||
"""
|
||
if original_code.startswith('SH'):
|
||
return original_code[2:] + '.SH'
|
||
elif original_code.startswith('SZ'):
|
||
return original_code[2:] + '.SZ'
|
||
else:
|
||
return original_code
|
||
|
||
def calculate_adjustment_ratio(self, target_weight: float, previous_weight: float) -> float:
|
||
"""
|
||
计算调仓幅度
|
||
调仓幅度 = 目标权重 - 前期调整权重
|
||
|
||
Args:
|
||
target_weight: 目标权重
|
||
previous_weight: 前期调整权重
|
||
|
||
Returns:
|
||
调仓幅度
|
||
"""
|
||
# 空值处理:当目标权重或前期调整权重为空时,视为0
|
||
if pd.isna(target_weight):
|
||
target_weight = 0.0
|
||
if pd.isna(previous_weight):
|
||
previous_weight = 0.0
|
||
|
||
adjustment_ratio = target_weight - previous_weight
|
||
return adjustment_ratio
|
||
|
||
def determine_trade_direction(self, adjustment_ratio: float) -> str:
|
||
"""
|
||
确定交易方向
|
||
|
||
Args:
|
||
adjustment_ratio: 调仓幅度
|
||
|
||
Returns:
|
||
交易方向:买入/卖出/无操作
|
||
"""
|
||
if adjustment_ratio > 0:
|
||
return "买入"
|
||
elif adjustment_ratio < 0:
|
||
return "卖出"
|
||
else:
|
||
return "无操作"
|
||
|
||
def calculate_follow_shares(self, account_total_assets: float, account_follow_ratio: float,
|
||
portfolio_ratio: float, adjustment_ratio: float,
|
||
latest_price: float, stock_code: str = "") -> int:
|
||
"""
|
||
计算跟单股数
|
||
|
||
Args:
|
||
account_total_assets: 账户总资产
|
||
account_follow_ratio: 账户跟单比例(%)
|
||
portfolio_ratio: 组合跟单比例(%)
|
||
adjustment_ratio: 调仓幅度(%)
|
||
latest_price: 标的最新价
|
||
stock_code: 股票代码,用于判断是否为可转债
|
||
|
||
Returns:
|
||
跟单股数(可转债向下取整到10股的整数倍,普通股票向下取整到100股的整数倍)
|
||
"""
|
||
# 跟单金额 = 账户总资产 × 账户跟随比例/100 × 组合跟随比例/100 × 调仓幅度的绝对值/100
|
||
follow_amount = account_total_assets * (account_follow_ratio / 100) * (portfolio_ratio / 100) * (abs(adjustment_ratio) / 100)
|
||
|
||
# 判断是否为可转债(11或12开头)
|
||
is_convertible_bond = stock_code.startswith('11') or stock_code.startswith('12')
|
||
|
||
if is_convertible_bond:
|
||
# 可转债:跟单股数 = 跟单金额 // (最新价 × 10) × 10
|
||
follow_shares = int(follow_amount // (latest_price * 10)) * 10
|
||
else:
|
||
# 普通股票:跟单股数 = 跟单金额 // (最新价 × 100) × 100
|
||
follow_shares = int(follow_amount // (latest_price * 100)) * 100
|
||
|
||
return max(0, follow_shares) # 确保不为负数
|
||
|
||
def get_portfolio_name(self, portfolio_id: str) -> str:
|
||
"""
|
||
根据组合ID获取组合名字
|
||
|
||
Args:
|
||
portfolio_id: 组合ID
|
||
|
||
Returns:
|
||
组合名字
|
||
"""
|
||
for config in self.portfolio_configs:
|
||
if config.get('组合ID') == portfolio_id:
|
||
return config.get('组合名字', f'组合{portfolio_id}')
|
||
return f'组合{portfolio_id}'
|
||
|
||
def get_portfolio_ratio(self, portfolio_id: str) -> float:
|
||
"""
|
||
根据组合ID获取组合跟单比例(%)
|
||
|
||
Args:
|
||
portfolio_id: 组合ID
|
||
|
||
Returns:
|
||
组合跟单比例(%)
|
||
"""
|
||
for config in self.portfolio_configs:
|
||
if config.get('组合ID') == portfolio_id:
|
||
return config.get('组合跟单比例(%)', 0.0)
|
||
return 0.0
|
||
|
||
def extend_data_fields(self, df: pd.DataFrame) -> pd.DataFrame:
|
||
"""
|
||
扩展数据字段
|
||
按照业务逻辑文档规范添加必要字段
|
||
|
||
Args:
|
||
df: 原始数据DataFrame
|
||
|
||
Returns:
|
||
扩展后的DataFrame
|
||
"""
|
||
import numpy as np
|
||
|
||
# 删除旧版本的跟单比例(%)列(已被组合跟单比例(%)替代)
|
||
if '跟单比例(%)' in df.columns:
|
||
df = df.drop('跟单比例(%)', axis=1)
|
||
self.logger.info('[字段扩展] 已删除旧版本跟单比例(%)列')
|
||
|
||
# 3.1 配置参数字段
|
||
required_fields = [
|
||
'账户跟单比例(%)', '组合名字', '组合ID', '组合跟单比例(%)',
|
||
'账户总资产', '当前权重', '调仓幅度', '交易方向', '代码',
|
||
'跟单股数', '是否委托', '调仓时间'
|
||
]
|
||
|
||
for field in required_fields:
|
||
if field not in df.columns:
|
||
if field in ['组合名字', '交易方向', '代码', '调仓时间']:
|
||
df[field] = ''
|
||
elif field == '是否委托':
|
||
df[field] = '否' # 初始化为"否"
|
||
elif field == '账户总资产':
|
||
df[field] = np.nan # 账户总资产初始化为NAN
|
||
else:
|
||
df[field] = 0.0
|
||
self.logger.info(f'[字段扩展] 已添加字段: {field}')
|
||
|
||
# 添加调仓时间字段:将更新时间的毫秒时间戳转换为YYYYMMDDHHMMSS格式
|
||
if '更新时间' in df.columns:
|
||
try:
|
||
# 将毫秒时间戳转换为datetime,然后格式化为YYYYMMDDHHMMSS
|
||
df['调仓时间'] = pd.to_datetime(df['更新时间'], unit='ms').dt.strftime('%Y%m%d%H%M%S')
|
||
self.logger.info('[字段扩展] 已添加调仓时间字段,格式:YYYYMMDDHHMMSS')
|
||
except Exception as e:
|
||
self.logger.error(f'[字段扩展] 转换调仓时间失败: {str(e)}')
|
||
df['调仓时间'] = ''
|
||
|
||
# 数据计算完成,记录总体统计信息
|
||
self.logger.info(f'[数据计算] 数据计算完成,共处理{len(df)}条记录')
|
||
return df
|
||
|
||
def process_data_calculations(self, df: pd.DataFrame) -> pd.DataFrame:
|
||
"""
|
||
处理数据计算
|
||
按照业务逻辑文档规范进行各项计算
|
||
|
||
Args:
|
||
df: 数据DataFrame
|
||
|
||
Returns:
|
||
处理后的DataFrame
|
||
"""
|
||
import numpy as np
|
||
|
||
# 预先获取账户总资产,避免每行都重复获取
|
||
account_total_assets = np.nan # 默认为NAN
|
||
if self.qmt is not None:
|
||
try:
|
||
assets = self.get_account_total_assets()
|
||
if assets and assets > 0:
|
||
account_total_assets = assets
|
||
self.logger.info(f'[数据计算] 获取账户总资产: {account_total_assets}')
|
||
else:
|
||
self.logger.info('[数据计算] QMT连接正常但获取资产失败,使用NAN')
|
||
except Exception:
|
||
self.logger.info('[数据计算] 获取账户总资产异常,使用NAN')
|
||
else:
|
||
self.logger.info('[数据计算] QMT未连接,使用NAN作为账户总资产')
|
||
|
||
# 批量处理所有数据,避免逐行调用QMT接口
|
||
for index, row in df.iterrows():
|
||
try:
|
||
# 2.2 调仓幅度计算 - 不依赖QMT
|
||
target_weight = row.get('目标权重', 0.0)
|
||
previous_weight = row.get('前期调整权重', 0.0)
|
||
adjustment_ratio = self.calculate_adjustment_ratio(target_weight, previous_weight)
|
||
# 调仓幅度保留2位小数并四舍五入
|
||
df.loc[index, '调仓幅度'] = round(adjustment_ratio, 2)
|
||
|
||
# 2.3 交易方向确定 - 不依赖QMT
|
||
trade_direction = self.determine_trade_direction(adjustment_ratio)
|
||
df.loc[index, '交易方向'] = trade_direction
|
||
|
||
# 3.3 股票代码格式转换 - 不依赖QMT
|
||
original_code = row.get('股票代码', '')
|
||
if original_code:
|
||
converted_code = self.convert_stock_code_format(original_code)
|
||
df.loc[index, '代码'] = converted_code
|
||
|
||
# 账户跟单比例 - 不依赖QMT,直接从配置获取
|
||
if hasattr(self, 'account_follow_ratio') and self.account_follow_ratio is not None and self.account_follow_ratio > 0:
|
||
df.loc[index, '账户跟单比例(%)'] = self.account_follow_ratio
|
||
else:
|
||
self.logger.warning('[数据计算] 账户跟单比例未正确配置,使用默认值10%')
|
||
df.loc[index, '账户跟单比例(%)'] = 10.0
|
||
|
||
# 组合相关信息 - 不依赖QMT,直接从配置获取
|
||
portfolio_id = row.get('组合ID', '')
|
||
if portfolio_id:
|
||
portfolio_name = self.get_portfolio_name(portfolio_id)
|
||
portfolio_ratio = self.get_portfolio_ratio(portfolio_id)
|
||
df.loc[index, '组合名字'] = portfolio_name
|
||
|
||
# 组合跟单比例 - 不依赖QMT,修复逻辑确保正确获取配置值
|
||
if portfolio_ratio is not None and portfolio_ratio > 0:
|
||
df.loc[index, '组合跟单比例(%)'] = portfolio_ratio
|
||
# 减少日志输出,只在第一次记录时输出
|
||
if not hasattr(self, '_logged_portfolio_ratios'):
|
||
self._logged_portfolio_ratios = set()
|
||
if portfolio_id not in self._logged_portfolio_ratios:
|
||
self.logger.info(f'[数据计算] 组合ID {portfolio_id} 跟单比例: {portfolio_ratio}%')
|
||
self._logged_portfolio_ratios.add(portfolio_id)
|
||
else:
|
||
# 如果配置中没有找到,使用默认值但记录警告
|
||
if not hasattr(self, '_logged_portfolio_warnings'):
|
||
self._logged_portfolio_warnings = set()
|
||
if portfolio_id not in self._logged_portfolio_warnings:
|
||
self.logger.warning(f'[数据计算] 组合ID {portfolio_id} 在配置中未找到,使用默认值50%')
|
||
self._logged_portfolio_warnings.add(portfolio_id)
|
||
df.loc[index, '组合跟单比例(%)'] = 50.0
|
||
else:
|
||
df.loc[index, '组合名字'] = ''
|
||
df.loc[index, '组合跟单比例(%)'] = 50.0 # 默认值
|
||
|
||
# 是否委托 - 不依赖QMT,初始化为"否"
|
||
if '是否委托' not in df.columns or pd.isna(df.loc[index, '是否委托']):
|
||
df.loc[index, '是否委托'] = '否'
|
||
|
||
# 3.2 账户资产字段 - 使用预先获取的值,不再重复调用QMT
|
||
df.loc[index, '账户总资产'] = account_total_assets
|
||
|
||
# 4.1 跟单股数计算 - 现在所有关键字段都有值,可以正常计算
|
||
current_account_assets = account_total_assets # 使用预先获取的值
|
||
current_account_ratio = df.loc[index, '账户跟单比例(%)']
|
||
current_portfolio_ratio = df.loc[index, '组合跟单比例(%)']
|
||
|
||
# 检查关键参数是否为NAN,如果任一为NAN则跟单股数也设为NAN
|
||
if (pd.isna(current_account_assets) or
|
||
pd.isna(current_account_ratio) or
|
||
pd.isna(current_portfolio_ratio)):
|
||
df.loc[index, '跟单股数'] = np.nan
|
||
continue
|
||
|
||
# 计算跟单股数 - 不再过滤调仓幅度为0的记录,处理所有有股票代码的记录
|
||
if df.loc[index, '代码']:
|
||
try:
|
||
# 获取股票真实价格
|
||
stock_code = df.loc[index, '代码']
|
||
try:
|
||
# 使用QMT接口获取最新价
|
||
latest_price = self.qmt.get_last_price(stock_code)
|
||
if latest_price is None or latest_price <= 0:
|
||
raise ValueError(f"获取到无效价格: {latest_price}")
|
||
|
||
self.logger.info(f'[价格获取] {stock_code} 最新价格: {latest_price}元')
|
||
|
||
except Exception as price_error:
|
||
# 价格获取失败时的处理
|
||
self.logger.warning(f'[价格获取] 获取 {stock_code} 最新价失败: {str(price_error)},跳过该股票下单')
|
||
df.loc[index, '跟单股数'] = 0
|
||
df.loc[index, '是否委托'] = '否'
|
||
continue
|
||
|
||
# 使用真实价格计算跟单股数
|
||
follow_shares = self.calculate_follow_shares(
|
||
current_account_assets, current_account_ratio,
|
||
current_portfolio_ratio, adjustment_ratio, latest_price, stock_code
|
||
)
|
||
df.loc[index, '跟单股数'] = follow_shares
|
||
|
||
except Exception as e:
|
||
self.logger.error(f'[数据计算] 计算跟单股数失败: {str(e)}')
|
||
df.loc[index, '跟单股数'] = 0
|
||
else:
|
||
# 无股票代码时,跟单股数为0,但仍保留记录(减少日志输出)
|
||
df.loc[index, '跟单股数'] = 0
|
||
|
||
except Exception as e:
|
||
self.logger.error(f'[数据计算] 处理第{index}行数据时发生异常: {str(e)}')
|
||
# 确保即使出错也有基本的字段值
|
||
df.loc[index, '调仓幅度'] = 0.0
|
||
df.loc[index, '交易方向'] = '无操作'
|
||
df.loc[index, '跟单股数'] = 0
|
||
df.loc[index, '是否委托'] = '否'
|
||
continue
|
||
|
||
return df
|
||
|
||
def execute_trade_order(self, symbol: str, volume: int, trade_direction: str,
|
||
strategy_name: str) -> Optional[int]:
|
||
"""
|
||
执行交易订单
|
||
|
||
Args:
|
||
symbol: 股票代码
|
||
volume: 交易数量
|
||
trade_direction: 交易方向
|
||
strategy_name: 策略名称
|
||
|
||
Returns:
|
||
订单ID,失败返回None
|
||
"""
|
||
try:
|
||
if not self.qmt_available or self.qmt is None:
|
||
self.logger.warning(f'[交易执行] QMT接口不可用,跳过交易: {trade_direction} {symbol} {volume}股')
|
||
return None
|
||
|
||
if trade_direction == "买入":
|
||
order_id = self.qmt.buy(symbol, volume, strategy_name=strategy_name)
|
||
elif trade_direction == "卖出":
|
||
order_id = self.qmt.sell(symbol, volume, strategy_name=strategy_name)
|
||
else:
|
||
self.logger.info(f'[交易执行] {symbol} 无需操作')
|
||
return None
|
||
|
||
if order_id and order_id > 0:
|
||
self.logger.info(f'[交易执行] {trade_direction} {symbol} {volume}股 成功,订单ID: {order_id}')
|
||
return order_id
|
||
else:
|
||
self.logger.error(f'[交易执行] {trade_direction} {symbol} {volume}股 失败')
|
||
return None
|
||
|
||
except Exception as e:
|
||
self.logger.error(f'[交易执行] 执行交易异常: {str(e)}')
|
||
return None
|
||
|
||
def update_trade_status(self, df: pd.DataFrame, index: int, success: bool) -> pd.DataFrame:
|
||
"""
|
||
更新交易状态
|
||
|
||
Args:
|
||
df: 数据DataFrame
|
||
index: 记录索引
|
||
success: 是否成功
|
||
|
||
Returns:
|
||
更新后的DataFrame
|
||
"""
|
||
if success:
|
||
df.loc[index, '是否委托'] = '是'
|
||
self.logger.info(f'[状态更新] 第{index+1}条记录交易状态已更新为:是')
|
||
else:
|
||
df.loc[index, '是否委托'] = '否'
|
||
self.logger.warning(f'[状态更新] 第{index+1}条记录交易失败,状态保持为:否')
|
||
|
||
return df
|
||
|
||
def save_data_with_backup(self, df: pd.DataFrame) -> bool:
|
||
"""
|
||
保存数据(已取消备份功能)
|
||
|
||
Args:
|
||
df: 要保存的DataFrame
|
||
|
||
Returns:
|
||
保存是否成功
|
||
"""
|
||
try:
|
||
# 创建副本以避免修改原始数据
|
||
df_to_save = df.copy()
|
||
|
||
# 将所有类型的空值(NaN、None、空字符串)替换为"NAN"字符串
|
||
import numpy as np
|
||
df_to_save = df_to_save.replace([np.nan, None, ''], 'NAN')
|
||
|
||
# 直接保存数据,不再创建备份文件
|
||
df_to_save.to_csv(self.data_file_path, index=False, encoding='utf-8-sig')
|
||
self.logger.info(f'[数据保存] 数据保存成功,共保存{len(df)}条数据到文件: {self.data_file_path}')
|
||
return True
|
||
|
||
except Exception as e:
|
||
self.logger.error(f'[数据保存] 保存数据失败: {str(e)}')
|
||
return False
|
||
|
||
def filter_duplicate_records(self, df: pd.DataFrame) -> pd.DataFrame:
|
||
"""
|
||
基于记录ID和调仓ID进行智能去重处理
|
||
修复重复下单问题:保持已委托记录的状态,只对真正的新记录设置"否"状态
|
||
|
||
Args:
|
||
df: 原始数据DataFrame(通常来自雪球原始数据)
|
||
|
||
Returns:
|
||
去重后需要处理的记录DataFrame
|
||
"""
|
||
if df.empty or '记录ID' not in df.columns:
|
||
self.logger.warning('[数据去重] 数据为空或缺少记录ID列')
|
||
return pd.DataFrame()
|
||
|
||
# 读取现有的已处理数据文件,获取当前的委托状态
|
||
existing_df = pd.DataFrame()
|
||
if os.path.exists(self.data_file_path):
|
||
try:
|
||
existing_df = pd.read_csv(self.data_file_path, encoding='utf-8-sig')
|
||
self.logger.info(f'[数据去重] 成功读取现有数据文件,共{len(existing_df)}条记录')
|
||
except Exception as e:
|
||
self.logger.error(f'[数据去重] 读取现有数据文件失败: {str(e)}')
|
||
existing_df = pd.DataFrame()
|
||
|
||
# 转换记录ID为字符串进行比较
|
||
df['记录ID_str'] = df['记录ID'].astype(str)
|
||
|
||
# 创建唯一标识:股票代码 + 调仓ID,用于更精确的重复判断
|
||
if '调仓ID' in df.columns and '股票代码' in df.columns:
|
||
df['唯一标识'] = df['股票代码'].astype(str) + '_' + df['调仓ID'].astype(str)
|
||
self.logger.info('[数据去重] 使用股票代码+调仓ID作为唯一标识')
|
||
else:
|
||
# 如果没有调仓ID,则使用记录ID作为唯一标识
|
||
df['唯一标识'] = df['记录ID'].astype(str)
|
||
self.logger.warning('[数据去重] 缺少调仓ID字段,使用记录ID作为唯一标识')
|
||
|
||
# 筛选出未处理过的记录ID(基于内存中的processed_record_ids)
|
||
new_records = df[~df['记录ID_str'].isin(self.processed_record_ids)].copy()
|
||
|
||
if len(new_records) > 0:
|
||
# 删除临时列
|
||
new_records = new_records.drop('记录ID_str', axis=1)
|
||
|
||
# 智能设置委托状态:检查是否在现有数据中已经存在且已委托
|
||
if '是否委托' not in new_records.columns:
|
||
# 如果原始数据中没有委托状态字段,则添加该字段
|
||
new_records['是否委托'] = '否'
|
||
self.logger.info('[数据去重] 原始数据无委托状态字段,为所有新记录初始化为"否"')
|
||
else:
|
||
# 如果原始数据中有委托状态字段,先填充空值为"否"
|
||
new_records['是否委托'] = new_records['是否委托'].fillna('否')
|
||
|
||
# 关键修复:检查现有数据中是否已经存在相同的记录且已委托
|
||
if not existing_df.empty and '唯一标识' in existing_df.columns and '是否委托' in existing_df.columns:
|
||
# 为现有数据也创建唯一标识
|
||
if '调仓ID' in existing_df.columns and '股票代码' in existing_df.columns:
|
||
existing_df['唯一标识'] = existing_df['股票代码'].astype(str) + '_' + existing_df['调仓ID'].astype(str)
|
||
else:
|
||
existing_df['唯一标识'] = existing_df['记录ID'].astype(str)
|
||
|
||
# 检查每条新记录是否在现有数据中已经存在且已委托
|
||
for idx, row in new_records.iterrows():
|
||
unique_id = row['唯一标识']
|
||
# 在现有数据中查找相同唯一标识的记录
|
||
existing_record = existing_df[existing_df['唯一标识'] == unique_id]
|
||
|
||
if not existing_record.empty:
|
||
# 如果找到相同记录,检查其委托状态
|
||
existing_status = existing_record.iloc[0]['是否委托']
|
||
if existing_status == '是':
|
||
# 如果已经委托,保持"是"状态,避免重复下单
|
||
new_records.loc[idx, '是否委托'] = '是'
|
||
self.logger.info(f'[数据去重] 记录{unique_id}已存在且已委托,保持"是"状态')
|
||
else:
|
||
# 如果未委托,保持"否"状态
|
||
new_records.loc[idx, '是否委托'] = '否'
|
||
self.logger.info(f'[数据去重] 记录{unique_id}已存在但未委托,保持"否"状态')
|
||
else:
|
||
# 如果是全新记录,设置为"否"
|
||
new_records.loc[idx, '是否委托'] = '否'
|
||
self.logger.info(f'[数据去重] 记录{unique_id}为全新记录,设置为"否"状态')
|
||
|
||
# 删除临时的唯一标识列
|
||
if '唯一标识' in new_records.columns:
|
||
new_records = new_records.drop('唯一标识', axis=1)
|
||
|
||
# 更新已处理记录ID集合
|
||
new_record_ids = set(new_records['记录ID'].astype(str))
|
||
self.processed_record_ids.update(new_record_ids)
|
||
|
||
# 统计委托状态分布
|
||
status_counts = new_records['是否委托'].value_counts()
|
||
self.logger.info(f'[数据去重] 发现{len(new_records)}条记录,委托状态分布: {dict(status_counts)}')
|
||
else:
|
||
self.logger.info('[数据去重] 没有发现新记录')
|
||
|
||
return new_records
|
||
|
||
def query_and_display_trades(self):
|
||
"""
|
||
查询并显示成交信息
|
||
按照业务逻辑文档规范输出交易结果
|
||
"""
|
||
try:
|
||
if not self.qmt_available or self.qmt is None:
|
||
self.logger.warning('[成交查询] QMT接口不可用,跳过成交信息查询')
|
||
return
|
||
|
||
# 查询今日成交记录
|
||
trades_df = self.qmt.query_stock_trades()
|
||
|
||
if trades_df is None or trades_df.empty:
|
||
self.logger.info('[成交查询] 当前没有成交记录')
|
||
return
|
||
|
||
# 处理成交时间格式
|
||
if '成交时间' in trades_df.columns:
|
||
trades_df['成交时间'] = pd.to_datetime(trades_df['成交时间'], unit='s', utc=True)
|
||
trades_df['成交时间'] = trades_df['成交时间'].dt.tz_convert('Asia/Shanghai')
|
||
trades_df['成交时间'] = trades_df['成交时间'].dt.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
# 按策略名称(组合)分组显示
|
||
if '策略名称' in trades_df.columns:
|
||
for strategy_name, group in trades_df.groupby('策略名称'):
|
||
if strategy_name and strategy_name != '未知':
|
||
self.logger.info(f'\n--- {strategy_name} 成交信息 ---')
|
||
for index, trade in group.iterrows():
|
||
order_type = '买入' if trade.get('委托类型') == 23 else '卖出' if trade.get('委托类型') == 24 else '未知'
|
||
self.logger.info(
|
||
f'[成交] {trade.get("证券代码", "未知")} | {order_type} | '
|
||
f'数量:{trade.get("成交数量", 0)} | 价格:{trade.get("成交均价", 0):.2f} | '
|
||
f'金额:{trade.get("成交金额", 0):.2f} | 时间:{trade.get("成交时间", "未知")}'
|
||
)
|
||
|
||
self.logger.info(f'[成交统计] 共 {len(trades_df)} 条成交记录')
|
||
|
||
except Exception as e:
|
||
self.logger.error(f'[成交查询] 查询成交信息失败: {str(e)}')
|
||
|
||
def handle_critical_error(self, operation: str, error: Exception, retry_count: int = 0) -> bool:
|
||
"""
|
||
处理关键错误
|
||
|
||
Args:
|
||
operation: 操作名称
|
||
error: 异常对象
|
||
retry_count: 重试次数
|
||
|
||
Returns:
|
||
是否应该继续运行
|
||
"""
|
||
error_msg = str(error)
|
||
|
||
# 记录错误详情
|
||
self.logger.error(f'[关键错误] {operation} 发生错误: {error_msg}')
|
||
self.logger.error(f'[关键错误] 错误类型: {type(error).__name__}')
|
||
|
||
# 根据错误类型决定处理策略
|
||
import requests
|
||
if isinstance(error, (ConnectionError, requests.exceptions.RequestException)):
|
||
self.logger.warning(f'[网络错误] {operation} 网络连接问题,重试次数: {retry_count}')
|
||
return retry_count < 3 # 网络错误最多重试3次
|
||
|
||
elif isinstance(error, FileNotFoundError):
|
||
self.logger.error(f'[文件错误] {operation} 文件不存在: {error_msg}')
|
||
return False # 文件错误不重试
|
||
|
||
elif isinstance(error, PermissionError):
|
||
self.logger.error(f'[权限错误] {operation} 权限不足: {error_msg}')
|
||
return False # 权限错误不重试
|
||
|
||
elif isinstance(error, (ValueError, TypeError)):
|
||
self.logger.error(f'[数据错误] {operation} 数据格式错误: {error_msg}')
|
||
return True # 数据错误可以继续运行
|
||
|
||
else:
|
||
self.logger.error(f'[未知错误] {operation} 未知错误类型: {error_msg}')
|
||
return retry_count < 2 # 未知错误最多重试2次
|
||
|
||
def handle_network_exception(self, operation: str, max_retries: int = 3) -> bool:
|
||
"""
|
||
处理网络异常的重试机制
|
||
|
||
Args:
|
||
operation: 操作名称
|
||
max_retries: 最大重试次数
|
||
|
||
Returns:
|
||
是否成功处理
|
||
"""
|
||
for retry in range(max_retries):
|
||
try:
|
||
self.logger.warning(f'[网络重试] {operation} 第{retry + 1}次重试')
|
||
time.sleep(2 ** retry) # 指数退避
|
||
return True
|
||
except Exception as e:
|
||
if retry == max_retries - 1:
|
||
self.logger.error(f'[网络异常] {operation} 重试{max_retries}次后仍然失败: {str(e)}')
|
||
return False
|
||
continue
|
||
return False
|
||
|
||
def validate_system_health(self) -> bool:
|
||
"""
|
||
系统健康检查
|
||
|
||
Returns:
|
||
系统是否健康
|
||
"""
|
||
try:
|
||
self.logger.info('[系统检查] 开始系统健康检查')
|
||
|
||
# 检查配置文件
|
||
if not hasattr(self, 'config') or not self.config:
|
||
self.logger.error('[系统检查] 配置文件未加载')
|
||
return False
|
||
|
||
# 检查必要的配置项
|
||
required_configs = ['cookie列表', 'QMT路径', '资金账号', '组合配置']
|
||
for config_key in required_configs:
|
||
if config_key not in self.config:
|
||
self.logger.error(f'[系统检查] 缺少必要配置项: {config_key}')
|
||
return False
|
||
|
||
# 检查数据文件路径
|
||
data_path = self.config.get('数据保存路径', '辅助文件\\数据.csv')
|
||
data_dir = os.path.dirname(data_path)
|
||
if not os.path.exists(data_dir):
|
||
self.logger.warning(f'[系统检查] 数据目录不存在,将创建: {data_dir}')
|
||
os.makedirs(data_dir, exist_ok=True)
|
||
|
||
# 检查QMT路径
|
||
qmt_path = self.config.get('QMT路径')
|
||
if qmt_path and not os.path.exists(qmt_path):
|
||
self.logger.warning(f'[系统检查] QMT路径不存在: {qmt_path}')
|
||
|
||
# 检查组合配置
|
||
portfolio_configs = self.config.get('组合配置', [])
|
||
if not portfolio_configs:
|
||
self.logger.error('[系统检查] 未配置任何投资组合')
|
||
return False
|
||
|
||
for i, portfolio in enumerate(portfolio_configs):
|
||
if '组合名字' not in portfolio or '组合ID' not in portfolio:
|
||
self.logger.error(f'[系统检查] 组合配置{i+1}缺少必要字段')
|
||
return False
|
||
|
||
self.logger.info('[系统检查] 系统健康检查通过')
|
||
return True
|
||
|
||
except Exception as e:
|
||
self.logger.error(f'[系统检查] 健康检查失败: {str(e)}')
|
||
return False
|
||
|
||
def validate_data_integrity(self, df: pd.DataFrame) -> Tuple[bool, str]:
|
||
"""
|
||
验证数据完整性
|
||
|
||
Args:
|
||
df: 数据DataFrame
|
||
|
||
Returns:
|
||
(是否有效, 错误信息)
|
||
"""
|
||
required_columns = ['记录ID', '股票代码', '股票名称', '目标权重', '组合ID']
|
||
|
||
# 检查必需列
|
||
missing_columns = [col for col in required_columns if col not in df.columns]
|
||
if missing_columns:
|
||
return False, f"缺少必需列: {', '.join(missing_columns)}"
|
||
|
||
# 检查数据类型
|
||
for index, row in df.iterrows():
|
||
try:
|
||
# 检查记录ID
|
||
if pd.isna(row['记录ID']) or str(row['记录ID']).strip() == '':
|
||
return False, f"第{index+1}行记录ID为空"
|
||
|
||
# 检查目标权重
|
||
if pd.isna(row['目标权重']):
|
||
return False, f"第{index+1}行目标权重为空"
|
||
|
||
target_weight = float(row['目标权重'])
|
||
if target_weight < 0 or target_weight > 100:
|
||
self.logger.warning(f'[数据验证] 第{index+1}行目标权重异常: {target_weight}')
|
||
|
||
except (ValueError, TypeError) as e:
|
||
return False, f"第{index+1}行数据类型错误: {str(e)}"
|
||
|
||
return True, "数据验证通过"
|
||
|
||
def process_trading_data(self) -> bool:
|
||
"""
|
||
处理交易数据的主要业务流程
|
||
严格按照业务逻辑文档规范执行,增强错误处理
|
||
|
||
Returns:
|
||
处理是否成功
|
||
"""
|
||
try:
|
||
# 1. 数据获取与处理 - 2.1 雪球信号获取
|
||
self.logger.info('[数据获取] 开始获取雪球数据...')
|
||
|
||
# 调用雪球数据获取脚本
|
||
success, error = self.execute_xueqiu_script()
|
||
if not success:
|
||
self.logger.error(f'[数据获取] 雪球数据获取失败: {error}')
|
||
# 尝试网络重试
|
||
if not self.handle_network_exception("xueqiu_data"):
|
||
self.logger.error('[数据获取] 雪球数据获取重试失败,使用现有数据继续处理')
|
||
else:
|
||
self.logger.info('[数据获取] 雪球数据获取成功')
|
||
|
||
if not os.path.exists(self.data_file_path):
|
||
self.logger.warning('[数据处理] 数据文件不存在,雪球数据获取可能失败,使用空数据继续处理')
|
||
# 创建空的数据文件以确保系统正常运行
|
||
empty_df = pd.DataFrame(columns=[
|
||
'记录ID', '目标权重', '前期调整权重', '调仓幅度', '交易方向',
|
||
'股票代码', '代码', '跟单股数', '是否委托', '账户总资产',
|
||
'账户跟单比例(%)', '组合名字', '组合ID', '组合跟单比例(%)'
|
||
])
|
||
try:
|
||
empty_df.to_csv(self.data_file_path, index=False, encoding='utf-8-sig')
|
||
self.logger.info('[数据处理] 已创建空数据文件,系统继续运行')
|
||
except Exception as e:
|
||
self.logger.error(f'[数据处理] 创建空数据文件失败: {str(e)}')
|
||
return False
|
||
|
||
# 智能数据读取与状态合并逻辑
|
||
try:
|
||
df = pd.DataFrame() # 初始化空DataFrame
|
||
|
||
# 1. 优先读取雪球原始数据文件(最新的信号数据)
|
||
if os.path.exists(self.raw_data_file_path):
|
||
raw_df = pd.read_csv(self.raw_data_file_path, encoding='utf-8-sig')
|
||
self.logger.info(f'[数据读取] 成功读取雪球原始数据文件,共{len(raw_df)}条记录')
|
||
|
||
# 2. 读取已处理数据文件(包含委托状态信息)
|
||
processed_df = pd.DataFrame()
|
||
if os.path.exists(self.data_file_path):
|
||
try:
|
||
processed_df = pd.read_csv(self.data_file_path, encoding='utf-8-sig')
|
||
self.logger.info(f'[数据读取] 成功读取已处理数据文件,共{len(processed_df)}条记录')
|
||
except Exception as e:
|
||
self.logger.warning(f'[数据读取] 读取已处理数据文件失败: {str(e)}')
|
||
processed_df = pd.DataFrame()
|
||
|
||
# 3. 智能合并原始数据与已处理数据的状态
|
||
if not processed_df.empty:
|
||
# 为原始数据添加默认的委托状态字段
|
||
if '是否委托' not in raw_df.columns:
|
||
raw_df['是否委托'] = '否' # 默认为未委托
|
||
self.logger.info('[数据合并] 为原始数据添加默认委托状态字段')
|
||
|
||
# 创建唯一标识用于状态匹配
|
||
if '调仓ID' in raw_df.columns and '股票代码' in raw_df.columns:
|
||
raw_df['唯一标识'] = raw_df['股票代码'].astype(str) + '_' + raw_df['调仓ID'].astype(str)
|
||
else:
|
||
raw_df['唯一标识'] = raw_df['记录ID'].astype(str)
|
||
|
||
if '调仓ID' in processed_df.columns and '股票代码' in processed_df.columns:
|
||
processed_df['唯一标识'] = processed_df['股票代码'].astype(str) + '_' + processed_df['调仓ID'].astype(str)
|
||
else:
|
||
processed_df['唯一标识'] = processed_df['记录ID'].astype(str)
|
||
|
||
# 从已处理数据中恢复委托状态到原始数据
|
||
status_updated_count = 0
|
||
for idx, row in raw_df.iterrows():
|
||
unique_id = row['唯一标识']
|
||
# 在已处理数据中查找相同唯一标识的记录
|
||
matching_record = processed_df[processed_df['唯一标识'] == unique_id]
|
||
|
||
if not matching_record.empty:
|
||
# 如果找到匹配记录,使用已处理数据中的委托状态
|
||
existing_status = matching_record.iloc[0]['是否委托']
|
||
if existing_status == '是':
|
||
raw_df.loc[idx, '是否委托'] = '是'
|
||
status_updated_count += 1
|
||
self.logger.debug(f'[数据合并] 恢复记录{unique_id}的委托状态为"是"')
|
||
|
||
# 删除临时的唯一标识列
|
||
raw_df = raw_df.drop('唯一标识', axis=1)
|
||
|
||
self.logger.info(f'[数据合并] 成功恢复{status_updated_count}条记录的委托状态')
|
||
else:
|
||
# 如果没有已处理数据,为原始数据添加默认委托状态
|
||
if '是否委托' not in raw_df.columns:
|
||
raw_df['是否委托'] = '否'
|
||
self.logger.info('[数据合并] 无已处理数据,为原始数据添加默认委托状态')
|
||
|
||
df = raw_df # 使用合并后的原始数据
|
||
|
||
# 4. 如果雪球原始数据不存在,使用已处理的数据文件
|
||
elif os.path.exists(self.data_file_path):
|
||
df = pd.read_csv(self.data_file_path, encoding='utf-8-sig')
|
||
self.logger.info(f'[数据读取] 雪球原始数据不存在,使用已处理数据文件,共{len(df)}条记录')
|
||
else:
|
||
self.logger.warning('[数据读取] 未找到任何数据文件,创建空DataFrame')
|
||
df = pd.DataFrame()
|
||
|
||
# 5. 数据为空的处理
|
||
if len(df) == 0:
|
||
self.logger.warning('[数据处理] 数据文件为空,当前无法获取雪球数据,等待下次循环')
|
||
return True # 返回True以继续系统运行
|
||
|
||
except Exception as e:
|
||
self.logger.error(f'[数据处理] 读取数据文件失败: {str(e)}')
|
||
return False
|
||
|
||
# 数据完整性验证
|
||
is_valid, error_msg = self.validate_data_integrity(df)
|
||
if not is_valid:
|
||
self.logger.error(f'[数据验证] 数据完整性验证失败: {error_msg}')
|
||
return False
|
||
|
||
self.logger.info('[数据验证] 数据完整性验证通过')
|
||
|
||
# 2. 数据字段扩展 - 先扩展字段再去重
|
||
try:
|
||
df = self.extend_data_fields(df)
|
||
except Exception as e:
|
||
self.logger.error(f'[数据扩展] 数据字段扩展失败: {str(e)}')
|
||
return False
|
||
|
||
# 3. 数据计算处理
|
||
try:
|
||
df = self.process_data_calculations(df)
|
||
except Exception as e:
|
||
self.logger.error(f'[数据计算] 数据计算处理失败: {str(e)}')
|
||
return False
|
||
|
||
# 4. 保存更新后的数据 - 包含所有原始数据记录
|
||
try:
|
||
if not self.save_data_with_backup(df):
|
||
self.logger.error('[数据保存] 保存数据文件失败')
|
||
return False
|
||
self.logger.info(f'[数据保存] 已保存所有{len(df)}条原始数据记录到CSV文件')
|
||
except Exception as e:
|
||
self.logger.error(f'[数据保存] 保存数据文件异常: {str(e)}')
|
||
return False
|
||
|
||
# 5. 取消数据去重过滤 - 直接处理所有数据
|
||
self.logger.info(f'[数据处理] 处理所有{len(df)}条原始数据记录')
|
||
|
||
# 6. 交易执行 - 只处理当日调仓记录
|
||
# 获取当前日期,格式为YYYYMMDD
|
||
current_date = datetime.datetime.now().strftime('%Y%m%d')
|
||
|
||
# 过滤出当日调仓记录且未委托的交易
|
||
pending_trades = df[
|
||
(df['是否委托'] == '否') &
|
||
(df['调仓时间'].str.startswith(current_date))
|
||
].copy()
|
||
|
||
if len(pending_trades) == 0:
|
||
self.logger.info(f'[交易执行] 没有需要执行的当日调仓交易(当前日期:{current_date})')
|
||
return True
|
||
|
||
self.logger.info(f'[交易执行] 发现{len(pending_trades)}条当日调仓记录需要执行(当前日期:{current_date})')
|
||
|
||
# 5. 执行交易
|
||
successful_trades = 0
|
||
failed_trades = 0
|
||
|
||
for index, row in pending_trades.iterrows():
|
||
try:
|
||
symbol = row['代码']
|
||
follow_shares = row['跟单股数']
|
||
trade_direction = row['交易方向']
|
||
strategy_name = row['组合名字']
|
||
stock_name = row.get('股票名称', '未知')
|
||
|
||
# 检查跟单股数是否为NAN
|
||
if pd.isna(follow_shares):
|
||
self.logger.warning(f'[交易执行] {stock_name}({symbol}) 跟单股数为NAN,跳过交易')
|
||
continue
|
||
|
||
volume = int(follow_shares)
|
||
if volume <= 0:
|
||
self.logger.warning(f'[交易执行] {stock_name}({symbol}) 跟单股数为0,跳过交易')
|
||
continue
|
||
|
||
self.logger.info(f'[交易执行] 准备{trade_direction} {stock_name}({symbol}) {volume}股')
|
||
|
||
# 执行交易
|
||
order_id = self.execute_trade_order(symbol, volume, trade_direction, strategy_name)
|
||
|
||
# 更新交易状态
|
||
success = order_id is not None
|
||
df = self.update_trade_status(df, index, success)
|
||
|
||
if success:
|
||
successful_trades += 1
|
||
else:
|
||
failed_trades += 1
|
||
|
||
except Exception as e:
|
||
self.logger.error(f'[交易执行] 处理第{index+1}条交易失败: {str(e)}')
|
||
failed_trades += 1
|
||
continue
|
||
|
||
# 保存最终状态
|
||
try:
|
||
if not self.save_data_with_backup(df):
|
||
self.logger.error('[交易执行] 保存交易状态失败')
|
||
except Exception as e:
|
||
self.logger.error(f'[交易执行] 保存交易状态异常: {str(e)}')
|
||
|
||
# 交易统计
|
||
self.logger.info(f'[交易统计] 成功: {successful_trades}笔, 失败: {failed_trades}笔')
|
||
|
||
# 查询并显示成交信息
|
||
self.query_and_display_trades()
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
self.logger.error(f'[数据处理] 处理交易数据发生未预期异常: {str(e)}')
|
||
return False
|
||
|
||
def connect_trading_account(self) -> bool:
|
||
"""
|
||
连接交易账户
|
||
|
||
Returns:
|
||
连接是否成功
|
||
"""
|
||
try:
|
||
if self.qmt is None:
|
||
self.logger.error('[账户连接] QMT接口未初始化')
|
||
return False
|
||
|
||
self.qmt._connect()
|
||
self.logger.info('[账户连接] 交易账户连接成功')
|
||
return True
|
||
|
||
except Exception as e:
|
||
self.logger.error(f'[账户连接] 连接交易账户失败: {str(e)}')
|
||
return False
|
||
|
||
def run_continuous_monitoring(self):
|
||
"""
|
||
持续监控模式
|
||
按照业务逻辑文档要求,在交易时间内持续运行
|
||
"""
|
||
self.logger.info('[持续监控] 启动持续监控模式')
|
||
|
||
# 系统健康检查
|
||
if not self.validate_system_health():
|
||
self.logger.error('[持续监控] 系统健康检查失败,退出程序')
|
||
return
|
||
|
||
retry_count = 0
|
||
max_retries = 5
|
||
|
||
while True:
|
||
try:
|
||
# 检查是否为交易时间
|
||
is_trading, time_msg = self.is_trading_time()
|
||
if not is_trading:
|
||
self.logger.info('[持续监控] 当前非交易时间,等待中...')
|
||
time.sleep(3) # 非交易时间每分钟检查一次
|
||
continue
|
||
|
||
self.logger.info('[持续监控] 开始执行交易业务逻辑')
|
||
|
||
# 执行主要业务逻辑
|
||
success = self.process_trading_data()
|
||
|
||
if success:
|
||
retry_count = 0 # 成功后重置重试计数
|
||
self.logger.info('[持续监控] 业务逻辑执行成功')
|
||
else:
|
||
retry_count += 1
|
||
self.logger.warning(f'[持续监控] 业务逻辑执行失败,重试次数: {retry_count}')
|
||
|
||
if retry_count >= max_retries:
|
||
self.logger.error('[持续监控] 达到最大重试次数,暂停监控')
|
||
time.sleep(300) # 暂停5分钟
|
||
retry_count = 0
|
||
|
||
# 等待下一次执行
|
||
interval = self.config.get('循环间隔', 30)
|
||
self.logger.info(f'[持续监控] 等待{interval}秒后继续监控')
|
||
time.sleep(interval)
|
||
|
||
except KeyboardInterrupt:
|
||
self.logger.info('[持续监控] 接收到停止信号,退出监控')
|
||
break
|
||
|
||
except Exception as e:
|
||
if not self.handle_critical_error('持续监控', e, retry_count):
|
||
self.logger.error('[持续监控] 发生不可恢复的错误,退出程序')
|
||
break
|
||
retry_count += 1
|
||
time.sleep(30) # 错误后等待30秒再重试
|
||
|
||
def run_once(self):
|
||
"""
|
||
单次运行模式
|
||
执行一次完整的交易业务逻辑
|
||
"""
|
||
self.logger.info('[单次运行] 开始执行单次交易业务逻辑')
|
||
|
||
try:
|
||
# 系统健康检查
|
||
if not self.validate_system_health():
|
||
self.logger.error('[单次运行] 系统健康检查失败')
|
||
return False
|
||
|
||
# 检查交易时间
|
||
is_trading, time_msg = self.is_trading_time()
|
||
if not is_trading:
|
||
self.logger.warning(f'[单次运行] {time_msg}')
|
||
return False
|
||
|
||
# 执行业务逻辑
|
||
success = self.process_trading_data()
|
||
|
||
if success:
|
||
self.logger.info('[单次运行] 交易业务逻辑执行成功')
|
||
else:
|
||
self.logger.error('[单次运行] 交易业务逻辑执行失败')
|
||
|
||
return success
|
||
|
||
except Exception as e:
|
||
self.handle_critical_error('单次运行', e)
|
||
return False
|
||
|
||
|
||
def main():
|
||
"""
|
||
主函数入口
|
||
根据业务逻辑文档要求实现系统启动逻辑
|
||
"""
|
||
import sys
|
||
|
||
try:
|
||
# 创建交易系统实例
|
||
trading_system = XueqiuTradingSystem()
|
||
|
||
# 根据命令行参数选择运行模式
|
||
if len(sys.argv) > 1 and sys.argv[1] == '--once':
|
||
print('[运行模式] 单次运行模式')
|
||
success = trading_system.run_once()
|
||
sys.exit(0 if success else 1)
|
||
else:
|
||
print('[运行模式] 持续监控模式(默认)')
|
||
print('[运行提示] 可用参数: --once (单次运行)')
|
||
trading_system.run_continuous_monitoring()
|
||
|
||
except KeyboardInterrupt:
|
||
print('\n程序被用户中断')
|
||
sys.exit(0)
|
||
except Exception as e:
|
||
print(f'程序启动失败: {str(e)}')
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main() |