commit;
This commit is contained in:
parent
c4c4e8622f
commit
f2400305e9
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
# 函数:显示状态概览
|
# 函数:显示状态概览
|
||||||
|
|
63
src/app.py
63
src/app.py
|
@ -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)}")
|
||||||
|
|
|
@ -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,20 +52,26 @@ 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',
|
||||||
|
@ -68,37 +79,103 @@ def fetch_and_store_stock_data(page_size=90):
|
||||||
'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为symbol,value为json字符串
|
# 存入Redis,使用hash结构,key为symbol,value为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) # 默认不使用代理
|
|
@ -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为symbol,value为json字符串
|
# 存入Redis,使用hash结构,key为symbol,value为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) # 默认不使用代理
|
|
@ -340,3 +340,36 @@ class EnhancedProxyManager:
|
||||||
'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
|
|
@ -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',
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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):
|
# 从Redis获取所有股票数据
|
||||||
params['page'] = page
|
all_stock_data = r.hgetall(REDIS_KEY)
|
||||||
response = requests.get(base_url, headers=headers, params=params)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
all_data.extend(data['data']['list'])
|
|
||||||
else:
|
|
||||||
print(f"请求 {stock_type} 数据第 {page} 页失败,状态码:{response.status_code}")
|
|
||||||
# 转换为 DataFrame
|
|
||||||
df = pd.DataFrame(all_data)
|
|
||||||
|
|
||||||
if not df.empty:
|
if not all_stock_data:
|
||||||
# 添加 id 列
|
print("Redis中没有找到股票数据")
|
||||||
df['id'] = range(1, len(df) + 1)
|
return pd.DataFrame()
|
||||||
|
|
||||||
# 创建数据库连接
|
# 转换为DataFrame
|
||||||
engine = create_engine(db_url)
|
stock_list = []
|
||||||
|
for symbol, value in all_stock_data.items():
|
||||||
|
try:
|
||||||
|
stock_data = json.loads(value)
|
||||||
|
stock_list.append(stock_data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(f"解析股票数据失败: {symbol}")
|
||||||
|
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:
|
||||||
stock_changes_df = pd.read_sql_table('stock_changes', con=engine)
|
print("Redis数据获取失败,无法进行分析")
|
||||||
|
return []
|
||||||
|
|
||||||
|
print(f"Redis数据获取成功,共 {len(stock_changes_df)} 条记录")
|
||||||
|
|
||||||
|
# 读取板块信息
|
||||||
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数据源
|
||||||
|
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)
|
get_top_industries_and_stocks(db_url, 1, 10)
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("板块分析完成")
|
||||||
|
print("=" * 60)
|
|
@ -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() {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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]:
|
||||||
|
|
Loading…
Reference in New Issue