From 900aff17dfa2e401197b35ecdcca38c2404b2bbf Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Thu, 27 Nov 2025 11:28:57 +0800 Subject: [PATCH] update pay function --- app.py | 254 ++-- app_vx.py | 23 +- concept_api_openapi.json | 1096 +++++++++++++++++ gunicorn_config.py | 39 + .../components/InvestmentCalendar.js | 178 ++- 5 files changed, 1469 insertions(+), 121 deletions(-) create mode 100644 concept_api_openapi.json create mode 100644 gunicorn_config.py diff --git a/app.py b/app.py index 138bf2ba..9b2f37b6 100755 --- a/app.py +++ b/app.py @@ -4609,20 +4609,10 @@ def get_my_following_future_events(): ) events = [] + # 所有返回的事件都是已关注的 + following_ids = set(future_event_ids) for row in result: - event_data = { - 'id': row.data_id, - 'title': row.title, - 'type': row.type, - 'calendar_time': row.calendar_time.isoformat(), - 'star': row.star, - 'former': row.former, - 'forecast': row.forecast, - 'fact': row.fact, - 'is_following': True, # 这些都是已关注的 - 'related_stocks': parse_json_field(row.related_stocks), - 'concepts': parse_json_field(row.concepts) - } + event_data = process_future_event_row(row, following_ids) events.append(event_data) return jsonify({'success': True, 'data': events}) @@ -6094,17 +6084,9 @@ def account_calendar_events(): future_events = [] if future_event_ids: + # 使用 SELECT * 以便获取所有字段(包括新字段) base_sql = """ - SELECT data_id, \ - title, \ - type, \ - calendar_time, \ - star, \ - former, \ - forecast, \ - fact, \ - related_stocks, \ - concepts + SELECT * FROM future_events WHERE data_id IN :event_ids \ """ @@ -6122,12 +6104,24 @@ def account_calendar_events(): result = db.session.execute(text(base_sql), params) for row in result: - # related_stocks 形如 [[code,name,reason,score], ...] - rs = parse_json_field(row.related_stocks) + # 使用新字段回退逻辑获取 former + former_value = get_future_event_field(row, 'second_modified_text', 'former') + + # 获取 related_stocks,优先使用 best_matches + best_matches = getattr(row, 'best_matches', None) if hasattr(row, 'best_matches') else None + if best_matches and str(best_matches).strip(): + rs = parse_best_matches(best_matches) + else: + rs = parse_json_field(getattr(row, 'related_stocks', None)) + + # 生成股票标签列表 stock_tags = [] try: for it in rs: - if isinstance(it, (list, tuple)) and len(it) >= 2: + if isinstance(it, dict): + # 新结构 + stock_tags.append(f"{it.get('code', '')} {it.get('name', '')}") + elif isinstance(it, (list, tuple)) and len(it) >= 2: stock_tags.append(f"{it[0]} {it[1]}") elif isinstance(it, str): stock_tags.append(it) @@ -6140,7 +6134,7 @@ def account_calendar_events(): 'event_date': (row.calendar_time.date().isoformat() if row.calendar_time else None), 'type': 'future_event', 'importance': int(row.star) if getattr(row, 'star', None) is not None else 3, - 'description': row.former or '', + 'description': former_value or '', 'stocks': stock_tags, 'is_following': True, 'source': 'future' @@ -7548,47 +7542,8 @@ def get_calendar_events(): user_following_ids = {f.future_event_id for f in follows} for row in result: - event_data = { - 'id': row.data_id, - 'title': row.title, - 'type': row.type, - 'calendar_time': row.calendar_time.isoformat(), - 'star': row.star, - 'former': row.former, - 'forecast': row.forecast, - 'fact': row.fact, - 'is_following': row.data_id in user_following_ids - } - - # 解析相关股票和概念 - if row.related_stocks: - try: - if isinstance(row.related_stocks, str): - if row.related_stocks.startswith('['): - event_data['related_stocks'] = json.loads(row.related_stocks) - else: - event_data['related_stocks'] = row.related_stocks.split(',') - else: - event_data['related_stocks'] = row.related_stocks - except: - event_data['related_stocks'] = [] - else: - event_data['related_stocks'] = [] - - if row.concepts: - try: - if isinstance(row.concepts, str): - if row.concepts.startswith('['): - event_data['concepts'] = json.loads(row.concepts) - else: - event_data['concepts'] = row.concepts.split(',') - else: - event_data['concepts'] = row.concepts - except: - event_data['concepts'] = [] - else: - event_data['concepts'] = [] - + # 使用统一的处理函数,支持新字段回退和 best_matches 解析 + event_data = process_future_event_row(row, user_following_ids) events.append(event_data) return jsonify({ @@ -7614,28 +7569,18 @@ def get_calendar_event_detail(event_id): 'error': 'Event not found' }), 404 - event_data = { - 'id': result.data_id, - 'title': result.title, - 'type': result.type, - 'calendar_time': result.calendar_time.isoformat(), - 'star': result.star, - 'former': result.former, - 'forecast': result.forecast, - 'fact': result.fact, - 'related_stocks': parse_json_field(result.related_stocks), - 'concepts': parse_json_field(result.concepts) - } - # 检查当前用户是否关注了该未来事件 + user_following_ids = set() if 'user_id' in session: is_following = FutureEventFollow.query.filter_by( user_id=session['user_id'], future_event_id=event_id ).first() is not None - event_data['is_following'] = is_following - else: - event_data['is_following'] = False + if is_following: + user_following_ids.add(event_id) + + # 使用统一的处理函数,支持新字段回退和 best_matches 解析 + event_data = process_future_event_row(result, user_following_ids) return jsonify({ 'success': True, @@ -7727,6 +7672,147 @@ def parse_json_field(field_value): return [] +def get_future_event_field(row, new_field, old_field): + """ + 获取 future_events 表字段值,支持新旧字段回退 + 如果新字段存在且不为空,使用新字段;否则使用旧字段 + """ + new_value = getattr(row, new_field, None) if hasattr(row, new_field) else None + old_value = getattr(row, old_field, None) if hasattr(row, old_field) else None + + # 如果新字段有值(不为空字符串),使用新字段 + if new_value is not None and str(new_value).strip(): + return new_value + return old_value + + +def parse_best_matches(best_matches_value): + """ + 解析新的 best_matches 数据结构(含研报引用信息) + + 新结构示例: + [ + { + "stock_code": "300451.SZ", + "company_name": "创业慧康", + "original_description": "核心标的,医疗信息化...", + "best_report_title": "报告标题", + "best_report_author": "作者", + "best_report_sentences": "相关内容", + "best_report_match_score": "好", + "best_report_match_ratio": 0.9285714285714286, + "best_report_declare_date": "2023-04-25T00:00:00", + "total_reports": 9, + "high_score_reports": 6 + }, + ... + ] + + 返回统一格式的股票列表,兼容旧格式 + """ + if not best_matches_value: + return [] + + try: + # 解析 JSON + if isinstance(best_matches_value, str): + data = json.loads(best_matches_value) + else: + data = best_matches_value + + if not isinstance(data, list): + return [] + + result = [] + for item in data: + if isinstance(item, dict): + # 新结构:包含研报信息的字典 + stock_info = { + 'code': item.get('stock_code', ''), + 'name': item.get('company_name', ''), + 'description': item.get('original_description', ''), + 'score': item.get('best_report_match_ratio', 0), + # 研报引用信息 + 'report': { + 'title': item.get('best_report_title', ''), + 'author': item.get('best_report_author', ''), + 'sentences': item.get('best_report_sentences', ''), + 'match_score': item.get('best_report_match_score', ''), + 'match_ratio': item.get('best_report_match_ratio', 0), + 'declare_date': item.get('best_report_declare_date', ''), + 'total_reports': item.get('total_reports', 0), + 'high_score_reports': item.get('high_score_reports', 0) + } if item.get('best_report_title') else None + } + result.append(stock_info) + elif isinstance(item, (list, tuple)) and len(item) >= 2: + # 旧结构:[code, name, description, score] + result.append({ + 'code': item[0], + 'name': item[1], + 'description': item[2] if len(item) > 2 else '', + 'score': item[3] if len(item) > 3 else 0, + 'report': None + }) + + return result + except Exception as e: + print(f"parse_best_matches error: {e}") + return [] + + +def process_future_event_row(row, user_following_ids=None): + """ + 统一处理 future_events 表的行数据 + 支持新字段回退和 best_matches 解析 + """ + if user_following_ids is None: + user_following_ids = set() + + # 获取字段值,支持新旧回退 + # second_modified_text -> former + # second_modified_text.1 -> forecast (MySQL 中用反引号) + former_value = get_future_event_field(row, 'second_modified_text', 'former') + + # 处理 second_modified_text.1 字段(特殊字段名) + forecast_new = None + if hasattr(row, 'second_modified_text.1'): + forecast_new = getattr(row, 'second_modified_text.1', None) + # 尝试其他可能的属性名 + for attr_name in ['second_modified_text.1', 'second_modified_text_1']: + if hasattr(row, attr_name): + val = getattr(row, attr_name, None) + if val and str(val).strip(): + forecast_new = val + break + forecast_value = forecast_new if (forecast_new and str(forecast_new).strip()) else getattr(row, 'forecast', None) + + # best_matches -> related_stocks + best_matches = getattr(row, 'best_matches', None) if hasattr(row, 'best_matches') else None + if best_matches and str(best_matches).strip(): + related_stocks = parse_best_matches(best_matches) + else: + related_stocks = parse_json_field(getattr(row, 'related_stocks', None)) + + # 构建事件数据 + event_data = { + 'id': row.data_id, + 'title': row.title, + 'type': getattr(row, 'type', None), + 'calendar_time': row.calendar_time.isoformat() if row.calendar_time else None, + 'star': row.star, + 'former': former_value, + 'forecast': forecast_value, + 'fact': getattr(row, 'fact', None), + 'is_following': row.data_id in user_following_ids, + 'related_stocks': related_stocks, + 'concepts': parse_json_field(getattr(row, 'concepts', None)), + 'update_time': getattr(row, 'update_time', None).isoformat() if getattr(row, 'update_time', None) else None + } + + return event_data + + # ==================== 行业API ==================== @app.route('/api/classifications', methods=['GET']) def get_classifications(): diff --git a/app_vx.py b/app_vx.py index 190a3045..b1491541 100644 --- a/app_vx.py +++ b/app_vx.py @@ -6407,10 +6407,31 @@ def api_method_not_allowed(error): return error +# 应用启动时自动初始化(兼容 Gunicorn 和直接运行) +_sywg_cache_initialized = False + + +def ensure_sywg_cache_initialized(): + """确保申银万国行业分类缓存已初始化(懒加载,首次请求时触发)""" + global _sywg_cache_initialized + if not _sywg_cache_initialized: + init_sywg_industry_cache() + _sywg_cache_initialized = True + + +@app.before_request +def before_request_init(): + """首次请求时初始化缓存""" + global _sywg_cache_initialized + if not _sywg_cache_initialized: + ensure_sywg_cache_initialized() + + if __name__ == '__main__': - # 初始化申银万国行业分类缓存 + # 直接运行时,立即初始化缓存 with app.app_context(): init_sywg_industry_cache() + _sywg_cache_initialized = True app.run( host='0.0.0.0', diff --git a/concept_api_openapi.json b/concept_api_openapi.json new file mode 100644 index 00000000..7fa96034 --- /dev/null +++ b/concept_api_openapi.json @@ -0,0 +1,1096 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "概念搜索API", + "description": "支持语义和关键词混合搜索的概念库API,包含概念涨跌幅数据。\n\n## 功能特性\n- 支持 KNN 优化的语义搜索\n- 关键词 + 语义混合搜索\n- 概念涨跌幅数据查询\n- 股票关联概念查询\n- 概念统计排行榜\n\n## 技术栈\n- FastAPI + Elasticsearch\n- OpenAI Embedding (qwen3-embedding-8b)\n- MySQL (概念涨跌幅数据)", + "version": "1.2.0", + "contact": { + "name": "ValueFrontier", + "url": "https://valuefrontier.cn/concept-api" + } + }, + "servers": [ + { + "url": "http://localhost:6801", + "description": "本地开发服务器" + }, + { + "url": "https://api.valuefrontier.cn:6801", + "description": "生产服务器" + } + ], + "tags": [ + { + "name": "Health", + "description": "健康检查接口" + }, + { + "name": "Search", + "description": "概念搜索接口" + }, + { + "name": "Concepts", + "description": "概念详情接口" + }, + { + "name": "Stocks", + "description": "股票相关接口" + }, + { + "name": "Price", + "description": "价格数据接口" + }, + { + "name": "Statistics", + "description": "统计数据接口" + } + ], + "paths": { + "/": { + "get": { + "tags": ["Health"], + "summary": "健康检查", + "description": "检查服务是否正常运行", + "operationId": "healthCheck", + "responses": { + "200": { + "description": "服务正常", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "healthy" + }, + "service": { + "type": "string", + "example": "概念搜索API" + }, + "version": { + "type": "string", + "example": "1.2.0" + } + } + } + } + } + } + } + } + }, + "/search": { + "post": { + "tags": ["Search"], + "summary": "搜索概念库", + "description": "支持 KNN 优化的语义搜索,混合搜索(KNN + 关键词),支持按涨跌幅、相关性等排序", + "operationId": "searchConcepts", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchRequest" + }, + "examples": { + "semantic_search": { + "summary": "语义搜索示例", + "value": { + "query": "新能源汽车电池技术", + "size": 10, + "page": 1, + "sort_by": "change_pct", + "use_knn": true + } + }, + "keyword_search": { + "summary": "关键词搜索示例", + "value": { + "query": "人工智能", + "size": 20, + "semantic_weight": 0, + "sort_by": "_score" + } + }, + "stock_filter": { + "summary": "股票过滤示例", + "value": { + "query": "芯片", + "filter_stocks": ["600519", "贵州茅台"], + "size": 10 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "搜索成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponse" + } + } + } + }, + "500": { + "description": "服务器错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + } + } + } + } + }, + "/concept/{concept_id}": { + "get": { + "tags": ["Concepts"], + "summary": "获取概念详情", + "description": "根据概念ID获取完整的概念信息,包含关联股票和涨跌幅数据", + "operationId": "getConcept", + "parameters": [ + { + "name": "concept_id", + "in": "path", + "required": true, + "description": "概念ID", + "schema": { + "type": "string" + }, + "example": "concept_ai_001" + }, + { + "name": "trade_date", + "in": "query", + "required": false, + "description": "交易日期,格式:YYYY-MM-DD,默认返回最新日期数据", + "schema": { + "type": "string", + "format": "date" + }, + "example": "2025-11-25" + } + ], + "responses": { + "200": { + "description": "获取成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConceptDetail" + } + } + } + }, + "404": { + "description": "概念不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPError" + } + } + } + } + } + } + }, + "/concept/{concept_id}/price-timeseries": { + "get": { + "tags": ["Price"], + "summary": "获取概念价格时间序列", + "description": "获取指定概念在日期范围内的涨跌幅时间序列数据", + "operationId": "getConceptPriceTimeseries", + "parameters": [ + { + "name": "concept_id", + "in": "path", + "required": true, + "description": "概念ID", + "schema": { + "type": "string" + } + }, + { + "name": "start_date", + "in": "query", + "required": true, + "description": "开始日期,格式:YYYY-MM-DD", + "schema": { + "type": "string", + "format": "date" + }, + "example": "2025-11-01" + }, + { + "name": "end_date", + "in": "query", + "required": true, + "description": "结束日期,格式:YYYY-MM-DD", + "schema": { + "type": "string", + "format": "date" + }, + "example": "2025-11-25" + } + ], + "responses": { + "200": { + "description": "获取成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PriceTimeSeriesResponse" + } + } + } + }, + "400": { + "description": "参数错误(开始日期晚于结束日期)" + }, + "404": { + "description": "未找到数据" + } + } + } + }, + "/stock/{stock_code}/concepts": { + "get": { + "tags": ["Stocks"], + "summary": "获取股票关联概念", + "description": "根据股票代码或名称查询该股票关联的所有概念", + "operationId": "getStockConcepts", + "parameters": [ + { + "name": "stock_code", + "in": "path", + "required": true, + "description": "股票代码或名称", + "schema": { + "type": "string" + }, + "example": "600519" + }, + { + "name": "size", + "in": "query", + "required": false, + "description": "返回概念数量", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "name": "sort_by", + "in": "query", + "required": false, + "description": "排序方式", + "schema": { + "type": "string", + "enum": ["stock_count", "concept_name", "recent"], + "default": "stock_count" + } + }, + { + "name": "include_description", + "in": "query", + "required": false, + "description": "是否包含概念描述", + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "trade_date", + "in": "query", + "required": false, + "description": "交易日期,格式:YYYY-MM-DD", + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "获取成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockConceptsResponse" + } + } + } + } + } + } + }, + "/stock/search": { + "get": { + "tags": ["Stocks"], + "summary": "搜索股票", + "description": "根据关键词搜索股票名称或代码", + "operationId": "searchStocks", + "parameters": [ + { + "name": "keyword", + "in": "query", + "required": true, + "description": "股票关键词", + "schema": { + "type": "string" + }, + "example": "茅台" + }, + { + "name": "size", + "in": "query", + "required": false, + "description": "返回数量", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "搜索成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockSearchResponse" + } + } + } + } + } + } + }, + "/price/latest": { + "get": { + "tags": ["Price"], + "summary": "获取最新价格日期", + "description": "获取数据库中最新的涨跌幅数据日期", + "operationId": "getLatestPriceDate", + "responses": { + "200": { + "description": "获取成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "latest_trade_date": { + "type": "string", + "format": "date", + "nullable": true, + "example": "2025-11-25" + }, + "has_data": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } + } + }, + "/statistics": { + "get": { + "tags": ["Statistics"], + "summary": "获取概念统计数据", + "description": "获取概念板块统计数据,包括涨幅榜、跌幅榜、活跃榜、波动榜、连涨榜", + "operationId": "getConceptStatistics", + "parameters": [ + { + "name": "days", + "in": "query", + "required": false, + "description": "统计天数范围(与 start_date/end_date 互斥)", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 90 + }, + "example": 7 + }, + { + "name": "start_date", + "in": "query", + "required": false, + "description": "开始日期,格式:YYYY-MM-DD", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "end_date", + "in": "query", + "required": false, + "description": "结束日期,格式:YYYY-MM-DD", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "min_stock_count", + "in": "query", + "required": false, + "description": "最少股票数量过滤", + "schema": { + "type": "integer", + "minimum": 1, + "default": 3 + } + } + ], + "responses": { + "200": { + "description": "获取成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConceptStatisticsResponse" + } + } + } + }, + "400": { + "description": "参数错误" + } + } + } + } + }, + "components": { + "schemas": { + "SearchRequest": { + "type": "object", + "required": ["query"], + "properties": { + "query": { + "type": "string", + "description": "搜索查询文本", + "example": "新能源汽车" + }, + "size": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 10, + "description": "每页返回结果数量" + }, + "page": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "页码" + }, + "search_size": { + "type": "integer", + "minimum": 10, + "maximum": 1000, + "default": 100, + "description": "搜索数量(从ES获取的结果数),用于排序后分页" + }, + "semantic_weight": { + "type": "number", + "minimum": 0, + "maximum": 1, + "nullable": true, + "description": "语义搜索权重(0-1),null表示自动计算" + }, + "filter_stocks": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "description": "过滤特定股票代码或名称" + }, + "trade_date": { + "type": "string", + "format": "date", + "nullable": true, + "description": "交易日期,格式:YYYY-MM-DD" + }, + "sort_by": { + "type": "string", + "enum": ["change_pct", "_score", "stock_count", "concept_name", "added_date"], + "default": "change_pct", + "description": "排序方式" + }, + "use_knn": { + "type": "boolean", + "default": true, + "description": "是否使用KNN搜索优化语义搜索" + } + } + }, + "SearchResponse": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "description": "总结果数" + }, + "took_ms": { + "type": "integer", + "description": "查询耗时(毫秒)" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConceptResult" + } + }, + "search_info": { + "type": "object", + "description": "搜索信息", + "properties": { + "query": { + "type": "string" + }, + "cleaned_query": { + "type": "string" + }, + "semantic_weight": { + "type": "number" + }, + "keyword_weight": { + "type": "number" + }, + "match_type": { + "type": "string", + "enum": ["keyword", "semantic", "hybrid", "semantic_knn", "hybrid_knn"] + }, + "stock_filters": { + "type": "array", + "items": { + "type": "string" + } + }, + "has_embedding": { + "type": "boolean" + }, + "sort_by": { + "type": "string" + }, + "use_knn": { + "type": "boolean" + } + } + }, + "price_date": { + "type": "string", + "format": "date", + "nullable": true, + "description": "价格数据日期" + }, + "page": { + "type": "integer", + "description": "当前页码" + }, + "total_pages": { + "type": "integer", + "description": "总页数" + } + } + }, + "ConceptResult": { + "type": "object", + "properties": { + "concept_id": { + "type": "string", + "description": "概念ID" + }, + "concept": { + "type": "string", + "description": "概念名称" + }, + "description": { + "type": "string", + "nullable": true, + "description": "概念描述" + }, + "stocks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockInfo" + }, + "description": "关联股票列表(最多20条)" + }, + "stock_count": { + "type": "integer", + "description": "关联股票总数" + }, + "happened_times": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "description": "发生时间列表" + }, + "score": { + "type": "number", + "description": "搜索相关性分数" + }, + "match_type": { + "type": "string", + "enum": ["semantic", "keyword", "hybrid", "semantic_knn", "hybrid_knn"], + "description": "匹配类型" + }, + "highlights": { + "type": "object", + "nullable": true, + "description": "高亮匹配信息" + }, + "price_info": { + "$ref": "#/components/schemas/ConceptPriceInfo" + } + } + }, + "StockInfo": { + "type": "object", + "properties": { + "stock_name": { + "type": "string", + "description": "股票名称" + }, + "stock_code": { + "type": "string", + "description": "股票代码" + }, + "reason": { + "type": "string", + "nullable": true, + "description": "关联原因" + }, + "industry": { + "type": "string", + "nullable": true, + "description": "所属行业" + }, + "project": { + "type": "string", + "nullable": true, + "description": "相关项目" + } + } + }, + "ConceptPriceInfo": { + "type": "object", + "nullable": true, + "properties": { + "trade_date": { + "type": "string", + "format": "date", + "description": "交易日期" + }, + "avg_change_pct": { + "type": "number", + "nullable": true, + "description": "平均涨跌幅(%)" + } + } + }, + "ConceptDetail": { + "type": "object", + "properties": { + "concept_id": { + "type": "string" + }, + "concept": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "stocks": { + "type": "array", + "items": { + "type": "object" + } + }, + "stocks_reason": { + "type": "object", + "description": "股票关联原因详情" + }, + "happened_times": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "created_at": { + "type": "string", + "nullable": true + }, + "price_info": { + "$ref": "#/components/schemas/ConceptPriceInfo" + } + } + }, + "StockConceptsResponse": { + "type": "object", + "properties": { + "stock_code": { + "type": "string", + "description": "股票代码" + }, + "stats": { + "type": "object", + "properties": { + "total_concepts": { + "type": "integer", + "description": "关联概念总数" + }, + "returned_concepts": { + "type": "integer", + "description": "返回概念数" + }, + "stock_info": { + "type": "object", + "nullable": true, + "properties": { + "stock_code": { + "type": "string" + }, + "stock_name": { + "type": "string" + } + } + }, + "concept_categories": { + "type": "object", + "description": "概念分类统计" + } + } + }, + "concepts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockConceptInfo" + } + }, + "price_date": { + "type": "string", + "format": "date", + "nullable": true + } + } + }, + "StockConceptInfo": { + "type": "object", + "properties": { + "concept_id": { + "type": "string" + }, + "concept": { + "type": "string" + }, + "stock_count": { + "type": "integer" + }, + "happened_times": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "stock_detail": { + "type": "object", + "nullable": true, + "description": "该股票在此概念中的详细信息" + }, + "price_info": { + "$ref": "#/components/schemas/ConceptPriceInfo" + } + } + }, + "StockSearchResponse": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "description": "搜索结果总数" + }, + "stocks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockSearchResult" + } + } + } + }, + "StockSearchResult": { + "type": "object", + "properties": { + "stock_code": { + "type": "string" + }, + "stock_name": { + "type": "string" + }, + "concept_count": { + "type": "integer", + "description": "关联概念数量" + } + } + }, + "PriceTimeSeriesResponse": { + "type": "object", + "properties": { + "concept_id": { + "type": "string" + }, + "concept_name": { + "type": "string" + }, + "start_date": { + "type": "string", + "format": "date" + }, + "end_date": { + "type": "string", + "format": "date" + }, + "data_points": { + "type": "integer", + "description": "数据点数量" + }, + "timeseries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PriceTimeSeriesItem" + } + } + } + }, + "PriceTimeSeriesItem": { + "type": "object", + "properties": { + "trade_date": { + "type": "string", + "format": "date" + }, + "avg_change_pct": { + "type": "number", + "nullable": true, + "description": "平均涨跌幅(%)" + }, + "stock_count": { + "type": "integer", + "nullable": true, + "description": "当日股票数量" + } + } + }, + "ConceptStatisticsResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "$ref": "#/components/schemas/ConceptStatistics" + }, + "params": { + "type": "object", + "properties": { + "days": { + "type": "integer" + }, + "min_stock_count": { + "type": "integer" + }, + "start_date": { + "type": "string" + }, + "end_date": { + "type": "string" + } + } + }, + "note": { + "type": "string", + "nullable": true + } + } + }, + "ConceptStatistics": { + "type": "object", + "properties": { + "hot_concepts": { + "type": "array", + "description": "涨幅榜", + "items": { + "$ref": "#/components/schemas/ConceptStatItem" + } + }, + "cold_concepts": { + "type": "array", + "description": "跌幅榜", + "items": { + "$ref": "#/components/schemas/ConceptStatItem" + } + }, + "active_concepts": { + "type": "array", + "description": "活跃榜", + "items": { + "$ref": "#/components/schemas/ConceptStatItem" + } + }, + "volatile_concepts": { + "type": "array", + "description": "波动榜", + "items": { + "$ref": "#/components/schemas/ConceptStatItem" + } + }, + "momentum_concepts": { + "type": "array", + "description": "连涨榜", + "items": { + "$ref": "#/components/schemas/ConceptStatItem" + } + }, + "summary": { + "type": "object", + "description": "统计摘要", + "properties": { + "total_concepts": { + "type": "integer" + }, + "positive_count": { + "type": "integer" + }, + "negative_count": { + "type": "integer" + }, + "avg_change": { + "type": "number" + }, + "update_time": { + "type": "string" + }, + "date_range": { + "type": "string" + }, + "days": { + "type": "integer" + }, + "start_date": { + "type": "string" + }, + "end_date": { + "type": "string" + } + } + } + } + }, + "ConceptStatItem": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "概念名称" + }, + "concept_id": { + "type": "string", + "nullable": true + }, + "change_pct": { + "type": "number", + "nullable": true, + "description": "涨跌幅" + }, + "stock_count": { + "type": "integer", + "nullable": true, + "description": "股票数量" + }, + "news_count": { + "type": "integer", + "nullable": true, + "description": "新闻数量/交易天数" + }, + "report_count": { + "type": "integer", + "nullable": true + }, + "total_mentions": { + "type": "integer", + "nullable": true + }, + "volatility": { + "type": "number", + "nullable": true, + "description": "波动率" + }, + "avg_change": { + "type": "number", + "nullable": true + }, + "max_change": { + "type": "number", + "nullable": true + }, + "consecutive_days": { + "type": "integer", + "nullable": true, + "description": "连涨天数" + }, + "total_change": { + "type": "number", + "nullable": true, + "description": "累计涨幅" + }, + "avg_daily": { + "type": "number", + "nullable": true, + "description": "日均涨幅" + } + } + }, + "HTTPError": { + "type": "object", + "properties": { + "detail": { + "type": "string", + "description": "错误详情" + } + } + } + } + } +} diff --git a/gunicorn_config.py b/gunicorn_config.py new file mode 100644 index 00000000..13738889 --- /dev/null +++ b/gunicorn_config.py @@ -0,0 +1,39 @@ +# Gunicorn 配置文件 + +# 基本配置 +bind = "0.0.0.0:5002" +workers = 4 +threads = 4 +timeout = 120 +worker_class = "gthread" + +# SSL 配置(如需要) +# certfile = "/etc/letsencrypt/live/api.valuefrontier.cn/fullchain.pem" +# keyfile = "/etc/letsencrypt/live/api.valuefrontier.cn/privkey.pem" + +# 日志配置 +loglevel = "info" +accesslog = "-" +errorlog = "-" + +# 预加载应用(在 fork 前加载,加快 worker 启动) +preload_app = True + + +def on_starting(server): + """主进程启动时调用""" + print("Gunicorn 主进程启动...") + + +def post_fork(server, worker): + """Worker 进程 fork 后调用""" + print(f"Worker {worker.pid} 已启动") + + +def when_ready(server): + """服务准备就绪时调用,初始化缓存""" + print("Gunicorn 服务准备就绪,开始初始化...") + from app_vx import app, init_sywg_industry_cache + with app.app_context(): + init_sywg_industry_cache() + print("初始化完成!") diff --git a/src/views/Community/components/InvestmentCalendar.js b/src/views/Community/components/InvestmentCalendar.js index 5ef091cb..03ade6cd 100644 --- a/src/views/Community/components/InvestmentCalendar.js +++ b/src/views/Community/components/InvestmentCalendar.js @@ -93,16 +93,60 @@ const InvestmentCalendar = () => { return code.split('.')[0]; }; + /** + * 归一化股票数据格式 + * 支持两种格式: + * 1. 旧格式数组:[code, name, description, score] + * 2. 新格式对象:{ code, name, description, score, report } + * 返回统一的对象格式 + */ + const normalizeStock = (stock) => { + if (!stock) return null; + + // 新格式:对象 + if (typeof stock === 'object' && !Array.isArray(stock)) { + return { + code: stock.code || '', + name: stock.name || '', + description: stock.description || '', + score: stock.score || 0, + report: stock.report || null // 研报引用信息 + }; + } + + // 旧格式:数组 [code, name, description, score] + if (Array.isArray(stock)) { + return { + code: stock[0] || '', + name: stock[1] || '', + description: stock[2] || '', + score: stock[3] || 0, + report: null + }; + } + + return null; + }; + + /** + * 归一化股票列表 + */ + const normalizeStocks = (stocks) => { + if (!stocks || !Array.isArray(stocks)) return []; + return stocks.map(normalizeStock).filter(Boolean); + }; + // 加载股票行情 const loadStockQuotes = async (stocks, eventTime) => { try { - const codes = stocks.map(stock => getSixDigitCode(stock[0])); // 确保使用六位代码 + const normalizedStocks = normalizeStocks(stocks); + const codes = normalizedStocks.map(stock => getSixDigitCode(stock.code)); const quotes = {}; - + // 使用市场API获取最新行情数据 for (let i = 0; i < codes.length; i++) { const code = codes[i]; - const originalCode = stocks[i][0]; // 保持原始代码作为key + const originalCode = normalizedStocks[i].code; // 使用归一化后的代码作为key try { const response = await fetch(`/api/market/trade/${code}?days=1`); if (response.ok) { @@ -257,11 +301,13 @@ const InvestmentCalendar = () => { message.info('暂无相关股票'); return; } - // 按相关度排序(限降序) - const sortedStocks = [...stocks].sort((a, b) => (b[3] || 0) - (a[3] || 0)); + + // 归一化数据后按相关度排序(降序) + const normalizedList = normalizeStocks(stocks); + const sortedStocks = normalizedList.sort((a, b) => (b.score || 0) - (a.score || 0)); setSelectedStocks(sortedStocks); setStockModalVisible(true); - loadStockQuotes(sortedStocks, eventTime); + loadStockQuotes(stocks, eventTime); // 传原始数据给 loadStockQuotes,它内部会归一化 }; // 添加交易所后缀 @@ -281,24 +327,27 @@ const InvestmentCalendar = () => { return sixDigitCode; }; - // 显示K线图 + // 显示K线图(支持新旧格式) const showKline = (stock) => { - const stockCode = addExchangeSuffix(stock[0]); + // 兼容新旧格式 + const code = stock.code || stock[0]; + const name = stock.name || stock[1]; + const stockCode = addExchangeSuffix(code); // 将 selectedDate 转换为 YYYY-MM-DD 格式(日K线只需要日期,不需要时间) const formattedEventTime = selectedDate ? selectedDate.format('YYYY-MM-DD') : null; console.log('[InvestmentCalendar] 打开K线图:', { - originalCode: stock[0], + originalCode: code, processedCode: stockCode, - stockName: stock[1], + stockName: name, selectedDate: selectedDate?.format('YYYY-MM-DD'), formattedEventTime: formattedEventTime }); setSelectedStock({ stock_code: stockCode, // 添加交易所后缀 - stock_name: stock[1] + stock_name: name }); setSelectedEventTime(formattedEventTime); setKlineModalVisible(true); @@ -330,10 +379,13 @@ const InvestmentCalendar = () => { } }; - // 添加单只股票到自选 + // 添加单只股票到自选(支持新旧格式) const addSingleToWatchlist = async (stock) => { - const stockCode = getSixDigitCode(stock[0]); - + // 兼容新旧格式 + const code = stock.code || stock[0]; + const name = stock.name || stock[1]; + const stockCode = getSixDigitCode(code); + setAddingToWatchlist(prev => ({ ...prev, [stockCode]: true })); try { @@ -345,20 +397,20 @@ const InvestmentCalendar = () => { credentials: 'include', body: JSON.stringify({ stock_code: stockCode, // 使用六位代码 - stock_name: stock[1] // 股票名称 + stock_name: name // 股票名称 }) }); const data = await response.json(); if (data.success) { - message.success(`已将 ${stock[1]}(${stockCode}) 添加到自选`); + message.success(`已将 ${name}(${stockCode}) 添加到自选`); } else { message.error(data.error || '添加失败'); } } catch (error) { logger.error('InvestmentCalendar', 'addSingleToWatchlist', error, { stockCode, - stockName: stock[1] + stockName: name }); message.error('添加失败,请重试'); } finally { @@ -415,7 +467,23 @@ const InvestmentCalendar = () => { ) }, - + { + title: '未来推演', + dataIndex: 'forecast', + key: 'forecast', + width: 80, + render: (text) => ( + + ) + }, { title: ( @@ -484,17 +552,17 @@ const InvestmentCalendar = () => { } ]; - // 股票表格列定义 + // 股票表格列定义(使用归一化后的对象格式) const stockColumns = [ { title: '代码', - dataIndex: '0', + dataIndex: 'code', key: 'code', width: 100, render: (code) => { const sixDigitCode = getSixDigitCode(code); return ( - { }, { title: '名称', - dataIndex: '1', + dataIndex: 'name', key: 'name', width: 100, render: (name, record) => { - const sixDigitCode = getSixDigitCode(record[0]); + const sixDigitCode = getSixDigitCode(record.code); return ( - { key: 'price', width: 80, render: (_, record) => { - const quote = stockQuotes[record[0]]; + const quote = stockQuotes[record.code]; if (quote && quote.price !== undefined) { return ( 0 ? 'danger' : 'success'}> @@ -543,7 +611,7 @@ const InvestmentCalendar = () => { key: 'change', width: 100, render: (_, record) => { - const quote = stockQuotes[record[0]]; + const quote = stockQuotes[record.code]; if (quote && quote.changePercent !== undefined) { const changePercent = quote.changePercent || 0; return ( @@ -557,11 +625,12 @@ const InvestmentCalendar = () => { }, { title: '关联理由', - dataIndex: '2', + dataIndex: 'description', key: 'reason', - render: (reason, record) => { - const stockCode = record[0]; + render: (description, record) => { + const stockCode = record.code; const isExpanded = expandedReasons[stockCode] || false; + const reason = description || ''; const shouldTruncate = reason && reason.length > 100; const toggleExpanded = () => { @@ -571,8 +640,8 @@ const InvestmentCalendar = () => { })); }; - // 检查是否有引用数据(reason 就是 record[2]) - const citationData = reason; + // 检查是否有引用数据 + const citationData = description; const hasCitation = citationData && citationData.data && Array.isArray(citationData.data); if (hasCitation) { @@ -582,11 +651,11 @@ const InvestmentCalendar = () => { if (processed) { // 计算所有段落的总长度 const totalLength = processed.segments.reduce((sum, seg) => sum + seg.text.length, 0); - const shouldTruncate = totalLength > 100; + const shouldTruncateProcessed = totalLength > 100; // 确定要显示的段落 let displaySegments = processed.segments; - if (shouldTruncate && !isExpanded) { + if (shouldTruncateProcessed && !isExpanded) { // 需要截断:计算应该显示到哪个段落 let charCount = 0; displaySegments = []; @@ -621,7 +690,7 @@ const InvestmentCalendar = () => { ))} - {shouldTruncate && ( + {shouldTruncateProcessed && (