This commit is contained in:
liao 2025-08-15 16:57:53 +08:00
parent c4c4e8622f
commit f2400305e9
13 changed files with 1040 additions and 241 deletions

View File

@ -11,7 +11,7 @@ show_help() {
echo " start [实例ID] 启动指定实例或所有实例" echo " start [实例ID] 启动指定实例或所有实例"
echo " stop [实例ID] 停止指定实例或所有实例" echo " stop [实例ID] 停止指定实例或所有实例"
echo " restart [实例ID] 重启指定实例或所有实例" echo " restart [实例ID] 重启指定实例或所有实例"
echo " logs [实例ID] 查看指定实例的日志" echo " logs [实例ID] 实时查看指定实例的日志 (Ctrl+C 退出)"
echo " status 显示实例状态概览" echo " status 显示实例状态概览"
echo " remove [实例ID] 删除指定实例或所有实例" echo " remove [实例ID] 删除指定实例或所有实例"
echo " rebuild [数量] 重新构建镜像并部署指定数量的实例" echo " rebuild [数量] 重新构建镜像并部署指定数量的实例"
@ -68,8 +68,9 @@ restart_instance() {
# 函数:查看实例日志 # 函数:查看实例日志
view_logs() { view_logs() {
echo "实例 $1 的日志:" echo "正在实时显示实例 $1 的日志 (按 Ctrl+C 退出):"
docker logs stock-app-$1 echo "----------------------------------------"
docker logs -f stock-app-$1
} }
# 函数:显示状态概览 # 函数:显示状态概览

View File

@ -2897,21 +2897,64 @@ def get_pep_stock_info_by_shortname():
@app.route('/api/pep_stock_info_by_code', methods=['GET']) @app.route('/api/pep_stock_info_by_code', methods=['GET'])
def get_pep_stock_info_by_code(): def get_pep_stock_info_by_code():
"""根据股票代码查询Redis中的实时行情并返回指定结构""" """根据股票代码查询Redis中的实时行情并返回指定结构支持A股和港股"""
short_code = request.args.get('code') short_code = request.args.get('code')
if not short_code: if not short_code:
return jsonify({'success': False, 'message': '缺少必要参数: short_code'}), 400 return jsonify({'success': False, 'message': '缺少必要参数: code'}), 400
try: try:
# 兼容600001.SH/SH600001等格式 # 判断股票类型并调用相应的查询函数
from src.quantitative_analysis.batch_stock_price_collector import get_stock_realtime_info_from_redis if is_hk_stock_code(short_code):
result = get_stock_realtime_info_from_redis(short_code) # 港股代码
if result: from src.quantitative_analysis.hk_stock_price_collector import get_hk_stock_realtime_info_from_redis
return jsonify(result) result = get_hk_stock_realtime_info_from_redis(short_code)
if result:
return jsonify(result)
else:
return jsonify({'success': False, 'message': f'未找到港股 {short_code} 的实时行情'}), 404
else: else:
return jsonify({'success': False, 'message': f'未找到股票 {short_code} 的实时行情'}), 404 # A股代码
from src.quantitative_analysis.batch_stock_price_collector import get_stock_realtime_info_from_redis
result = get_stock_realtime_info_from_redis(short_code)
if result:
return jsonify(result)
else:
return jsonify({'success': False, 'message': f'未找到A股 {short_code} 的实时行情'}), 404
except Exception as e: except Exception as e:
return jsonify({'success': False, 'message': f'服务器错误: {str(e)}'}), 500 return jsonify({'success': False, 'message': f'服务器错误: {str(e)}'}), 500
def is_hk_stock_code(stock_code):
"""
判断是否为港股代码
支持格式00700, 00700.HK, HK00700, 0700.HK等
"""
if not stock_code:
return False
stock_code = stock_code.upper().strip()
# 港股代码特征:
# 1. 包含.HK后缀
if '.HK' in stock_code:
return True
# 2. 以HK开头
if stock_code.startswith('HK'):
return True
# 3. 纯数字且长度为4-5位港股代码通常是4-5位数字
if stock_code.isdigit() and 4 <= len(stock_code) <= 5:
# 进一步判断港股代码通常以0、1、2、3、6、8、9开头
if stock_code[0] in ['0', '1', '2', '3', '6', '8', '9']:
return True
# 4. 特殊港股代码如腾讯00700、阿里巴巴09988等
common_hk_codes = ['00700', '09988', '03690', '09888', '06618', '02318', '02020', '01810']
if stock_code in common_hk_codes:
return True
return False
@app.route('/api/industry/crowding/filter', methods=['GET']) @app.route('/api/industry/crowding/filter', methods=['GET'])
def filter_industry_crowding(): def filter_industry_crowding():
"""根据拥挤度百分位区间筛选行业和概念板块""" """根据拥挤度百分位区间筛选行业和概念板块"""
@ -2991,7 +3034,7 @@ def get_momentum_by_plate():
def run_batch_stock_price_collection(): def run_batch_stock_price_collection():
"""批量采集A股行情并保存到数据库""" """批量采集A股行情并保存到数据库"""
try: try:
fetch_and_store_stock_data() fetch_and_store_stock_data(use_proxy=True)
return jsonify({"status": "success", "message": "批量采集A股行情并保存到数据库成功"}) return jsonify({"status": "success", "message": "批量采集A股行情并保存到数据库成功"})
except Exception as e: except Exception as e:
logger.error(f"批量采集A股行情失败: {str(e)}") logger.error(f"批量采集A股行情失败: {str(e)}")
@ -3001,7 +3044,7 @@ def run_batch_stock_price_collection():
def run_batch_hk_stock_price_collection(): def run_batch_hk_stock_price_collection():
"""批量采集港股行情并保存到数据库""" """批量采集港股行情并保存到数据库"""
try: try:
fetch_and_store_hk_stock_data() fetch_and_store_hk_stock_data(use_proxy=True)
return jsonify({"status": "success", "message": "批量采集A股行情并保存到数据库成功"}) return jsonify({"status": "success", "message": "批量采集A股行情并保存到数据库成功"})
except Exception as e: except Exception as e:
logger.error(f"批量采集A股行情失败: {str(e)}") logger.error(f"批量采集A股行情失败: {str(e)}")

View File

@ -5,14 +5,14 @@ import sys
import os import os
import redis import redis
import json import json
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
# 添加项目根目录到路径便于导入scripts.config # 添加项目根目录到路径便于导入scripts.config
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(project_root) sys.path.append(project_root)
# 导入代理管理器
from src.scripts.ProxyIP import EnhancedProxyManager
# 读取雪球headers和Redis配置 # 读取雪球headers和Redis配置
try: try:
from src.scripts.config import XUEQIU_HEADERS from src.scripts.config import XUEQIU_HEADERS
@ -31,8 +31,13 @@ except ImportError:
REDIS_KEY = 'xq_stock_changes_latest' # 存放行情的主键 REDIS_KEY = 'xq_stock_changes_latest' # 存放行情的主键
# 创建全局代理管理器实例 # 条件导入代理管理器
proxy_manager = EnhancedProxyManager() proxy_manager = None
try:
from src.scripts.ProxyIP import EnhancedProxyManager
proxy_manager = EnhancedProxyManager()
except ImportError:
print("代理管理器导入失败,将使用直接请求模式")
def get_redis_conn(): def get_redis_conn():
@ -47,58 +52,130 @@ def get_redis_conn():
return redis.Redis(connection_pool=pool) return redis.Redis(connection_pool=pool)
def fetch_and_store_stock_data(page_size=90): def fetch_and_store_stock_data(page_size=90, max_workers=10, use_proxy=False):
""" """
批量采集雪球A股上证深证科创板股票的最新行情数据并保存到Redis 批量采集雪球A股上证深证科创板股票的最新行情数据并保存到Redis
使用线程池并行请求提高采集效率
:param page_size: 每页采集数量 :param page_size: 每页采集数量
:param max_workers: 线程池最大工作线程数
:param use_proxy: 是否使用代理默认False
""" """
base_url = 'https://stock.xueqiu.com/v5/stock/screener/quote/list.json' base_url = 'https://stock.xueqiu.com/v5/stock/screener/quote/list.json'
types = ['sha', 'sza', 'kcb'] # 上证、深证、科创板 types = ['sha', 'sza', 'kcb'] # 上证、深证、科创板
headers = XUEQIU_HEADERS headers = XUEQIU_HEADERS
all_data = [] all_data = []
data_lock = threading.Lock() # 线程安全锁
for stock_type in types: def fetch_page_data(stock_type, page):
"""获取单页数据的函数"""
params = { params = {
'page': 1, 'page': page,
'size': page_size, 'size': page_size,
'order': 'desc', 'order': 'desc',
'order_by': 'dividend_yield', 'order_by': 'dividend_yield',
'market': 'CN', 'market': 'CN',
'type': stock_type 'type': stock_type
} }
# 初次请求以获取总页数,使用代理 try:
response = proxy_manager.request_with_proxy('get', base_url, headers=headers, params=params) # 根据配置选择是否使用代理
# response = requests.get(base_url, headers=headers, params=params) if use_proxy and proxy_manager:
if response.status_code != 200: response = proxy_manager.request_with_proxy('get', base_url, headers=headers, params=params)
print(f"请求 {stock_type} 数据失败,状态码:{response.status_code}") else:
continue response = requests.get(base_url, headers=headers, params=params, timeout=10)
data = response.json()
total_count = data['data']['count']
total_pages = (total_count // page_size) + 1
for page in range(1, total_pages + 1):
params['page'] = page
# response = requests.get(base_url, headers=headers, params=params)
response = proxy_manager.request_with_proxy('get', base_url, headers=headers, params=params)
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
all_data.extend(data['data']['list']) page_data = data['data']['list']
print(f"成功采集第 {page}/{total_pages} 页数据")
# 线程安全地添加数据
with data_lock:
all_data.extend(page_data)
print(f"成功采集 {stock_type}{page} 页数据,获取 {len(page_data)} 条记录")
return len(page_data)
else: else:
print(f"请求 {stock_type} 数据第 {page} 页失败,状态码:{response.status_code}") print(f"请求 {stock_type} 数据第 {page} 页失败,状态码:{response.status_code}")
return 0
except Exception as e:
print(f"请求 {stock_type} 数据第 {page} 页异常:{e}")
return 0
# 使用线程池并行采集数据
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = []
for stock_type in types:
# 先获取总页数
params = {
'page': 1,
'size': page_size,
'order': 'desc',
'order_by': 'dividend_yield',
'market': 'CN',
'type': stock_type
}
try:
# 根据配置选择是否使用代理
if use_proxy and proxy_manager:
response = proxy_manager.request_with_proxy('get', base_url, headers=headers, params=params)
else:
response = requests.get(base_url, headers=headers, params=params, timeout=10)
if response.status_code != 200:
print(f"请求 {stock_type} 数据失败,状态码:{response.status_code}")
continue
data = response.json()
total_count = data['data']['count']
total_pages = (total_count // page_size) + 1
print(f"开始采集 {stock_type} 数据,共 {total_pages} 页,总计 {total_count} 条记录")
# 提交所有页面的采集任务
for page in range(1, total_pages + 1):
future = executor.submit(fetch_page_data, stock_type, page)
futures.append(future)
except Exception as e:
print(f"获取 {stock_type} 总页数失败:{e}")
continue
# 等待所有任务完成
print(f"正在并行采集数据,使用 {max_workers} 个线程...")
start_time = time.time()
completed_count = 0
for future in as_completed(futures):
completed_count += 1
try:
result = future.result()
if result > 0:
print(f"进度: {completed_count}/{len(futures)} 页完成")
except Exception as e:
print(f"采集任务异常:{e}")
end_time = time.time()
print(f"数据采集完成,耗时: {end_time - start_time:.2f}")
# 转换为 DataFrame # 转换为 DataFrame
df = pd.DataFrame(all_data) df = pd.DataFrame(all_data)
if not df.empty: if not df.empty:
df['fetch_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') df['fetch_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 存入Redis使用hash结构key为symbolvalue为json字符串 # 存入Redis使用hash结构key为symbolvalue为json字符串
r = get_redis_conn() r = get_redis_conn()
pipe = r.pipeline() pipe = r.pipeline()
# 先清空旧数据 # 先清空旧数据
r.delete(REDIS_KEY) r.delete(REDIS_KEY)
print(f"正在将 {len(df)} 条记录写入Redis...")
for _, row in df.iterrows(): for _, row in df.iterrows():
symbol = row.get('symbol') symbol = row.get('symbol')
if not symbol: if not symbol:
@ -106,6 +183,7 @@ def fetch_and_store_stock_data(page_size=90):
# 只保留必要字段也可直接存row.to_dict() # 只保留必要字段也可直接存row.to_dict()
value = row.to_dict() value = row.to_dict()
pipe.hset(REDIS_KEY, symbol, json.dumps(value, ensure_ascii=False)) pipe.hset(REDIS_KEY, symbol, json.dumps(value, ensure_ascii=False))
pipe.execute() pipe.execute()
print(f"成功将数据写入Redis哈希 {REDIS_KEY},共{len(df)}条记录。") print(f"成功将数据写入Redis哈希 {REDIS_KEY},共{len(df)}条记录。")
@ -192,5 +270,35 @@ def get_stock_realtime_info_from_redis(stock_code):
return result return result
def fetch_and_store_stock_data_optimized(page_size=90, max_workers=15, use_proxy=False):
"""
优化版本的批量采集函数支持更灵活的配置
:param page_size: 每页采集数量
:param max_workers: 线程池最大工作线程数建议10-20之间
:param use_proxy: 是否使用代理默认False
"""
print(f"开始批量采集A股数据...")
print(f"配置: 每页 {page_size} 条记录,最大线程数 {max_workers}")
print(f"代理模式: {'启用' if use_proxy else '禁用'}")
print(f"预计采集: 上证、深证、科创板所有股票数据")
print("-" * 50)
try:
result = fetch_and_store_stock_data(page_size, max_workers, use_proxy)
if not result.empty:
print(f"采集完成!共获取 {len(result)} 只股票的数据")
print(f"数据已保存到Redis键: {REDIS_KEY}")
else:
print("采集完成,但未获取到数据")
except Exception as e:
print(f"采集过程中发生错误: {e}")
return None
return result
if __name__ == '__main__': if __name__ == '__main__':
fetch_and_store_stock_data() # 可以根据需要调整参数
# fetch_and_store_stock_data_optimized(page_size=100, max_workers=15, use_proxy=True)
fetch_and_store_stock_data_optimized(use_proxy=False) # 默认不使用代理

View File

@ -5,14 +5,14 @@ import sys
import os import os
import redis import redis
import json import json
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
# 添加项目根目录到路径便于导入scripts.config # 添加项目根目录到路径便于导入scripts.config
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(project_root) sys.path.append(project_root)
# 导入代理管理器
from src.scripts.ProxyIP import EnhancedProxyManager
# 读取雪球headers和Redis配置 # 读取雪球headers和Redis配置
try: try:
from src.scripts.config import XUEQIU_HEADERS from src.scripts.config import XUEQIU_HEADERS
@ -31,8 +31,13 @@ except ImportError:
REDIS_KEY = 'xq_hk_stock_changes_latest' # 存放港股行情的主键 REDIS_KEY = 'xq_hk_stock_changes_latest' # 存放港股行情的主键
# 创建全局代理管理器实例 # 条件导入代理管理器
proxy_manager = EnhancedProxyManager() proxy_manager = None
try:
from src.scripts.ProxyIP import EnhancedProxyManager
proxy_manager = EnhancedProxyManager()
except ImportError:
print("代理管理器导入失败,将使用直接请求模式")
def get_redis_conn(): def get_redis_conn():
@ -47,67 +52,128 @@ def get_redis_conn():
return redis.Redis(connection_pool=pool) return redis.Redis(connection_pool=pool)
def fetch_and_store_hk_stock_data(page_size=90): def fetch_and_store_hk_stock_data(page_size=90, max_workers=10, use_proxy=False):
""" """
批量采集雪球港股所有股票的最新行情数据并保存到Redis 批量采集雪球港股所有股票的最新行情数据并保存到Redis
使用线程池并行请求提高采集效率
:param page_size: 每页采集数量 :param page_size: 每页采集数量
:param max_workers: 线程池最大工作线程数
:param use_proxy: 是否使用代理默认False
""" """
base_url = 'https://stock.xueqiu.com/v5/stock/screener/quote/list.json' base_url = 'https://stock.xueqiu.com/v5/stock/screener/quote/list.json'
headers = XUEQIU_HEADERS headers = XUEQIU_HEADERS
all_data = [] all_data = []
data_lock = threading.Lock() # 线程安全锁
# 使用港股API参数 def fetch_page_data(page):
"""获取单页数据的函数"""
params = {
'page': page,
'size': page_size,
'order': 'desc',
'order_by': 'dividend_yield',
'market': 'HK', # 港股市场
'type': 'hk' # 港股类型
}
try:
# 根据配置选择是否使用代理
if use_proxy and proxy_manager:
response = proxy_manager.request_with_proxy('get', base_url, headers=headers, params=params)
else:
response = requests.get(base_url, headers=headers, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
page_data = data['data']['list']
# 线程安全地添加数据
with data_lock:
all_data.extend(page_data)
print(f"成功采集港股第 {page} 页数据,获取 {len(page_data)} 条记录")
return len(page_data)
else:
print(f"请求港股数据第 {page} 页失败,状态码:{response.status_code}")
return 0
except Exception as e:
print(f"请求港股数据第 {page} 页异常:{e}")
return 0
# 先获取总页数
params = { params = {
'page': 1, 'page': 1,
'size': page_size, 'size': page_size,
'order': 'desc', 'order': 'desc',
'order_by': 'dividend_yield', 'order_by': 'dividend_yield',
'market': 'HK', # 港股市场 'market': 'HK',
'type': 'hk' # 港股类型 'type': 'hk'
} }
# 初次请求以获取总页数,使用代理
try: try:
response = proxy_manager.request_with_proxy('get', base_url, headers=headers, params=params) # 根据配置选择是否使用代理
if use_proxy and proxy_manager:
response = proxy_manager.request_with_proxy('get', base_url, headers=headers, params=params)
else:
response = requests.get(base_url, headers=headers, params=params, timeout=10)
if response.status_code != 200: if response.status_code != 200:
print(f"请求港股数据失败,状态码:{response.status_code}") print(f"请求港股数据失败,状态码:{response.status_code}")
return return pd.DataFrame()
data = response.json()
total_count = data['data']['count']
total_pages = (total_count // page_size) + 1
print(f"开始采集港股数据,共 {total_pages} 页,总计 {total_count} 条记录")
# 使用线程池并行采集数据
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = []
# 提交所有页面的采集任务
for page in range(1, total_pages + 1):
future = executor.submit(fetch_page_data, page)
futures.append(future)
# 等待所有任务完成
print(f"正在并行采集港股数据,使用 {max_workers} 个线程...")
start_time = time.time()
completed_count = 0
for future in as_completed(futures):
completed_count += 1
try:
result = future.result()
if result > 0:
print(f"进度: {completed_count}/{len(futures)} 页完成")
except Exception as e:
print(f"采集任务异常:{e}")
end_time = time.time()
print(f"港股数据采集完成,耗时: {end_time - start_time:.2f}")
except Exception as e: except Exception as e:
print(f"请求港股数据时发生异常:{e}") print(f"获取港股总页数失败{e}")
return return pd.DataFrame()
data = response.json()
total_count = data['data']['count']
total_pages = (total_count // page_size) + 1
print(f"开始采集港股数据,共 {total_pages} 页,{total_count} 条记录")
# 循环获取所有页面的数据
for page in range(1, total_pages + 1):
params['page'] = page
try:
response = proxy_manager.request_with_proxy('get', base_url, headers=headers, params=params)
if response.status_code == 200:
data = response.json()
all_data.extend(data['data']['list'])
print(f"成功采集港股第 {page}/{total_pages} 页数据")
else:
print(f"请求港股数据第 {page} 页失败,状态码:{response.status_code}")
except Exception as e:
print(f"请求港股数据第 {page} 页时发生异常:{e}")
continue
# 转换为 DataFrame # 转换为 DataFrame
df = pd.DataFrame(all_data) df = pd.DataFrame(all_data)
if not df.empty: if not df.empty:
df['fetch_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') df['fetch_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 存入Redis使用hash结构key为symbolvalue为json字符串 # 存入Redis使用hash结构key为symbolvalue为json字符串
r = get_redis_conn() r = get_redis_conn()
pipe = r.pipeline() pipe = r.pipeline()
# 先清空旧数据 # 先清空旧数据
r.delete(REDIS_KEY) r.delete(REDIS_KEY)
print(f"正在将 {len(df)} 条港股记录写入Redis...")
for _, row in df.iterrows(): for _, row in df.iterrows():
symbol = row.get('symbol') symbol = row.get('symbol')
if not symbol: if not symbol:
@ -115,10 +181,15 @@ def fetch_and_store_hk_stock_data(page_size=90):
# 只保留必要字段也可直接存row.to_dict() # 只保留必要字段也可直接存row.to_dict()
value = row.to_dict() value = row.to_dict()
pipe.hset(REDIS_KEY, symbol, json.dumps(value, ensure_ascii=False)) pipe.hset(REDIS_KEY, symbol, json.dumps(value, ensure_ascii=False))
pipe.execute() pipe.execute()
print(f"成功将港股数据写入Redis哈希 {REDIS_KEY},共{len(df)}条记录。") print(f"成功将港股数据写入Redis哈希 {REDIS_KEY},共{len(df)}条记录。")
# 返回DataFrame供其他脚本使用
return df
else: else:
print("未获取到任何港股数据。") print("未获取到任何港股数据。")
return pd.DataFrame()
def format_hk_stock_code(stock_code): def format_hk_stock_code(stock_code):
@ -175,7 +246,7 @@ def get_hk_stock_realtime_info_from_redis(stock_code):
result["crawlDate"] = data.get("fetch_time") result["crawlDate"] = data.get("fetch_time")
result["marketValue"] = data.get("market_capital") result["marketValue"] = data.get("market_capital")
result["maxPrice"] = data.get("high") if "high" in data else data.get("high52w") result["maxPrice"] = data.get("high") if "high" in data else data.get("high52w")
result["minPrice"] = data.get("low") if "low" in data else data.get("low52w") result["minPrice"] = data.get("low") if "low" in data else data.get("high52w")
result["nowPrice"] = data.get("current") result["nowPrice"] = data.get("current")
result["pbRate"] = data.get("pb") result["pbRate"] = data.get("pb")
result["rangeRiseAndFall"] = data.get("percent") result["rangeRiseAndFall"] = data.get("percent")
@ -193,5 +264,35 @@ def get_hk_stock_realtime_info_from_redis(stock_code):
return result return result
def fetch_and_store_hk_stock_data_optimized(page_size=90, max_workers=15, use_proxy=False):
"""
优化版本的港股批量采集函数支持更灵活的配置
:param page_size: 每页采集数量
:param max_workers: 线程池最大工作线程数建议10-20之间
:param use_proxy: 是否使用代理默认False
"""
print(f"开始批量采集港股数据...")
print(f"配置: 每页 {page_size} 条记录,最大线程数 {max_workers}")
print(f"代理模式: {'启用' if use_proxy else '禁用'}")
print(f"预计采集: 港股所有股票数据")
print("-" * 50)
try:
result = fetch_and_store_hk_stock_data(page_size, max_workers, use_proxy)
if not result.empty:
print(f"港股采集完成!共获取 {len(result)} 只股票的数据")
print(f"数据已保存到Redis键: {REDIS_KEY}")
else:
print("港股采集完成,但未获取到数据")
except Exception as e:
print(f"港股采集过程中发生错误: {e}")
return None
return result
if __name__ == '__main__': if __name__ == '__main__':
fetch_and_store_hk_stock_data() # 可以根据需要调整参数
# fetch_and_store_hk_stock_data_optimized(page_size=100, max_workers=15, use_proxy=True)
fetch_and_store_hk_stock_data_optimized(use_proxy=False) # 默认不使用代理

View File

@ -339,4 +339,37 @@ class EnhancedProxyManager:
'manual_proxies': self.redis_conn.hlen(manual_key), 'manual_proxies': self.redis_conn.hlen(manual_key),
'auto_refresh': self.auto_refresh, 'auto_refresh': self.auto_refresh,
'last_update': datetime.now().strftime("%Y-%m-%d %H:%M:%S") 'last_update': datetime.now().strftime("%Y-%m-%d %H:%M:%S")
} }
def get_multiple_proxies(self, count: int = 5) -> List[Dict]:
"""
获取多个代理用于并发请求
:param count: 需要的代理数量
:return: 代理列表
"""
proxies = []
manual_key = self._get_redis_key('manual')
# 获取手动代理池中的所有代理
manual_proxies = self.redis_conn.hgetall(manual_key)
active_proxies = []
for proxy_json in manual_proxies.values():
proxy = json.loads(proxy_json)
if proxy.get('status') == 'active':
active_proxies.append(proxy)
if not active_proxies:
return []
# 随机选择指定数量的代理
selected_count = min(count, len(active_proxies))
selected_proxies = random.sample(active_proxies, selected_count)
# 为每个代理添加Redis键信息
for proxy in selected_proxies:
proxy['_redis_key'] = self._get_redis_key(proxy['source'])
proxies.append(proxy)
return proxies

View File

@ -11,7 +11,7 @@ XUEQIU_HEADERS = {
'Accept-Encoding': 'gzip, deflate, br, zstd', 'Accept-Encoding': 'gzip, deflate, br, zstd',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Client-Version': 'v2.44.75', 'Client-Version': 'v2.44.75',
'Cookie': 'cookiesu=811743062689927; device_id=33fa3c7fca4a65f8f4354e10ed6b7470; smidV2=20250327160437f244626e8b47ca2a7992f30f389e4e790074ae48656a22f10; HMACCOUNT=8B64A2E3C307C8C0; s=c611ttmqlj; xq_is_login=1; u=8493411634; bid=4065a77ca57a69c83405d6e591ab5449_m8r2nhs8; __utma=1.434320573.1747189698.1747189698.1747189698.1; __utmc=1; __utmz=1.1747189698.1.1.utmcsr=(direct)|utmccn=(direct)|utmcmd=(none); snbim_minify=true; _c_WBKFRo=dsWgHR8i8KGPbIyhFlN51PHOzVuuNytvUAFppfkD; _nb_ioWEgULi=; xq_a_token=ada154d4707b8d3f8aa521ff0c960aa7f81cbf9e; xqat=ada154d4707b8d3f8aa521ff0c960aa7f81cbf9e; xq_id_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1aWQiOjg0OTM0MTE2MzQsImlzcyI6InVjIiwiZXhwIjoxNzU2MDAyNjgyLCJjdG0iOjE3NTM0MTA2ODI0MTQsImNpZCI6ImQ5ZDBuNEFadXAifQ.AlnzQSY7oGKGABfaQcFLg0lAJsDdvBMiwUbgpCMCBlbx6VZPKhzERxWiylQb4dFIyyECvRRJ73SbO9cD46fAqgzOgTxArNHtTKD4lQapTnyb11diDADnpb_nzzaRr4k_BYQRKXWtcJxdUMzde2WLy-eAkSf76QkXmKrwS3kvRm5gfqhdye44whw5XMEGoZ_lXHzGLWGz_PludHZp6W3v-wwZc_0wLU6cTb_KdrwWUWT_8jw5JHXnJEmuZmQI8QWf60DtiHIYCYXarxv8XtyHK7lLKhIAa3C2QmGWw5wv2HGz4I5DPqm2uMPKumgkQxycfAk56-RWviLZ8LAPF-XcbA; xq_r_token=92527e51353f90ba14d5fd16581e5a7a2780baa2; acw_tc=1a0c655917546366986673411e68d25d3c69c1719d6d1d6283c7271cc1529f; is_overseas=0; Hm_lvt_1db88642e346389874251b5a1eded6e3=1754636834; Hm_lpvt_1db88642e346389874251b5a1eded6e3=1754636837; .thumbcache_f24b8bbe5a5934237bbc0eda20c1b6e7=Hvg6Ac+qmPnDgzOvFuCePWwm7reK8TPoE9ayL8cyLnFg+Jhg1RJO2WnkeH2T8Q18+iV9bDh+UAq222GxdelHBg%3D%3D; ssxmod_itna=1-eqGxBDnGKYuxcD4kDRgxYq7ueYKS8DBP01Dp2xQyP08D60DB40Q0P6Dw1PtDCuqbKOOQYMxPsMKjqDsqze4GzDiLPGhDBWAFdYjdqN4NCtAoqzWWF2ruqe8bOZqKKFS96SM6sXUGQKhexGLDY=DCuXiieGGU4GwDGoD34DiDDpLD03Db4D_nWrD7ORQMluokjeDQ4GyDiUk3ObDm4DfDDLorA6osQ4DGqDSFcyTxD3DfRb4DDN4CIDu_mDDbObt5jcbUx7OBCGxIeDMixGXzGC4InyRNvDrgjMXvzEKH1aDtqD9_au4XxKdr3NEAEP4KGGpC0inpge_5neOQDqix1oeee4eQvxQ5O7Gv0DOGDz0G4ix_jwP_RUWjiihW9PeGAShXZ=E/ZND6q3mi40weUmXjmvYIzSQzWDW9wsemhYedCrwihQYbKYvWRD3YD; ssxmod_itna2=1-eqGxBDnGKYuxcD4kDRgxYq7ueYKS8DBP01Dp2xQyP08D60DB40Q0P6Dw1PtDCuqbKOOQYMxPsMKe4DWhzmxhTKRDjR_xWs_DDs6KmhfHjRKnZkBxNA3TIO4Arip5wU2kO0SwUfkEzryfSk6Rzud3ARD49fiKFd344obYvCv1lxYhY3qdzQe3vWD', 'Cookie': 'cookiesu=811743062689927; device_id=33fa3c7fca4a65f8f4354e10ed6b7470; smidV2=20250327160437f244626e8b47ca2a7992f30f389e4e790074ae48656a22f10; HMACCOUNT=8B64A2E3C307C8C0; s=c611ttmqlj; xq_is_login=1; u=8493411634; bid=4065a77ca57a69c83405d6e591ab5449_m8r2nhs8; __utma=1.434320573.1747189698.1747189698.1747189698.1; __utmc=1; __utmz=1.1747189698.1.1.utmcsr=(direct)|utmccn=(direct)|utmcmd=(none); snbim_minify=true; _c_WBKFRo=dsWgHR8i8KGPbIyhFlN51PHOzVuuNytvUAFppfkD; _nb_ioWEgULi=; Hm_lvt_1db88642e346389874251b5a1eded6e3=1754636834; xq_a_token=4ea8af8f9cb5850af2ba654c5255cbf6bf797b39; xqat=4ea8af8f9cb5850af2ba654c5255cbf6bf797b39; xq_id_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1aWQiOjg0OTM0MTE2MzQsImlzcyI6InVjIiwiZXhwIjoxNzU3NjM3OTA4LCJjdG0iOjE3NTUwNDU5MDg0ODksImNpZCI6ImQ5ZDBuNEFadXAifQ.jAeKlW2r1xRuyoZ3cuy2rTgfSoW_79wGJeuup7I7sZMSH5QeRDJrGx5JWXO4373YRpNW0qnAXR51Ygd8Plmko1u99iN8LifGzyMtblXDPgs17aS0zyHr6cMAsURU984wCXkmZxdvRMCHdevc8XWNHnuqeGfQNSgBSdO6Zv7Xc5-t965TJba96UOsNBpv2GghV9B2mcrUQyW3edi9kRAN_Fxmx5M1Iri4Yfppcaj-VSZYkdZtUpizrN5BbVYujcnQjj4kceUYYAl3Ccs273KVNSMFKpHMIOJcMJATY6PRgLvbEu8_ttIfBnbG4mmZ71bU7RXigleXIj1qhcDL2rDzQQ; xq_r_token=2b5db3e3897cb3e46b8fa2fa384471b334ec59cb; acw_tc=ac11000117550489614555169ef3ec63ec008e1bfba0fe5321bc8a30c2deb8; Hm_lpvt_1db88642e346389874251b5a1eded6e3=1755050072; .thumbcache_f24b8bbe5a5934237bbc0eda20c1b6e7=kE/XuROkIJ4APDhfxUYOb9lRiDFNJT8KxiXYwJAuoCeNlkaxlcytBSuiCXGjqxhydALLguC/FB4qIXfLut408Q%3D%3D; ssxmod_itna=1-eqGxBDnGKYuxcD4kDRgxYq7ueYKS8DBP01Dp2xQyP08D60DB40Q0P6Dw1PtDCuuqxGeR0qxPehAjqDsqze4GzDiLPGhDBWAFdYjw7ivq8RG5pxMBrGHuhLMEzoiqds=iCpxusTh1K/Zjw=KmoeK4xGLDY=DCTKq1QeD4S3Dt4DIDAYDDxDWU4DLDYoDY3nYxGP=xpWTcmRbD0YDzqDgUEe=xi3DA4DjnehqYiTdwDDBDGtO=9aDG4GfSmDD0wDLoGQQoDGWnCneE6mkiFIr6TTDjqPD/Shc59791vGW56CM9zo3paFDtqD90aAFn=GrvFaE_n93e4F4qibH7GYziTmrzt4xmrKi44mBDmAQQ0TKe4Bxq3DPQDhtTH7OY8FYS_Qqx4Gn/lHDcnDd7YerPCYC70bYbC42q3i8WTx3e8/rijIosDPRGrE0WdoqzGh_YPeD; ssxmod_itna2=1-eqGxBDnGKYuxcD4kDRgxYq7ueYKS8DBP01Dp2xQyP08D60DB40Q0P6Dw1PtDCuuqxGeR0qxPehAe4DWhYeRonANsR7vNG4g2BxpQTxTiD',
'Referer': 'https://weibo.com/u/7735765253', 'Referer': 'https://weibo.com/u/7735765253',
'Sec-Ch-Ua': '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"', 'Sec-Ch-Ua': '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
'Sec-Ch-Ua-Mobile': '?0', 'Sec-Ch-Ua-Mobile': '?0',

View File

@ -184,55 +184,6 @@ def analyze_price_changes(db_url):
except Exception as e: except Exception as e:
print("分析数据时发生错误: {}".format(str(e))) print("分析数据时发生错误: {}".format(str(e)))
def get_previous_sector_ranks(engine):
"""获取上一次的板块排名"""
try:
query = text("""
SELECT sector_name, rank_num
FROM sector_performance
WHERE DATE(add_time) = CURDATE()
ORDER BY add_time DESC
LIMIT 1
""")
result = pd.read_sql_query(query, engine)
if result.empty:
return {}
return dict(zip(result['sector_name'], result['rank_num']))
except Exception as e:
print("获取上一次排名数据时发生错误: {}".format(str(e)))
return {}
def calculate_rank_change(row, previous_ranks):
"""计算排名变化"""
previous_rank = previous_ranks.get(row['sector_name'])
if previous_rank is None:
return 0
return previous_rank - row['rank_num']
def get_cache_mark():
"""获取当前时间对应的缓存标记"""
current_minute = datetime.now().minute
mark = (current_minute % 10) // 2 * 2
return "{}m".format(mark)
def save_sector_cache(engine, df_result, cache_mark):
"""保存板块数据到缓存表"""
try:
df_cache = df_result.copy()
df_cache['cache_mark'] = cache_mark
with engine.connect() as conn:
delete_query = text("DELETE FROM sector_performance_cache WHERE cache_mark = :cache_mark")
conn.execute(delete_query, {'cache_mark': cache_mark})
conn.commit()
df_cache.to_sql('sector_performance_cache', con=engine, if_exists='append', index=False)
print(f"缓存数据已保存,标记: {cache_mark}")
except Exception as e:
print("保存缓存数据时发生错误: {}".format(str(e)))
def main(db_url): def main(db_url):
"""主函数""" """主函数"""
engine = create_engine(db_url) engine = create_engine(db_url)

View File

@ -1,62 +1,74 @@
# coding:utf-8 # coding:utf-8
import requests
import pandas as pd import pandas as pd
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
from datetime import datetime from datetime import datetime
from config import XUEQIU_HEADERS import redis
import json
import sys
import os
def fetch_and_store_stock_data(db_url, table_name='stock_changes', page_size=90): # 添加项目根目录到路径便于导入config
"""获取雪球数据并保存到数据库""" project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
base_url = 'https://stock.xueqiu.com/v5/stock/screener/quote/list.json' sys.path.append(project_root)
types = ['sha', 'sza', 'kcb'] # 数据类型
headers = XUEQIU_HEADERS
all_data = [] # 读取配置
try:
from src.valuation_analysis.config import REDIS_CONFIG
except ImportError:
REDIS_CONFIG = {
'host': 'localhost',
'port': 6379,
'db': 0,
'password': None
}
for stock_type in types: REDIS_KEY = 'xq_stock_changes_latest' # Redis中存放行情的主键
params = {
'page': 1,
'size': page_size,
'order': 'desc',
'order_by': 'percent',
'market': 'CN',
'type': stock_type
}
# 初次请求以获取总页数 def get_redis_conn():
response = requests.get(base_url, headers=headers, params=params) """获取Redis连接"""
if response.status_code != 200: pool = redis.ConnectionPool(
print(f"请求 {stock_type} 数据失败,状态码:{response.status_code}") host=REDIS_CONFIG['host'],
continue port=REDIS_CONFIG['port'],
db=REDIS_CONFIG.get('db', 0),
password=REDIS_CONFIG.get('password', None),
decode_responses=True
)
return redis.Redis(connection_pool=pool)
data = response.json() def fetch_stock_data_from_redis():
total_count = data['data']['count'] """从Redis获取股票数据"""
total_pages = (total_count // page_size) + 1 try:
r = get_redis_conn()
for page in range(1, total_pages + 1):
params['page'] = page # 从Redis获取所有股票数据
response = requests.get(base_url, headers=headers, params=params) all_stock_data = r.hgetall(REDIS_KEY)
if response.status_code == 200:
data = response.json() if not all_stock_data:
all_data.extend(data['data']['list']) print("Redis中没有找到股票数据")
else: return pd.DataFrame()
print(f"请求 {stock_type} 数据第 {page} 页失败,状态码:{response.status_code}")
# 转换为 DataFrame # 转换为DataFrame
df = pd.DataFrame(all_data) stock_list = []
for symbol, value in all_stock_data.items():
if not df.empty: try:
# 添加 id 列 stock_data = json.loads(value)
df['id'] = range(1, len(df) + 1) stock_list.append(stock_data)
except json.JSONDecodeError:
# 创建数据库连接 print(f"解析股票数据失败: {symbol}")
engine = create_engine(db_url) continue
# 将数据写入数据库表 if stock_list:
df.to_sql(table_name, con=engine, if_exists='replace', index=False) df = pd.DataFrame(stock_list)
print(f"成功将数据写入数据库表 {table_name}") print(f"从Redis成功获取 {len(df)} 条股票数据")
return df
else: else:
print("未获取到任何数据。") print("Redis数据解析失败")
return pd.DataFrame()
except Exception as e:
print(f"从Redis获取数据时发生错误: {str(e)}")
return pd.DataFrame()
def get_cache_mark(): def get_cache_mark():
"""获取当前时间对应的缓存标记0m, 2m, 4m, 6m, 8m""" """获取当前时间对应的缓存标记0m, 2m, 4m, 6m, 8m"""
@ -164,21 +176,43 @@ def get_high_performance_stocks(db_url):
# 确保缓存表存在 # 确保缓存表存在
ensure_cache_table_exists(engine) ensure_cache_table_exists(engine)
# 先获取最新数据 # 从Redis获取股票数据
fetch_and_store_stock_data(db_url) print("从Redis获取股票数据...")
stock_changes_df = fetch_stock_data_from_redis()
if stock_changes_df.empty:
print("Redis数据获取失败无法进行分析")
return []
# 读取数据 print(f"Redis数据获取成功{len(stock_changes_df)} 条记录")
stock_changes_df = pd.read_sql_table('stock_changes', con=engine)
# 读取板块信息
gp_gnbk_df = pd.read_sql_table('gp_gnbk', con=engine) gp_gnbk_df = pd.read_sql_table('gp_gnbk', con=engine)
# 去掉 symbol 字段的前两个字符 # 处理symbol字段 - 根据Redis数据格式symbol已经是完整格式如SZ002916
stock_changes_df['symbol'] = stock_changes_df['symbol'].str[2:] if 'symbol' in stock_changes_df.columns:
# 保持原始格式用于关联板块表因为板块表使用的是完整格式如SZ000973
stock_changes_df['symbol_clean'] = stock_changes_df['symbol']
else:
print("股票数据中缺少symbol字段")
return []
# 筛选涨幅超过 1.5% 的股票 # 筛选涨幅超过 1.5% 的股票
high_performance_stocks = stock_changes_df[stock_changes_df['percent'] > 1.5] high_performance_stocks = stock_changes_df[stock_changes_df['percent'] > 1.5]
print(f"筛选结果: {len(high_performance_stocks)} 只股票涨幅超过1.5%")
if len(high_performance_stocks) == 0:
print("没有股票涨幅超过1.5%尝试降低阈值到0.5%...")
high_performance_stocks = stock_changes_df[stock_changes_df['percent'] > 0.5]
print(f"筛选结果: {len(high_performance_stocks)} 只股票涨幅超过0.5%")
if len(high_performance_stocks) == 0:
print("仍然没有股票涨幅超过0.5%,尝试获取所有上涨股票...")
high_performance_stocks = stock_changes_df[stock_changes_df['percent'] > 0]
print(f"筛选结果: {len(high_performance_stocks)} 只股票上涨")
# 关联两个表,获取 bk_name # 关联两个表,获取 bk_name
merged_df = high_performance_stocks.merge(gp_gnbk_df, left_on='symbol', right_on='gp_code') merged_df = high_performance_stocks.merge(gp_gnbk_df, left_on='symbol_clean', right_on='gp_code')
# 统计每个 bk_name 的数量 # 统计每个 bk_name 的数量
total_counts = gp_gnbk_df['bk_name'].value_counts() total_counts = gp_gnbk_df['bk_name'].value_counts()
@ -215,6 +249,7 @@ def get_high_performance_stocks(db_url):
save_sector_cache(engine, df_result, cache_mark) save_sector_cache(engine, df_result, cache_mark)
# 输出结果 # 输出结果
print("\n板块分析结果:")
for _, row in df_result.iterrows(): for _, row in df_result.iterrows():
print("板块名称: {}, 上涨家数: {}, 总数: {}, 比重: {:.2%}, 排名: {}, 排名变化: {}".format( print("板块名称: {}, 上涨家数: {}, 总数: {}, 比重: {:.2%}, 排名: {}, 排名变化: {}".format(
row['sector_name'], row['up_count'], row['total_count'], row['sector_name'], row['up_count'], row['total_count'],
@ -251,21 +286,31 @@ def get_top_industries_and_stocks(db_url, top_start=1, top_end=10):
# 提取指定范围的行名称 # 提取指定范围的行名称
top_industry_names = [industry[0] for industry in top_industries[top_start-1:top_end]] top_industry_names = [industry[0] for industry in top_industries[top_start-1:top_end]]
# 读取数据 # 从Redis获取股票数据
stock_changes_df = pd.read_sql_table('stock_changes', con=engine) stock_changes_df = fetch_stock_data_from_redis()
if stock_changes_df.empty:
print("无法获取股票数据")
return
# 读取板块信息
gp_gnbk_df = pd.read_sql_table('gp_gnbk', con=engine) gp_gnbk_df = pd.read_sql_table('gp_gnbk', con=engine)
# 去掉 symbol 字段的前两个字符 # 处理symbol字段
stock_changes_df['symbol'] = stock_changes_df['symbol'].str[2:] if 'symbol' in stock_changes_df.columns:
# 保持原始格式用于关联板块表
stock_changes_df['symbol_clean'] = stock_changes_df['symbol']
else:
print("股票数据中缺少symbol字段")
return
# 关联两个表,获取 bk_name 和 gp_name # 关联两个表,获取 bk_name 和 gp_name
merged_df = stock_changes_df.merge(gp_gnbk_df, left_on='symbol', right_on='gp_code') merged_df = stock_changes_df.merge(gp_gnbk_df, left_on='symbol_clean', right_on='gp_code')
# 筛选指定范围的行业的股票 # 筛选指定范围的行业的股票
filtered_df = merged_df[merged_df['bk_name'].isin(top_industry_names)] filtered_df = merged_df[merged_df['bk_name'].isin(top_industry_names)]
# 统计每只股票命中行业的数量 # 统计每只股票命中行业的数量
stock_industry_counts = filtered_df.groupby(['symbol', 'gp_name'])['bk_name'].nunique().sort_values(ascending=False).head(10) stock_industry_counts = filtered_df.groupby(['symbol', 'name'])['bk_name'].nunique().sort_values(ascending=False).head(10)
# 获取每只股票的命中行业数量 # 获取每只股票的命中行业数量
stock_industry_list = stock_industry_counts.reset_index() stock_industry_list = stock_industry_counts.reset_index()
@ -332,5 +377,17 @@ if __name__ == "__main__":
# 清理历史数据 # 清理历史数据
clean_historical_data(engine) clean_historical_data(engine)
# 执行主要分析 # 执行主要分析 - 完全使用Redis数据源
get_top_industries_and_stocks(db_url, 1, 10) print("=" * 60)
print("板块分析脚本启动 - Redis数据源版本")
print("=" * 60)
print("使用Redis作为数据源")
print("确保batch_stock_price_collector.py脚本正在运行")
print("-" * 60)
# 执行分析
get_top_industries_and_stocks(db_url, 1, 10)
print("=" * 60)
print("板块分析完成")
print("=" * 60)

View File

@ -172,6 +172,13 @@ $(function() {
// 隐藏重置按钮 // 隐藏重置按钮
$('#resetViewBtn').hide(); $('#resetViewBtn').hide();
// 静默请求接口,不传递行业参数
$.get('https://spb.bmbs.tech/api/dify/webSelectStockIndustry')
.fail(function(xhr, status, error) {
// 静默处理错误,不显示给用户
console.log('返回默认视图通知接口调用失败:', error);
});
// 恢复第二行的原始布局 // 恢复第二行的原始布局
const rowContainer = document.querySelector('.row.d-flex2'); const rowContainer = document.querySelector('.row.d-flex2');
rowContainer.innerHTML = ` rowContainer.innerHTML = `
@ -223,6 +230,9 @@ $(function() {
element.remove(); element.remove();
} }
}); });
// 重新绑定持仓点击事件
setTimeout(bindIndustryHoldingsClick, 1000); // 延迟1秒绑定确保数据加载完成
} }
// 加载行业详情函数 // 加载行业详情函数
@ -399,10 +409,10 @@ $(function() {
<!-- 持仓和因子的左右布局 --> <!-- 持仓和因子的左右布局 -->
<div class="factor-holding-row"> <div class="factor-holding-row">
<!-- 持仓数据 --> <!-- 持仓数据 -->
<div class="holding-section"> <div class="holding-section">
<h5>持仓情况</h5> <h5>持仓情况</h5>
<div class="holding-details"> <div class="holding-details">
`; `;
if (stockHoldings.length > 0) { if (stockHoldings.length > 0) {
@ -431,9 +441,21 @@ $(function() {
<div class="factor-details"> <div class="factor-details">
`; `;
if (factorInfo && factorInfo.details) { if (factorInfo && factorInfo.factors_details) {
Object.entries(factorInfo.details).forEach(([factor, value]) => { factorInfo.factors_details.forEach((factorDetail) => {
html += `<p><strong>${factor}:</strong> ${value}</p>`; const factor = factorDetail.factor;
const detail = factorDetail.detail;
const signal = factorDetail.signal;
// 根据signal显示箭头
let arrowIcon = '';
if (signal === '买') {
arrowIcon = '<span class="factor-arrow buy">⬆</span>';
} else if (signal === '卖') {
arrowIcon = '<span class="factor-arrow sell">⬇</span>';
}
html += `<p>${arrowIcon}<strong>${factor}:</strong> ${detail}</p>`;
}); });
} else { } else {
html += '<p style="color: #999;">暂无因子数据</p>'; html += '<p style="color: #999;">暂无因子数据</p>';
@ -485,10 +507,6 @@ $(function() {
container.innerHTML = html; container.innerHTML = html;
} }
// 这个函数已经不再使用,删除
// 这个函数已经不再使用,删除
function convertStockCode(stockCode) { function convertStockCode(stockCode) {
// 将 600584.SH 格式转换为 SH600584 格式 // 将 600584.SH 格式转换为 SH600584 格式
const parts = stockCode.split('.'); const parts = stockCode.split('.');
@ -702,13 +720,16 @@ $(function() {
function bindIndustryHoldingsClick() { function bindIndustryHoldingsClick() {
// 为每个持仓容器添加点击事件 // 为每个持仓容器添加点击事件
const holdingsContainers = ['holdings_xjfz', 'holdings_xp', 'holdings_xfdz', 'holdings_jqr']; const holdingsContainers = ['holdings_xjfz', 'holdings_xp', 'holdings_xfdz', 'holdings_jqr'];
const industries = ['半导体', '自动化设备', '军工电子', '消费电子']; // 默认行业,实际会根据持仓动态更新
holdingsContainers.forEach((containerId, index) => { holdingsContainers.forEach((containerId, index) => {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (container) { if (container) {
container.style.cursor = 'pointer'; // 移除之前的事件监听器,避免重复绑定
container.addEventListener('click', function() { container.replaceWith(container.cloneNode(true));
const newContainer = document.getElementById(containerId);
newContainer.style.cursor = 'pointer';
newContainer.addEventListener('click', function() {
// 获取当前显示的行业名称 // 获取当前显示的行业名称
const industryTitle = this.parentElement.querySelector('.chart-title'); const industryTitle = this.parentElement.querySelector('.chart-title');
if (industryTitle) { if (industryTitle) {
@ -752,7 +773,7 @@ $(function() {
html = '<div class="notice-item" style="text-align: center; color: #999; padding: 20px;">暂无重要提醒</div>'; html = '<div class="notice-item" style="text-align: center; color: #999; padding: 20px;">暂无重要提醒</div>';
} else { } else {
// 只显示一次内容,不再重复 // 只显示一次内容,不再重复
notices.forEach(notice => { notices.forEach((notice, index) => {
html += `<div class="notice-item">${notice}</div>`; html += `<div class="notice-item">${notice}</div>`;
}); });
} }
@ -1120,34 +1141,34 @@ $(function() {
}); });
// 弹窗相关函数 - 移到全局作用域 // 弹窗相关函数 - 移到全局作用域
function showNoticeModal() { function showNoticeModal() {
const modal = document.getElementById('noticeModal'); const modal = document.getElementById('noticeModal');
const modalContent = document.getElementById('modalNoticeContent'); const modalContent = document.getElementById('modalNoticeContent');
// 获取当前所有提醒数据 // 获取当前所有提醒数据
const notices = getCurrentNotices(); const notices = getCurrentNotices();
// 生成弹窗内容 // 生成弹窗内容
let html = ''; let html = '';
notices.forEach(notice => { notices.forEach(notice => {
html += `<div class="modal-notice-item">${notice}</div>`; html += `<div class="modal-notice-item">${notice}</div>`;
}); });
modalContent.innerHTML = html; modalContent.innerHTML = html;
// 显示弹窗 // 显示弹窗
modal.style.display = 'flex'; modal.style.display = 'flex';
// 阻止背景滚动 // 阻止背景滚动
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
} }
function closeNoticeModal() { function closeNoticeModal() {
const modal = document.getElementById('noticeModal'); const modal = document.getElementById('noticeModal');
modal.style.display = 'none'; modal.style.display = 'none';
// 恢复背景滚动 // 恢复背景滚动
document.body.style.overflow = 'auto'; document.body.style.overflow = 'auto';
} }
function closeIndustryHoldingsModal() { function closeIndustryHoldingsModal() {
const modal = document.getElementById('industryHoldingsModal'); const modal = document.getElementById('industryHoldingsModal');
@ -1157,14 +1178,14 @@ function closeIndustryHoldingsModal() {
document.body.style.overflow = 'auto'; document.body.style.overflow = 'auto';
} }
function getCurrentNotices() { function getCurrentNotices() {
// 从当前显示的提醒框中获取数据,或者使用默认数据 // 从当前显示的提醒框中获取数据,或者使用默认数据
const noticeItems = document.querySelectorAll('.notice-item'); const noticeItems = document.querySelectorAll('.notice-item');
if (noticeItems.length > 0) { if (noticeItems.length > 0) {
return Array.from(noticeItems).map(item => item.textContent.trim()); return Array.from(noticeItems).map(item => item.textContent.trim());
}
return getDefaultNotices();
} }
return getDefaultNotices();
}
// 页面加载完成后绑定事件 // 页面加载完成后绑定事件
$(function() { $(function() {

View File

@ -103,6 +103,14 @@
border-left: 3px solid #5470c6; border-left: 3px solid #5470c6;
} }
.holding-item:nth-child(odd) {
background-color: #f8f9fa;
}
.holding-item:nth-child(even) {
background-color: #ffffff;
}
.holding-name { .holding-name {
font-weight: bold; font-weight: bold;
color: #333; color: #333;
@ -162,6 +170,20 @@
line-height: 1.4; line-height: 1.4;
} }
.notice-item:nth-child(odd) {
background-color: #f8f9fa;
padding: 8px 10px;
margin: 0 -10px;
border-radius: 4px;
}
.notice-item:nth-child(even) {
background-color: #ffffff;
padding: 8px 10px;
margin: 0 -10px;
border-radius: 4px;
}
.notice-item:last-child { .notice-item:last-child {
border-bottom: none; border-bottom: none;
} }
@ -566,6 +588,22 @@
color: #34495e; color: #34495e;
} }
/* 因子箭头样式 */
.factor-arrow {
display: inline-block;
font-weight: bold;
margin-right: 8px;
font-size: 14px;
}
.factor-arrow.buy {
color: #ff4444;
}
.factor-arrow.sell {
color: #44cc44;
}
.holding-detail-item { .holding-detail-item {
margin-bottom: 3px; margin-bottom: 3px;
padding: 3px; padding: 3px;

View File

@ -0,0 +1,313 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>个股指数对比分析</title>
<!-- 引入 ECharts -->
<script src="../static/js/echarts.min.js"></script>
<style>
.container {
padding: 20px;
font-family: Arial, sans-serif;
}
.input-group {
margin-bottom: 20px;
}
.input-group label {
display: inline-block;
width: 100px;
margin-right: 10px;
}
.input-group input, .input-group select {
padding: 5px;
margin-right: 10px;
}
.result-panel {
background-color: #f5f5f5;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.strength-indicator {
font-size: 24px;
font-weight: bold;
color: #ff4d4f;
}
.charts-container {
display: flex;
gap: 20px;
}
.chart-item {
flex: 1;
}
</style>
</head>
<body>
<div class="container">
<h1>个股指数对比分析</h1>
<!-- 输入控制区 -->
<div class="input-group">
<label>个股代码:</label>
<input type="text" id="stockCode" placeholder="输入股票代码">
</div>
<div class="input-group">
<label>对比指数:</label>
<select id="indexSelect">
<option value="000001|1">000001 - 上证指数</option>
<option value="399001|0">399001 - 深证成指</option>
<option value="399006|0">399006 - 创业板指</option>
<option value="000688|1">000688 - 科创50指数</option>
</select>
</div>
<div class="input-group">
<button onclick="fetchAndAnalyze()" style="padding: 10px 20px; background-color: #1890ff; color: white; border: none; border-radius: 4px; cursor: pointer;">开始分析</button>
</div>
<!-- 结果展示区 -->
<div class="result-panel" id="resultPanel" style="display: none;">
<h3>逆市强度分析结果</h3>
<div>
<span>逆市强度: </span>
<span class="strength-indicator" id="strengthValue">-</span>
</div>
<div style="margin-top: 10px; font-size: 14px; color: #666;">
指数弱势次数: <span id="indexWeakCount">-</span> |
个股逆市次数: <span id="stockStrongCount">-</span>
</div>
</div>
<!-- 图表展示区 -->
<div class="charts-container">
<div class="chart-item">
<h3>个股买卖方能量</h3>
<div id="stockChart" style="width: 100%; height: 400px;"></div>
</div>
<div class="chart-item">
<h3>指数买卖方能量</h3>
<div id="indexChart" style="width: 100%; height: 400px;"></div>
</div>
</div>
</div>
<script>
// 初始化图表
var stockChart = echarts.init(document.getElementById('stockChart'));
var indexChart = echarts.init(document.getElementById('indexChart'));
// 获取分时数据
async function fetchStockData(code, market) {
var baseUrl = "http://push2ex.eastmoney.com/getStockFenShi?pagesize=6000&ut=7eea3edcaed734bea9cbfc24409ed989&dpt=wzfscj&pageindex=0&id=3007792&sort=1&ft=1&code=REPLACE_CODE&market=REPLACE_MARKET";
var url = baseUrl.replace("REPLACE_CODE", code).replace("REPLACE_MARKET", market);
try {
const response = await fetch(url);
const jsonData = await response.json();
return jsonData.data.data;
} catch (error) {
console.log('Request failed for ' + code, error);
return null;
}
}3
// 处理数据:按分钟聚合买卖方数据
function processTradeData(data) {
var buyData = {};
var sellData = {};
if (!data) return { buyData, sellData };
data.forEach(function(item) {
var minute = Math.floor(item.t / 100);
if (item.bs === 1) {
// 卖方
if (!sellData[minute]) sellData[minute] = 0;
sellData[minute] += item.v;
} else if (item.bs === 2) {
// 买方
if (!buyData[minute]) buyData[minute] = 0;
buyData[minute] += item.v;
}
});
return { buyData, sellData };
}
// 计算逆市强度
function calculateCounterTrendStrength(stockBuyData, stockSellData, indexBuyData, indexSellData) {
var indexWeakCount = 0; // 指数弱势次数(卖方 > 买方)
var stockStrongCount = 0; // 个股逆市强势次数
// 获取所有时间点
var allMinutes = new Set([
...Object.keys(stockBuyData),
...Object.keys(stockSellData),
...Object.keys(indexBuyData),
...Object.keys(indexSellData)
]);
allMinutes.forEach(function(minute) {
var stockBuy = stockBuyData[minute] || 0;
var stockSell = stockSellData[minute] || 0;
var indexBuy = indexBuyData[minute] || 0;
var indexSell = indexSellData[minute] || 0;
// 指数弱势:卖方量 > 买方量
if (indexSell > indexBuy) {
indexWeakCount++;
// 个股强势:买方量 > 卖方量
if (stockBuy > stockSell) {
stockStrongCount++;
}
}
});
var strength = indexWeakCount > 0 ? (stockStrongCount / indexWeakCount * 100) : 0;
return {
strength: strength,
indexWeakCount: indexWeakCount,
stockStrongCount: stockStrongCount
};
}
// 生成图表配置
function generateChartOption(buyData, sellData, title) {
var allMinutes = new Set([...Object.keys(buyData), ...Object.keys(sellData)]);
var sortedMinutes = Array.from(allMinutes).map(Number).sort((a, b) => a - b);
var categories = [];
var buyValues = [];
var sellValues = [];
sortedMinutes.forEach(function(minute) {
var timeStr = minute.toString().padStart(6, '0');
var formattedTime = timeStr.substring(0,2) + ':' + timeStr.substring(2,4) + ':' + timeStr.substring(4,6);
categories.push(formattedTime);
buyValues.push(buyData[minute] || 0);
sellValues.push(sellData[minute] || 0);
});
return {
title: {
text: title
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['买方', '卖方']
},
xAxis: {
data: categories,
axisLabel: {
rotate: 45
}
},
yAxis: {
type: 'value',
name: '交易量'
},
series: [{
name: '买方',
type: 'bar',
data: buyValues,
stack: '总量',
itemStyle: {
color: '#ff4d4f'
}
}, {
name: '卖方',
type: 'bar',
data: sellValues,
stack: '总量',
itemStyle: {
color: '#52c41a'
}
}],
dataZoom: [{
type: 'inside',
start: 0,
end: 100
}, {
type: 'slider',
start: 0,
end: 100
}]
};
}
// 主分析函数
async function fetchAndAnalyze() {
var stockCode = document.getElementById('stockCode').value;
var indexInfo = document.getElementById('indexSelect').value.split('|');
var indexCode = indexInfo[0];
var indexMarket = indexInfo[1];
if (!stockCode) {
alert('请输入股票代码');
return;
}
// 确定个股市场
var stockMarket = stockCode.startsWith('6') ? '1' : '0';
console.log('开始获取数据...');
console.log('个股:', stockCode, '市场:', stockMarket);
console.log('指数:', indexCode, '市场:', indexMarket);
try {
// 并行获取个股和指数数据
const [stockData, indexData] = await Promise.all([
fetchStockData(stockCode, stockMarket),
fetchStockData(indexCode, indexMarket)
]);
if (!stockData || !indexData) {
alert('数据获取失败,请检查代码是否正确');
return;
}
// 处理数据
var stockResult = processTradeData(stockData);
var indexResult = processTradeData(indexData);
// 计算逆市强度
var analysis = calculateCounterTrendStrength(
stockResult.buyData,
stockResult.sellData,
indexResult.buyData,
indexResult.sellData
);
// 显示结果
document.getElementById('resultPanel').style.display = 'block';
document.getElementById('strengthValue').textContent = analysis.strength.toFixed(2) + '%';
document.getElementById('indexWeakCount').textContent = analysis.indexWeakCount;
document.getElementById('stockStrongCount').textContent = analysis.stockStrongCount;
// 生成图表
var stockOption = generateChartOption(stockResult.buyData, stockResult.sellData, '个股: ' + stockCode);
var indexOption = generateChartOption(indexResult.buyData, indexResult.sellData, '指数: ' + indexCode);
stockChart.setOption(stockOption);
indexChart.setOption(indexOption);
console.log('分析完成');
console.log('逆市强度:', analysis.strength.toFixed(2) + '%');
} catch (error) {
console.error('分析过程中出现错误:', error);
alert('分析过程中出现错误,请重试');
}
}
</script>
</body>
</html>

View File

@ -0,0 +1,133 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>ECharts 示例</title>
<!-- 引入 ECharts -->
<script src="../static/js/echarts.min.js"></script>
</head>
<body>
<!-- 输入框和按钮 -->
<div>
<input type="text" id="stockCode" placeholder="输入股票代码">
<input type="text" id="jysl" value="0" placeholder="输入交易量阈值">
<button onclick="fetchData()">查询</button>
</div>
<!-- 为ECharts准备一个具备大小宽高的Dom -->
<div id="main" style="width: 1000px;height:600px;"></div>
<script>
// ECharts图表初始化
var myChart = echarts.init(document.getElementById('main'));
function fetchData() {
let inputValue = document.getElementById('jysl').value;
var stockCode = document.getElementById('stockCode').value;
// 检查code是否以'6'开头据此设置market值
var market = stockCode.startsWith('6') ? '1' : '0';
var baseUrl = "http://push2ex.eastmoney.com/getStockFenShi?pagesize=6000&ut=7eea3edcaed734bea9cbfc24409ed989&dpt=wzfscj&pageindex=0&id=3007792&sort=1&ft=1&code=REPLACE_CODE&market=REPLACE_MARKET";
var url = baseUrl.replace("REPLACE_CODE", stockCode).replace("REPLACE_MARKET", market);
fetch(url)
.then(function(response) {
return response.json();
})
.then(function(jsonData) {
// 这里的jsonData就是你从服务器获取的数据
// 下面是数据处理的代码,可能需要根据实际数据结构进行调整
var data = jsonData.data.data; // 根据实际结构调整路径
// 数据处理:按每分钟聚合
var buyData = {}; // 买方数据
var sellData = {}; // 卖方数据
data.forEach(function(item) {
var minute = Math.floor(item.t / 100); // 取整到分钟
if (item.v >= inputValue) { // 只有当v值大于或等于100时才进行处理
if (item.bs === 1) {
if (!sellData[minute]) sellData[minute] = 0;
sellData[minute] += item.v;
} else if (item.bs === 2) {
if (!buyData[minute]) buyData[minute] = 0;
buyData[minute] += item.v;
}
}
});
// 准备ECharts数据
var categories = [];
var buyValues = [];
var sellValues = [];
// 获取所有时间点并按数字大小排序
var allMinutes = new Set([...Object.keys(buyData), ...Object.keys(sellData)]);
var sortedMinutes = Array.from(allMinutes).map(Number).sort((a, b) => a - b);
sortedMinutes.forEach(function(minute) {
// 格式化时间显示 (例如: 093000 -> 09:30:00)
var timeStr = minute.toString().padStart(6, '0');
var formattedTime = timeStr.substring(0,2) + ':' + timeStr.substring(2,4) + ':' + timeStr.substring(4,6);
categories.push(formattedTime);
buyValues.push(buyData[minute] || 0);
sellValues.push(sellData[minute] || 0);
});
// 设置ECharts选项
var option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data:['买方', '卖方']
},
xAxis: {
data: categories,
axisLabel: {
rotate: 45 // 旋转标签以避免重叠
}
},
yAxis: {
type: 'value',
name: '交易量'
},
series: [{
name: '买方',
type: 'bar',
data: buyValues, // 修复:买方显示买方数据
stack: '总量',
itemStyle: {
color: '#ff4d4f' // 红色表示买方
}
}, {
name: '卖方',
type: 'bar',
data: sellValues, // 修复:卖方显示卖方数据
stack: '总量',
itemStyle: {
color: '#52c41a' // 绿色表示卖方
}
}],
dataZoom: [{
type: 'inside', // 内置于坐标系中,通过鼠标滚轮或触控板操作进行数据区域缩放
start: 0, // 数据窗口范围的起始百分比,表示数据窗口包含数据序列的起始部分
end: 100 // 数据窗口范围的结束百分比初始设置为100%,即展示全部数据
}, {
type: 'slider', // 滑动条型数据区域缩放组件
start: 0,
end: 100
}],
};
// 显示图表。
myChart.setOption(option);
})
.catch(function(error) {
console.log('Request failed', error);
});
}
</script>
</body>
</html>

View File

@ -43,7 +43,7 @@ class PortfolioAnalyzer:
pool_recycle=3600 pool_recycle=3600
) )
self.api_url = ("https://to.bmbs.tech/aim/app/v1/derivativeTrading/getTradingRecordList?" self.api_url = ("https://to.bmbs.tech/aim/app/v1/derivativeTrading/getTradingRecordList?"
"projectId=182AE38B8C254BC88C715D86C643A6DD,C9B533175D294648A2372CB2966BCC96") "projectId=182AE38B8C254BC88C715D86C643A6DD,C9B533175D294648A2372CB2966BCC96&dataScope=all")
logger.info("持仓分析器初始化完成") logger.info("持仓分析器初始化完成")
def get_trading_records(self) -> Optional[Dict]: def get_trading_records(self) -> Optional[Dict]: